The swarm settings gain `runtime` (`api-key`, the default, or `acp`) and an `acp` provider record: an https base URL, the provider's model, and its prices per million tokens. A PUT now changes only the fields its body names, so the settings form that sends model, effort and limit leaves the runtime alone; `null` clears `effort` or `acp`. The body stays strict, and the stored document keeps #5192's lenient read. The controller sends the runtime in each run request. On `acp` the classifier runs one prompt on a new `opencode acp` session per classification through hive-runtime's AcpRuntime, in a scratch directory, with a config rendered from the settings. opencode runs `read` and other tools without asking, so OPENCODE_PERMISSION {"*":"deny"} takes every tool away from the model, and a deny-all policy refuses any request on top. The title call, which resends the prompt, is turned off. The deadline cancels the turn, and a turn that does not end within 15s of that is dropped, which also covers an agent stuck before its prompt. The answer is the final assistant text, as ACP has no result event. The provider key is read per run from swarm/services/review-classifier/acp-provider, which the bao UI's admins login may create and update, and reaches opencode through AcpRuntime::with_provider_key and the trampoline's allowlist. Each ACP classification's cost is computed from the configured prices and the reported tokens, and added to hyperhive.review_classifier.cost.usage labelled repo, pr, model and currency. Part of #5096
216 lines
9.1 KiB
Nix
216 lines
9.1 KiB
Nix
# The review classifier, in a `swarm-review-classifier` container on the host
|
|
# that sets `deploy.review-classifier.enable`, which `deploy.allSwarmServices`
|
|
# defaults on where the host has the classifier's store identity. It answers the
|
|
# swarm UI's review try-outs: swarm-controller sends a PR and the operator's classifications over
|
|
# the queue, and the daemon runs `claude`, or `opencode` on the ACP runtime,
|
|
# once per classification and replies.
|
|
# docs/swarm/review-classifier.md has the design.
|
|
#
|
|
# ⚠️ Its own network namespace on the bridge, as ./swarm-ci-runner.nix has: a
|
|
# PR's diff and commit messages are model input, so the container reaches the
|
|
# swarm services by name and nothing on the host's loopback.
|
|
#
|
|
# The container holds one credential, its own store leaf, which nspawn hands in
|
|
# as a system credential with no copy on disk. The daemon reads everything else
|
|
# from the store with it: its queue client secret at start and the run's
|
|
# runtime's key on every run.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
cfg = hyperhiveCfg.deploy.review-classifier;
|
|
baoCfg = hyperhiveCfg.swarm.bao;
|
|
baoDeploy = hyperhiveCfg.deploy.bao;
|
|
natsCfg = hyperhiveCfg.swarm.nats;
|
|
autheliaCfg = hyperhiveCfg.swarm.authelia;
|
|
gatewayCfg = hyperhiveCfg.gateway;
|
|
networkCfg = hyperhiveCfg.network;
|
|
otelCfg = hyperhiveCfg.otel;
|
|
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
|
|
|
|
machine = "swarm-review-classifier";
|
|
unit = "swarm-review-classifier";
|
|
clientId = hyperhiveCfg.swarm.review-classifier.clientId;
|
|
|
|
# The daemon reaches authelia, the store and the queue by name over TLS, and
|
|
# in self-signed mode their leaves chain to the hive CA.
|
|
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
|
caBundleModule = caTrust.trustBundle {
|
|
inherit pkgs;
|
|
name = machine;
|
|
consumers = [ unit ];
|
|
};
|
|
|
|
haveClientIdentity =
|
|
baoDeploy.reviewClassifierClientCertFile != null && baoDeploy.reviewClassifierClientKeyFile != null;
|
|
|
|
# Credential id → host source. nspawn reads each source as root when the
|
|
# container starts and hands it in as a system credential, held in memory;
|
|
# the daemon's unit alone loads them. No copy lands on disk, as for an
|
|
# agent's own leaf (`nix/agent-modules/bao.nix`). Ids carry no dot, the
|
|
# spelling `--load-credential` is safe with.
|
|
storeFiles = lib.filterAttrs (_: source: source != null) {
|
|
review-classifier-bao-cert = baoDeploy.reviewClassifierClientCertFile;
|
|
review-classifier-bao-key = baoDeploy.reviewClassifierClientKeyFile;
|
|
review-classifier-bao-server-ca = baoDeploy.serverCaFile;
|
|
};
|
|
# nixos-container expands `extraFlags` unquoted, so a source path must be
|
|
# one word.
|
|
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
|
|
credential = id: "/run/credentials/${unit}.service/${id}";
|
|
|
|
# What `claude` exports its metrics with, and the daemon its ACP runs' cost,
|
|
# where this host runs a hive-tier collector on the bridge. The daemon adds
|
|
# the repo and the PR to each `claude` run's resource attributes, and to
|
|
# each cost record's attributes.
|
|
otelEnv = lib.optionalAttrs otelCfg.enable {
|
|
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
|
OTEL_METRICS_EXPORTER = "otlp";
|
|
OTEL_EXPORTER_OTLP_ENDPOINT = "http://${networkCfg.bridgeIp}:${toString otelCfg.collector.port}";
|
|
OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf";
|
|
# Claude Code defaults to delta, which the metrics store drops.
|
|
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
|
|
OTEL_RESOURCE_ATTRIBUTES = "service.name=hyperhive-review-classifier,swarm=${
|
|
if hyperhiveCfg.swarm.name == null then "unknown" else hyperhiveCfg.swarm.name
|
|
}";
|
|
};
|
|
|
|
privateNetwork = true;
|
|
in
|
|
{
|
|
options.services.hyperhive.swarm.review-classifier.clientId = lib.mkOption {
|
|
type = lib.types.str;
|
|
readOnly = true;
|
|
default = "swarm-review-classifier";
|
|
description = ''
|
|
The OAuth2 client id the review classifier presents to the swarm queue.
|
|
Read-only: the host running authelia registers it, the queue's
|
|
auth-callout responder grants it the try-out subject, and the secret
|
|
publisher carries its secret to the store, all from this one value.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.deploy.review-classifier.package = lib.mkOption {
|
|
type = lib.types.package;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-review-classifier";
|
|
description = "The review classifier daemon package.";
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
assertion = haveClientIdentity;
|
|
message = ''
|
|
services.hyperhive.deploy.review-classifier.enable requires the
|
|
classifier's swarm-secret-store client identity: set both
|
|
|
|
services.hyperhive.deploy.bao.reviewClassifierClientCertFile
|
|
services.hyperhive.deploy.bao.reviewClassifierClientKeyFile
|
|
|
|
The classifier reads its queue client secret and its runtime keys with it.
|
|
|
|
Both default to the pair glue-bao-tls.nix mints when
|
|
services.hyperhive.deploy.bao.enable is true (the host that runs the
|
|
store). Otherwise copy review-classifier.pem and
|
|
review-classifier-key.pem from the store host's /var/lib/swarm-bao-pki/
|
|
and name them in those two options.
|
|
'';
|
|
}
|
|
{
|
|
assertion = lib.all oneWord (lib.attrValues storeFiles);
|
|
message = ''
|
|
The review classifier's store identity paths must not contain
|
|
whitespace: nspawn's --load-credential receives them through
|
|
nixos-container's unquoted extra flags.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# The container hangs off the bridge, takes its address from the gateway's
|
|
# DHCP and resolves through its dnsmasq.
|
|
services.hyperhive.network.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
|
|
|
# nspawn fails to start the container when a credential source is
|
|
# missing, so where this host mints the leaf the container waits for it.
|
|
systemd.services."container@${machine}" = lib.mkMerge [
|
|
caTrust.containerOrdering
|
|
{ after = [ "swarm-bao-pki.service" ]; }
|
|
];
|
|
|
|
containers.${machine} = {
|
|
autoStart = true;
|
|
# The daemon keeps no state; its scratch directories are under PrivateTmp.
|
|
ephemeral = false;
|
|
# Journal files on the host, not inside the container: nixpkgs hardcodes
|
|
# --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it.
|
|
extraFlags = [
|
|
"--link-journal=host"
|
|
]
|
|
++ lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
|
|
inherit privateNetwork;
|
|
hostBridge = networkCfg.bridgeName;
|
|
bindMounts = caTrust.bindMount;
|
|
|
|
config =
|
|
{ pkgs, lib, ... }:
|
|
{
|
|
imports = [
|
|
../container-modules/swarm-container.nix
|
|
caBundleModule
|
|
];
|
|
|
|
services.hyperhive.swarmContainer = {
|
|
inherit privateNetwork;
|
|
writesOwnResolvConf = false;
|
|
};
|
|
networking.nameservers = [ networkCfg.bridgeIp ];
|
|
networking.interfaces.eth0.useDHCP = true;
|
|
|
|
# `claude-code` is unfree, and this container evaluates its own
|
|
# nixpkgs, so the host's allowance does not reach here.
|
|
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "claude-code" ];
|
|
|
|
systemd.services.${unit} = {
|
|
description = "answer the swarm UI's review try-outs";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
environment = {
|
|
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
|
BAO_CLIENT_CERT = credential "review-classifier-bao-cert";
|
|
BAO_CLIENT_KEY = credential "review-classifier-bao-key";
|
|
SWARM_REVIEW_CLASSIFIER_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}";
|
|
SWARM_REVIEW_CLASSIFIER_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token";
|
|
SWARM_REVIEW_CLASSIFIER_OIDC_CLIENT_ID = clientId;
|
|
SWARM_REVIEW_CLASSIFIER_CLAUDE = "${pkgs.claude-code}/bin/claude";
|
|
SWARM_REVIEW_CLASSIFIER_OPENCODE = "${pkgs.opencode}/bin/opencode";
|
|
}
|
|
// lib.optionalAttrs (storeFiles ? review-classifier-bao-server-ca) {
|
|
BAO_CACERT = credential "review-classifier-bao-server-ca";
|
|
}
|
|
// lib.optionalAttrs caTrust.useSelfSigned {
|
|
SWARM_REVIEW_CLASSIFIER_OIDC_CA_FILE = caTrust.bundlePathFor machine;
|
|
}
|
|
// otelEnv;
|
|
serviceConfig = {
|
|
ExecStart = "${cfg.package}/bin/swarm-review-classifier";
|
|
DynamicUser = true;
|
|
# Each classification's scratch directory, gone with the unit.
|
|
PrivateTmp = true;
|
|
# Bare ids: the system credentials nspawn handed in.
|
|
LoadCredential = lib.attrNames storeFiles;
|
|
Restart = "on-failure";
|
|
RestartSec = "10s";
|
|
SyslogIdentifier = unit;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|