use std::convert::Infallible;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::State;
use axum::response::sse::{Event, Sse};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::attribution::Attribution;
use crate::decode_task::{self, DecodeHandles};
use crate::generate::GenerationParams;
use crate::sampling_knobs::SamplingKnobs;
use crate::{sse, stats, unsupported_feature, ApiError, AppState};
use ferrox_models::tokenizer::SpecialTokens;
mod wire;
use wire::{final_body, frame, partial_body};
const N_PREDICT_UNBOUNDED: i64 = -1;
struct Unsupported {
field: &'static str,
inert: Inert,
missing: &'static str,
}
enum Inert {
Num(f64),
False,
EmptyList,
}
impl Unsupported {
fn is_inert(&self, value: &Value) -> bool {
match &self.inert {
Inert::Num(off) => value
.as_f64()
.is_some_and(|v| (v - off).abs() < f64::EPSILON),
Inert::False => value.as_bool() == Some(false),
Inert::EmptyList => match value {
Value::Null => true,
Value::Array(items) => items.is_empty(),
Value::Object(fields) => fields.is_empty(),
_ => false,
},
}
}
}
const fn off_at(field: &'static str, off: f64, missing: &'static str) -> Unsupported {
Unsupported {
field,
inert: Inert::Num(off),
missing,
}
}
const fn off_when_false(field: &'static str, missing: &'static str) -> Unsupported {
Unsupported {
field,
inert: Inert::False,
missing,
}
}
const fn off_when_empty(field: &'static str, missing: &'static str) -> Unsupported {
Unsupported {
field,
inert: Inert::EmptyList,
missing,
}
}
const UNSUPPORTED: &[Unsupported] = &[
off_at(
"dynatemp_range",
0.0,
"dynamic temperature sampling is not implemented",
),
off_at("mirostat", 0.0, "mirostat sampling is not implemented"),
off_at(
"n_probs",
0.0,
"per-token logprobs are not implemented; the sampler does not publish the \
candidate distribution",
),
off_when_false(
"post_sampling_probs",
"per-token probabilities are not implemented; the sampler does not publish the \
candidate distribution",
),
off_at(
"min_keep",
0.0,
"the min-keep floor is not implemented; this engine's truncation filters have no \
minimum-candidate guarantee",
),
off_when_false(
"return_tokens",
"raw generated token ids are not returned; the decode loop hands this layer text, \
not ids",
),
off_at(
"n_indent",
0.0,
"indentation-aware stopping is not implemented",
),
off_at(
"n_keep",
0.0,
"context-shift retention is not implemented: ferrox refuses a request that does not \
fit its context rather than discarding tokens from the middle of it, so there is \
nothing for n_keep to protect",
),
off_at(
"n_cmpl",
1.0,
"more than one completion per prompt is not implemented; send the request again",
),
off_at(
"n_cache_reuse",
0.0,
"cache reuse via KV shifting is not implemented; ferrox's radix prefix cache reuses \
a shared PREFIX only",
),
off_at(
"t_max_predict_ms",
0.0,
"a wall-clock limit on generation is not implemented; bound the work with n_predict",
),
off_at(
"id_slot",
-1.0,
"this server has no slots to pin a request to; concurrency is per request, not per \
slot",
),
off_when_empty("lora", "LoRA adapters are not implemented"),
off_when_empty(
"response_fields",
"response field projection is not implemented; the whole object is returned",
),
off_when_false(
"return_progress",
"prompt-processing progress frames are not implemented",
),
off_when_false(
"timings_per_token",
"per-token timings are not implemented; `timings` is reported once, on the final \
frame",
),
off_when_empty(
"sse_ping_interval",
"the keepalive interval is fixed at 15s for every stream this server serves and is \
not settable per request",
),
];
#[derive(Debug, Deserialize)]
pub(crate) struct CompletionRequest {
#[serde(default)]
prompt: Value,
#[serde(default)]
n_predict: Option<i64>,
#[serde(default)]
stream: Option<bool>,
#[serde(default)]
stop: Option<Vec<String>>,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
top_p: Option<f32>,
#[serde(default)]
min_p: Option<f32>,
#[serde(default)]
top_k: Option<usize>,
#[serde(flatten)]
extra_samplers: crate::sampling_knobs::ExtraSamplerFields,
#[serde(default)]
repeat_penalty: Option<f32>,
#[serde(default)]
repeat_last_n: Option<i64>,
#[serde(default)]
presence_penalty: Option<f32>,
#[serde(default)]
frequency_penalty: Option<f32>,
#[serde(default)]
seed: Option<i64>,
#[serde(default)]
ignore_eos: Option<bool>,
#[serde(default)]
grammar: Option<String>,
#[serde(default)]
json_schema: Option<Value>,
#[serde(default)]
cache_prompt: Option<bool>,
#[serde(default)]
logit_bias: Option<Value>,
#[serde(default)]
samplers: Option<Value>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Debug)]
enum Budget {
Fixed(usize),
UntilContextFull,
}
impl CompletionRequest {
fn prompt_text(&self) -> Result<&str, ApiError> {
match &self.prompt {
Value::String(s) => Ok(s),
Value::Array(_) => Err(unsupported_feature(
"`prompt` must be a string on /completion. Token-id prompts, mixed \
token/string arrays and multiple prompts in one request are llama.cpp \
shapes this server does not implement; the response would have to be an \
array, which no part of this engine produces",
)),
Value::Object(fields) if fields.contains_key("multimodal_data") => {
Err(unsupported_feature(
"`prompt.multimodal_data` is not implemented: this server has no \
multimodal projector, so image or audio input cannot reach the model",
))
}
Value::Object(fields) => match fields.get("prompt_string") {
Some(Value::String(s)) => Ok(s),
_ => Err(crate::invalid_request(
"a `prompt` object must carry a string `prompt_string`",
"prompt",
)),
},
Value::Null => Err(crate::invalid_request(
"missing `prompt`: /completion needs the text to continue",
"prompt",
)),
_ => Err(crate::invalid_request(
"`prompt` must be a string",
"prompt",
)),
}
}
pub(crate) fn sampling_knobs(&self) -> Result<SamplingKnobs, ApiError> {
let penalty_last_n = match self.repeat_last_n {
None => None,
Some(n) if n >= 0 => Some(n as usize),
Some(_) => {
return Err(unsupported_feature(
"`repeat_last_n: -1` (llama.cpp's \"the whole context\") is not \
implemented: this server's penalty window is a fixed count, and it has \
no context length to expand -1 into on a deployment with no derived \
ceiling. Send a concrete window, or 0 to disable the penalties",
))
}
};
let mut knobs = SamplingKnobs {
temperature: self.temperature,
top_p: self.top_p,
min_p: self.min_p,
top_k: self.top_k,
repetition_penalty: self.repeat_penalty,
presence_penalty: self.presence_penalty,
frequency_penalty: self.frequency_penalty,
penalty_last_n,
sampler_order: crate::unsupported_sampling::parse_sampler_order(
self.samplers.as_ref(),
ferrox_api::routes::COMPLETION,
)?,
..SamplingKnobs::default()
};
self.extra_samplers.apply(&mut knobs);
Ok(knobs)
}
fn budget(&self) -> Result<Budget, ApiError> {
match self.n_predict.unwrap_or(N_PREDICT_UNBOUNDED) {
N_PREDICT_UNBOUNDED => Ok(Budget::UntilContextFull),
n if n >= 0 => Ok(Budget::Fixed(n as usize)),
other => Err(crate::invalid_request(
&format!(
"`n_predict` must be -1 (until the context is full) or a non-negative \
count; got {other}"
),
"n_predict",
)),
}
}
fn seed(&self) -> u64 {
match self.seed {
None | Some(-1) => SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64 ^ d.as_secs())
.unwrap_or(0),
Some(s) => s as u64,
}
}
fn wants_stream(&self) -> bool {
self.stream.unwrap_or(false)
}
pub(crate) fn validate(&self, has_prefix_cache: bool) -> Result<(), ApiError> {
crate::unsupported_sampling::refuse_logit_bias(
self.logit_bias.as_ref(),
ferrox_api::routes::COMPLETION,
)?;
crate::unsupported_sampling::parse_sampler_order(
self.samplers.as_ref(),
ferrox_api::routes::COMPLETION,
)?;
for option in UNSUPPORTED {
let sent = self.extra.get(option.field);
let asked_for_something = match sent {
None => false,
Some(value) => !option.is_inert(value),
};
if asked_for_something {
return Err(unsupported_feature(&format!(
"`{}` is not implemented on /completion: {}",
option.field, option.missing
)));
}
}
if self.cache_prompt == Some(false) && has_prefix_cache {
return Err(unsupported_feature(
"`cache_prompt: false` cannot be honoured while a prefix cache is configured \
(FERROX_PREFIX_CACHE_ENTRIES): this server's radix cache reuses a shared \
prompt prefix for every request and has no per-request opt-out. Unset that \
variable to serve requests that require a cold prompt",
));
}
self.constraint()?;
Ok(())
}
fn constraint(&self) -> Result<Option<Arc<ferrox_models::grammar::Grammar>>, ApiError> {
match (&self.grammar, &self.json_schema) {
(Some(g), Some(_)) if !g.trim().is_empty() => Err(crate::invalid_request(
"a \"grammar\" and a \"json_schema\" are two different constraints on the same \
generation; send one",
"json_schema",
)),
(_, Some(schema)) => {
crate::grammar_request::from_schema(schema, "json_schema").map(Some)
}
(grammar, None) => crate::grammar_request::for_request(grammar.as_deref(), None),
}
}
}
pub(crate) async fn completion(
State(state): State<Arc<AppState>>,
matched: Option<axum::extract::MatchedPath>,
headers: axum::http::HeaderMap,
Json(req): Json<CompletionRequest>,
) -> Result<Response, ApiError> {
crate::cache_admin::check_admission(&state)?;
let request_id = ferrox_api::next_request_id();
let started = std::time::Instant::now();
let attribution = Attribution::from_headers(&headers);
let route = matched
.as_ref()
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| ferrox_api::routes::COMPLETION.to_string());
let active = state.require_active()?;
let handles = DecodeHandles::take(&state, &active)?;
req.validate(handles.has_prefix_cache())?;
let prompt = req.prompt_text()?.to_string();
let mut params = GenerationParams {
reasoning: None,
max_tokens: 0,
sampling: req
.sampling_knobs()?
.resolve(active.sampler_model())
.map_err(|e| {
crate::unsupported_feature(&format!("`dry_multiplier` on /completion: {e}"))
})?,
seed: req.seed(),
stop: req.stop.clone().unwrap_or_default(),
json_object: false,
grammar: req.constraint()?,
stop_token_ids: Vec::new(),
cancel: None,
ignore_eos: req.ignore_eos.unwrap_or(false),
reasoning_budget: crate::reasoning_budget::ReasoningBudget::Unrestricted,
};
params.max_tokens = match req.budget()? {
Budget::Fixed(n) => n,
Budget::UntilContextFull => {
let limit = active
.ceiling
.as_ref()
.and_then(|c| c.limit())
.ok_or_else(|| {
unsupported_feature(
"`n_predict: -1` means \"generate until the context is full\", and this \
server has no context ceiling for the loaded model to be full of -- it \
could not be priced at load (see the startup log). Send an explicit \
n_predict, or set FERROX_CB_MAX_CONTEXT so -1 has a bound. Note that \
an ABSENT n_predict is -1 too: that is llama.cpp's default, and this \
server does not quietly substitute a smaller one",
)
})?;
let prompt_tokens = handles.model().encode(&prompt, SpecialTokens::Parse).len();
limit.saturating_sub(prompt_tokens)
}
};
let model_name = handles.model().name().to_string();
if req.wants_stream() {
return stream(
state,
handles,
params,
prompt,
model_name,
route,
request_id,
started,
attribution,
)
.await;
}
let (chunks, finish, usage) =
decode_task::buffered(handles, prompt.clone(), params.clone()).await?;
let content = chunks.concat();
state.record_request(stats::Record {
request_id: &request_id,
route: &route,
model: Some(model_name.clone()),
status: 200,
stream: false,
duration_ms: started.elapsed().as_millis() as u64,
usage: Some(&usage),
attribution: &attribution,
});
Ok(Json(final_body(
&content,
&finish,
&usage,
¶ms,
&model_name,
&prompt,
))
.into_response())
}
#[allow(clippy::too_many_arguments)] async fn stream(
state: Arc<AppState>,
handles: DecodeHandles,
mut params: GenerationParams,
prompt: String,
model_name: String,
route: String,
request_id: String,
started: std::time::Instant,
attribution: Attribution,
) -> Result<Response, ApiError> {
let (cancel_token, cancel_guard) = state.cancels.register(&request_id);
params.cancel = Some(cancel_token.clone());
let stats_state = Arc::clone(&state);
let stats_request_id = request_id.clone();
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
let keepalive = sse::keepalive_event(&partial_body(""));
tokio::task::spawn_blocking(move || {
let _cancel_guard = cancel_guard;
let orphan = sse::orphan_timeout_from_env();
let send = |event: Event| {
if sse::send_or_orphan(&tx, Ok(event), orphan).is_err() {
cancel_token.cancel();
}
};
let result = handles.run_emit(&prompt, ¶ms, |chunk| {
if !chunk.is_empty() {
send(frame(&partial_body(chunk)));
}
});
match result {
Ok((finish, usage, content)) => {
stats_state.record_request(stats::Record {
request_id: &stats_request_id,
route: &route,
model: Some(model_name.clone()),
status: 200,
stream: true,
duration_ms: started.elapsed().as_millis() as u64,
usage: Some(&usage),
attribution: &attribution,
});
send(frame(&final_body(
&content,
&finish,
&usage,
¶ms,
&model_name,
&prompt,
)));
}
Err(e) => {
tracing::warn!("decode error on streamed completion {stats_request_id}: {e}");
let (status, body) = crate::decode_error_response(e);
stats_state.record_request(stats::Record {
request_id: &stats_request_id,
route: &route,
model: Some(model_name.clone()),
status: status.as_u16(),
stream: true,
duration_ms: started.elapsed().as_millis() as u64,
usage: None,
attribution: &attribution,
});
send(frame(&json!({ "error": body.0["error"] })));
}
}
});
let stream = sse::with_keepalive(rx, keepalive, sse::KEEPALIVE_INTERVAL);
Ok((
[(
axum::http::HeaderName::from_static("x-accel-buffering"),
axum::http::HeaderValue::from_static("no"),
)],
Sse::new(stream),
)
.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use ferrox_models::sampling::SamplingParams;
fn request(value: Value) -> CompletionRequest {
serde_json::from_value(value).expect("request")
}
#[test]
fn llama_cpps_sampler_spellings_reach_ferroxs_knobs() {
let knobs = request(json!({
"prompt": "hi",
"temperature": 0.7,
"top_p": 0.9,
"min_p": 0.05,
"top_k": 40,
"repeat_penalty": 1.15,
"repeat_last_n": 128,
"presence_penalty": 0.25,
"frequency_penalty": 0.5,
}))
.sampling_knobs()
.expect("all supported")
.resolve(crate::sampling_knobs::SamplerModel::absent())
.expect("no dry");
assert_eq!(knobs.temperature, 0.7);
assert_eq!(knobs.top_p, 0.9);
assert_eq!(knobs.min_p, 0.05);
assert_eq!(knobs.top_k, 40);
assert_eq!(knobs.repetition_penalty, 1.15);
assert_eq!(knobs.penalty_last_n, 128);
assert_eq!(knobs.presence_penalty, 0.25);
assert_eq!(knobs.frequency_penalty, 0.5);
}
#[test]
fn an_empty_request_resolves_to_the_same_defaults_the_openai_routes_use() {
let mine = request(json!({"prompt": "hi"}))
.sampling_knobs()
.expect("nothing to refuse")
.resolve(crate::sampling_knobs::SamplerModel::absent())
.expect("no dry");
let shared = SamplingKnobs::default()
.resolve(crate::sampling_knobs::SamplerModel::absent())
.expect("no dry");
assert_eq!(mine.temperature, shared.temperature);
assert_eq!(mine.top_p, shared.top_p);
assert_eq!(mine.min_p, shared.min_p);
assert_eq!(mine.top_k, shared.top_k);
assert_eq!(mine.repetition_penalty, shared.repetition_penalty);
assert_eq!(mine.penalty_last_n, shared.penalty_last_n);
assert_eq!(
shared.penalty_last_n,
SamplingParams::default().penalty_last_n
);
}
#[test]
fn n_predict_keeps_llama_cpps_meaning_including_its_default() {
assert!(matches!(
request(json!({"prompt": "hi"})).budget().unwrap(),
Budget::UntilContextFull
));
assert!(matches!(
request(json!({"prompt": "hi", "n_predict": -1}))
.budget()
.unwrap(),
Budget::UntilContextFull
));
assert!(matches!(
request(json!({"prompt": "hi", "n_predict": 0}))
.budget()
.unwrap(),
Budget::Fixed(0)
));
assert!(matches!(
request(json!({"prompt": "hi", "n_predict": 128}))
.budget()
.unwrap(),
Budget::Fixed(128)
));
let (status, _) = request(json!({"prompt": "hi", "n_predict": -7}))
.budget()
.expect_err("-7 means nothing");
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[test]
fn a_stock_client_sending_every_option_at_its_default_is_served() {
let stock = request(json!({
"prompt": "hi",
"dynatemp_range": 0.0,
"dynatemp_exponent": 1.0,
"typical_p": 1.0,
"xtc_probability": 0.0,
"xtc_threshold": 0.1,
"mirostat": 0,
"mirostat_tau": 5.0,
"mirostat_eta": 0.1,
"dry_multiplier": 0.0,
"dry_base": 1.75,
"dry_allowed_length": 2,
"dry_penalty_last_n": -1,
"dry_sequence_breakers": ["\n", ":", "\"", "*"],
"samplers": [],
"n_probs": 0,
"post_sampling_probs": false,
"min_keep": 0,
"return_tokens": false,
"n_indent": 0,
"n_keep": 0,
"n_cmpl": 1,
"n_cache_reuse": 0,
"t_max_predict_ms": 0,
"id_slot": -1,
"lora": [],
"response_fields": [],
"return_progress": false,
"timings_per_token": false,
"cache_prompt": true,
}));
stock
.validate(false)
.expect("every one of these asks for nothing");
}
#[test]
fn a_bare_json_schema_becomes_the_requests_grammar() {
let req = request(json!({
"prompt": "hi",
"json_schema": {
"type": "object",
"properties": {"ok": {"type": "boolean"}},
"required": ["ok"],
"additionalProperties": false,
},
}));
req.validate(false).expect("this schema converts");
let grammar = req
.constraint()
.expect("and compiles")
.expect("into a grammar");
let mut good = (*grammar).clone();
good.accept_token(0, br#"{"ok": true}"#)
.expect("a schema-valid document");
assert!(good.allows_eog(), "and it completes the parse");
let mut bad = (*grammar).clone();
assert!(
bad.accept_token(0, br#"{"ok": "yes""#).is_err(),
"a property's own type must be enforced"
);
}
#[test]
fn a_json_schema_with_no_grammar_is_a_400_naming_the_keyword() {
let (status, body) = request(json!({
"prompt": "hi",
"json_schema": {"type": "object", "patternProperties": {"^a": {}}},
}))
.validate(false)
.expect_err("patternProperties has no grammar");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.0["error"]["message"]
.as_str()
.unwrap()
.contains("patternProperties"),
"the refusal must name the keyword: {body:?}"
);
}
#[test]
fn a_grammar_and_a_json_schema_together_are_refused() {
let (status, body) = request(json!({
"prompt": "hi",
"grammar": "root ::= \"a\"",
"json_schema": {"type": "boolean"},
}))
.validate(false)
.expect_err("two constraints, one generation");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.0["error"]["param"], "json_schema");
assert!(
request(json!({"prompt": "hi", "grammar": "root ::= \"a\""}))
.constraint()
.unwrap()
.is_some()
);
assert!(
request(json!({"prompt": "hi", "json_schema": {"type": "boolean"}}))
.constraint()
.unwrap()
.is_some()
);
}
#[test]
fn every_unsupported_option_is_refused_by_its_own_name() {
let asking: &[(&str, Value)] = &[
("dynatemp_range", json!(0.5)),
("mirostat", json!(2)),
("n_probs", json!(5)),
("post_sampling_probs", json!(true)),
("min_keep", json!(1)),
("return_tokens", json!(true)),
("n_indent", json!(4)),
("n_keep", json!(32)),
("n_cmpl", json!(4)),
("n_cache_reuse", json!(256)),
("t_max_predict_ms", json!(5000)),
("id_slot", json!(3)),
("lora", json!([{"id": 0, "scale": 0.5}])),
("response_fields", json!(["content"])),
("return_progress", json!(true)),
("timings_per_token", json!(true)),
("sse_ping_interval", json!(5)),
];
for (field, value) in asking {
let mut body = json!({"prompt": "hi"});
body[field] = value.clone();
let (status, message) = request(body)
.validate(false)
.expect_err("{field} asks for something this server lacks");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{field}");
assert!(
message.0["error"]["message"]
.as_str()
.unwrap()
.contains(field),
"the refusal for {field} must name it: {message:?}"
);
}
assert_eq!(asking.len(), UNSUPPORTED.len());
for option in UNSUPPORTED {
assert!(
asking.iter().any(|(field, _)| *field == option.field),
"{} is in the table with no test",
option.field
);
}
}
#[test]
fn a_field_neither_server_defines_is_ignored() {
request(json!({"prompt": "hi", "some_client_extension": 7}))
.validate(false)
.expect("nothing to refuse");
}
#[test]
fn cache_prompt_false_is_refused_only_where_it_cannot_be_kept() {
request(json!({"prompt": "hi", "cache_prompt": false}))
.validate(false)
.expect("no prefix cache: nothing is reused, so the promise holds");
let (status, _) = request(json!({"prompt": "hi", "cache_prompt": false}))
.validate(true)
.expect_err("a configured prefix cache reuses regardless");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
request(json!({"prompt": "hi", "cache_prompt": true}))
.validate(true)
.expect("true is upstream's default");
}
#[test]
fn the_prompt_shapes_this_server_cannot_serve_are_named() {
assert_eq!(
request(json!({"prompt": "hello"})).prompt_text().unwrap(),
"hello"
);
assert_eq!(
request(json!({"prompt": {"prompt_string": "hello"}}))
.prompt_text()
.unwrap(),
"hello"
);
for body in [
json!({"prompt": [12, 34, 56]}),
json!({"prompt": ["one", "two"]}),
json!({"prompt": {"prompt_string": "hi", "multimodal_data": ["AAA"]}}),
] {
let (status, _) = request(body.clone())
.prompt_text()
.expect_err("not implemented: {body}");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{body}");
}
let (status, _) = request(json!({}))
.prompt_text()
.expect_err("no prompt at all");
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[test]
fn the_context_wide_penalty_window_is_refused_rather_than_shrunk() {
let (status, message) = request(json!({"prompt": "hi", "repeat_last_n": -1}))
.sampling_knobs()
.expect_err("no context length to expand -1 into");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
assert!(message.0["error"]["message"]
.as_str()
.unwrap()
.contains("repeat_last_n"));
assert_eq!(
request(json!({"prompt": "hi", "repeat_last_n": 0}))
.sampling_knobs()
.unwrap()
.resolve(crate::sampling_knobs::SamplerModel::absent())
.expect("no dry")
.penalty_last_n,
0
);
}
#[test]
fn an_explicit_seed_is_kept_and_minus_one_is_not_a_constant() {
assert_eq!(request(json!({"prompt": "hi", "seed": 42})).seed(), 42);
let a = request(json!({"prompt": "hi", "seed": -1})).seed();
let b = request(json!({"prompt": "hi"})).seed();
assert!(a != b || a != 0, "a random seed must vary: {a} {b}");
}
}