Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/review_classifier/settings.rs
atlas 1b4e764e5c 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
2026-10-14 10:14:36 +02:00

801 lines
29 KiB
Rust

//! `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 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;
use anyhow::Context as _;
use async_nats::jetstream::kv::Store;
use axum::Json;
use axum::extract::State;
use axum::extract::rejection::JsonRejection;
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};
use crate::{AppState, error_problem, wanted_error_status};
/// The bucket holding the settings. No other service holds a grant on it.
pub const BUCKET: &str = "review-classifier-settings";
/// The one key in [`BUCKET`].
pub const KEY: &str = "all";
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// What `max_concurrent_reviews` may be. Each running review is one `claude`
/// 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 what both
/// routes answer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Settings {
/// 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` 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 {
fn default() -> Self {
Self {
model: swarm_queue_client::review_classifier::DEFAULT_MODEL.to_owned(),
effort: None,
max_concurrent_reviews: 2,
runtime: RuntimeKind::ApiKey,
acp: None,
}
}
}
impl Settings {
/// What is wrong with these settings, if anything.
fn check(&self) -> Result<(), String> {
crate::inference::check_model(&self.model, "model")?;
if !CONCURRENT_REVIEWS.contains(&self.max_concurrent_reviews) {
return Err(format!(
"max_concurrent_reviews {} is not {} to {}",
self.max_concurrent_reviews,
CONCURRENT_REVIEWS.start(),
CONCURRENT_REVIEWS.end()
));
}
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),
}
}
}
/// Where a run reads the settings: the queue in production, a fake in the
/// tests.
pub(crate) trait SettingsSource {
fn settings(&self) -> impl Future<Output = anyhow::Result<Settings>> + Send;
}
impl SettingsSource for async_nats::Client {
async fn settings(&self) -> anyhow::Result<Settings> {
let (settings, _) = stored(&open(self).await?)
.await
.context("reading the review classifier's settings")?;
Ok(settings)
}
}
/// The stored settings, with the revision to write them back against.
async fn stored(store: &Store) -> anyhow::Result<(Settings, Option<u64>)> {
read_with(store, KEY, |bytes| Ok(decode(bytes))).await
}
/// Every field of [`Settings`]. `a_full_document_round_trips` fails when one
/// is missing.
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
/// that still does not decode is [`Settings::default`], with a warning,
/// until the next `PUT` replaces it.
fn decode(bytes: &[u8]) -> Settings {
let decoded = serde_json::from_slice(bytes).and_then(|mut value: serde_json::Value| {
if let Some(fields) = value.as_object_mut() {
fields.retain(|name, _| FIELDS.contains(&name.as_str()));
}
serde_json::from_value(value)
});
decoded.unwrap_or_else(|e| {
tracing::warn!(
bucket = BUCKET, key = KEY, error = %e,
"the review classifier settings are not decodable; using the defaults"
);
Settings::default()
})
}
/// Open [`BUCKET`], creating it if nothing has yet.
async fn open(client: &async_nats::Client) -> anyhow::Result<Store> {
let js = async_nats::jetstream::new(client.clone());
if let Ok(store) = js.get_key_value(BUCKET).await {
return Ok(store);
}
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: BUCKET.to_owned(),
description: "The review classifier's swarm-wide settings".to_owned(),
history: 1,
..Default::default()
})
.await
.with_context(|| format!("creating the {BUCKET} bucket"))
}
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
tracing::warn!(error = %format!("{e:#}"), "review classifier settings: queue failure");
error_problem(wanted_error_status(e), &format!("{e:#}"))
}
/// Read the settings.
#[utoipa::path(
get,
path = "/api/review-classifier/settings",
responses(
(status = 200, description = "the settings in force; with none stored, \
`{\"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),
(status = 500, description = "the settings could not be read (problem+json)",
body = String),
),
tag = "review-classifier"
)]
pub(crate) async fn get_settings(
State(state): State<AppState>,
) -> Result<Json<Settings>, ProblemDetails> {
let client = queue(&state).map_err(refused)?;
client
.settings()
.await
.map(Json)
.map_err(|e| queue_problem(&e))
}
/// Change the settings.
#[utoipa::path(
put,
path = "/api/review-classifier/settings",
request_body = SettingsUpdate,
responses(
(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 \
not connected (problem+json)", body = String),
(status = 500, description = "the settings could not be written (problem+json)",
body = String),
),
tag = "review-classifier"
)]
pub(crate) async fn put_settings(
State(state): State<AppState>,
headers: HeaderMap,
body: Result<Json<SettingsUpdate>, JsonRejection>,
) -> Result<Json<Settings>, ProblemDetails> {
let operator = super::operator(&headers).ok_or_else(|| {
error_problem(
StatusCode::UNAUTHORIZED,
"no Remote-User header: the gateway names the operator, and this request did not \
come through it",
)
})?;
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 (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))?
{
state.reviews.resize(value.max_concurrent_reviews);
tracing::info!(%operator, ?value, "set the review classifier's settings");
return Ok(Json(value));
}
}
Err(queue_problem(&anyhow::anyhow!(
"gave up writing the review classifier's settings after {MAX_ATTEMPTS} conflicting \
writes"
)))
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
use super::*;
use crate::test_nats::TestNats;
fn settings(json: &str) -> serde_json::Result<Settings> {
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,
"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
/// them, so a field missing from [`FIELDS`] fails here.
#[test]
fn a_full_document_round_trips() {
let full = Settings {
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();
let mut fields = FIELDS.to_vec();
keys.sort_unstable();
fields.sort_unstable();
assert_eq!(keys, fields);
assert_eq!(decode(&serde_json::to_vec(&full).unwrap()), full);
}
#[test]
fn bad_settings_are_refused() {
for json in [
r#"{"model":"","max_concurrent_reviews":2}"#,
r#"{"model":"opus 4","max_concurrent_reviews":2}"#,
r#"{"model":"--print","max_concurrent_reviews":2}"#,
r#"{"model":"haiku","max_concurrent_reviews":0}"#,
r#"{"model":"haiku","max_concurrent_reviews":9}"#,
] {
assert!(settings(json).unwrap().check().is_err(), "{json}");
}
// Control: the bounds themselves, and a full model id.
for json in [
r#"{"model":"claude-haiku-4-5","max_concurrent_reviews":1}"#,
r#"{"model":"haiku","effort":"max","max_concurrent_reviews":8}"#,
] {
assert!(settings(json).unwrap().check().is_ok(), "{json}");
}
}
#[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#"{"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!(update(json).is_err(), "{json}");
}
// 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!(
(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!(
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
}
);
}
/// An app whose queue is `nats`.
async fn app(nats: &TestNats) -> (axum::Router, AppState) {
let (mut state, _) = crate::tests::state_with_roster();
state.status = Some(std::sync::Arc::new(crate::status::StatusReader::new(
nats.client().await,
std::time::Duration::from_mins(1),
)));
(crate::build_app(state.clone()), state)
}
async fn send(
app: &axum::Router,
req: Request<Body>,
) -> (StatusCode, serde_json::Value, Option<String>) {
let res = app.clone().oneshot(req).await.expect("infallible");
let status = res.status();
let kind = res
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.expect("a body");
(
status,
serde_json::from_slice(&bytes).unwrap_or_default(),
kind,
)
}
fn get() -> Request<Body> {
Request::get("/api/review-classifier/settings")
.body(Body::empty())
.expect("a request")
}
fn put(body: &str) -> Request<Body> {
Request::put("/api/review-classifier/settings")
.header("content-type", "application/json")
.header("remote-user", "alice")
.body(Body::from(body.to_owned()))
.expect("a request")
}
/// A stored field this build does not know is ignored, and a document
/// that still does not decode reads as the defaults; either way the GET
/// and a run's read keep working, and a PUT, even of the defaults,
/// replaces it with one that decodes.
#[tokio::test]
async fn an_undecodable_document_reads_as_the_defaults_until_a_put() {
let nats = TestNats::start();
let (app, _) = app(&nats).await;
let client = nats.client().await;
let store = open(&client).await.expect("the bucket");
let defaults = serde_json::to_value(Settings::default()).unwrap();
let newer = serde_json::json!({
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3, "later": true,
});
store
.put(KEY, newer.to_string().into())
.await
.expect("written");
let (status, read, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(
read,
serde_json::json!({
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3,
"runtime": "api-key", "acp": null,
})
);
store
.put(
KEY,
br#"{"model":"sonnet","effort":"later"}"#.to_vec().into(),
)
.await
.expect("written");
let (status, read, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(read, defaults);
assert_eq!(
client.settings().await.expect("readable"),
Settings::default()
);
let (status, stored, _) = send(&app, put(&defaults.to_string())).await;
assert_eq!(status, StatusCode::OK, "{stored}");
let entry = store.get(KEY).await.expect("readable").expect("a document");
assert_eq!(
serde_json::from_slice::<Settings>(&entry).expect("decodes strictly"),
Settings::default()
);
}
#[tokio::test]
async fn a_put_is_what_the_get_and_the_next_run_read() {
let nats = TestNats::start();
let (app, state) = app(&nats).await;
let (status, before, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::OK, "{before}");
assert_eq!(before, serde_json::to_value(Settings::default()).unwrap());
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}");
assert_eq!(stored, wanted);
let (_, after, _) = send(&app, get()).await;
assert_eq!(after, wanted);
assert_eq!(state.reviews.limit(), 5);
let client = nats.client().await;
let read = client.settings().await.expect("readable");
assert_eq!(
(read.model.as_str(), read.effort),
("sonnet", Some(Effort::High))
);
// The same value again is no conflict.
let (status, _, _) = send(&app, put(&wanted.to_string())).await;
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":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}");
assert!(problem["detail"].is_string(), "{bad}: {problem}");
assert_eq!(kind.as_deref(), Some("application/problem+json"), "{bad}");
}
let (_, after, _) = send(&app, get()).await;
assert_eq!(after, serde_json::to_value(Settings::default()).unwrap());
assert_eq!(state.reviews.limit(), 2);
}
#[tokio::test]
async fn a_put_without_an_operator_is_401() {
let (state, _) = crate::tests::state_with_roster();
let req = Request::put("/api/review-classifier/settings")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&Settings::default()).unwrap(),
))
.expect("a request");
let (status, _, _) = send(&crate::build_app(state), req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn without_a_queue_both_verbs_are_503() {
let (state, _) = crate::tests::state_with_roster();
let app = crate::build_app(state);
let (status, _, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
let body = serde_json::to_string(&Settings::default()).unwrap();
let (status, _, _) = send(&app, put(&body)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
}