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
|
|
@ -64,7 +64,7 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
(`POST /api/review-classifier/try-out`, `GET /api/review-classifier/try-out/{id}`,
|
||||
`GET /api/review-classifier/rules`, `GET /api/review-classifier/runs`) →
|
||||
[`docs/swarm/review-classifier.md`](../docs/swarm/review-classifier.md).
|
||||
- **Review classifier settings** — the classifier's model, effort and how
|
||||
- **Review classifier settings** — the classifier's runtime, model, effort and how
|
||||
many requested reviews run at once, one document in the
|
||||
`review-classifier-settings` KV bucket, which this daemon alone reads and
|
||||
writes (`GET`/`PUT /api/review-classifier/settings`) →
|
||||
|
|
|
|||
|
|
@ -638,6 +638,9 @@ async fn start(
|
|||
settings: Settings,
|
||||
) -> Result<(StatusCode, Json<TryOutAccepted>), ProblemDetails> {
|
||||
let started_unix = crate::agent_renewal::unix_now();
|
||||
let runtime = settings
|
||||
.runtime()
|
||||
.map_err(|e| error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e))?;
|
||||
let repo = format!("{}/{}", run.owner, run.name);
|
||||
let pull =
|
||||
forge::within_forge_deadline("review try-out", pulls.pull(&run.owner, &run.name, run.pr))
|
||||
|
|
@ -659,6 +662,7 @@ async fn start(
|
|||
classifications: run.classifications,
|
||||
model: settings.model,
|
||||
effort: settings.effort,
|
||||
runtime,
|
||||
};
|
||||
let payload = serde_json::to_vec(&request).map_err(|e| {
|
||||
error_problem(
|
||||
|
|
@ -940,15 +944,28 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_try_out_runs_on_the_stored_model_and_effort() {
|
||||
async fn a_try_out_runs_on_the_stored_runtime_model_and_effort() {
|
||||
use swarm_queue_client::agent_inference::Effort;
|
||||
use swarm_queue_client::review_classifier::Runtime;
|
||||
|
||||
let acp = swarm_queue_client::review_classifier::Acp {
|
||||
base_url: "https://llm.example/v1".to_owned(),
|
||||
model: "qwen".to_owned(),
|
||||
prices: swarm_queue_client::review_classifier::Prices {
|
||||
input: 1.0,
|
||||
output: 2.0,
|
||||
cache_read: None,
|
||||
cache_write: None,
|
||||
},
|
||||
};
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let fake = Arc::new(Fake {
|
||||
settings: Settings {
|
||||
model: "opus".to_owned(),
|
||||
effort: Some(Effort::High),
|
||||
max_concurrent_reviews: 2,
|
||||
runtime: settings::RuntimeKind::Acp,
|
||||
acp: Some(acp.clone()),
|
||||
},
|
||||
..Fake::new(done(), Vec::new())
|
||||
});
|
||||
|
|
@ -963,6 +980,7 @@ pub(crate) mod tests {
|
|||
(sent[0].model.as_str(), sent[0].effort),
|
||||
("opus", Some(Effort::High))
|
||||
);
|
||||
assert_eq!(sent[0].runtime, Runtime::Acp(acp));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -598,6 +598,15 @@ async fn classify(
|
|||
);
|
||||
}
|
||||
};
|
||||
let runtime = match settings.runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
return (
|
||||
head,
|
||||
Outcome::NotRun(format!("the classifier's settings: {e}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let request = TryOut {
|
||||
operator: req.requester.clone(),
|
||||
repo,
|
||||
|
|
@ -608,6 +617,7 @@ async fn classify(
|
|||
classifications: rules.classifications,
|
||||
model: settings.model,
|
||||
effort: settings.effort,
|
||||
runtime,
|
||||
};
|
||||
let sent = request.classifications.len();
|
||||
let outcome = match serde_json::to_vec(&request) {
|
||||
|
|
@ -1349,7 +1359,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_review_runs_on_the_stored_model_and_effort() {
|
||||
async fn a_review_runs_on_the_stored_runtime_model_and_effort() {
|
||||
use swarm_queue_client::agent_inference::Effort;
|
||||
|
||||
let mut fake = Fake::new(
|
||||
|
|
@ -1359,10 +1369,22 @@ mod tests {
|
|||
},
|
||||
vec![rules_json()],
|
||||
);
|
||||
let acp = swarm_queue_client::review_classifier::Acp {
|
||||
base_url: "https://llm.example/v1".to_owned(),
|
||||
model: "qwen".to_owned(),
|
||||
prices: swarm_queue_client::review_classifier::Prices {
|
||||
input: 1.0,
|
||||
output: 2.0,
|
||||
cache_read: None,
|
||||
cache_write: None,
|
||||
},
|
||||
};
|
||||
fake.settings = Settings {
|
||||
model: "sonnet".to_owned(),
|
||||
effort: Some(Effort::Medium),
|
||||
max_concurrent_reviews: 2,
|
||||
runtime: super::super::settings::RuntimeKind::Acp,
|
||||
acp: Some(acp.clone()),
|
||||
};
|
||||
let poster = Posted::default();
|
||||
run(&fake, &fake, &fake, &poster, &request("r")).await;
|
||||
|
|
@ -1372,6 +1394,10 @@ mod tests {
|
|||
(sent[0].model.as_str(), sent[0].effort),
|
||||
("sonnet", Some(Effort::Medium))
|
||||
);
|
||||
assert_eq!(
|
||||
sent[0].runtime,
|
||||
swarm_queue_client::review_classifier::Runtime::Acp(acp)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
//! `GET` and `PUT /api/review-classifier/settings`: the model and effort
|
||||
//! every run uses, and how many requested reviews run at once, for the whole
|
||||
//! swarm.
|
||||
//! `GET` and `PUT /api/review-classifier/settings`: the runtime, model and
|
||||
//! effort every run uses, and how many requested reviews run at once, for the
|
||||
//! whole swarm.
|
||||
//!
|
||||
//! The settings are one document under one key ([`KEY`] in [`BUCKET`]),
|
||||
//! which only this daemon reads or writes, as the schedule definitions are.
|
||||
//! No document is [`Settings::default`]. A try-out reads them when it starts
|
||||
//! and a review when it gets its place, and each sends the model and effort
|
||||
//! in its run request, so a change applies to the next run with no restart.
|
||||
//! and a review when it gets its place, and each sends the runtime, model and
|
||||
//! effort in its run request, so a change applies to the next run with no
|
||||
//! restart. A `PUT` changes only the fields its body names.
|
||||
|
||||
use std::future::Future;
|
||||
use std::ops::RangeInclusive;
|
||||
|
|
@ -20,6 +21,7 @@ use axum::http::{HeaderMap, StatusCode};
|
|||
use problem_details::ProblemDetails;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_queue_client::agent_inference::Effort;
|
||||
use swarm_queue_client::review_classifier::{Acp, Runtime};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::schedules::{queue, read_with, refused, write};
|
||||
|
|
@ -39,21 +41,39 @@ const MAX_ATTEMPTS: usize = 5;
|
|||
/// at a time in the classifier's container, which has no bound of its own.
|
||||
pub(crate) const CONCURRENT_REVIEWS: RangeInclusive<usize> = 1..=8;
|
||||
|
||||
/// The review classifier's settings: the value under [`KEY`], and the body of
|
||||
/// both routes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
/// The review classifier's settings: the value under [`KEY`], and what both
|
||||
/// routes answer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Settings {
|
||||
/// The model every classification runs on, as claude's `--model` takes
|
||||
/// it: one word, not starting with `-`.
|
||||
/// The model every classification on the API key runtime runs on, as
|
||||
/// claude's `--model` takes it: one word, not starting with `-`.
|
||||
pub(crate) model: String,
|
||||
/// One of `low`, `medium`, `high`, `xhigh`, `max`; `null` or absent runs
|
||||
/// at claude's own default.
|
||||
/// One of `low`, `medium`, `high`, `xhigh`, `max`; `null` runs at the
|
||||
/// runtime's own default. An ACP agent gets it only where it offers it.
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub(crate) effort: Option<Effort>,
|
||||
/// How many requested reviews run at once, 1 to 8. Try-outs don't count.
|
||||
pub(crate) max_concurrent_reviews: usize,
|
||||
/// What every classification runs on.
|
||||
#[serde(default)]
|
||||
pub(crate) runtime: RuntimeKind,
|
||||
/// The provider the `acp` runtime runs on. Kept while the runtime is
|
||||
/// `api-key`, so switching back and forth needs no re-entry.
|
||||
#[serde(default)]
|
||||
pub(crate) acp: Option<Acp>,
|
||||
}
|
||||
|
||||
/// Which runtime the classifications run on.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum RuntimeKind {
|
||||
/// `claude` on the classifier's Anthropic API key.
|
||||
#[default]
|
||||
ApiKey,
|
||||
/// An ACP agent on the classifier's ACP provider key, at [`Settings::acp`].
|
||||
Acp,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
|
|
@ -62,6 +82,8 @@ impl Default for Settings {
|
|||
model: swarm_queue_client::review_classifier::DEFAULT_MODEL.to_owned(),
|
||||
effort: None,
|
||||
max_concurrent_reviews: 2,
|
||||
runtime: RuntimeKind::ApiKey,
|
||||
acp: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +100,126 @@ impl Settings {
|
|||
CONCURRENT_REVIEWS.end()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
if let Some(acp) = &self.acp {
|
||||
check_acp(acp)?;
|
||||
}
|
||||
self.runtime().map(|_| ())
|
||||
}
|
||||
|
||||
/// The runtime a run request carries. An `acp` runtime with no provider
|
||||
/// is an error rather than the API key runtime, which would send the PR
|
||||
/// to a provider nobody chose.
|
||||
pub(crate) fn runtime(&self) -> Result<Runtime, String> {
|
||||
match (self.runtime, &self.acp) {
|
||||
(RuntimeKind::ApiKey, _) => Ok(Runtime::ApiKey),
|
||||
(RuntimeKind::Acp, Some(acp)) => Ok(Runtime::Acp(acp.clone())),
|
||||
(RuntimeKind::Acp, None) => {
|
||||
Err("the runtime is `acp` but no `acp` provider is set".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is wrong with `acp`, if anything.
|
||||
fn check_acp(acp: &Acp) -> Result<(), String> {
|
||||
let url = url::Url::parse(&acp.base_url)
|
||||
.map_err(|e| format!("acp.base_url {:?} is not a URL: {e}", acp.base_url))?;
|
||||
if url.scheme() != "https" {
|
||||
return Err(format!("acp.base_url {:?} is not https://", acp.base_url));
|
||||
}
|
||||
if acp.model.is_empty()
|
||||
|| acp
|
||||
.model
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c.is_control())
|
||||
{
|
||||
return Err(format!(
|
||||
"acp.model {:?} is empty or has whitespace",
|
||||
acp.model
|
||||
));
|
||||
}
|
||||
let prices = &acp.prices;
|
||||
for (name, price) in [
|
||||
("input", Some(prices.input)),
|
||||
("output", Some(prices.output)),
|
||||
("cache_read", prices.cache_read),
|
||||
("cache_write", prices.cache_write),
|
||||
] {
|
||||
if price.is_some_and(|p| !p.is_finite() || p < 0.0) {
|
||||
return Err(format!("acp.prices.{name} is not a price of 0 or more"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The body of a `PUT`: any of the fields of [`Settings`]. A field left out
|
||||
/// keeps its stored value; `null` sets `effort` or `acp` to none.
|
||||
#[derive(Debug, Clone, Default, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct SettingsUpdate {
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<String>, nullable)]
|
||||
effort: Nullable<Effort>,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schema(value_type = Option<usize>)]
|
||||
max_concurrent_reviews: Option<usize>,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schema(value_type = Option<RuntimeKind>)]
|
||||
runtime: Option<RuntimeKind>,
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<Acp>, nullable)]
|
||||
acp: Nullable<Acp>,
|
||||
}
|
||||
|
||||
/// A field of a [`SettingsUpdate`] that may be `null`.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
enum Nullable<T> {
|
||||
/// Left out of the body.
|
||||
#[default]
|
||||
Absent,
|
||||
/// In the body, `None` for `null`.
|
||||
Present(Option<T>),
|
||||
}
|
||||
|
||||
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Nullable<T> {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(value: D) -> Result<Self, D::Error> {
|
||||
Option::<T>::deserialize(value).map(Self::Present)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Nullable<T> {
|
||||
/// The value in the body, else `stored`.
|
||||
fn or(self, stored: Option<T>) -> Option<T> {
|
||||
match self {
|
||||
Self::Absent => stored,
|
||||
Self::Present(value) => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A field that is in the body, as its own type allows it: `null` only where
|
||||
/// that type is an `Option`.
|
||||
fn present<'de, T: Deserialize<'de>, D: serde::Deserializer<'de>>(
|
||||
value: D,
|
||||
) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(value).map(Some)
|
||||
}
|
||||
|
||||
impl SettingsUpdate {
|
||||
/// `stored` with this update's fields in place of its own.
|
||||
fn apply(self, stored: Settings) -> Settings {
|
||||
Settings {
|
||||
model: self.model.unwrap_or(stored.model),
|
||||
effort: self.effort.or(stored.effort),
|
||||
max_concurrent_reviews: self
|
||||
.max_concurrent_reviews
|
||||
.unwrap_or(stored.max_concurrent_reviews),
|
||||
runtime: self.runtime.unwrap_or(stored.runtime),
|
||||
acp: self.acp.or(stored.acp),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +245,13 @@ async fn stored(store: &Store) -> anyhow::Result<(Settings, Option<u64>)> {
|
|||
|
||||
/// Every field of [`Settings`]. `a_full_document_round_trips` fails when one
|
||||
/// is missing.
|
||||
const FIELDS: &[&str] = &["model", "effort", "max_concurrent_reviews"];
|
||||
const FIELDS: &[&str] = &[
|
||||
"model",
|
||||
"effort",
|
||||
"max_concurrent_reviews",
|
||||
"runtime",
|
||||
"acp",
|
||||
];
|
||||
|
||||
/// `bytes`, the stored document, as settings. Unlike a `PUT` body, a field
|
||||
/// not in [`FIELDS`], such as one a newer build added, is ignored; a document
|
||||
|
|
@ -153,7 +300,8 @@ fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
|
|||
path = "/api/review-classifier/settings",
|
||||
responses(
|
||||
(status = 200, description = "the settings in force; with none stored, \
|
||||
`{\"model\":\"haiku\",\"effort\":null,\"max_concurrent_reviews\":2}`",
|
||||
`{\"model\":\"haiku\",\"effort\":null,\"max_concurrent_reviews\":2,\
|
||||
\"runtime\":\"api-key\",\"acp\":null}`",
|
||||
body = Settings),
|
||||
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
||||
not connected (problem+json)", body = String),
|
||||
|
|
@ -173,19 +321,22 @@ pub(crate) async fn get_settings(
|
|||
.map_err(|e| queue_problem(&e))
|
||||
}
|
||||
|
||||
/// Replace the settings.
|
||||
/// Change the settings.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/review-classifier/settings",
|
||||
request_body = Settings,
|
||||
request_body = SettingsUpdate,
|
||||
responses(
|
||||
(status = 200, description = "the settings as stored; they replace the previous \
|
||||
ones whole and apply from the next run", body = Settings),
|
||||
(status = 400, description = "not JSON or not this shape: a missing `model` or \
|
||||
`max_concurrent_reviews`, an unknown field, an effort other than the five \
|
||||
levels, a negative or non-integer `max_concurrent_reviews`; or a model that is \
|
||||
empty, has whitespace or starts with `-`; or `max_concurrent_reviews` outside \
|
||||
1 to 8 (problem+json)", body = String),
|
||||
(status = 200, description = "the settings as stored: the body's fields over the \
|
||||
stored ones, which apply from the next run", body = Settings),
|
||||
(status = 400, description = "not JSON or not this shape: an unknown field, a \
|
||||
`null` other than for `effort` or `acp`, an effort other than the five \
|
||||
levels, a negative or non-integer `max_concurrent_reviews`, a runtime other \
|
||||
than `api-key` or `acp`, an `acp` without `base_url`, `model` or `prices`; or, \
|
||||
once applied, a model that is empty, has whitespace or starts with `-`, \
|
||||
`max_concurrent_reviews` outside 1 to 8, an `acp.base_url` that is not \
|
||||
https://, an empty `acp.model` or one with whitespace, a negative price, or \
|
||||
the `acp` runtime with no `acp` provider (problem+json)", body = String),
|
||||
(status = 401, description = "the request carries no `Remote-User`, so there is no \
|
||||
operator to name (problem+json)", body = String),
|
||||
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
||||
|
|
@ -198,7 +349,7 @@ pub(crate) async fn get_settings(
|
|||
pub(crate) async fn put_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Result<Json<Settings>, JsonRejection>,
|
||||
body: Result<Json<SettingsUpdate>, JsonRejection>,
|
||||
) -> Result<Json<Settings>, ProblemDetails> {
|
||||
let operator = super::operator(&headers).ok_or_else(|| {
|
||||
error_problem(
|
||||
|
|
@ -207,14 +358,15 @@ pub(crate) async fn put_settings(
|
|||
come through it",
|
||||
)
|
||||
})?;
|
||||
let Json(value) = body.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?;
|
||||
value
|
||||
.check()
|
||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
let Json(update) = body.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?;
|
||||
let client = queue(&state).map_err(refused)?;
|
||||
let store = open(&client).await.map_err(|e| queue_problem(&e))?;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let (_, revision) = stored(&store).await.map_err(|e| queue_problem(&e))?;
|
||||
let (current, revision) = stored(&store).await.map_err(|e| queue_problem(&e))?;
|
||||
let value = update.clone().apply(current);
|
||||
value
|
||||
.check()
|
||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
if write(&store, KEY, &value, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
|
|
@ -243,12 +395,34 @@ mod tests {
|
|||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
fn update(json: &str) -> serde_json::Result<SettingsUpdate> {
|
||||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
/// A valid ACP provider with every price set.
|
||||
fn provider() -> Acp {
|
||||
Acp {
|
||||
base_url: "https://llm.example/v1".to_owned(),
|
||||
model: "Qwen/Qwen3-35B".to_owned(),
|
||||
prices: swarm_queue_client::review_classifier::Prices {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cache_read: Some(0.05),
|
||||
cache_write: Some(0.6),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_document_is_todays_values() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(Settings::default()).unwrap(),
|
||||
serde_json::json!({"model": "haiku", "effort": null, "max_concurrent_reviews": 2})
|
||||
serde_json::json!({
|
||||
"model": "haiku", "effort": null, "max_concurrent_reviews": 2,
|
||||
"runtime": "api-key", "acp": null,
|
||||
})
|
||||
);
|
||||
assert_eq!(Settings::default().runtime(), Ok(Runtime::ApiKey));
|
||||
}
|
||||
|
||||
/// Every field set, every `Option` `Some`: a stored document keeps all of
|
||||
|
|
@ -259,6 +433,8 @@ mod tests {
|
|||
model: "sonnet".to_owned(),
|
||||
effort: Some(Effort::High),
|
||||
max_concurrent_reviews: 5,
|
||||
runtime: RuntimeKind::Acp,
|
||||
acp: Some(provider()),
|
||||
};
|
||||
let encoded = serde_json::to_value(&full).unwrap();
|
||||
let mut keys: Vec<_> = encoded.as_object().unwrap().keys().collect();
|
||||
|
|
@ -289,27 +465,134 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_acp_provider_is_refused() {
|
||||
let with = |change: fn(&mut Acp)| {
|
||||
let mut acp = provider();
|
||||
change(&mut acp);
|
||||
Settings {
|
||||
runtime: RuntimeKind::Acp,
|
||||
acp: Some(acp),
|
||||
..Settings::default()
|
||||
}
|
||||
};
|
||||
for (why, bad) in [
|
||||
(
|
||||
"http",
|
||||
with(|a| a.base_url = "http://llm.example/v1".to_owned()),
|
||||
),
|
||||
("no URL", with(|a| a.base_url = "llm.example".to_owned())),
|
||||
(
|
||||
"ftp",
|
||||
with(|a| a.base_url = "ftp://llm.example/v1".to_owned()),
|
||||
),
|
||||
("empty model", with(|a| a.model = String::new())),
|
||||
(
|
||||
"model with a space",
|
||||
with(|a| a.model = "qwen 3".to_owned()),
|
||||
),
|
||||
("negative input", with(|a| a.prices.input = -1.0)),
|
||||
("negative output", with(|a| a.prices.output = -1.0)),
|
||||
(
|
||||
"negative cache read",
|
||||
with(|a| a.prices.cache_read = Some(-0.1)),
|
||||
),
|
||||
(
|
||||
"negative cache write",
|
||||
with(|a| a.prices.cache_write = Some(-0.1)),
|
||||
),
|
||||
("infinite", with(|a| a.prices.input = f64::INFINITY)),
|
||||
] {
|
||||
assert!(bad.check().is_err(), "{why}");
|
||||
}
|
||||
// Control: the valid provider, and one with free tokens and no cache
|
||||
// prices.
|
||||
assert!(with(|_| ()).check().is_ok());
|
||||
let free = with(|a| {
|
||||
a.prices.input = 0.0;
|
||||
a.prices.cache_read = None;
|
||||
a.prices.cache_write = None;
|
||||
});
|
||||
assert!(free.check().is_ok());
|
||||
assert_eq!(free.runtime(), Ok(Runtime::Acp(free.acp.clone().unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_acp_runtime_without_a_provider_is_refused_not_run_on_the_api_key() {
|
||||
let missing = Settings {
|
||||
runtime: RuntimeKind::Acp,
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(missing.runtime().is_err());
|
||||
assert!(missing.check().is_err());
|
||||
// Control: a provider kept while the runtime is the API key's is
|
||||
// fine, and runs on the API key.
|
||||
let kept = Settings {
|
||||
acp: Some(provider()),
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(kept.check().is_ok());
|
||||
assert_eq!(kept.runtime(), Ok(Runtime::ApiKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_shape_does_not_decode() {
|
||||
for json in [
|
||||
r#"{"max_concurrent_reviews":2}"#,
|
||||
r#"{"model":"haiku"}"#,
|
||||
r#"{"model":"haiku","effort":"extreme","max_concurrent_reviews":2}"#,
|
||||
r#"{"model":"haiku","effort":"HIGH","max_concurrent_reviews":2}"#,
|
||||
r#"{"model":"haiku","max_concurrent_reviews":-1}"#,
|
||||
r#"{"model":"haiku","max_concurrent_reviews":2.5}"#,
|
||||
r#"{"model":"haiku","max_concurrent_reviews":2,"api_key":"x"}"#,
|
||||
r#"{"model":null}"#,
|
||||
r#"{"max_concurrent_reviews":null}"#,
|
||||
r#"{"runtime":null}"#,
|
||||
r#"{"effort":"extreme"}"#,
|
||||
r#"{"effort":"HIGH"}"#,
|
||||
r#"{"max_concurrent_reviews":-1}"#,
|
||||
r#"{"max_concurrent_reviews":2.5}"#,
|
||||
r#"{"api_key":"x"}"#,
|
||||
r#"{"runtime":"subscription"}"#,
|
||||
r#"{"acp":{"base_url":"https://x","model":"m"}}"#,
|
||||
r#"{"acp":{"base_url":"https://x","model":"m","prices":{"input":1}}}"#,
|
||||
r#"{"acp":{"base_url":"https://x","model":"m","prices":{"input":1,"output":1},"api_key":"x"}}"#,
|
||||
] {
|
||||
assert!(settings(json).is_err(), "{json}");
|
||||
assert!(update(json).is_err(), "{json}");
|
||||
}
|
||||
// Control: effort may be left out or null.
|
||||
// Control: every field may be left out, and `effort` and `acp` may be
|
||||
// null.
|
||||
let empty = update("{}").unwrap();
|
||||
assert_eq!(empty.apply(Settings::default()), Settings::default());
|
||||
let cleared = update(r#"{"effort":null,"acp":null}"#).unwrap();
|
||||
assert_eq!(
|
||||
settings(r#"{"model":"haiku","max_concurrent_reviews":2}"#).unwrap(),
|
||||
Settings::default()
|
||||
(cleared.effort, cleared.acp),
|
||||
(Nullable::Present(None), Nullable::Present(None)),
|
||||
"null is a value, not a field left out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_changes_only_the_fields_it_names() {
|
||||
let stored = Settings {
|
||||
model: "sonnet".to_owned(),
|
||||
effort: Some(Effort::High),
|
||||
max_concurrent_reviews: 3,
|
||||
runtime: RuntimeKind::Acp,
|
||||
acp: Some(provider()),
|
||||
};
|
||||
let model_only = update(r#"{"model":"opus"}"#).unwrap().apply(stored.clone());
|
||||
assert_eq!(
|
||||
settings(r#"{"model":"haiku","effort":null,"max_concurrent_reviews":2}"#).unwrap(),
|
||||
Settings::default()
|
||||
model_only,
|
||||
Settings {
|
||||
model: "opus".to_owned(),
|
||||
..stored.clone()
|
||||
}
|
||||
);
|
||||
let cleared = update(r#"{"effort":null,"runtime":"api-key","acp":null}"#)
|
||||
.unwrap()
|
||||
.apply(stored.clone());
|
||||
assert_eq!(
|
||||
cleared,
|
||||
Settings {
|
||||
effort: None,
|
||||
runtime: RuntimeKind::ApiKey,
|
||||
acp: None,
|
||||
..stored
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +664,10 @@ mod tests {
|
|||
assert_eq!(status, StatusCode::OK, "{read}");
|
||||
assert_eq!(
|
||||
read,
|
||||
serde_json::json!({"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3})
|
||||
serde_json::json!({
|
||||
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3,
|
||||
"runtime": "api-key", "acp": null,
|
||||
})
|
||||
);
|
||||
|
||||
store
|
||||
|
|
@ -418,6 +704,7 @@ mod tests {
|
|||
|
||||
let wanted = serde_json::json!({
|
||||
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 5,
|
||||
"runtime": "api-key", "acp": null,
|
||||
});
|
||||
let (status, stored, _) = send(&app, put(&wanted.to_string())).await;
|
||||
assert_eq!(status, StatusCode::OK, "{stored}");
|
||||
|
|
@ -438,16 +725,45 @@ mod tests {
|
|||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
/// The swarm UI's settings form PUTs the model, effort and limit alone;
|
||||
/// that save keeps the runtime and the provider.
|
||||
#[tokio::test]
|
||||
async fn a_put_without_the_runtime_keeps_the_stored_one() {
|
||||
let nats = TestNats::start();
|
||||
let (app, _) = app(&nats).await;
|
||||
let acp = serde_json::to_value(provider()).unwrap();
|
||||
let switch = serde_json::json!({ "runtime": "acp", "acp": acp });
|
||||
let (status, stored, _) = send(&app, put(&switch.to_string())).await;
|
||||
assert_eq!(status, StatusCode::OK, "{stored}");
|
||||
assert_eq!(stored["model"], "haiku", "the rest kept its defaults");
|
||||
|
||||
let form = r#"{"model":"sonnet","effort":null,"max_concurrent_reviews":4}"#;
|
||||
let (status, stored, _) = send(&app, put(form)).await;
|
||||
assert_eq!(status, StatusCode::OK, "{stored}");
|
||||
let (_, after, _) = send(&app, get()).await;
|
||||
assert_eq!(
|
||||
after,
|
||||
serde_json::json!({
|
||||
"model": "sonnet", "effort": null, "max_concurrent_reviews": 4,
|
||||
"runtime": "acp", "acp": acp,
|
||||
})
|
||||
);
|
||||
let read = nats.client().await.settings().await.expect("readable");
|
||||
assert_eq!(read.runtime(), Ok(Runtime::Acp(provider())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_put_is_a_problem_and_changes_nothing() {
|
||||
let nats = TestNats::start();
|
||||
let (app, state) = app(&nats).await;
|
||||
for bad in [
|
||||
"not json",
|
||||
r#"{"model":"haiku"}"#,
|
||||
r#"{"model":null}"#,
|
||||
r#"{"model":"haiku","effort":"extreme","max_concurrent_reviews":2}"#,
|
||||
r#"{"model":"-x","max_concurrent_reviews":2}"#,
|
||||
r#"{"model":"haiku","max_concurrent_reviews":9}"#,
|
||||
r#"{"runtime":"acp"}"#,
|
||||
r#"{"acp":{"base_url":"http://x","model":"m","prices":{"input":1,"output":1}}}"#,
|
||||
] {
|
||||
let (status, problem, kind) = send(&app, put(bad)).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{bad}: {problem}");
|
||||
|
|
|
|||
Loading…
Reference in a new issue