review classifier: run on ACP when the swarm settings choose it
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
This commit is contained in:
parent
38ee68c31d
commit
1b4e764e5c
24 changed files with 1622 additions and 262 deletions
|
|
@ -43,7 +43,7 @@ fn default_model() -> String {
|
|||
|
||||
/// One run: the PR as the controller read it, and the classifications to
|
||||
/// answer about it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TryOut {
|
||||
/// Who started the run: the swarm UI user, as the gateway named them, or
|
||||
/// the forge user who requested the classifier's review.
|
||||
|
|
@ -65,6 +65,52 @@ pub struct TryOut {
|
|||
pub model: String,
|
||||
/// The effort every classification runs at; `None` passes no `--effort`.
|
||||
pub effort: Option<Effort>,
|
||||
/// What every classification runs on. Absent is [`Runtime::ApiKey`], so a
|
||||
/// controller that sends none still gets its run answered.
|
||||
#[serde(default)]
|
||||
pub runtime: Runtime,
|
||||
}
|
||||
|
||||
/// What a run's classifications run on.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum Runtime {
|
||||
/// `claude` on the classifier's Anthropic API key, at [`TryOut::model`]
|
||||
/// and [`TryOut::effort`].
|
||||
#[default]
|
||||
ApiKey,
|
||||
/// An ACP agent on the classifier's ACP provider key. [`TryOut::model`]
|
||||
/// is claude's and unused here; [`TryOut::effort`] applies where the
|
||||
/// agent offers it.
|
||||
Acp(Acp),
|
||||
}
|
||||
|
||||
/// The ACP provider a run's classifications go to.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Acp {
|
||||
/// The provider's OpenAI-compatible API root, `https://` only.
|
||||
pub base_url: String,
|
||||
/// The model, as the provider names it.
|
||||
pub model: String,
|
||||
pub prices: Prices,
|
||||
}
|
||||
|
||||
/// What the provider charges, in US dollars per million tokens. A run's cost
|
||||
/// is computed from these and the tokens the agent reports.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Prices {
|
||||
pub input: f64,
|
||||
pub output: f64,
|
||||
/// Absent prices cache reads as input.
|
||||
#[serde(default)]
|
||||
pub cache_read: Option<f64>,
|
||||
/// Absent prices cache writes as input.
|
||||
#[serde(default)]
|
||||
pub cache_write: Option<f64>,
|
||||
}
|
||||
|
||||
/// One commit of the PR.
|
||||
|
|
@ -201,6 +247,7 @@ mod tests {
|
|||
});
|
||||
let run: TryOut = serde_json::from_value(request.clone()).expect("decodes");
|
||||
assert_eq!((run.model.as_str(), run.effort), (DEFAULT_MODEL, None));
|
||||
assert_eq!(run.runtime, Runtime::ApiKey);
|
||||
// Control: a named model and effort are the ones used.
|
||||
request["model"] = "sonnet".into();
|
||||
request["effort"] = "high".into();
|
||||
|
|
@ -210,4 +257,29 @@ mod tests {
|
|||
("sonnet", Some(Effort::High))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_acp_runtime_is_tagged_by_kind() {
|
||||
let acp = Runtime::Acp(Acp {
|
||||
base_url: "https://llm.example/v1".to_owned(),
|
||||
model: "qwen".to_owned(),
|
||||
prices: Prices {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cache_read: None,
|
||||
cache_write: None,
|
||||
},
|
||||
});
|
||||
let json = serde_json::to_value(&acp).expect("encodes");
|
||||
assert_eq!(json["kind"], "acp");
|
||||
assert_eq!(json["prices"]["input"], 0.5);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Runtime>(json).expect("decodes"),
|
||||
acp
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(Runtime::ApiKey).expect("encodes"),
|
||||
serde_json::json!({"kind": "api-key"})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue