801 lines
29 KiB
Rust
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);
|
|
}
|
|
}
|