Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/review_classifier/settings.rs

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_wire::agent_inference::Effort;
use swarm_wire::review_classifier::{Acp, Runtime};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{queue, read_with, refused, write};
/// 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_wire::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_wire::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::routes::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::startup::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::routes::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::startup::build_app(state), req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn without_a_queue_both_verbs_are_503() {
let (state, _) = crate::routes::tests::state_with_roster();
let app = crate::startup::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);
}
}