mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## Summary `buzz-agent` builds its LLM `reqwest::Client` without a User-Agent, and reqwest sends none by default, so OpenAI-compatible providers that identify clients by product token see buzz-agent as an anonymous caller. This sets `User-Agent: buzz-agent/<crate version>` on the shared LLM client in `crates/buzz-agent/src/llm.rs`. The version is `CARGO_PKG_VERSION`, the same value `agentInfo` reports over ACP. No request body or endpoint changes. Open point: the crate version is the workspace version (`0.1.0`), so every build reports the same version. A per-build version would need the desktop to pass one through at spawn time; this PR does not do that. ### Related issue None found. ### Testing - The test stub now captures the `User-Agent` request header; new `llm_requests_carry_buzz_agent_user_agent` asserts `buzz-agent/<version>` on a Chat Completions POST. - Verified red without the `.user_agent(...)` line (`left: None`), green with it. - `cargo test -p buzz-agent --lib llm::tests`: 194 passed. `cargo fmt` clean; the one clippy warning is pre-existing (`hints.rs:33`). - Not exercised in a built desktop app. Signed-off-by: Tom Bedor <tbedor@squareup.com> Co-authored-by: Tom Bedor <tbedor@squareup.com>
8050 lines
319 KiB
Rust
8050 lines
319 KiB
Rust
use std::collections::BTreeSet;
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use reqwest::Client;
|
||
use serde_json::{json, Map, Value};
|
||
|
||
use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource};
|
||
use crate::config::{
|
||
is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_databricks_v2,
|
||
normalize_effort_for_provider, Config, OpenAiApi, Provider, ThinkingEffort,
|
||
MAX_TOOL_CALLS_PER_TURN,
|
||
};
|
||
use crate::types::{
|
||
AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef, ToolResultContent,
|
||
};
|
||
|
||
/// Databricks OAuth client_id — the public Databricks-published CLI client.
|
||
/// PKCE-only, no secret. Same identifier goose uses, so a user's browser
|
||
/// consent for `databricks-cli` covers buzz-agent too.
|
||
const DATABRICKS_CLIENT_ID: &str = "databricks-cli";
|
||
const DATABRICKS_OAUTH_SCOPES: &[&str] = &["all-apis", "offline_access"];
|
||
|
||
const MAX_LLM_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
|
||
const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024;
|
||
const STALL_NOTICE_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(300);
|
||
|
||
/// Parser for an OpenAI-family JSON response. Per-endpoint pair lives
|
||
/// alongside its `_body` serializer.
|
||
type OpenAiParse = fn(Value) -> Result<LlmResponse, AgentError>;
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum DatabricksV2Route {
|
||
OpenAiResponses,
|
||
AnthropicMessages,
|
||
MlflowChatCompletions,
|
||
}
|
||
|
||
pub struct Llm {
|
||
http: Client,
|
||
/// One-shot sticky flag: set when a Chat Completions request comes
|
||
/// back with a "use /v1/responses" provider error while `cfg.openai_api
|
||
/// == Auto`. Subsequent OpenAI calls then go straight to Responses
|
||
/// for the lifetime of the process.
|
||
auto_upgraded: AtomicBool,
|
||
/// Bearer-token source for OpenAI-family requests. Static for OpenAI
|
||
/// (the `OPENAI_COMPAT_API_KEY` env var) and Databricks-with-token
|
||
/// (the `DATABRICKS_TOKEN` env var); a refreshable PKCE engine for
|
||
/// Databricks otherwise. Anthropic doesn't use this — it always
|
||
/// reads `cfg.api_key` directly because the API expects `x-api-key`.
|
||
auth: Arc<dyn TokenSource>,
|
||
}
|
||
|
||
/// Connect-phase timeout applied to every outgoing LLM HTTP request.
|
||
///
|
||
/// A 10-second budget is generous for a TLS + HTTP/2 handshake to a
|
||
/// well-provisioned gateway. Repeated connect timeouts indicate a
|
||
/// network/reachability problem, not a slow generation.
|
||
const LLM_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||
|
||
/// `User-Agent` sent on every LLM request. reqwest sends no User-Agent by
|
||
/// default, so OpenAI-compatible providers that identify clients by product
|
||
/// token saw buzz-agent as an anonymous caller. The version is the crate
|
||
/// version, matching the `agentInfo` reported over ACP.
|
||
const LLM_USER_AGENT: &str = concat!("buzz-agent/", env!("CARGO_PKG_VERSION"));
|
||
|
||
impl Llm {
|
||
pub fn new(cfg: &Config) -> Result<Self, AgentError> {
|
||
let http = Client::builder()
|
||
.user_agent(LLM_USER_AGENT)
|
||
.connect_timeout(LLM_CONNECT_TIMEOUT)
|
||
// No client-level read_timeout: we apply a per-request total
|
||
// timeout via RequestBuilder::timeout() so that escalated budgets
|
||
// on slow models are not silently floored by a fixed client-level
|
||
// value. The connect_timeout above still bounds the handshake phase.
|
||
.build()
|
||
.map_err(|e| AgentError::Llm(format!("http: {e}")))?;
|
||
let auth = build_token_source(cfg)?;
|
||
Ok(Self {
|
||
http,
|
||
auto_upgraded: AtomicBool::new(false),
|
||
auth,
|
||
})
|
||
}
|
||
|
||
pub async fn complete(
|
||
&self,
|
||
cfg: &Config,
|
||
system_prompt: &str,
|
||
history: &[HistoryItem],
|
||
tools: &[ToolDef],
|
||
effective_model: &str,
|
||
) -> Result<LlmResponse, AgentError> {
|
||
let effort = cfg.thinking_effort;
|
||
let call_start = std::time::Instant::now();
|
||
let result = match cfg.provider {
|
||
Provider::Anthropic => self
|
||
.post_anthropic(
|
||
cfg,
|
||
&anthropic_body(
|
||
cfg,
|
||
system_prompt,
|
||
history,
|
||
tools,
|
||
effective_model,
|
||
effort,
|
||
"anthropic",
|
||
),
|
||
)
|
||
.await
|
||
.and_then(parse_anthropic),
|
||
Provider::OpenRouter => {
|
||
let mut body =
|
||
openai_body(cfg, system_prompt, history, tools, effective_model, None);
|
||
apply_openrouter_mutations(
|
||
&mut body,
|
||
cfg.thinking_effort,
|
||
effective_model,
|
||
cfg.prompt_caching,
|
||
);
|
||
self.post_openrouter(cfg, &body)
|
||
.await
|
||
.and_then(parse_openai_with_reasoning_details)
|
||
}
|
||
Provider::OpenAi | Provider::Databricks => {
|
||
let provider_str = match cfg.provider {
|
||
Provider::OpenAi => "openai",
|
||
Provider::Databricks => "databricks",
|
||
_ => unreachable!(),
|
||
};
|
||
self.openai_request(cfg, effective_model, |use_responses, request_model| {
|
||
// Normalize effort via the manifest: resolve the actual provider/model
|
||
// record and apply resolve_openai_effort over its supported_efforts.
|
||
// Adopted exact-record corrections (e.g. databricks-gpt-5-4-mini →
|
||
// [low,medium,high]) are enforced here; the openai fallback's effort set
|
||
// carries the former "unknown model: max→xhigh, others pass" behavior.
|
||
let e = effort
|
||
.map(|ef| normalize_effort_for_provider(provider_str, request_model, ef));
|
||
if use_responses {
|
||
(
|
||
responses_body(cfg, system_prompt, history, tools, request_model, e),
|
||
parse_responses as OpenAiParse,
|
||
)
|
||
} else {
|
||
(
|
||
openai_body(cfg, system_prompt, history, tools, request_model, e),
|
||
parse_openai as OpenAiParse,
|
||
)
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
Provider::DatabricksV2 => {
|
||
self.databricks_v2_request(cfg, effective_model, |route| match route {
|
||
DatabricksV2Route::OpenAiResponses => {
|
||
// OpenAI Responses path: normalize effort via manifest normalization_policy.
|
||
let e = effort
|
||
.map(|ef| normalize_effort_for_databricks_v2(ef, effective_model));
|
||
(
|
||
responses_body(cfg, system_prompt, history, tools, effective_model, e),
|
||
parse_responses as OpenAiParse,
|
||
)
|
||
}
|
||
DatabricksV2Route::AnthropicMessages => {
|
||
// Anthropic Messages path: normalize effort (none|minimal → omit).
|
||
let e = effort.and_then(normalize_effort_for_anthropic_route);
|
||
(
|
||
anthropic_body(
|
||
cfg,
|
||
system_prompt,
|
||
history,
|
||
tools,
|
||
effective_model,
|
||
e,
|
||
"databricks_v2",
|
||
),
|
||
parse_anthropic as OpenAiParse,
|
||
)
|
||
}
|
||
DatabricksV2Route::MlflowChatCompletions => {
|
||
let e = effort
|
||
.map(|ef| normalize_effort_for_databricks_v2(ef, effective_model));
|
||
let body =
|
||
openai_body(cfg, system_prompt, history, tools, effective_model, e);
|
||
(body, parse_openai as OpenAiParse)
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
};
|
||
// Stamp the actually-requested model into the response so the usage
|
||
// accumulation path can derive pricingIdentity. For OpenAi/Databricks
|
||
// this is done per-response inside openai_request_for_model (where the
|
||
// resolved request_model is known after mesh/auto resolution). For the
|
||
// remaining providers the effective_model IS the request model.
|
||
let result = result.map(|mut r| {
|
||
if r.request_model.is_none() {
|
||
r.request_model = Some(effective_model.to_string());
|
||
}
|
||
r
|
||
});
|
||
// Stamp the effective model into Llm errors so log lines carry
|
||
// `llm: (model-name) 404 Not Found: …` instead of the bare status.
|
||
// The `llm: ` prefix comes from `Display for AgentError::Llm`; the
|
||
// map_err here prepends `(model-name) ` to the inner string only.
|
||
// This is the single place all provider paths converge, so the mapping
|
||
// is centralized and never needs to be repeated in each provider arm.
|
||
// Every arm above returns its `Result` into this mapper rather than
|
||
// using `?` — an early return would silently skip the stamp, which is
|
||
// exactly what the Anthropic and OpenRouter arms used to do.
|
||
let stamped = result.map_err(|e| match e {
|
||
AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")),
|
||
AgentError::LlmModelNotFound(s) => {
|
||
AgentError::LlmModelNotFound(format!("({effective_model}) {s}"))
|
||
}
|
||
// Stamped like the others: this is the error most likely to be read
|
||
// during an incident, so it must name the model whose window was
|
||
// exceeded. Without an explicit arm it would fall through `other`
|
||
// and be the only unstamped provider error.
|
||
AgentError::LlmContextExceeded(s) => {
|
||
AgentError::LlmContextExceeded(format!("({effective_model}) {s}"))
|
||
}
|
||
other => other,
|
||
});
|
||
// Emit one INFO event per successful LLM call. This gives operators
|
||
// visibility into healthy-but-slow generation (which previously logged
|
||
// nothing at INFO) and provides a wall-clock record even when no error
|
||
// fires. Token counts use `?`-formatting to preserve the None-vs-zero
|
||
// distinction: `None` means the provider omitted usage entirely.
|
||
if let Ok(ref response) = stamped {
|
||
let duration_ms = call_start.elapsed().as_millis();
|
||
tracing::info!(
|
||
model = effective_model,
|
||
provider = ?cfg.provider,
|
||
thinking_effort = ?cfg.thinking_effort,
|
||
duration_ms,
|
||
input_tokens = ?response.input_tokens,
|
||
cached_input_tokens = ?response.cached_input_tokens,
|
||
output_tokens = ?response.output_tokens,
|
||
stop = ?response.stop,
|
||
"llm: call completed"
|
||
);
|
||
}
|
||
stamped
|
||
}
|
||
|
||
pub async fn summarize(
|
||
&self,
|
||
cfg: &Config,
|
||
system_prompt: &str,
|
||
user_prompt: &str,
|
||
max_output_tokens: u32,
|
||
effective_model: &str,
|
||
) -> Result<String, AgentError> {
|
||
let call_start = std::time::Instant::now();
|
||
let result = (async {
|
||
match cfg.provider {
|
||
Provider::Anthropic => {
|
||
let body = json!({
|
||
"model": effective_model,
|
||
"max_tokens": max_output_tokens,
|
||
"system": system_prompt,
|
||
"messages": [{
|
||
"role": "user",
|
||
"content": [{ "type": "text", "text": user_prompt }],
|
||
}],
|
||
});
|
||
Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text)
|
||
}
|
||
Provider::OpenRouter => {
|
||
let body = openrouter_summary_body(
|
||
effective_model,
|
||
system_prompt,
|
||
user_prompt,
|
||
max_output_tokens,
|
||
);
|
||
let v = self.post_openrouter(cfg, &body).await?;
|
||
Ok(parse_openai(v)?.text)
|
||
}
|
||
Provider::OpenAi | Provider::Databricks => {
|
||
let r = self
|
||
.openai_request(cfg, effective_model, |use_responses, request_model| {
|
||
if use_responses {
|
||
(
|
||
json!({
|
||
"model": request_model,
|
||
"max_output_tokens": max_output_tokens,
|
||
"instructions": system_prompt,
|
||
"input": user_prompt,
|
||
}),
|
||
parse_responses as OpenAiParse,
|
||
)
|
||
} else {
|
||
(
|
||
json!({
|
||
"model": request_model,
|
||
"stream": false,
|
||
"max_completion_tokens": max_output_tokens,
|
||
"messages": [
|
||
{ "role": "system", "content": system_prompt },
|
||
{ "role": "user", "content": user_prompt },
|
||
],
|
||
}),
|
||
parse_openai as OpenAiParse,
|
||
)
|
||
}
|
||
})
|
||
.await?;
|
||
Ok(r.text)
|
||
}
|
||
Provider::DatabricksV2 => {
|
||
let r = self
|
||
.databricks_v2_request(cfg, effective_model, |route| match route {
|
||
DatabricksV2Route::OpenAiResponses => (
|
||
json!({
|
||
"model": effective_model,
|
||
"max_output_tokens": max_output_tokens,
|
||
"instructions": system_prompt,
|
||
"input": user_prompt,
|
||
}),
|
||
parse_responses as OpenAiParse,
|
||
),
|
||
DatabricksV2Route::AnthropicMessages => (
|
||
json!({
|
||
"model": effective_model,
|
||
"max_tokens": max_output_tokens,
|
||
"system": system_prompt,
|
||
"messages": [{
|
||
"role": "user",
|
||
"content": [{ "type": "text", "text": user_prompt }],
|
||
}],
|
||
}),
|
||
parse_anthropic as OpenAiParse,
|
||
),
|
||
DatabricksV2Route::MlflowChatCompletions => {
|
||
let body = json!({
|
||
"model": effective_model,
|
||
"stream": false,
|
||
"max_completion_tokens": max_output_tokens,
|
||
"messages": [
|
||
{ "role": "system", "content": system_prompt },
|
||
{ "role": "user", "content": user_prompt },
|
||
],
|
||
});
|
||
(body, parse_openai as OpenAiParse)
|
||
}
|
||
})
|
||
.await?;
|
||
Ok(r.text)
|
||
}
|
||
}
|
||
})
|
||
.await;
|
||
if result.is_ok() {
|
||
let duration_ms = call_start.elapsed().as_millis();
|
||
tracing::info!(
|
||
model = effective_model,
|
||
provider = ?cfg.provider,
|
||
duration_ms,
|
||
"llm: summarize completed"
|
||
);
|
||
}
|
||
result
|
||
}
|
||
|
||
async fn post_anthropic(&self, cfg: &Config, body: &Value) -> Result<Value, AgentError> {
|
||
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
|
||
post(&self.http, &url, body, cfg.llm_timeout, |r| {
|
||
r.header("x-api-key", &cfg.api_key)
|
||
.header("anthropic-version", &cfg.anthropic_api_version)
|
||
})
|
||
.await
|
||
.map_err(PostError::into_agent)
|
||
}
|
||
|
||
/// OpenAI dispatch. The configured model is sent as given: callers that
|
||
/// route through a mesh (Buzz shared compute) resolve their own model name
|
||
/// before spawning the agent, so nothing here needs to know about meshes.
|
||
async fn openai_request<F>(
|
||
&self,
|
||
cfg: &Config,
|
||
effective_model: &str,
|
||
mut build: F,
|
||
) -> Result<LlmResponse, AgentError>
|
||
where
|
||
F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send,
|
||
{
|
||
self.openai_request_for_model(cfg, effective_model, &mut build)
|
||
.await
|
||
.map_err(PostError::into_agent)
|
||
}
|
||
|
||
/// Dispatch one OpenAI request for an already-resolved model. Endpoint
|
||
/// selection remains unchanged: pinned > sticky-upgraded > host default,
|
||
/// with the existing one-shot Chat-to-Responses upgrade.
|
||
async fn openai_request_for_model<F>(
|
||
&self,
|
||
cfg: &Config,
|
||
request_model: &str,
|
||
build: &mut F,
|
||
) -> Result<LlmResponse, PostError>
|
||
where
|
||
F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send,
|
||
{
|
||
let use_responses = self.auto_upgraded.load(Ordering::Relaxed)
|
||
|| matches!(cfg.openai_api, OpenAiApi::Responses)
|
||
|| matches!(cfg.openai_api, OpenAiApi::Auto) && is_openai_host(&cfg.base_url);
|
||
|
||
if use_responses {
|
||
let (body, parse) = build(true, request_model);
|
||
return parse(
|
||
self.post_openai(cfg, "/responses", &body, request_model)
|
||
.await?,
|
||
)
|
||
.map(|mut r| {
|
||
r.request_model = Some(request_model.to_string());
|
||
r
|
||
})
|
||
.map_err(PostError::from);
|
||
}
|
||
let (body, parse) = build(false, request_model);
|
||
match self
|
||
.post_openai(cfg, "/chat/completions", &body, request_model)
|
||
.await
|
||
{
|
||
Ok(value) => parse(value)
|
||
.map(|mut r| {
|
||
r.request_model = Some(request_model.to_string());
|
||
r
|
||
})
|
||
.map_err(PostError::from),
|
||
Err(PostError::Agent(error))
|
||
if cfg.openai_api == OpenAiApi::Auto && self.try_upgrade(&error) =>
|
||
{
|
||
let (body, parse) = build(true, request_model);
|
||
parse(
|
||
self.post_openai(cfg, "/responses", &body, request_model)
|
||
.await?,
|
||
)
|
||
.map(|mut r| {
|
||
r.request_model = Some(request_model.to_string());
|
||
r
|
||
})
|
||
.map_err(PostError::from)
|
||
}
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
|
||
async fn databricks_v2_request<F>(
|
||
&self,
|
||
cfg: &Config,
|
||
effective_model: &str,
|
||
build: F,
|
||
) -> Result<LlmResponse, AgentError>
|
||
where
|
||
F: FnOnce(DatabricksV2Route) -> (Value, OpenAiParse) + Send,
|
||
{
|
||
let route = databricks_v2_route(effective_model);
|
||
let (body, parse) = build(route);
|
||
parse(
|
||
self.post_openai(cfg, databricks_v2_path(route), &body, effective_model)
|
||
.await
|
||
.map_err(PostError::into_agent)?,
|
||
)
|
||
}
|
||
|
||
/// POST to an OpenAI-family endpoint. For OpenAI-compat this is just
|
||
/// `{base_url}{path}` with the body untouched. For Databricks the URL
|
||
/// becomes `{base_url}/serving-endpoints/{model}/invocations` and the
|
||
/// `model` field is stripped from the body (Databricks rejects it —
|
||
/// the endpoint path already names the model).
|
||
async fn post_openai(
|
||
&self,
|
||
cfg: &Config,
|
||
path: &str,
|
||
body: &Value,
|
||
effective_model: &str,
|
||
) -> Result<Value, PostError> {
|
||
let (url, body_owned);
|
||
let body_ref: &Value = match cfg.provider {
|
||
Provider::Databricks => {
|
||
url = format!(
|
||
"{}/serving-endpoints/{}/invocations",
|
||
cfg.base_url.trim_end_matches('/'),
|
||
effective_model
|
||
);
|
||
body_owned = strip_model(body);
|
||
&body_owned
|
||
}
|
||
_ => {
|
||
url = format!("{}{}", cfg.base_url.trim_end_matches('/'), path);
|
||
body
|
||
}
|
||
};
|
||
|
||
// A 401 or 403 can mean the local expiry clock disagreed with the
|
||
// server (skew, revocation, a node that never saw the token). On the
|
||
// first such rejection, force a refresh keyed off the rejected bearer
|
||
// and retry once. The guard is local to this call so an earlier turn's
|
||
// rejection can never suppress a later turn's legitimate retry. Both
|
||
// statuses map to `LlmAuth` in `post`: a 403 is indistinguishable from
|
||
// an expired-token 403 here, so we refresh once and let it propagate.
|
||
let mut bearer = self.auth.bearer().await.map_err(PostError::from)?;
|
||
let mut refreshed = false;
|
||
loop {
|
||
match post(&self.http, &url, body_ref, cfg.llm_timeout, |r| {
|
||
r.bearer_auth(&bearer)
|
||
})
|
||
.await
|
||
{
|
||
Err(PostError::Agent(AgentError::LlmAuth(_))) if !refreshed => {
|
||
refreshed = true;
|
||
bearer = self
|
||
.auth
|
||
.refresh_now(&bearer)
|
||
.await
|
||
.map_err(PostError::from)?;
|
||
}
|
||
result => return result,
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn post_openrouter(&self, cfg: &Config, body: &Value) -> Result<Value, AgentError> {
|
||
let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
|
||
let mut bearer = self.auth.bearer().await?;
|
||
let mut refreshed = false;
|
||
loop {
|
||
match openrouter_post(&self.http, &url, body, &bearer, cfg.llm_timeout).await {
|
||
Err(AgentError::LlmAuth(_)) if !refreshed => {
|
||
refreshed = true;
|
||
let new_bearer = self.auth.refresh_now(&bearer).await?;
|
||
// A static key refreshes to itself — a byte-identical retry
|
||
// would be a guaranteed duplicate request against a key the
|
||
// server just rejected. Fail terminal immediately; the retry
|
||
// is only meaningful when the source can actually mint a
|
||
// distinct token (e.g., a PKCE OAuth source).
|
||
if new_bearer == bearer {
|
||
return Err(AgentError::LlmAuth(
|
||
"401: static key rejected — update key in agent settings".into(),
|
||
));
|
||
}
|
||
bearer = new_bearer;
|
||
}
|
||
result => return result,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// If `err` names `/v1/responses` / "use the Responses API", latch a
|
||
/// sticky upgrade so subsequent OpenAI calls hit Responses. Logged once.
|
||
fn try_upgrade(&self, err: &AgentError) -> bool {
|
||
let body = match err {
|
||
AgentError::Llm(s) => s.as_str(),
|
||
_ => return false, // auth/transport aren't "use the other endpoint" signals
|
||
};
|
||
if !is_responses_required_error(body) {
|
||
return false;
|
||
}
|
||
if !self.auto_upgraded.swap(true, Ordering::Relaxed) {
|
||
tracing::warn!(
|
||
provider_message = body,
|
||
"openai: provider asked for the Responses API; \
|
||
routing subsequent OpenAI calls to /v1/responses for this process"
|
||
);
|
||
}
|
||
true
|
||
}
|
||
}
|
||
|
||
fn anthropic_body(
|
||
cfg: &Config,
|
||
system_prompt: &str,
|
||
history: &[HistoryItem],
|
||
tools: &[ToolDef],
|
||
effective_model: &str,
|
||
effort: Option<ThinkingEffort>,
|
||
provider: &str,
|
||
) -> Value {
|
||
let mut messages: Vec<Value> = Vec::new();
|
||
let mut pending: Vec<Value> = Vec::new();
|
||
let flush = |out: &mut Vec<Value>, p: &mut Vec<Value>| {
|
||
if !p.is_empty() {
|
||
out.push(json!({ "role": "user", "content": std::mem::take(p) }));
|
||
}
|
||
};
|
||
for item in history {
|
||
match item {
|
||
HistoryItem::User(text) => {
|
||
flush(&mut messages, &mut pending);
|
||
messages.push(json!({ "role": "user",
|
||
"content": [{ "type": "text", "text": text }] }));
|
||
}
|
||
HistoryItem::Assistant {
|
||
text,
|
||
tool_calls,
|
||
reasoning_details,
|
||
} => {
|
||
flush(&mut messages, &mut pending);
|
||
// Native Anthropic blocks carry signed, potentially interleaved
|
||
// thinking. Replay the original order, not flattened text/calls.
|
||
if let Some(blocks) = reasoning_details
|
||
.as_ref()
|
||
.and_then(|v| v.get("anthropic_content"))
|
||
.and_then(Value::as_array)
|
||
{
|
||
if !blocks.is_empty() {
|
||
messages.push(json!({ "role": "assistant", "content": blocks }));
|
||
}
|
||
continue;
|
||
}
|
||
let mut content: Vec<Value> = Vec::new();
|
||
if !text.is_empty() {
|
||
content.push(json!({ "type": "text", "text": text }));
|
||
}
|
||
for c in tool_calls {
|
||
content.push(json!({ "type": "tool_use", "id": c.provider_id,
|
||
"name": c.name, "input": c.arguments }));
|
||
}
|
||
if content.is_empty() {
|
||
// Empty assistant turn (no text, no tool calls) — skip it.
|
||
// Anthropic rejects empty text blocks, and a placeholder
|
||
// just defers the problem. No tool_use = no pairing
|
||
// constraint, so omitting is safe.
|
||
continue;
|
||
}
|
||
messages.push(json!({ "role": "assistant", "content": content }));
|
||
}
|
||
HistoryItem::ToolResult(r) => pending.push(json!({
|
||
"type": "tool_result", "tool_use_id": r.provider_id,
|
||
"content": anthropic_tool_result_content(&r.content), "is_error": r.is_error })),
|
||
}
|
||
}
|
||
flush(&mut messages, &mut pending);
|
||
// Rolling cache breakpoint: mark the tail of the (append-only) conversation
|
||
// so the next turn re-reads this whole prefix from cache instead of paying
|
||
// full input price for it. See `stamp_rolling_cache_breakpoint`.
|
||
if cfg.prompt_caching {
|
||
stamp_rolling_cache_breakpoint(&mut messages);
|
||
}
|
||
let tools_json: Vec<Value> = tools
|
||
.iter()
|
||
.map(|t| {
|
||
json!({
|
||
"name": t.name, "description": t.description, "input_schema": t.input_schema })
|
||
})
|
||
.collect();
|
||
// Static prefix breakpoint: caching the `system` block caches the whole
|
||
// prefix up to and including it — and the prefix order is
|
||
// `tools -> system -> messages`, so this single marker caches tools +
|
||
// system together. Requires the structured (array) form of `system`; skip
|
||
// it for an empty prompt since Anthropic rejects empty text blocks.
|
||
let system_value = if cfg.prompt_caching && !system_prompt.is_empty() {
|
||
json!([{ "type": "text", "text": system_prompt,
|
||
"cache_control": { "type": "ephemeral" } }])
|
||
} else {
|
||
json!(system_prompt)
|
||
};
|
||
let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens,
|
||
"system": system_value, "messages": messages });
|
||
if let Some(e) = effort {
|
||
let (thinking, output_config) = crate::config::anthropic_thinking_config(
|
||
provider,
|
||
effective_model,
|
||
e,
|
||
cfg.max_output_tokens,
|
||
);
|
||
if let Some(t) = thinking {
|
||
body["thinking"] = t;
|
||
}
|
||
if let Some(oc) = output_config {
|
||
body["output_config"] = oc;
|
||
}
|
||
}
|
||
if !tools_json.is_empty() {
|
||
body["tools"] = Value::Array(tools_json);
|
||
}
|
||
body
|
||
}
|
||
|
||
/// Attach ephemeral `cache_control` markers to the tail of the conversation so
|
||
/// the next turn re-reads the whole prior prefix from cache (~0.1x input price)
|
||
/// rather than re-billing it as fresh input. Anthropic caches the prefix up to
|
||
/// and including each marked block.
|
||
///
|
||
/// We mark the last content block of the last *two* messages, not just the
|
||
/// final one. Each Anthropic breakpoint walks back at most 20 content blocks to
|
||
/// find a prior cache entry, and one agentic turn can append ~17 blocks at the
|
||
/// default `max_parallel_tools` (1 assistant text + N `tool_use` +
|
||
/// N `tool_result`). With only a tail marker, consecutive breakpoints sit a
|
||
/// full turn apart, which slips past the 20-block window as soon as parallelism
|
||
/// rises or a turn carries extra blocks — and the miss is silent. Marking the
|
||
/// last two messages halves the gap (to ~N+1 blocks), keeping a live cache
|
||
/// entry comfortably within reach. Uses 2 of the 4 allowed breakpoints; the
|
||
/// static `system` marker is the third.
|
||
///
|
||
/// A no-op for messages whose content is empty or whose tail block is not a
|
||
/// JSON object.
|
||
fn stamp_rolling_cache_breakpoint(messages: &mut [Value]) {
|
||
let n = messages.len();
|
||
// The two most-recently-appended messages (the current turn's tool results
|
||
// and the assistant turn before them). `checked_sub` + `flatten` skips the
|
||
// second index when there is only one message.
|
||
for idx in [n.checked_sub(1), n.checked_sub(2)].into_iter().flatten() {
|
||
if let Some(block) = messages[idx]
|
||
.get_mut("content")
|
||
.and_then(Value::as_array_mut)
|
||
.and_then(|c| c.last_mut())
|
||
// Thinking blocks must remain byte-for-byte unchanged, and cannot
|
||
// carry an explicit cache breakpoint.
|
||
.filter(|b| !matches!(b["type"].as_str(), Some("thinking" | "redacted_thinking")))
|
||
.and_then(Value::as_object_mut)
|
||
{
|
||
block.insert("cache_control".into(), json!({ "type": "ephemeral" }));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec<Value> {
|
||
content
|
||
.iter()
|
||
.map(|c| match c {
|
||
ToolResultContent::Text(text) => json!({ "type": "text", "text": text }),
|
||
ToolResultContent::Image { data, mime_type } => json!({
|
||
"type": "image",
|
||
"source": { "type": "base64", "media_type": mime_type, "data": data },
|
||
}),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn openai_body(
|
||
cfg: &Config,
|
||
system_prompt: &str,
|
||
history: &[HistoryItem],
|
||
tools: &[ToolDef],
|
||
effective_model: &str,
|
||
effort: Option<ThinkingEffort>,
|
||
) -> Value {
|
||
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": system_prompt })];
|
||
// Images returned from tool calls ride on a trailing `role:"user"`
|
||
// message because OpenAI Chat's `role:"tool"` content is text-only. We
|
||
// batch them across a run of adjacent ToolResult items so that all
|
||
// `role:"tool"` messages stay contiguous — splitting them with a user
|
||
// turn breaks OpenAI-Chat-compatible frontends that translate back to
|
||
// Anthropic `tool_result` (notably Databricks model serving), since
|
||
// Anthropic requires every `tool_use` in one assistant turn to be
|
||
// answered by a single immediately-following user message.
|
||
let mut pending_images: Vec<Value> = Vec::new();
|
||
let flush_images = |messages: &mut Vec<Value>, pending: &mut Vec<Value>| {
|
||
if !pending.is_empty() {
|
||
messages.push(json!({ "role": "user", "content": std::mem::take(pending) }));
|
||
}
|
||
};
|
||
for item in history {
|
||
match item {
|
||
HistoryItem::User(text) => {
|
||
flush_images(&mut messages, &mut pending_images);
|
||
messages.push(json!({ "role": "user", "content": text }));
|
||
}
|
||
HistoryItem::Assistant {
|
||
text,
|
||
tool_calls,
|
||
reasoning_details,
|
||
} => {
|
||
flush_images(&mut messages, &mut pending_images);
|
||
let mut msg = serde_json::Map::new();
|
||
msg.insert("role".into(), json!("assistant"));
|
||
msg.insert("content".into(), json!(text.as_str()));
|
||
// OpenRouter owns the array shape; native Anthropic state must
|
||
// not leak into Chat requests after a session model switch.
|
||
if let Some(details) = reasoning_details.as_ref().filter(|v| v.is_array()) {
|
||
msg.insert("reasoning_details".into(), details.clone());
|
||
}
|
||
if !tool_calls.is_empty() {
|
||
let calls: Vec<Value> = tool_calls
|
||
.iter()
|
||
.map(|c| {
|
||
let mut call = serde_json::Map::new();
|
||
call.insert("id".into(), json!(c.provider_id));
|
||
call.insert("type".into(), json!("function"));
|
||
// Provider-owned fields go back beside `function`,
|
||
// which is where the provider put them. Position is
|
||
// load-bearing, not cosmetic: Gemini rejects a
|
||
// `thoughtSignature` nested inside `function{}` with
|
||
// the same 400 it gives for one that is missing.
|
||
for (k, v) in &c.provider_extra {
|
||
call.insert(k.clone(), v.clone());
|
||
}
|
||
call.insert(
|
||
"function".into(),
|
||
json!({ "name": c.name,
|
||
"arguments": serde_json::to_string(&c.arguments)
|
||
.unwrap_or_else(|_| "{}".into()) }),
|
||
);
|
||
Value::Object(call)
|
||
})
|
||
.collect();
|
||
msg.insert("tool_calls".into(), Value::Array(calls));
|
||
}
|
||
messages.push(Value::Object(msg));
|
||
}
|
||
HistoryItem::ToolResult(r) => {
|
||
messages.push(json!({
|
||
"role": "tool", "tool_call_id": r.provider_id,
|
||
"content": openai_tool_text_content(&r.content) }));
|
||
pending_images.extend(openai_image_user_content(&r.content));
|
||
}
|
||
}
|
||
}
|
||
flush_images(&mut messages, &mut pending_images);
|
||
let tools_json: Vec<Value> = tools
|
||
.iter()
|
||
.map(|t| {
|
||
json!({
|
||
"type": "function",
|
||
"function": { "name": t.name, "description": t.description,
|
||
"parameters": t.input_schema } })
|
||
})
|
||
.collect();
|
||
let mut body = json!({ "model": effective_model, "stream": false,
|
||
"max_completion_tokens": cfg.max_output_tokens, "messages": messages });
|
||
if let Some(e) = effort {
|
||
body["reasoning_effort"] = json!(e.openai_effort_str());
|
||
}
|
||
if !tools_json.is_empty() {
|
||
body["tools"] = Value::Array(tools_json);
|
||
body["tool_choice"] = json!("auto");
|
||
}
|
||
body
|
||
}
|
||
|
||
fn openai_tool_text_content(content: &[ToolResultContent]) -> String {
|
||
let mut parts = Vec::new();
|
||
for c in content {
|
||
match c {
|
||
ToolResultContent::Text(text) => parts.push(text.clone()),
|
||
ToolResultContent::Image { data, mime_type } => parts.push(format!(
|
||
"This tool result included an image ({mime_type}, {} base64 bytes) that is provided in the next user message.",
|
||
data.len()
|
||
)),
|
||
}
|
||
}
|
||
parts.join("\n")
|
||
}
|
||
|
||
fn openai_image_user_content(content: &[ToolResultContent]) -> Vec<Value> {
|
||
content
|
||
.iter()
|
||
.filter_map(|c| match c {
|
||
ToolResultContent::Image { data, mime_type } => Some(json!({
|
||
"type": "image_url",
|
||
"image_url": { "url": format!("data:{mime_type};base64,{data}") },
|
||
})),
|
||
ToolResultContent::Text(_) => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// Spec: https://platform.openai.com/docs/api-reference/responses
|
||
//
|
||
// Replay invariant: each assistant `function_call` input item **must**
|
||
// precede its matching `function_call_output`, or the API rejects with
|
||
// "No tool call found for call_id ...". `HistoryItem` ordering already
|
||
// guarantees this.
|
||
|
||
fn responses_body(
|
||
cfg: &Config,
|
||
system_prompt: &str,
|
||
history: &[HistoryItem],
|
||
tools: &[ToolDef],
|
||
effective_model: &str,
|
||
effort: Option<ThinkingEffort>,
|
||
) -> Value {
|
||
let mut input: Vec<Value> = Vec::with_capacity(history.len());
|
||
for item in history {
|
||
match item {
|
||
HistoryItem::User(text) => input.push(json!({
|
||
"role": "user",
|
||
"content": [{ "type": "input_text", "text": text }],
|
||
})),
|
||
HistoryItem::Assistant {
|
||
text,
|
||
tool_calls,
|
||
reasoning_details: _,
|
||
} => {
|
||
if !text.is_empty() {
|
||
input.push(json!({
|
||
"role": "assistant",
|
||
"content": [{ "type": "output_text", "text": text }],
|
||
}));
|
||
}
|
||
for c in tool_calls {
|
||
input.push(json!({
|
||
"type": "function_call",
|
||
"call_id": c.provider_id,
|
||
"name": c.name,
|
||
"arguments": serde_json::to_string(&c.arguments)
|
||
.unwrap_or_else(|_| "{}".into()),
|
||
}));
|
||
}
|
||
}
|
||
HistoryItem::ToolResult(r) => {
|
||
input.push(json!({
|
||
"type": "function_call_output",
|
||
"call_id": r.provider_id,
|
||
"output": openai_tool_text_content(&r.content),
|
||
}));
|
||
// Responses takes images as `input_image` parts on a user message.
|
||
let images: Vec<Value> = r
|
||
.content
|
||
.iter()
|
||
.filter_map(|c| match c {
|
||
ToolResultContent::Image { data, mime_type } => Some(json!({
|
||
"type": "input_image",
|
||
"image_url": format!("data:{mime_type};base64,{data}"),
|
||
})),
|
||
ToolResultContent::Text(_) => None,
|
||
})
|
||
.collect();
|
||
if !images.is_empty() {
|
||
input.push(json!({ "role": "user", "content": images }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let tools_json: Vec<Value> = tools
|
||
.iter()
|
||
.map(|t| {
|
||
json!({
|
||
"type": "function",
|
||
"name": t.name,
|
||
"description": t.description,
|
||
"parameters": t.input_schema,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let mut body = json!({
|
||
"model": effective_model,
|
||
"instructions": system_prompt,
|
||
"max_output_tokens": cfg.max_output_tokens,
|
||
"input": input,
|
||
});
|
||
if let Some(e) = effort {
|
||
body["reasoning"] = json!({
|
||
"effort": e.openai_effort_str(),
|
||
"summary": cfg.thinking_summary.as_str(),
|
||
});
|
||
}
|
||
if !tools_json.is_empty() {
|
||
body["tools"] = Value::Array(tools_json);
|
||
body["tool_choice"] = json!("auto");
|
||
}
|
||
body
|
||
}
|
||
|
||
/// Narrow matcher for "the input exceeded the model's context window" provider
|
||
/// errors — the ground-truth signal that history must shrink. Only consulted
|
||
/// alongside an HTTP 400 (see the two `!status.is_success()` classification
|
||
/// sites), never on its own: the phrases below are specific, but pairing them
|
||
/// with the status keeps an unrelated 4xx that happens to quote one of them
|
||
/// from triggering a recovery.
|
||
///
|
||
/// Deliberately tight. A generic 400 must stay `AgentError::Llm` so it remains
|
||
/// terminal — misclassifying one as recoverable would spend the whole recovery
|
||
/// budget on an error that shrinking history cannot fix, replacing a clear
|
||
/// failure with a slow one.
|
||
fn is_context_length_error(body: &str) -> bool {
|
||
let b = body.to_ascii_lowercase();
|
||
// OpenAI/Databricks machine-readable code; the most reliable marker.
|
||
b.contains("context_length_exceeded")
|
||
// Prose forms: OpenAI's classic phrasing and the Databricks gateway's
|
||
// "context window of this model" variant seen in both bug reports.
|
||
|| b.contains("maximum context length")
|
||
|| b.contains("context window")
|
||
// Anthropic: "prompt is too long: N tokens > M maximum".
|
||
|| b.contains("prompt is too long")
|
||
}
|
||
|
||
/// Narrow matcher for "you should be on the Responses API" provider errors,
|
||
/// the signal we use to auto-upgrade. Triggers on the literal path
|
||
/// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose
|
||
/// "use the Responses API" / "Responses API instead".
|
||
fn is_responses_required_error(body: &str) -> bool {
|
||
let b = body.to_ascii_lowercase();
|
||
b.contains("/v1/responses")
|
||
|| b.contains("responses api instead")
|
||
|| b.contains("use the responses api")
|
||
}
|
||
|
||
/// Resolve the Databricks v2 AI Gateway wire route for `model`.
|
||
///
|
||
/// The capability resolver owns Unity Catalog FQN classification so the Rust
|
||
/// request path and desktop effort picker cannot disagree.
|
||
fn databricks_v2_route(model: &str) -> DatabricksV2Route {
|
||
use crate::model_capabilities::DatabricksV2Route as Manifest;
|
||
match crate::model_capabilities::resolve("databricks_v2", model).databricks_v2_wire_route {
|
||
Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses,
|
||
Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages,
|
||
Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => {
|
||
DatabricksV2Route::MlflowChatCompletions
|
||
}
|
||
}
|
||
}
|
||
|
||
fn databricks_v2_path(route: DatabricksV2Route) -> &'static str {
|
||
match route {
|
||
DatabricksV2Route::OpenAiResponses => "/ai-gateway/openai/v1/responses",
|
||
DatabricksV2Route::AnthropicMessages => "/ai-gateway/anthropic/v1/messages",
|
||
DatabricksV2Route::MlflowChatCompletions => "/ai-gateway/mlflow/v1/chat/completions",
|
||
}
|
||
}
|
||
|
||
fn parse_responses(v: Value) -> Result<LlmResponse, AgentError> {
|
||
let max_tokens = v.get("status").and_then(Value::as_str) == Some("incomplete")
|
||
&& v.get("incomplete_details")
|
||
.and_then(|d| d.get("reason"))
|
||
.and_then(Value::as_str)
|
||
== Some("max_output_tokens");
|
||
let mut text = String::new();
|
||
let mut reasoning = String::new();
|
||
let mut tool_calls = Vec::new();
|
||
let mut saw_function_call = false;
|
||
|
||
for item in v
|
||
.get("output")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
match item.get("type").and_then(Value::as_str) {
|
||
Some("message") => {
|
||
for p in item
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
// Responses emits "output_text"; accept "text" forward-compat.
|
||
if matches!(
|
||
p.get("type").and_then(Value::as_str),
|
||
Some("output_text" | "text")
|
||
) {
|
||
if let Some(t) = p.get("text").and_then(Value::as_str) {
|
||
text.push_str(t);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some("function_call") if !max_tokens => {
|
||
saw_function_call = true;
|
||
let raw = item
|
||
.get("arguments")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("{}");
|
||
let args: Value = serde_json::from_str(raw).map_err(|e| {
|
||
AgentError::Llm(format!("function_call.arguments not valid JSON: {e}"))
|
||
})?;
|
||
// No passthrough on this route: `responses_body` replays a
|
||
// function call as `{call_id, name, arguments}` and the Responses
|
||
// API asks for nothing else, so an empty map keeps the request
|
||
// byte-identical to before.
|
||
tool_calls.push(make_tool_call(
|
||
str_field(item, "call_id"),
|
||
str_field(item, "name"),
|
||
args,
|
||
Default::default(),
|
||
)?);
|
||
}
|
||
// Incomplete Responses output can carry a partial function call.
|
||
// It is intentionally discarded by the in-turn recovery path.
|
||
Some("function_call") => {}
|
||
Some("reasoning") => {
|
||
// Reasoning summary items from the Responses API. Each item has a
|
||
// `summary` array of `{"type": "summary_text", "text": "..."}` objects.
|
||
for s in item
|
||
.get("summary")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
if matches!(
|
||
s.get("type").and_then(Value::as_str),
|
||
Some("summary_text" | "text")
|
||
) {
|
||
if let Some(t) = s.get("text").and_then(Value::as_str) {
|
||
if !reasoning.is_empty() {
|
||
reasoning.push('\n');
|
||
}
|
||
reasoning.push_str(t);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Unknown types ignored for forward-compat.
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let stop = match v.get("status").and_then(Value::as_str) {
|
||
Some("incomplete") => {
|
||
let reason = v
|
||
.get("incomplete_details")
|
||
.and_then(|d| d.get("reason"))
|
||
.and_then(Value::as_str);
|
||
if reason == Some("max_output_tokens") {
|
||
ProviderStop::MaxTokens
|
||
} else {
|
||
ProviderStop::Other
|
||
}
|
||
}
|
||
Some("completed") if saw_function_call => ProviderStop::ToolUse,
|
||
Some("completed") => ProviderStop::EndTurn,
|
||
_ => ProviderStop::Other,
|
||
};
|
||
let input_tokens = sum_usage(&v, &["input_tokens"]).and_then(SumUsageResult::into_exact);
|
||
let output_tokens = sum_usage(&v, &["output_tokens"]).and_then(SumUsageResult::into_exact);
|
||
// The Responses API nests the cache split under `input_tokens_details`.
|
||
let cached_input_tokens = usage_first(
|
||
&v,
|
||
&["cache_read_input_tokens"],
|
||
&[("input_tokens_details", "cached_tokens")],
|
||
);
|
||
// Responses API does not expose cache-write tokens today; stays None.
|
||
let cache_write_tokens: Option<u64> = None;
|
||
// Responses API reports a genuine provider total. Read it directly —
|
||
// never derived, so it stays None when the provider omits it.
|
||
let total_tokens = sum_usage(&v, &["total_tokens"]).and_then(SumUsageResult::into_exact);
|
||
Ok(LlmResponse {
|
||
text,
|
||
tool_calls,
|
||
stop,
|
||
input_tokens,
|
||
input_tokens_overflowed: false, // input_tokens is a single field; cannot overflow
|
||
cached_input_tokens,
|
||
cache_write_tokens,
|
||
output_tokens,
|
||
total_tokens,
|
||
reasoning,
|
||
reasoning_details: None,
|
||
// Stamped by the dispatch layer (openai_request_for_model) after parse.
|
||
request_model: None,
|
||
})
|
||
}
|
||
|
||
fn map_stop(s: Option<&str>) -> ProviderStop {
|
||
match s {
|
||
Some("end_turn" | "stop") => ProviderStop::EndTurn,
|
||
Some("tool_use" | "tool_calls") => ProviderStop::ToolUse,
|
||
Some("max_tokens" | "length" | "model_context_window_exceeded") => ProviderStop::MaxTokens,
|
||
Some("refusal" | "content_filter") => ProviderStop::Refusal,
|
||
_ => ProviderStop::Other,
|
||
}
|
||
}
|
||
|
||
/// Result of a multi-field token sum — distinguishes a successful total from an
|
||
/// arithmetic overflow so callers can propagate the overflow signal rather than
|
||
/// silently clamping to `u64::MAX`.
|
||
#[derive(Debug, PartialEq)]
|
||
enum SumUsageResult {
|
||
/// At least one field was present and the total did not overflow.
|
||
Exact(u64),
|
||
/// At least one field was present but the cumulative sum overflowed `u64`.
|
||
Overflow,
|
||
}
|
||
|
||
impl SumUsageResult {
|
||
/// Returns `Some(n)` when exact, `None` when overflow or when the caller
|
||
/// needs to signal an explicit absence. Single-field callers whose sums
|
||
/// cannot overflow use this to convert back to `Option<u64>`.
|
||
fn into_exact(self) -> Option<u64> {
|
||
match self {
|
||
SumUsageResult::Exact(n) => Some(n),
|
||
SumUsageResult::Overflow => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sum a set of `usage` token fields, returning `None` only when the `usage`
|
||
/// object is absent or carries none of the requested fields. A field that is
|
||
/// present is added; a field that is missing contributes 0. This keeps the
|
||
/// result an inclusive total (so cached tokens are never silently dropped)
|
||
/// while still distinguishing "no usage reported" from "usage was zero".
|
||
///
|
||
/// Returns [`SumUsageResult::Overflow`] if the cumulative total exceeds `u64::MAX`.
|
||
/// Single-field callers whose sums cannot overflow may call `.into_exact()` to
|
||
/// convert back to `Option<u64>` without loss.
|
||
fn sum_usage(v: &Value, fields: &[&str]) -> Option<SumUsageResult> {
|
||
let usage = v.get("usage")?;
|
||
let mut total: u64 = 0;
|
||
let mut saw_any = false;
|
||
let mut overflowed = false;
|
||
for f in fields {
|
||
if let Some(n) = usage.get(*f).and_then(Value::as_u64) {
|
||
match total.checked_add(n) {
|
||
Some(t) => total = t,
|
||
None => overflowed = true,
|
||
}
|
||
saw_any = true;
|
||
}
|
||
}
|
||
if !saw_any {
|
||
return None;
|
||
}
|
||
if overflowed {
|
||
Some(SumUsageResult::Overflow)
|
||
} else {
|
||
Some(SumUsageResult::Exact(total))
|
||
}
|
||
}
|
||
|
||
/// Input-token total for Anthropic / Databricks (Anthropic-style) responses.
|
||
/// `input_tokens` alone EXCLUDES cached tokens, so we sum it with the two
|
||
/// cache fields to get the inclusive total the context budget must gate on.
|
||
///
|
||
/// Returns [`SumUsageResult::Overflow`] when the sum of the three fields
|
||
/// exceeds `u64::MAX` — the caller must propagate the overflow signal rather
|
||
/// than clamping.
|
||
fn anthropic_input_tokens(v: &Value) -> Option<SumUsageResult> {
|
||
sum_usage(
|
||
v,
|
||
&[
|
||
"input_tokens",
|
||
"cache_read_input_tokens",
|
||
"cache_creation_input_tokens",
|
||
],
|
||
)
|
||
}
|
||
|
||
/// Input-token total for OpenAI Chat Completions and Databricks MLflow-route
|
||
/// responses. `prompt_tokens` is already the inclusive input total on both, so
|
||
/// it is read alone and never summed with the cache fields.
|
||
///
|
||
/// Vanilla OpenAI nests the cache split under `prompt_tokens_details` and
|
||
/// `prompt_tokens` includes it. The Databricks MLflow route reports the split
|
||
/// with the flat Anthropic spelling (`cache_read_input_tokens`) *alongside* an
|
||
/// already-inclusive `prompt_tokens` — so summing double-counts. Verified on
|
||
/// `databricks-glm-5-2` (2026-07-28): `prompt_tokens 13320`,
|
||
/// `cache_read_input_tokens 13312`, `completion_tokens 30`, `total_tokens
|
||
/// 13350`; since `prompt_tokens + completion_tokens == total_tokens`, the 13312
|
||
/// cached tokens are contained in the 13320, not additional to it. Summing gave
|
||
/// 26632 — nearly double — inflating both the context-budget gate and cost.
|
||
///
|
||
/// This differs from Anthropic's native route (see [`anthropic_input_tokens`]),
|
||
/// where `input_tokens` genuinely EXCLUDES the cache fields and must be summed.
|
||
/// The two never collide here: the router sends `claude*` models to the
|
||
/// Anthropic route, so `parse_openai` only ever sees inclusive `prompt_tokens`.
|
||
fn openai_chat_input_tokens(v: &Value) -> Option<u64> {
|
||
sum_usage(v, &["prompt_tokens"]).and_then(SumUsageResult::into_exact)
|
||
}
|
||
|
||
/// First present value among `usage.<field>` and `usage.<nested>.<leaf>` pairs.
|
||
///
|
||
/// Cache counts are the one usage figure providers do not agree on the shape of.
|
||
/// Anthropic puts `cache_read_input_tokens` flat on `usage`; OpenAI nests the
|
||
/// same quantity one level down, under `prompt_tokens_details` on
|
||
/// `/chat/completions` and `input_tokens_details` on `/responses`. [`sum_usage`]
|
||
/// only reads flat keys, which is why the OpenAI split was invisible for so
|
||
/// long: `prompt_tokens` is already inclusive, so the *total* was right and
|
||
/// nothing looked broken while the discount silently went unclaimed.
|
||
///
|
||
/// Returns the first candidate that resolves, not a sum — these are alternative
|
||
/// spellings of one number, so adding them would double-count on Databricks,
|
||
/// which reports both shapes.
|
||
fn usage_first(v: &Value, flat: &[&str], nested: &[(&str, &str)]) -> Option<u64> {
|
||
let usage = v.get("usage")?;
|
||
for f in flat {
|
||
if let Some(n) = usage.get(*f).and_then(Value::as_u64) {
|
||
return Some(n);
|
||
}
|
||
}
|
||
for (outer, leaf) in nested {
|
||
if let Some(n) = usage
|
||
.get(*outer)
|
||
.and_then(|o| o.get(*leaf))
|
||
.and_then(Value::as_u64)
|
||
{
|
||
return Some(n);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Cache-read tokens for an OpenAI Chat Completions response.
|
||
///
|
||
/// `prompt_tokens_details.cached_tokens` is where vanilla OpenAI reports it.
|
||
/// The flat Anthropic spelling is checked first for Databricks, which routes
|
||
/// Anthropic models through an OpenAI-shaped envelope.
|
||
fn openai_chat_cached_tokens(v: &Value) -> Option<u64> {
|
||
usage_first(
|
||
v,
|
||
&["cache_read_input_tokens"],
|
||
&[("prompt_tokens_details", "cached_tokens")],
|
||
)
|
||
}
|
||
|
||
fn str_field(v: &Value, key: &str) -> String {
|
||
v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
|
||
}
|
||
|
||
/// Append `part` to `buf` on its own line, ignoring empties.
|
||
fn push_part(buf: &mut String, part: &str) {
|
||
if part.is_empty() {
|
||
return;
|
||
}
|
||
if !buf.is_empty() {
|
||
buf.push('\n');
|
||
}
|
||
buf.push_str(part);
|
||
}
|
||
|
||
/// Split an OpenAI-shaped `message.content` into `(text, reasoning)`.
|
||
///
|
||
/// Standard OpenAI sends a string. Several models on the Databricks MLflow route
|
||
/// — Gemini, Qwen35, gpt-oss — send an array of typed blocks instead, and
|
||
/// `as_str()` yields nothing for an array, so their entire answer was being
|
||
/// discarded: no error, no warning, just a turn that looked like the model had
|
||
/// said nothing. `parse_anthropic` already walks a block array; this gives
|
||
/// `parse_openai` the same tolerance.
|
||
fn openai_content_parts(content: Option<&Value>) -> (String, String) {
|
||
let mut text = String::new();
|
||
let mut reasoning = String::new();
|
||
match content {
|
||
Some(Value::String(s)) => text.push_str(s),
|
||
Some(Value::Array(blocks)) => {
|
||
for b in blocks {
|
||
match b.get("type").and_then(Value::as_str) {
|
||
Some("text") => push_part(&mut text, &str_field(b, "text")),
|
||
Some("reasoning") => match b.get("summary").and_then(Value::as_array) {
|
||
// Gemini nests the prose one level down under `summary`.
|
||
Some(summary) => {
|
||
for s in summary {
|
||
push_part(&mut reasoning, &str_field(s, "text"));
|
||
}
|
||
}
|
||
None => push_part(&mut reasoning, &str_field(b, "text")),
|
||
},
|
||
// An untyped block carrying text is still the model talking;
|
||
// treating it as text loses nothing and keeps one more
|
||
// provider out of the silent-empty-answer failure mode.
|
||
_ => push_part(&mut text, &str_field(b, "text")),
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
(text, reasoning)
|
||
}
|
||
|
||
/// Make `provider_id` unique across one assistant turn's tool calls.
|
||
///
|
||
/// Gemini returns the function name as the id, so two parallel calls to the same
|
||
/// function arrive sharing one id — and that id is what pairs a `role:"tool"`
|
||
/// result back to its call, leaving two results indistinguishable. Rewriting is
|
||
/// safe because both halves of that pairing are re-emitted from this same value;
|
||
/// the provider never sees its original id again.
|
||
fn dedupe_provider_ids(calls: &mut [ToolCall]) {
|
||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||
for c in calls.iter_mut() {
|
||
if seen.contains(&c.provider_id) {
|
||
let mut n = 2;
|
||
while seen.contains(&format!("{}-{n}", c.provider_id)) {
|
||
n += 1;
|
||
}
|
||
c.provider_id = format!("{}-{n}", c.provider_id);
|
||
}
|
||
seen.insert(c.provider_id.clone());
|
||
}
|
||
}
|
||
|
||
fn parse_anthropic(v: Value) -> Result<LlmResponse, AgentError> {
|
||
let stop = map_stop(v.get("stop_reason").and_then(Value::as_str));
|
||
let mut tool_calls = Vec::new();
|
||
let mut text = String::new();
|
||
let mut reasoning = String::new();
|
||
if let Some(blocks) = v.get("content").and_then(Value::as_array) {
|
||
for b in blocks {
|
||
match b.get("type").and_then(Value::as_str) {
|
||
Some("text") => {
|
||
if let Some(t) = b.get("text").and_then(Value::as_str) {
|
||
text.push_str(t);
|
||
}
|
||
}
|
||
Some("thinking") => {
|
||
// Anthropic extended thinking block: `{"type": "thinking", "thinking": "..."}`
|
||
if let Some(t) = b.get("thinking").and_then(Value::as_str) {
|
||
if !reasoning.is_empty() {
|
||
reasoning.push('\n');
|
||
}
|
||
reasoning.push_str(t);
|
||
}
|
||
}
|
||
// A max-token response may end in the middle of a tool input.
|
||
// The agent discards all calls from truncated responses, so do
|
||
// not reject the whole response trying to parse an input that
|
||
// can never be executed.
|
||
Some("tool_use") if stop != ProviderStop::MaxTokens => {
|
||
tool_calls.push(make_tool_call(
|
||
str_field(b, "id"),
|
||
str_field(b, "name"),
|
||
b.get("input").cloned().unwrap_or(Value::Null),
|
||
Default::default(),
|
||
)?)
|
||
}
|
||
Some("tool_use") => {}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
// The run loop caps other providers' calls by truncating them. Native
|
||
// content must be replayed intact, so reject oversized turns before any
|
||
// tools execute rather than silently altering signed/interleaved state.
|
||
if tool_calls.len() > MAX_TOOL_CALLS_PER_TURN {
|
||
return Err(AgentError::Llm(format!(
|
||
"Anthropic response exceeds {MAX_TOOL_CALLS_PER_TURN} tool calls; cannot truncate native content"
|
||
)));
|
||
}
|
||
// anthropic_input_tokens() returns Option<SumUsageResult> because it sums
|
||
// three fields that can collectively overflow u64. Propagate the overflow
|
||
// signal via `input_tokens_overflowed` so the run loop can poison the
|
||
// turn accumulator rather than treating u64::MAX as an exact reading.
|
||
let (input_tokens, input_tokens_overflowed) = match anthropic_input_tokens(&v) {
|
||
Some(SumUsageResult::Exact(n)) => (Some(n), false),
|
||
Some(SumUsageResult::Overflow) => (None, true),
|
||
None => (None, false),
|
||
};
|
||
let output_tokens = sum_usage(&v, &["output_tokens"]).and_then(SumUsageResult::into_exact);
|
||
// Anthropic reports the cache split flat on `usage`. Note this is already
|
||
// part of `input_tokens` above, which sums it in deliberately.
|
||
let cached_input_tokens = usage_first(&v, &["cache_read_input_tokens"], &[]);
|
||
// Anthropic reports cache-creation tokens as `cache_creation_input_tokens`;
|
||
// also a subset of `input_tokens` (already folded in above).
|
||
let cache_write_tokens = usage_first(&v, &["cache_creation_input_tokens"], &[]);
|
||
Ok(LlmResponse {
|
||
text,
|
||
tool_calls,
|
||
stop,
|
||
input_tokens,
|
||
input_tokens_overflowed,
|
||
cached_input_tokens,
|
||
cache_write_tokens,
|
||
output_tokens,
|
||
// Anthropic reports only category counts; NIP-AM forbids deriving a
|
||
// total from them. Always None for this provider.
|
||
total_tokens: None,
|
||
reasoning,
|
||
// Truncated content is not a valid signed assistant turn; the run loop
|
||
// keeps only its text and never executes its tool calls.
|
||
reasoning_details: v
|
||
.get("content")
|
||
.filter(|v| v.is_array())
|
||
.filter(|_| stop != ProviderStop::MaxTokens)
|
||
.map(|blocks| json!({ "anthropic_content": blocks })),
|
||
// Stamped by the dispatch layer (complete) after parse.
|
||
request_model: None,
|
||
})
|
||
}
|
||
|
||
fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
|
||
// A5: error-inside-200 check — choice-level `finish_reason == "error"`
|
||
if let Some(choice) = v
|
||
.get("choices")
|
||
.and_then(Value::as_array)
|
||
.and_then(|a| a.first())
|
||
{
|
||
if choice.get("finish_reason").and_then(Value::as_str) == Some("error") {
|
||
let err = choice.get("error").cloned().unwrap_or(Value::Null);
|
||
// OpenRouter's `error.code` is numeric; other OpenAI-compat hosts
|
||
// may send a string. Accept either rather than discarding the
|
||
// typed code as "unknown".
|
||
let code = err
|
||
.get("code")
|
||
.and_then(|c| {
|
||
c.as_str()
|
||
.map(str::to_string)
|
||
.or_else(|| c.as_i64().map(|n| n.to_string()))
|
||
})
|
||
.unwrap_or_else(|| "unknown".into());
|
||
let message = err
|
||
.get("message")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("provider error in 200 response");
|
||
let error_type = err
|
||
.get("metadata")
|
||
.and_then(|m| m.get("error_type"))
|
||
.and_then(Value::as_str);
|
||
return Err(AgentError::Llm(match error_type {
|
||
Some(et) => format!("provider error ({code}, {et}): {message}"),
|
||
None => format!("provider error ({code}): {message}"),
|
||
}));
|
||
}
|
||
}
|
||
let choice = v
|
||
.get("choices")
|
||
.and_then(Value::as_array)
|
||
.and_then(|a| a.first())
|
||
.ok_or_else(|| AgentError::Llm("response missing choices".into()))?;
|
||
let stop = map_stop(choice.get("finish_reason").and_then(Value::as_str));
|
||
let msg = choice
|
||
.get("message")
|
||
.ok_or_else(|| AgentError::Llm("missing message".into()))?;
|
||
let (text, block_reasoning) = openai_content_parts(msg.get("content"));
|
||
// DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the
|
||
// message object. Prefer `reasoning_content` (DeepSeek's field name); fall
|
||
// back to `reasoning` (some other providers), and last to reasoning blocks
|
||
// found inside `content`. All three are absent for standard OpenAI
|
||
// responses, which leaves this empty without any special-casing.
|
||
let reasoning = {
|
||
let rc = str_field(msg, "reasoning_content");
|
||
let rc = if rc.is_empty() {
|
||
str_field(msg, "reasoning")
|
||
} else {
|
||
rc
|
||
};
|
||
if rc.is_empty() {
|
||
block_reasoning
|
||
} else {
|
||
rc
|
||
}
|
||
};
|
||
let mut tool_calls = Vec::new();
|
||
if stop != ProviderStop::MaxTokens {
|
||
if let Some(arr) = msg.get("tool_calls").and_then(Value::as_array) {
|
||
for tc in arr {
|
||
let f = tc
|
||
.get("function")
|
||
.ok_or_else(|| AgentError::Llm("tool_call missing function".into()))?;
|
||
let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}");
|
||
let args: Value = serde_json::from_str(raw).map_err(|e| {
|
||
AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}"))
|
||
})?;
|
||
// Everything on the wire object we do not model, kept for replay.
|
||
let extra = tc
|
||
.as_object()
|
||
.map(|o| {
|
||
o.iter()
|
||
.filter(|(k, _)| !matches!(k.as_str(), "id" | "type" | "function"))
|
||
.map(|(k, v)| (k.clone(), v.clone()))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
tool_calls.push(make_tool_call(
|
||
str_field(tc, "id"),
|
||
str_field(f, "name"),
|
||
args,
|
||
extra,
|
||
)?);
|
||
}
|
||
}
|
||
}
|
||
dedupe_provider_ids(&mut tool_calls);
|
||
let input_tokens = openai_chat_input_tokens(&v);
|
||
let output_tokens = sum_usage(&v, &["completion_tokens"]).and_then(SumUsageResult::into_exact);
|
||
let cached_input_tokens = openai_chat_cached_tokens(&v);
|
||
// OpenAI Chat Completions reports cache-write tokens under
|
||
// `prompt_tokens_details.cache_write_tokens`. A subset of `prompt_tokens`.
|
||
let cache_write_tokens =
|
||
usage_first(&v, &[], &[("prompt_tokens_details", "cache_write_tokens")]);
|
||
// OpenAI Chat Completions reports a genuine provider total. Read it
|
||
// directly — never derived, so it stays None when the provider omits it.
|
||
let total_tokens = sum_usage(&v, &["total_tokens"]).and_then(SumUsageResult::into_exact);
|
||
Ok(LlmResponse {
|
||
text,
|
||
tool_calls,
|
||
stop,
|
||
input_tokens,
|
||
input_tokens_overflowed: false, // prompt_tokens is a single field; cannot overflow
|
||
cached_input_tokens,
|
||
cache_write_tokens,
|
||
output_tokens,
|
||
total_tokens,
|
||
reasoning,
|
||
reasoning_details: None,
|
||
// Stamped by the dispatch layer (openai_request_for_model) after parse.
|
||
request_model: None,
|
||
})
|
||
}
|
||
|
||
fn parse_openai_with_reasoning_details(v: Value) -> Result<LlmResponse, AgentError> {
|
||
let reasoning_details = v
|
||
.get("choices")
|
||
.and_then(Value::as_array)
|
||
.and_then(|a| a.first())
|
||
.and_then(|c| c.get("message"))
|
||
.and_then(|m| m.get("reasoning_details"))
|
||
.filter(|rd| rd.is_array())
|
||
.cloned();
|
||
let mut response = parse_openai(v)?;
|
||
response.reasoning_details = reasoning_details;
|
||
Ok(response)
|
||
}
|
||
|
||
fn make_tool_call(
|
||
id: String,
|
||
name: String,
|
||
args: Value,
|
||
provider_extra: Map<String, Value>,
|
||
) -> Result<ToolCall, AgentError> {
|
||
if id.is_empty() || name.is_empty() {
|
||
return Err(AgentError::Llm("tool_call missing id or name".into()));
|
||
}
|
||
let arguments = match args {
|
||
Value::Object(_) => args,
|
||
Value::Null => Value::Object(Default::default()),
|
||
_ => {
|
||
return Err(AgentError::Llm(
|
||
"tool_call arguments must be a JSON object".into(),
|
||
))
|
||
}
|
||
};
|
||
Ok(ToolCall {
|
||
provider_id: id,
|
||
name,
|
||
arguments,
|
||
provider_extra,
|
||
})
|
||
}
|
||
|
||
async fn read_error_body(mut resp: reqwest::Response) -> String {
|
||
let mut buf: Vec<u8> = Vec::new();
|
||
while buf.len() < MAX_LLM_ERROR_BODY_BYTES {
|
||
match resp.chunk().await {
|
||
Ok(Some(chunk)) => {
|
||
let take = chunk.len().min(MAX_LLM_ERROR_BODY_BYTES - buf.len());
|
||
buf.extend_from_slice(&chunk[..take]);
|
||
if take < chunk.len() {
|
||
break;
|
||
}
|
||
}
|
||
_ => break,
|
||
}
|
||
}
|
||
String::from_utf8_lossy(&buf).into_owned()
|
||
}
|
||
|
||
const MAX_RETRIES: u32 = 3;
|
||
const BASE_BACKOFF_MS: u64 = 500;
|
||
const MAX_BACKOFF_MS: u64 = 8_000;
|
||
|
||
/// Maximum per-request timeout after escalation.
|
||
///
|
||
/// Each attempt that ends with a timeout doubles the budget for the next
|
||
/// attempt: base, 2×base, 4×base, … The escalation is bounded here so a
|
||
/// slow model cannot keep a worker alive indefinitely. The cap is also
|
||
/// applied when `base` already exceeds it (e.g. an operator-set 1500 s stays
|
||
/// 1500 s instead of being truncated to 1200 s — we never shrink the budget).
|
||
const ESCALATION_TIMEOUT_CAP: std::time::Duration = std::time::Duration::from_secs(1200);
|
||
|
||
/// Compute the per-attempt timeout after `timeout_failures` prior attempts
|
||
/// timed out.
|
||
///
|
||
/// The budget doubles for every timeout failure: `base × 2^timeout_failures`.
|
||
/// Non-timeout retryable failures (429, 5xx, connect resets) are not counted
|
||
/// here — they are not evidence of a slow model. The budget is capped at
|
||
/// `max(ESCALATION_TIMEOUT_CAP, base)` so a base already above the cap is
|
||
/// preserved as-is.
|
||
///
|
||
/// Examples at base = 240 s: 0 failures → 240 s; 1 → 480 s; 2 → 960 s; 3 → 1200 s.
|
||
///
|
||
/// **Worst-case turn ceiling** (all 3 attempts time out, `MAX_RETRIES = 3`):
|
||
///
|
||
/// ```text
|
||
/// total ≤ base + min(2×base, cap) + min(4×base, cap)
|
||
/// ```
|
||
///
|
||
/// Concrete anchors operators can use to size their outer turn timeout:
|
||
/// - 240 s base (default): 240 + 480 + 960 = **1680 s** (~28 min)
|
||
/// - 900 s base: 900 + 1200 + 1200 = **3300 s** (~55 min)
|
||
fn escalated_timeout(base: std::time::Duration, timeout_failures: u32) -> std::time::Duration {
|
||
// `checked_shl` returns None when the shift would overflow u32; saturate to
|
||
// u32::MAX so the cap below clamps it rather than panicking or wrapping.
|
||
let multiplier = 1u32.checked_shl(timeout_failures).unwrap_or(u32::MAX);
|
||
let scaled = base.saturating_mul(multiplier);
|
||
let cap = ESCALATION_TIMEOUT_CAP.max(base);
|
||
scaled.min(cap)
|
||
}
|
||
|
||
async fn backoff_with_jitter(attempt: u32) {
|
||
let base = BASE_BACKOFF_MS
|
||
.saturating_mul(1u64 << attempt)
|
||
.min(MAX_BACKOFF_MS);
|
||
let mut buf = [0u8; 8];
|
||
let jitter_range = base / 2;
|
||
let delay = if jitter_range > 0 && getrandom::fill(&mut buf).is_ok() {
|
||
let r = u64::from_le_bytes(buf) % jitter_range;
|
||
base - jitter_range + r
|
||
} else {
|
||
base
|
||
};
|
||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||
}
|
||
|
||
/// Transport-layer errors safe to retry for non-streaming LLM POSTs.
|
||
///
|
||
/// Covers timeouts, connect failures, and the broader request-class errors
|
||
/// reqwest reports for pre-response failures: TLS handshake aborts, sockets
|
||
/// dropped or reset mid-send, h2 GOAWAY/RST_STREAM, hyper protocol errors.
|
||
/// Body-serialization happens before the retry loop, so `is_request()` here
|
||
/// is always a network failure, never a malformed request we'd just resend.
|
||
fn is_retryable_transport_error(e: &reqwest::Error) -> bool {
|
||
e.is_timeout() || e.is_connect() || e.is_request()
|
||
}
|
||
|
||
/// Which phase of an HTTP exchange produced a timeout error.
|
||
///
|
||
/// Used by `timeout_message` to choose the right factual description.
|
||
#[derive(Clone, Copy)]
|
||
enum TimeoutPhase {
|
||
/// Timeout before any response bytes — transport/send phase.
|
||
Transport,
|
||
/// Timeout after headers were received, while reading body chunks.
|
||
BodyRead,
|
||
}
|
||
|
||
/// Pure function: build the human-readable timeout message for an LLM call.
|
||
///
|
||
/// Takes the two reqwest flags and the per-request total timeout rather than a
|
||
/// `&reqwest::Error` so the flag-precedence logic can be tested without any
|
||
/// network involvement.
|
||
///
|
||
/// `per_request_timeout` is the `RequestBuilder::timeout()` value applied to
|
||
/// the attempt that fired; this is computed by `escalated_timeout` and may be
|
||
/// larger than `cfg.llm_timeout` when earlier attempts already timed out.
|
||
/// Connect timeouts use `LLM_CONNECT_TIMEOUT`.
|
||
///
|
||
/// This message appears in the terminal error produced by `classify_transport_error`
|
||
/// (transport-phase timeout, always terminal) and `classify_body_read_error`
|
||
/// (body-read timeout, terminal only on the final attempt — earlier attempts
|
||
/// are retried with an escalated budget before reaching this message).
|
||
fn timeout_message(
|
||
is_connect: bool,
|
||
per_request_timeout: std::time::Duration,
|
||
phase: TimeoutPhase,
|
||
) -> String {
|
||
if is_connect {
|
||
// Connect-phase timeout: the TCP/TLS handshake didn't complete.
|
||
// reqwest sets both is_timeout() and is_connect() for this case.
|
||
format!("connect timeout: no connection established within {LLM_CONNECT_TIMEOUT:?}")
|
||
} else {
|
||
match phase {
|
||
TimeoutPhase::Transport => format!(
|
||
"read timeout: no response bytes received within {per_request_timeout:?} \
|
||
(consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)"
|
||
),
|
||
// Total-request timeout fired during body streaming: the response
|
||
// started but did not complete within the window.
|
||
TimeoutPhase::BodyRead => format!(
|
||
"request timed out: response did not complete within {per_request_timeout:?} \
|
||
(consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Produce a human-readable description of a transport-layer reqwest error.
|
||
///
|
||
/// reqwest's `Display` for a timeout fire is the opaque
|
||
/// `"error sending request for url (...)"` — the same text as every other
|
||
/// pre-response failure — because the HTTP layer lumps them together.
|
||
/// We replace that string with a factual message that names which kind of
|
||
/// timeout fired, making it immediately obvious in logs whether the client
|
||
/// never connected or whether no response bytes arrived within the window.
|
||
///
|
||
/// `per_request_timeout` is the `RequestBuilder::timeout()` value applied to
|
||
/// the attempt that fired; it is the `escalated_timeout` for that attempt and
|
||
/// may be larger than `cfg.llm_timeout` on retried attempts.
|
||
fn classify_transport_error(
|
||
e: &reqwest::Error,
|
||
per_request_timeout: std::time::Duration,
|
||
) -> String {
|
||
if e.is_timeout() {
|
||
timeout_message(e.is_connect(), per_request_timeout, TimeoutPhase::Transport)
|
||
} else {
|
||
format!("transport: {e}")
|
||
}
|
||
}
|
||
|
||
/// Produce a human-readable description of an error that occurred while
|
||
/// reading response body chunks (`resp.chunk()`).
|
||
///
|
||
/// A timeout here means the total per-request budget expired during body
|
||
/// streaming — headers (and possibly some body bytes) arrived but the response
|
||
/// did not complete within the window. Non-final-attempt body timeouts are
|
||
/// retried with an escalated budget before this function is called, so the
|
||
/// message is only produced on the final attempt. Any other body-decode failure
|
||
/// preserves the `"body read: ..."` prefix expected by callers and existing
|
||
/// tests.
|
||
///
|
||
/// `per_request_timeout` is the `RequestBuilder::timeout()` value applied to
|
||
/// the attempt that fired; it is the `escalated_timeout` for that attempt and
|
||
/// may be larger than `cfg.llm_timeout` on retried attempts.
|
||
fn classify_body_read_error(
|
||
e: &reqwest::Error,
|
||
per_request_timeout: std::time::Duration,
|
||
) -> String {
|
||
if e.is_timeout() {
|
||
timeout_message(e.is_connect(), per_request_timeout, TimeoutPhase::BodyRead)
|
||
} else {
|
||
format!("body read: {e}")
|
||
}
|
||
}
|
||
|
||
/// Provider bodies that mean "this model cannot accept image input", the
|
||
/// signal the agent loop uses to strip rejected images from history and
|
||
/// continue the turn (see `replace_unsupported_images`).
|
||
///
|
||
/// Deliberately tight, same doctrine as [`is_context_length_error`]: each
|
||
/// phrase is a verbatim capability rejection observed live. Misclassifying a
|
||
/// generic 400 as recoverable would mutate history for an error that removing
|
||
/// images cannot fix.
|
||
fn is_unsupported_image_input_error(body: &str) -> bool {
|
||
let b = body.to_ascii_lowercase();
|
||
// OpenRouter 404: no provider endpoint accepts images for this model.
|
||
b.contains("no endpoints found that support image input")
|
||
// OpenAI-compatible 400 from text-only single-model deployments,
|
||
// e.g. Crusoe serverless GLM: `"crusoeai/GLM-5.2-NVFP4 is not a
|
||
// multimodal model"`. Without this arm the 400 is terminal, the image
|
||
// stays in history, and every subsequent request in the session fails
|
||
// identically — the turn wedges until the harness/user gives up.
|
||
|| b.contains("is not a multimodal model")
|
||
}
|
||
|
||
/// Build the terminal `AgentError::Llm` for a `post()` exit that has given up
|
||
/// retrying — persistent retryable status, transport failure, or a body-read
|
||
/// break. `detail` carries the specific cause (status/body, or the transport
|
||
/// error text); `elapsed` and `attempts` are the cumulative cost of every
|
||
/// attempt made on this call, including the retries that failed before this
|
||
/// one. When cumulative time crosses `STALL_NOTICE_THRESHOLD`, this also logs
|
||
/// a `tracing::warn!` so a slow-building outage is visible in logs even
|
||
/// before an operator reads the returned error text.
|
||
fn terminal_llm_error(elapsed: std::time::Duration, attempts: u32, detail: &str) -> AgentError {
|
||
if elapsed >= STALL_NOTICE_THRESHOLD {
|
||
tracing::warn!(
|
||
cumulative_stall = ?elapsed,
|
||
attempts,
|
||
"llm: cumulative stall {elapsed:?} across {attempts} attempts ({detail})"
|
||
);
|
||
}
|
||
AgentError::Llm(format!(
|
||
"{detail} (cumulative {elapsed:?}, {attempts} attempt{})",
|
||
if attempts == 1 { "" } else { "s" },
|
||
))
|
||
}
|
||
|
||
/// Internal HTTP failure wrapper for the OpenAI-family `post` helper.
|
||
#[derive(Debug)]
|
||
enum PostError {
|
||
Agent(AgentError),
|
||
}
|
||
|
||
impl PostError {
|
||
fn into_agent(self) -> AgentError {
|
||
match self {
|
||
Self::Agent(error) => error,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<AgentError> for PostError {
|
||
fn from(error: AgentError) -> Self {
|
||
Self::Agent(error)
|
||
}
|
||
}
|
||
|
||
async fn post<F>(
|
||
http: &Client,
|
||
url: &str,
|
||
body: &Value,
|
||
base_timeout: std::time::Duration,
|
||
apply: F,
|
||
) -> Result<Value, PostError>
|
||
where
|
||
F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
|
||
{
|
||
let body_bytes = serde_json::to_vec(body)
|
||
.map_err(|e| PostError::Agent(AgentError::Llm(format!("serialize: {e}"))))?;
|
||
let call_start = std::time::Instant::now();
|
||
// Count prior attempts that ended with a timeout so we can double the
|
||
// budget on the next attempt. Non-timeout retryable failures (429, 5xx,
|
||
// connect resets) are not evidence of a slow model and do NOT escalate.
|
||
let mut timeout_failures: u32 = 0;
|
||
'attempt: for attempt in 0..MAX_RETRIES {
|
||
let per_request_timeout = escalated_timeout(base_timeout, timeout_failures);
|
||
let resp = match apply(
|
||
http.post(url)
|
||
.header("content-type", "application/json")
|
||
.body(body_bytes.clone())
|
||
.timeout(per_request_timeout),
|
||
)
|
||
.send()
|
||
.await
|
||
{
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
if e.is_timeout() {
|
||
timeout_failures += 1;
|
||
}
|
||
if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) {
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
error = %e,
|
||
is_timeout = e.is_timeout(),
|
||
timeout_failures,
|
||
"llm: transport error, retrying"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue;
|
||
}
|
||
return Err(PostError::Agent(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&classify_transport_error(&e, per_request_timeout),
|
||
)));
|
||
}
|
||
};
|
||
let status = resp.status();
|
||
// Both 401 and 403 are treated as refreshable: a 403 can mean an
|
||
// expired or revoked token, not just a pure authorization verdict, and
|
||
// the two are indistinguishable at the HTTP-status layer. The caller's
|
||
// retry loop keys off `LlmAuth` and refreshes once; the per-call guard
|
||
// bounds a pure-authz 403 to one wasted refresh before it propagates.
|
||
// Not a stall path: auth failures are not surfaced through
|
||
// terminal_llm_error, they resolve on the next call after refresh.
|
||
if status == 401 || status == 403 {
|
||
return Err(PostError::Agent(AgentError::LlmAuth(
|
||
read_error_body(resp).await,
|
||
)));
|
||
}
|
||
if status.is_server_error() || status == 429 || status.as_u16() == 499 {
|
||
let body = read_error_body(resp).await;
|
||
if attempt + 1 < MAX_RETRIES {
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
%status,
|
||
"llm: retryable status, retrying"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue;
|
||
}
|
||
return Err(PostError::Agent(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&format!("exhausted retries: {status}: {body}"),
|
||
)));
|
||
}
|
||
// Not a stall path: the model is misconfigured, not the transport or
|
||
// upstream capacity — no retry was attempted, so cumulative duration
|
||
// would be misleading.
|
||
if status == 404 {
|
||
let error_body = read_error_body(resp).await;
|
||
if is_unsupported_image_input_error(&error_body) {
|
||
return Err(PostError::Agent(AgentError::UnsupportedImageInput(
|
||
error_body,
|
||
)));
|
||
}
|
||
return Err(PostError::Agent(AgentError::LlmModelNotFound(format!(
|
||
"{status}: {error_body}"
|
||
))));
|
||
}
|
||
if !status.is_success() {
|
||
let body = read_error_body(resp).await;
|
||
// Context-window overflow is a recovery signal, not a terminal
|
||
// error: classify it here, where status and body are still separate
|
||
// values. Callers must never re-derive this from the formatted
|
||
// string — `Llm::complete` stamps the model name onto it before the
|
||
// agent loop ever sees it.
|
||
if status == 400 && is_context_length_error(&body) {
|
||
return Err(PostError::Agent(AgentError::LlmContextExceeded(format!(
|
||
"{status}: {body}"
|
||
))));
|
||
}
|
||
// Image-capability rejection is equally recoverable and equally
|
||
// deterministic: a text-only deployment 400s the same request
|
||
// forever. Typed here (not just on the 404 arm) because
|
||
// OpenAI-compatible providers report it as a 400.
|
||
if status == 400 && is_unsupported_image_input_error(&body) {
|
||
return Err(PostError::Agent(AgentError::UnsupportedImageInput(body)));
|
||
}
|
||
return Err(PostError::Agent(AgentError::Llm(format!(
|
||
"{status}: {body}"
|
||
))));
|
||
}
|
||
if let Some(len) = resp.content_length() {
|
||
if len as usize > MAX_LLM_RESPONSE_BYTES {
|
||
return Err(PostError::Agent(AgentError::Llm(format!(
|
||
"response too large: {len} > {MAX_LLM_RESPONSE_BYTES}"
|
||
))));
|
||
}
|
||
}
|
||
let mut buf: Vec<u8> = Vec::new();
|
||
let mut stream = resp;
|
||
loop {
|
||
match stream.chunk().await {
|
||
Ok(Some(chunk)) => {
|
||
if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES {
|
||
// Size overflow: not a timeout, not retryable.
|
||
return Err(PostError::Agent(AgentError::Llm(format!(
|
||
"response exceeded {MAX_LLM_RESPONSE_BYTES} bytes"
|
||
))));
|
||
}
|
||
buf.extend_from_slice(&chunk);
|
||
}
|
||
Ok(None) => break,
|
||
Err(e) => {
|
||
// A timeout during body streaming means the total
|
||
// per-request budget expired mid-body (headers arrived but
|
||
// the response stalled). Retry with an escalated budget,
|
||
// same as a transport-phase timeout — the server may be
|
||
// slow to flush, not permanently broken.
|
||
if e.is_timeout() && attempt + 1 < MAX_RETRIES {
|
||
timeout_failures += 1;
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
is_timeout = true,
|
||
timeout_failures,
|
||
error = %e,
|
||
"llm: body-read timeout, retrying with escalated budget"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue 'attempt;
|
||
}
|
||
return Err(PostError::Agent(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&classify_body_read_error(&e, per_request_timeout),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
// A 2xx body that fails to parse (e.g. a provider flushing a
|
||
// truncated JSON document and closing the stream cleanly) is a
|
||
// transient upstream fault, not a request problem: retry it like a
|
||
// 5xx. Safe to re-send — a malformed body produced no parsed
|
||
// response, so no tool call was ever extracted from it, and the
|
||
// retried request is the identical completion POST captured in
|
||
// `body_bytes` at function entry.
|
||
match serde_json::from_slice(&buf) {
|
||
Ok(value) => return Ok(value),
|
||
Err(e) => {
|
||
if attempt + 1 < MAX_RETRIES {
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
error = %e,
|
||
"llm: malformed response body, retrying"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue;
|
||
}
|
||
return Err(PostError::Agent(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&format!("json: {e}"),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
// Unreachable in practice: every iteration either returns or continues.
|
||
// A fallthrough here would mean MAX_RETRIES was 0, which is rejected at
|
||
// config validation. Return a terminal error rather than panic so the
|
||
// invariant is not load-bearing.
|
||
Err(PostError::Agent(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
MAX_RETRIES,
|
||
"exhausted retries",
|
||
)))
|
||
}
|
||
|
||
pub(crate) fn databricks_pkce_config(
|
||
host: &str,
|
||
cache_dir_override: Option<PathBuf>,
|
||
) -> PkceOAuthConfig {
|
||
PkceOAuthConfig {
|
||
discovery_url: format!(
|
||
"{}/oidc/.well-known/oauth-authorization-server",
|
||
host.trim_end_matches('/')
|
||
),
|
||
client_id: DATABRICKS_CLIENT_ID.into(),
|
||
scopes: DATABRICKS_OAUTH_SCOPES
|
||
.iter()
|
||
.map(|scope| (*scope).into())
|
||
.collect(),
|
||
cache_namespace: "databricks".into(),
|
||
cache_dir_override,
|
||
}
|
||
}
|
||
|
||
/// Build the `TokenSource` for the configured provider.
|
||
///
|
||
/// - `Provider::Anthropic`: a static source seeded from `cfg.api_key`. It's
|
||
/// never read for Anthropic requests (those go through `post_anthropic` with
|
||
/// `x-api-key`), but Llm holds one to keep the field non-`Option`.
|
||
/// - `Provider::OpenAi`: a static source over `OPENAI_COMPAT_API_KEY`.
|
||
/// - `Provider::Databricks`: if `DATABRICKS_TOKEN` is set, a static source.
|
||
/// Otherwise a `PkceOAuthTokenSource` pointed at the workspace's OIDC
|
||
/// discovery URL. First request without a cached token triggers a browser
|
||
/// flow; subsequent requests use the cache + refresh transparently.
|
||
pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
|
||
match cfg.provider {
|
||
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => {
|
||
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
|
||
}
|
||
Provider::Databricks | Provider::DatabricksV2 => {
|
||
if !cfg.api_key.is_empty() {
|
||
return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())));
|
||
}
|
||
Ok(PkceOAuthTokenSource::new(databricks_pkce_config(
|
||
&cfg.base_url,
|
||
None,
|
||
))?)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Completion-token cap that [`Llm::summarize`] actually requests from
|
||
/// `provider`, given the caller's visible-text budget. OpenRouter grants
|
||
/// reasoning a separate, equal budget on top of the text budget (see
|
||
/// [`openrouter_summary_body`]), so its top-level cap is double the caller's
|
||
/// budget; every other provider requests the caller's budget unchanged.
|
||
/// Callers that reserve output headroom in an input budget
|
||
/// (`handoff_prompt_budget_bytes`) must reserve THIS value, not the text
|
||
/// budget — otherwise input plus the actual completion allowance can exceed
|
||
/// the configured context window.
|
||
pub(crate) fn summary_completion_cap(provider: Provider, max_output_tokens: u32) -> u32 {
|
||
match provider {
|
||
Provider::OpenRouter => max_output_tokens.saturating_mul(2),
|
||
Provider::Anthropic | Provider::OpenAi | Provider::Databricks | Provider::DatabricksV2 => {
|
||
max_output_tokens
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build the request body for `Llm::summarize` on `Provider::OpenRouter`.
|
||
/// Extracted so tests can assert on the actual wire shape instead of a
|
||
/// hand-rolled literal — summaries never carry config-driven reasoning
|
||
/// *effort* (see `apply_openrouter_mutations`, which the summary path never
|
||
/// calls). It spells the token limit `max_tokens` directly for the same
|
||
/// reason: the mutation that renames it is never applied here.
|
||
fn openrouter_summary_body(
|
||
effective_model: &str,
|
||
system_prompt: &str,
|
||
user_prompt: &str,
|
||
max_output_tokens: u32,
|
||
) -> Value {
|
||
// Reasoning models spend output tokens thinking before emitting any
|
||
// visible text, and that spend counts against `max_tokens`. Left
|
||
// unseparated, a model can burn the entire cap mid-reasoning and return an
|
||
// empty `content` — observed with deepseek-v4-flash, where 13 consecutive
|
||
// handoff attempts length-stopped inside the reasoning channel and every
|
||
// one degraded to lossy history truncation. Give reasoning its own
|
||
// equal-sized budget on top of the text budget so `max_output_tokens`
|
||
// remains what the caller means: visible summary text. `exclude` keeps the
|
||
// reasoning out of the response body; `summarize()` only reads `content`.
|
||
// Non-reasoning endpoints ignore the `reasoning` object (see
|
||
// `apply_openrouter_mutations` on why it is never paired with
|
||
// `provider.require_parameters`).
|
||
json!({
|
||
"model": effective_model,
|
||
"stream": false,
|
||
"max_tokens": summary_completion_cap(Provider::OpenRouter, max_output_tokens),
|
||
"reasoning": {
|
||
"max_tokens": max_output_tokens,
|
||
"exclude": true,
|
||
},
|
||
"messages": [
|
||
{ "role": "system", "content": system_prompt },
|
||
{ "role": "user", "content": user_prompt },
|
||
],
|
||
})
|
||
}
|
||
|
||
/// Return a clone of `body` with any top-level `"model"` field removed.
|
||
/// Used for Databricks model-serving, which encodes the model in the URL
|
||
/// path and rejects the field in the body.
|
||
fn strip_model(body: &Value) -> Value {
|
||
match body {
|
||
Value::Object(map) => {
|
||
let mut m = map.clone();
|
||
m.remove("model");
|
||
Value::Object(m)
|
||
}
|
||
other => other.clone(),
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
enum OpenRouterErrorClass {
|
||
Retryable(Option<std::time::Duration>),
|
||
Unknown,
|
||
}
|
||
|
||
/// Ceiling applied to the server-supplied `Retry-After` header before we
|
||
/// sleep on it. OpenRouter can advertise waits up to an hour, but
|
||
/// `openrouter_post`'s per-attempt sleep happens *outside* the per-request
|
||
/// `.timeout()` budget — an unclamped hint could keep a single turn alive for
|
||
/// up to two full-duration sleeps across `MAX_RETRIES`. Clamping (never
|
||
/// rejecting) keeps us honoring the server's backoff signal while bounding
|
||
/// worst-case turn latency to a value smaller than the per-request timeout.
|
||
const RETRY_AFTER_CAP_SECS: u64 = 60;
|
||
|
||
/// Compute the retry delay from a raw `Retry-After` header value.
|
||
///
|
||
/// Returns `Some(duration)` when the header is present, non-zero, and
|
||
/// parseable as a decimal number of seconds; the value is capped at
|
||
/// `RETRY_AFTER_CAP_SECS`. Returns `None` when the header is absent,
|
||
/// unparseable, or zero — callers should fall back to `backoff_with_jitter`.
|
||
///
|
||
/// This is the single place the cap logic lives; `parse_retry_after_header`
|
||
/// delegates here so the cap can be unit tested without an HTTP header map.
|
||
fn retry_delay_for_429(header_value: Option<&str>) -> Option<std::time::Duration> {
|
||
let val = header_value?;
|
||
let secs: u64 = val.trim().parse().ok()?;
|
||
(secs > 0).then(|| std::time::Duration::from_secs(secs.min(RETRY_AFTER_CAP_SECS)))
|
||
}
|
||
|
||
fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option<std::time::Duration> {
|
||
let val = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
|
||
retry_delay_for_429(Some(val))
|
||
}
|
||
|
||
fn classify_openrouter_error(
|
||
status: u16,
|
||
body: &str,
|
||
header_retry_after: Option<std::time::Duration>,
|
||
) -> OpenRouterErrorClass {
|
||
let parsed: Option<Value> = serde_json::from_str(body).ok();
|
||
let error_type = parsed
|
||
.as_ref()
|
||
.and_then(|v| v.get("error"))
|
||
.and_then(|e| e.get("metadata"))
|
||
.and_then(|m| m.get("error_type"))
|
||
.and_then(Value::as_str);
|
||
// OpenRouter's documented retry hint is the HTTP `Retry-After` header
|
||
// (see https://openrouter.ai/docs/api_reference/errors-and-debugging);
|
||
// no current doc specifies a body-level retry field, so we don't parse one.
|
||
let retry_after = header_retry_after;
|
||
|
||
match (status, error_type) {
|
||
(429, _) => OpenRouterErrorClass::Retryable(retry_after),
|
||
(502, _) => OpenRouterErrorClass::Retryable(None),
|
||
(503, Some("provider_overloaded")) => OpenRouterErrorClass::Retryable(retry_after),
|
||
_ => OpenRouterErrorClass::Unknown,
|
||
}
|
||
}
|
||
|
||
/// The one place the parameter-routing failure is worded. OpenRouter reports it
|
||
/// two ways — a 404 `No endpoints found ...` (what a `require_parameters`-style
|
||
/// or unsupported-parameter body actually returns) and an untyped 503 — and both
|
||
/// mean the same thing to the user: the model id is fine, the request shape is
|
||
/// not serveable by any endpoint behind it.
|
||
fn openrouter_parameter_routing_error(error_body: &str) -> AgentError {
|
||
AgentError::Llm(format!(
|
||
"no OpenRouter endpoint supports the requested parameters — \
|
||
check model, effort, and tool requirements: {error_body}"
|
||
))
|
||
}
|
||
|
||
async fn openrouter_post(
|
||
http: &Client,
|
||
url: &str,
|
||
body: &Value,
|
||
bearer: &str,
|
||
base_timeout: std::time::Duration,
|
||
) -> Result<Value, AgentError> {
|
||
let body_bytes =
|
||
serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?;
|
||
let call_start = std::time::Instant::now();
|
||
// Count prior timeout failures for per-attempt budget escalation; see
|
||
// `escalated_timeout` for the doubling strategy.
|
||
let mut timeout_failures: u32 = 0;
|
||
'attempt: for attempt in 0..MAX_RETRIES {
|
||
let per_request_timeout = escalated_timeout(base_timeout, timeout_failures);
|
||
let resp = match http
|
||
.post(url)
|
||
.header("content-type", "application/json")
|
||
.header("HTTP-Referer", "https://github.com/block/buzz")
|
||
.header("X-OpenRouter-Title", "Buzz")
|
||
.bearer_auth(bearer)
|
||
.body(body_bytes.clone())
|
||
.timeout(per_request_timeout)
|
||
.send()
|
||
.await
|
||
{
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
if e.is_timeout() {
|
||
timeout_failures += 1;
|
||
}
|
||
if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) {
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
error = %e,
|
||
is_timeout = e.is_timeout(),
|
||
timeout_failures,
|
||
"llm: openrouter transport error, retrying"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue;
|
||
}
|
||
return Err(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&classify_transport_error(&e, per_request_timeout),
|
||
));
|
||
}
|
||
};
|
||
let status = resp.status();
|
||
// Unlike the generic `post` path, OpenRouter's static-key auth makes
|
||
// 401 and 403 distinguishable: 401 is an invalid/expired key (worth
|
||
// one refresh-and-retry), while OpenRouter documents 403 as a
|
||
// guardrail/moderation/permission rejection the same key will always
|
||
// reproduce. Refreshing a static key returns the identical key, so
|
||
// classifying 403 as `LlmAuth` would just waste a duplicate request
|
||
// and surface Desktop's unrelated "access denied" copy.
|
||
if status == 401 {
|
||
return Err(AgentError::LlmAuth(read_error_body(resp).await));
|
||
}
|
||
if status == 403 {
|
||
return Err(AgentError::Llm(format!(
|
||
"{status}: {}",
|
||
read_error_body(resp).await
|
||
)));
|
||
}
|
||
if status == 402 {
|
||
return Err(AgentError::Llm(
|
||
"OpenRouter credits exhausted — check https://openrouter.ai/credits".into(),
|
||
));
|
||
}
|
||
if status == 404 {
|
||
// OpenRouter overloads 404: a genuinely unknown/unavailable model id
|
||
// and a valid model whose parameter set no endpoint can serve both
|
||
// land here. Discriminate on the full parameter-routing phrase rather
|
||
// than a prefix — "No endpoints found for <model>"-style bodies are
|
||
// about the model, and reporting a parameter problem as
|
||
// `LlmModelNotFound` (or vice versa) sends the user to the wrong fix.
|
||
let error_body = read_error_body(resp).await;
|
||
if is_unsupported_image_input_error(&error_body) {
|
||
return Err(AgentError::UnsupportedImageInput(error_body));
|
||
}
|
||
if error_body.contains("No endpoints found that can handle the requested parameters") {
|
||
return Err(openrouter_parameter_routing_error(&error_body));
|
||
}
|
||
return Err(AgentError::LlmModelNotFound(format!(
|
||
"{status}: {error_body}"
|
||
)));
|
||
}
|
||
// A6: status+error_type retry matrix
|
||
// 499 (Client Closed Request) is included: OpenRouter may emit it when a
|
||
// turn times out mid-stream. Will added 499 to the shared `post()` path in
|
||
// #2175 for the same reason; OpenRouter must match to avoid silently opting
|
||
// out of that stall-surfacing recovery.
|
||
if status.is_server_error() || status == 429 || status.as_u16() == 499 {
|
||
let header_retry_after = parse_retry_after_header(resp.headers());
|
||
let error_body = read_error_body(resp).await;
|
||
let should_retry = if attempt + 1 < MAX_RETRIES {
|
||
match classify_openrouter_error(status.as_u16(), &error_body, header_retry_after) {
|
||
OpenRouterErrorClass::Retryable(delay) => {
|
||
if let Some(d) = delay {
|
||
tokio::time::sleep(d).await;
|
||
} else {
|
||
backoff_with_jitter(attempt).await;
|
||
}
|
||
true
|
||
}
|
||
OpenRouterErrorClass::Unknown => {
|
||
backoff_with_jitter(attempt).await;
|
||
true
|
||
}
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
if should_retry {
|
||
continue;
|
||
}
|
||
// Terminal: classify for the user
|
||
return if status == 429 {
|
||
Err(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&format!("rate limited: {error_body}"),
|
||
))
|
||
} else {
|
||
let parsed: Option<Value> = serde_json::from_str(&error_body).ok();
|
||
let has_error_type = parsed
|
||
.as_ref()
|
||
.and_then(|v| v.get("error"))
|
||
.and_then(|e| e.get("metadata"))
|
||
.and_then(|m| m.get("error_type"))
|
||
.and_then(Value::as_str)
|
||
.is_some();
|
||
Err(if !has_error_type && status.as_u16() == 503 {
|
||
openrouter_parameter_routing_error(&error_body)
|
||
} else {
|
||
terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&format!("exhausted retries: {status}: {error_body}"),
|
||
)
|
||
})
|
||
};
|
||
}
|
||
if !status.is_success() {
|
||
let body = read_error_body(resp).await;
|
||
// Same recovery classification as the shared `post()` terminal:
|
||
// `openrouter_post` is a separate implementation with its own retry
|
||
// loop and status ladder, so it needs its own arm or OpenRouter
|
||
// agents keep the permanent context-400 stuck loop.
|
||
if status == 400 && is_context_length_error(&body) {
|
||
return Err(AgentError::LlmContextExceeded(format!("{status}: {body}")));
|
||
}
|
||
// Same 400-shaped image rejection as the shared `post()` terminal:
|
||
// OpenRouter normally reports this as a 404 (handled above), but a
|
||
// BYOK/passthrough upstream can surface the provider's own 400.
|
||
if status == 400 && is_unsupported_image_input_error(&body) {
|
||
return Err(AgentError::UnsupportedImageInput(body));
|
||
}
|
||
return Err(AgentError::Llm(format!("{status}: {body}")));
|
||
}
|
||
if let Some(len) = resp.content_length() {
|
||
if len as usize > MAX_LLM_RESPONSE_BYTES {
|
||
return Err(AgentError::Llm(format!(
|
||
"response too large: {len} > {MAX_LLM_RESPONSE_BYTES}"
|
||
)));
|
||
}
|
||
}
|
||
let mut buf: Vec<u8> = Vec::new();
|
||
let mut stream = resp;
|
||
loop {
|
||
match stream.chunk().await {
|
||
Ok(Some(chunk)) => {
|
||
if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES {
|
||
return Err(AgentError::Llm(format!(
|
||
"response exceeded {MAX_LLM_RESPONSE_BYTES} bytes"
|
||
)));
|
||
}
|
||
buf.extend_from_slice(&chunk);
|
||
}
|
||
Ok(None) => break,
|
||
Err(e) => {
|
||
// Body-read timeout: retry with escalated budget, same as
|
||
// transport-phase timeouts — see the corresponding arm in
|
||
// `post()` for the full rationale.
|
||
if e.is_timeout() && attempt + 1 < MAX_RETRIES {
|
||
timeout_failures += 1;
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
is_timeout = true,
|
||
timeout_failures,
|
||
error = %e,
|
||
"llm: openrouter body-read timeout, retrying with escalated budget"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue 'attempt;
|
||
}
|
||
return Err(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&classify_body_read_error(&e, per_request_timeout),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
// Same malformed-body retry as the shared `post()` terminal: a
|
||
// truncated 2xx JSON body is transient upstream trouble, and
|
||
// re-sending is provably tool-safe — nothing was parsed, so no tool
|
||
// call could have been extracted, and the retry re-uses the
|
||
// identical `body_bytes` completion request.
|
||
match serde_json::from_slice(&buf) {
|
||
Ok(value) => return Ok(value),
|
||
Err(e) => {
|
||
if attempt + 1 < MAX_RETRIES {
|
||
tracing::warn!(
|
||
attempt = attempt + 1,
|
||
max_attempts = MAX_RETRIES,
|
||
error = %e,
|
||
"llm: openrouter malformed response body, retrying"
|
||
);
|
||
backoff_with_jitter(attempt).await;
|
||
continue;
|
||
}
|
||
return Err(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
attempt + 1,
|
||
&format!("json: {e}"),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err(terminal_llm_error(
|
||
call_start.elapsed(),
|
||
MAX_RETRIES,
|
||
"exhausted retries",
|
||
))
|
||
}
|
||
|
||
fn apply_openrouter_mutations(
|
||
body: &mut Value,
|
||
effort: Option<ThinkingEffort>,
|
||
effective_model: &str,
|
||
prompt_caching: bool,
|
||
) {
|
||
if let Some(obj) = body.as_object_mut() {
|
||
// OpenRouter's Chat Completions API spells the output cap `max_tokens`;
|
||
// `max_completion_tokens` is OpenAI-native and only 53 of 274
|
||
// tools-capable OpenRouter models advertise it. Sending the OpenAI
|
||
// spelling risks the cap being dropped on the floor by the endpoint we
|
||
// route to, so translate it here rather than at the shared `openai_body`
|
||
// (which OpenAI and Databricks also use, where the OpenAI spelling is
|
||
// correct).
|
||
if let Some(max_tokens) = obj.remove("max_completion_tokens") {
|
||
obj.insert("max_tokens".into(), max_tokens);
|
||
}
|
||
|
||
// A2/A3: Add OpenRouter reasoning object when effort is configured.
|
||
// Deliberately NOT paired with `provider.require_parameters`: that filter
|
||
// routes only to endpoints advertising every parameter in the body, and
|
||
// 83 of 274 tools-capable models do not advertise `reasoning`, so it turns
|
||
// an opt-in effort setting into a hard 404 ("No endpoints found that can
|
||
// handle the requested parameters") on a model id that is perfectly
|
||
// valid. OpenRouter already best-effort routes on `tools`/`max_tokens`
|
||
// without the filter, so we accept a request served without reasoning
|
||
// over a request that cannot be served at all.
|
||
if let Some(e) = effort {
|
||
obj.insert(
|
||
"reasoning".into(),
|
||
json!({ "effort": e.openai_effort_str() }),
|
||
);
|
||
}
|
||
|
||
// A7: Anthropic cache_control injection for anthropic/* models. Gated on
|
||
// `prompt_caching` (`BUZZ_AGENT_PROMPT_CACHING`) for the same reason as
|
||
// the native Anthropic route: these are Anthropic-dialect breakpoints on
|
||
// an Anthropic model, so the documented kill switch must reach them too.
|
||
if prompt_caching && effective_model.starts_with("anthropic/") {
|
||
apply_anthropic_cache_control(obj);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn apply_anthropic_cache_control(body: &mut serde_json::Map<String, Value>) {
|
||
if let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) {
|
||
// Cache the system message
|
||
if let Some(system_msg) = messages
|
||
.iter_mut()
|
||
.find(|m| m.get("role").and_then(Value::as_str) == Some("system"))
|
||
{
|
||
if let Some(content) = system_msg.get("content").and_then(Value::as_str) {
|
||
let content_str = content.to_string();
|
||
if let Some(obj) = system_msg.as_object_mut() {
|
||
obj.insert(
|
||
"content".into(),
|
||
json!([{
|
||
"type": "text",
|
||
"text": content_str,
|
||
"cache_control": { "type": "ephemeral" }
|
||
}]),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cache last 2 user messages (skip image-only ones — A7 mixed-content regression)
|
||
let mut user_count = 0;
|
||
for msg in messages.iter_mut().rev() {
|
||
if msg.get("role").and_then(Value::as_str) != Some("user") {
|
||
continue;
|
||
}
|
||
// Only cache string content (plain text user messages), not array content
|
||
// (image batches from tool results). This prevents corrupting image-only
|
||
// user messages by converting them to text cache breakpoints.
|
||
if let Some(content) = msg.get("content").and_then(Value::as_str) {
|
||
let content_str = content.to_string();
|
||
if let Some(obj) = msg.as_object_mut() {
|
||
obj.insert(
|
||
"content".into(),
|
||
json!([{
|
||
"type": "text",
|
||
"text": content_str,
|
||
"cache_control": { "type": "ephemeral" }
|
||
}]),
|
||
);
|
||
}
|
||
user_count += 1;
|
||
}
|
||
if user_count >= 2 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Cache the last tool definition
|
||
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
|
||
if let Some(last_tool) = tools.last_mut() {
|
||
if let Some(function) = last_tool.get_mut("function").and_then(Value::as_object_mut) {
|
||
function.insert("cache_control".into(), json!({ "type": "ephemeral" }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
include!("llm_fqn_tests.rs");
|
||
use super::*;
|
||
use crate::config::{Config, HookServers, OpenAiApi, Provider, ThinkingSummary};
|
||
use crate::types::{HistoryItem, ToolCall, ToolResult, ToolResultContent};
|
||
use std::collections::VecDeque;
|
||
use std::time::Duration;
|
||
use tokio::sync::Mutex;
|
||
use tracing_subscriber::layer::SubscriberExt;
|
||
|
||
fn cfg(provider: Provider) -> Config {
|
||
Config {
|
||
provider,
|
||
system_prompt: "system".into(),
|
||
max_rounds: 10,
|
||
max_output_tokens: 1024,
|
||
max_token_recoveries: 3,
|
||
llm_timeout: Duration::from_secs(10),
|
||
tool_timeout: Duration::from_secs(10),
|
||
mcp_init_timeout: Duration::from_secs(10),
|
||
mcp_max_restart_attempts: 1,
|
||
mcp_restart_base_ms: 1,
|
||
mcp_restart_max_ms: 1,
|
||
max_sessions: 1,
|
||
max_line_bytes: 1024 * 1024,
|
||
max_history_bytes: 16 * 1024 * 1024,
|
||
max_tool_result_text_bytes: 50 * 1024,
|
||
max_context_tokens: 200_000,
|
||
max_handoffs: 1,
|
||
max_parallel_tools: 1,
|
||
max_pending_permissions: 32,
|
||
permission_timeout: Duration::from_secs(330),
|
||
hook_timeout: Duration::from_secs(1),
|
||
stop_max_rejections: 0,
|
||
require_reply: false,
|
||
hook_servers: HookServers::None,
|
||
databricks_model_filter: None,
|
||
api_key: "key".into(),
|
||
model: "model".into(),
|
||
base_url: "http://example.invalid".into(),
|
||
anthropic_api_version: "2023-06-01".into(),
|
||
openai_api: OpenAiApi::Chat,
|
||
hints_enabled: true,
|
||
thinking_effort: None,
|
||
thinking_summary: ThinkingSummary::Auto,
|
||
prompt_caching: true,
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct CapturedHttpRequest {
|
||
method: String,
|
||
path: String,
|
||
user_agent: Option<String>,
|
||
body: Option<Value>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct StubHttpResponse {
|
||
status: u16,
|
||
body: Value,
|
||
}
|
||
|
||
impl StubHttpResponse {
|
||
fn ok(body: Value) -> Self {
|
||
Self { status: 200, body }
|
||
}
|
||
|
||
fn error(status: u16, message: &str) -> Self {
|
||
Self {
|
||
status,
|
||
body: json!({
|
||
"error": {
|
||
"message": message,
|
||
"type": "server_error",
|
||
"code": "service_unavailable",
|
||
}
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn spawn_sequence_stub(
|
||
responses: Vec<StubHttpResponse>,
|
||
) -> (String, Arc<Mutex<Vec<CapturedHttpRequest>>>) {
|
||
use std::collections::VecDeque;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let base_url = format!("http://{}/v1", listener.local_addr().unwrap());
|
||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||
let captured_for_server = captured.clone();
|
||
let responses = Arc::new(Mutex::new(VecDeque::from(responses)));
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut socket, _) = match listener.accept().await {
|
||
Ok(connection) => connection,
|
||
Err(_) => return,
|
||
};
|
||
let mut bytes = Vec::new();
|
||
let mut chunk = [0u8; 4096];
|
||
let header_end = loop {
|
||
if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
||
break index + 4;
|
||
}
|
||
match socket.read(&mut chunk).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(read) => bytes.extend_from_slice(&chunk[..read]),
|
||
}
|
||
};
|
||
let header_text = String::from_utf8_lossy(&bytes[..header_end]).into_owned();
|
||
let content_length = header_text
|
||
.lines()
|
||
.find_map(|line| {
|
||
let (name, value) = line.split_once(':')?;
|
||
name.eq_ignore_ascii_case("content-length")
|
||
.then(|| value.trim().parse::<usize>().ok())
|
||
.flatten()
|
||
})
|
||
.unwrap_or(0);
|
||
while bytes.len() < header_end + content_length {
|
||
match socket.read(&mut chunk).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(read) => bytes.extend_from_slice(&chunk[..read]),
|
||
}
|
||
}
|
||
let user_agent = header_text.lines().find_map(|line| {
|
||
let (name, value) = line.split_once(':')?;
|
||
name.eq_ignore_ascii_case("user-agent")
|
||
.then(|| value.trim().to_string())
|
||
});
|
||
let mut request_line = header_text.lines().next().unwrap_or_default().split(' ');
|
||
let method = request_line.next().unwrap_or_default().to_string();
|
||
let path = request_line.next().unwrap_or_default().to_string();
|
||
let body = if content_length == 0 {
|
||
None
|
||
} else {
|
||
serde_json::from_slice(&bytes[header_end..header_end + content_length]).ok()
|
||
};
|
||
captured_for_server.lock().await.push(CapturedHttpRequest {
|
||
method,
|
||
path,
|
||
user_agent,
|
||
body,
|
||
});
|
||
|
||
let response = responses.lock().await.pop_front().unwrap_or_else(|| {
|
||
StubHttpResponse::error(500, "stub response sequence exhausted")
|
||
});
|
||
let status_text = match response.status {
|
||
200 => "OK",
|
||
400 => "Bad Request",
|
||
413 => "Payload Too Large",
|
||
500 => "Internal Server Error",
|
||
502 => "Bad Gateway",
|
||
503 => "Service Unavailable",
|
||
other => panic!("unsupported stub status: {other}"),
|
||
};
|
||
let body = response.body.to_string();
|
||
let wire = format!(
|
||
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
response.status,
|
||
status_text,
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = socket.write_all(wire.as_bytes()).await;
|
||
let _ = socket.shutdown().await;
|
||
}
|
||
});
|
||
(base_url, captured)
|
||
}
|
||
|
||
fn chat_response(text: &str) -> Value {
|
||
json!({
|
||
"choices": [{
|
||
"finish_reason": "stop",
|
||
"message": { "content": text },
|
||
}]
|
||
})
|
||
}
|
||
|
||
async fn complete_model(
|
||
llm: &Llm,
|
||
cfg: &Config,
|
||
model: &str,
|
||
) -> Result<LlmResponse, AgentError> {
|
||
llm.complete(
|
||
cfg,
|
||
"system",
|
||
&[HistoryItem::User("hello".into())],
|
||
&[],
|
||
model,
|
||
)
|
||
.await
|
||
}
|
||
|
||
fn posted_models(requests: &[CapturedHttpRequest]) -> Vec<&str> {
|
||
requests
|
||
.iter()
|
||
.filter(|request| request.method == "POST")
|
||
.filter_map(|request| request.body.as_ref()?.get("model")?.as_str())
|
||
.collect()
|
||
}
|
||
|
||
/// Every LLM request identifies the client to the provider via the
|
||
/// first User-Agent product token.
|
||
#[tokio::test]
|
||
async fn llm_requests_carry_buzz_agent_user_agent() {
|
||
let (base_url, captured) =
|
||
spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("ok"))]).await;
|
||
let mut config = cfg(Provider::OpenAi);
|
||
config.base_url = base_url;
|
||
let llm = Llm::new(&config).unwrap();
|
||
|
||
complete_model(&llm, &config, "gpt-test").await.unwrap();
|
||
let requests = captured.lock().await;
|
||
let expected = format!("buzz-agent/{}", env!("CARGO_PKG_VERSION"));
|
||
assert_eq!(requests.len(), 1);
|
||
assert_eq!(requests[0].user_agent.as_deref(), Some(expected.as_str()));
|
||
}
|
||
|
||
/// An explicit model is sent verbatim and never rewritten to something
|
||
/// else. A server error is retried under the *same* model (the ordinary
|
||
/// transport retry) and then surfaced -- there is no second model to fall
|
||
/// back to now that MeshLLM resolves `mesh` server-side.
|
||
#[tokio::test]
|
||
async fn explicit_models_are_never_rewritten_or_fallback_retried() {
|
||
let (base_url, captured) = spawn_sequence_stub(
|
||
(0..MAX_RETRIES)
|
||
.map(|_| StubHttpResponse::error(503, "upstream unavailable"))
|
||
.collect(),
|
||
)
|
||
.await;
|
||
let mut config = cfg(Provider::OpenAi);
|
||
config.base_url = base_url;
|
||
let llm = Llm::new(&config).unwrap();
|
||
|
||
let error = complete_model(&llm, &config, "mesh").await.unwrap_err();
|
||
assert!(error.to_string().contains("upstream unavailable"));
|
||
let requests = captured.lock().await;
|
||
assert!(
|
||
requests
|
||
.iter()
|
||
.filter(|request| request.method == "POST")
|
||
.all(|request| request.body.as_ref().and_then(|b| b.get("model"))
|
||
== Some(&json!("mesh"))),
|
||
"every attempt must keep the explicit model: {:?}",
|
||
posted_models(&requests)
|
||
);
|
||
assert!(requests.iter().all(|request| request.path != "/v1/models"));
|
||
}
|
||
|
||
/// Whatever model the config names is what goes on the wire -- including
|
||
/// `auto`, which is a real provider model name for plain OpenAI hosts. A
|
||
/// caller routing through a mesh resolves its own name before spawning the
|
||
/// agent, so there is nothing to rewrite here and no catalog to probe.
|
||
#[tokio::test]
|
||
async fn the_configured_model_is_sent_verbatim() {
|
||
for model in ["auto", "mesh", "unsloth/Qwen3-8B-GGUF:Q4_K_M"] {
|
||
let (base_url, captured) =
|
||
spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("ok"))]).await;
|
||
let mut config = cfg(Provider::OpenAi);
|
||
config.base_url = base_url;
|
||
let llm = Llm::new(&config).unwrap();
|
||
|
||
let response = complete_model(&llm, &config, model).await.unwrap();
|
||
assert_eq!(response.text, "ok");
|
||
let requests = captured.lock().await;
|
||
assert_eq!(posted_models(&requests), vec![model]);
|
||
assert!(
|
||
requests.iter().all(|request| request.path != "/v1/models"),
|
||
"no catalog probe belongs on this path"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn databricks_v2_claude_model_service_fqn_summary_uses_anthropic_messages() {
|
||
let model = "catalog.schema.claude-gpt-5";
|
||
let response = json!({
|
||
"content": [{"type": "text", "text": "summary"}],
|
||
"stop_reason": "end_turn"
|
||
});
|
||
let (base_url, captured) = spawn_sequence_stub(vec![StubHttpResponse::ok(response)]).await;
|
||
let mut config = cfg(Provider::DatabricksV2);
|
||
config.base_url = base_url;
|
||
let llm = Llm::new(&config).unwrap();
|
||
|
||
let summary = llm
|
||
.summarize(&config, "system", "history", 128, model)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(summary, "summary");
|
||
|
||
let requests = captured.lock().await;
|
||
let request = requests
|
||
.iter()
|
||
.find(|request| request.method == "POST")
|
||
.expect("summary must issue one POST");
|
||
assert_eq!(request.path, "/v1/ai-gateway/anthropic/v1/messages");
|
||
let body = request.body.as_ref().expect("summary body");
|
||
assert_eq!(body["model"], model);
|
||
assert!(body["messages"].is_array());
|
||
assert_eq!(body["max_tokens"], 128);
|
||
}
|
||
|
||
fn image_history() -> Vec<HistoryItem> {
|
||
vec![
|
||
HistoryItem::User("describe the image".into()),
|
||
HistoryItem::Assistant {
|
||
text: String::new(),
|
||
tool_calls: vec![ToolCall {
|
||
provider_id: "toolu_1".into(),
|
||
name: "dev__view_image".into(),
|
||
arguments: serde_json::json!({"source":"x.png"}),
|
||
provider_extra: Default::default(),
|
||
}],
|
||
reasoning_details: None,
|
||
},
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "toolu_1".into(),
|
||
content: vec![
|
||
ToolResultContent::Text("10×10, 70 B (image/png from x.png)".into()),
|
||
ToolResultContent::Image {
|
||
data: "aW1n".into(),
|
||
mime_type: "image/png".into(),
|
||
},
|
||
],
|
||
is_error: false,
|
||
}),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_tool_result_preserves_image_block() {
|
||
let body = anthropic_body(
|
||
&cfg(Provider::Anthropic),
|
||
"system",
|
||
&image_history(),
|
||
&[],
|
||
"model",
|
||
None,
|
||
"anthropic",
|
||
);
|
||
let content = &body["messages"][2]["content"][0]["content"];
|
||
assert_eq!(content[0]["type"], "text");
|
||
assert_eq!(content[1]["type"], "image");
|
||
assert_eq!(content[1]["source"]["type"], "base64");
|
||
assert_eq!(content[1]["source"]["media_type"], "image/png");
|
||
assert_eq!(content[1]["source"]["data"], "aW1n");
|
||
}
|
||
|
||
fn cfg_responses() -> Config {
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.openai_api = OpenAiApi::Responses;
|
||
c
|
||
}
|
||
|
||
fn tool_call_history() -> Vec<HistoryItem> {
|
||
vec![
|
||
HistoryItem::User("call the tool".into()),
|
||
HistoryItem::Assistant {
|
||
text: "ok, calling".into(),
|
||
tool_calls: vec![ToolCall {
|
||
provider_id: "call_abc".into(),
|
||
name: "dev__shell".into(),
|
||
arguments: serde_json::json!({"command": "ls"}),
|
||
provider_extra: Default::default(),
|
||
}],
|
||
reasoning_details: None,
|
||
},
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "call_abc".into(),
|
||
content: vec![ToolResultContent::Text("file.txt".into())],
|
||
is_error: false,
|
||
}),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_top_level_shape() {
|
||
let tools = vec![ToolDef {
|
||
name: "dev__shell".into(),
|
||
description: "run a shell command".into(),
|
||
input_schema: serde_json::json!({
|
||
"type": "object",
|
||
"properties": {"command": {"type": "string"}},
|
||
}),
|
||
}];
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&tools,
|
||
"model",
|
||
None,
|
||
);
|
||
assert_eq!(body["model"], "model");
|
||
assert_eq!(body["instructions"], "system");
|
||
assert_eq!(body["max_output_tokens"], 1024);
|
||
assert!(
|
||
body.get("messages").is_none(),
|
||
"must use `input`, not `messages`"
|
||
);
|
||
assert!(body.get("max_tokens").is_none());
|
||
assert!(body.get("max_completion_tokens").is_none());
|
||
|
||
// Tools are flat — top-level type/name/description/parameters.
|
||
let tool = &body["tools"][0];
|
||
assert_eq!(tool["type"], "function");
|
||
assert_eq!(tool["name"], "dev__shell");
|
||
assert!(
|
||
tool.get("function").is_none(),
|
||
"Responses tool schema is flat"
|
||
);
|
||
assert_eq!(body["tool_choice"], "auto");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_replay_emits_function_call_before_output() {
|
||
// Replay requirement from the live API: the assistant's prior
|
||
// function_call item *must* appear in `input[]` before its matching
|
||
// function_call_output, otherwise the API rejects with
|
||
// "No tool call found for call_id ...".
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&tool_call_history(),
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
let input = body["input"].as_array().unwrap();
|
||
|
||
// [0] user, [1] assistant text, [2] function_call, [3] function_call_output
|
||
assert_eq!(input[0]["role"], "user");
|
||
assert_eq!(input[0]["content"][0]["type"], "input_text");
|
||
assert_eq!(input[0]["content"][0]["text"], "call the tool");
|
||
|
||
assert_eq!(input[1]["role"], "assistant");
|
||
assert_eq!(input[1]["content"][0]["type"], "output_text");
|
||
assert_eq!(input[1]["content"][0]["text"], "ok, calling");
|
||
|
||
assert_eq!(input[2]["type"], "function_call");
|
||
assert_eq!(input[2]["call_id"], "call_abc");
|
||
assert_eq!(input[2]["name"], "dev__shell");
|
||
// Arguments are a JSON-encoded string per spec.
|
||
assert_eq!(input[2]["arguments"], "{\"command\":\"ls\"}");
|
||
|
||
assert_eq!(input[3]["type"], "function_call_output");
|
||
assert_eq!(input[3]["call_id"], "call_abc");
|
||
assert_eq!(input[3]["output"], "file.txt");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_skips_empty_assistant_text() {
|
||
// Mirrors the Chat Completions behavior (#559/#560): empty assistant
|
||
// turns are skipped so we don't emit an empty `output_text` block,
|
||
// but the tool_call(s) on that assistant turn still go through.
|
||
let history = vec![
|
||
HistoryItem::User("u".into()),
|
||
HistoryItem::Assistant {
|
||
text: String::new(),
|
||
tool_calls: vec![ToolCall {
|
||
provider_id: "call_x".into(),
|
||
name: "t".into(),
|
||
arguments: serde_json::json!({}),
|
||
provider_extra: Default::default(),
|
||
}],
|
||
reasoning_details: None,
|
||
},
|
||
];
|
||
let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None);
|
||
let input = body["input"].as_array().unwrap();
|
||
assert_eq!(input.len(), 2);
|
||
assert_eq!(input[0]["role"], "user");
|
||
assert_eq!(input[1]["type"], "function_call");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_image_tool_result_attaches_input_image() {
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&image_history(),
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
let input = body["input"].as_array().unwrap();
|
||
// function_call_output carries the text part; image rides on a
|
||
// trailing user message as `input_image`.
|
||
let fco = input
|
||
.iter()
|
||
.find(|i| i["type"] == "function_call_output")
|
||
.unwrap();
|
||
assert_eq!(fco["call_id"], "toolu_1");
|
||
let img_msg = input.iter().rev().find(|i| i["role"] == "user").unwrap();
|
||
assert_eq!(img_msg["content"][0]["type"], "input_image");
|
||
assert_eq!(
|
||
img_msg["content"][0]["image_url"],
|
||
"data:image/png;base64,aW1n"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_completed_with_text_is_end_turn() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "hello"}],
|
||
}],
|
||
});
|
||
let r = parse_responses(v).unwrap();
|
||
assert_eq!(r.text, "hello");
|
||
assert!(r.tool_calls.is_empty());
|
||
assert_eq!(r.stop, ProviderStop::EndTurn);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_completed_with_function_call_is_tool_use() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [
|
||
{"type": "reasoning", "id": "rs_1", "summary": []},
|
||
{
|
||
"type": "function_call",
|
||
"call_id": "call_z",
|
||
"name": "dev__shell",
|
||
"arguments": "{\"command\":\"ls\"}",
|
||
},
|
||
],
|
||
});
|
||
let r = parse_responses(v).unwrap();
|
||
assert_eq!(r.text, "");
|
||
assert_eq!(r.tool_calls.len(), 1);
|
||
assert_eq!(r.tool_calls[0].provider_id, "call_z");
|
||
assert_eq!(r.tool_calls[0].name, "dev__shell");
|
||
assert_eq!(
|
||
r.tool_calls[0].arguments,
|
||
serde_json::json!({"command": "ls"})
|
||
);
|
||
assert_eq!(r.stop, ProviderStop::ToolUse);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_incomplete_max_output_tokens() {
|
||
let v = serde_json::json!({
|
||
"status": "incomplete",
|
||
"incomplete_details": {"reason": "max_output_tokens"},
|
||
"output": [{
|
||
"type": "function_call",
|
||
"call_id": "partial",
|
||
"name": "dev__shell",
|
||
"arguments": "{\"command\":\"unterminated",
|
||
}],
|
||
});
|
||
let r = parse_responses(v).unwrap();
|
||
assert_eq!(r.stop, ProviderStop::MaxTokens);
|
||
assert!(r.tool_calls.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn truncated_openai_tool_arguments_are_discarded_not_rejected() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "length",
|
||
"message": {
|
||
"content": "partial text",
|
||
"tool_calls": [{
|
||
"id": "partial",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "dev__shell",
|
||
"arguments": "{\"command\":\"unterminated",
|
||
},
|
||
}],
|
||
},
|
||
}],
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.stop, ProviderStop::MaxTokens);
|
||
assert_eq!(r.text, "partial text");
|
||
assert!(r.tool_calls.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_context_window_exhaustion_is_truncation() {
|
||
let v = serde_json::json!({
|
||
"stop_reason": "model_context_window_exceeded",
|
||
"content": [{"type": "text", "text": "partial text"}],
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.stop, ProviderStop::MaxTokens);
|
||
assert_eq!(r.text, "partial text");
|
||
}
|
||
|
||
#[test]
|
||
fn truncated_anthropic_tool_use_is_discarded_not_rejected() {
|
||
let v = serde_json::json!({
|
||
"stop_reason": "max_tokens",
|
||
"content": [{"type": "tool_use", "id": "", "name": "", "input": null}],
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.stop, ProviderStop::MaxTokens);
|
||
assert!(r.tool_calls.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn is_responses_required_error_matrix() {
|
||
for (body, want) in [
|
||
// Databricks GPT-5.5 (the actual case we observed).
|
||
("Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead.", true),
|
||
// Forward-compat: OpenAI saying the same thing in prose.
|
||
("This model requires the Responses API. Please use the Responses API instead.", true),
|
||
// Negatives — must NOT trigger on unrelated 4xx.
|
||
("{\"error\":\"invalid_api_key\"}", false),
|
||
("max_tokens is not supported with this model", false),
|
||
("", false),
|
||
] {
|
||
assert_eq!(is_responses_required_error(body), want, "body={body:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn databricks_v2_dispatch_routes_from_manifest() {
|
||
use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses};
|
||
// Exercises the production dispatch seam (`databricks_v2_route` +
|
||
// `databricks_v2_path`), not the interpreter — these are the exact
|
||
// functions `databricks_v2_request` calls to pick a wire. Expected
|
||
// values are the manifest-ratified answers (corpus class F et al.), so
|
||
// this is the wire-visible contract, not a restatement of the resolver.
|
||
for (model, route, path) in [
|
||
// OpenAI-shaped: the gpt family plus the GPT-5 code names.
|
||
(
|
||
"databricks-gpt-5-5",
|
||
OpenAiResponses,
|
||
"/ai-gateway/openai/v1/responses",
|
||
),
|
||
(
|
||
"databricks-gpt-5-6-luna",
|
||
OpenAiResponses,
|
||
"/ai-gateway/openai/v1/responses",
|
||
),
|
||
(
|
||
"databricks-gpt-5-6-sol",
|
||
OpenAiResponses,
|
||
"/ai-gateway/openai/v1/responses",
|
||
),
|
||
// Anthropic-shaped: curated `databricks-claude-*` names keep the
|
||
// cache-capable Messages wire via the manifest prefix rule.
|
||
(
|
||
"databricks-claude-opus-4-7",
|
||
AnthropicMessages,
|
||
"/ai-gateway/anthropic/v1/messages",
|
||
),
|
||
(
|
||
"Databricks-Claude-Opus-5",
|
||
AnthropicMessages,
|
||
"/ai-gateway/anthropic/v1/messages",
|
||
),
|
||
// WIRE-VISIBLE CHANGE (corpus class F): an *uncurated* Claude
|
||
// code-name endpoint no longer routes to Anthropic Messages. The
|
||
// legacy segment classifier sent `goose-opus-5` to the cache-capable
|
||
// wire off the bare `opus` segment; the manifest treats only
|
||
// curated `databricks-claude-*` / exact records as Anthropic, so
|
||
// bare code names fall to the MLflow chat route (losing Anthropic
|
||
// prompt caching on those names). See the mutation guard below.
|
||
(
|
||
"goose-opus-5",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
(
|
||
"opus-5",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
// Unrecognised names still fall through to the MLflow chat route.
|
||
(
|
||
"custom-tool-model",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
// Collision guard: short code names must match only as whole
|
||
// segments, never as substrings of an unrelated custom alias.
|
||
(
|
||
"consolidated-llama",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
(
|
||
"terraform-coder",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
(
|
||
"corpus-reranker",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
(
|
||
"octopus-model",
|
||
MlflowChatCompletions,
|
||
"/ai-gateway/mlflow/v1/chat/completions",
|
||
),
|
||
] {
|
||
let got = databricks_v2_route(model);
|
||
assert_eq!(got, route, "model={model}");
|
||
assert_eq!(databricks_v2_path(got), path, "model={model}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn databricks_v2_model_service_fqn_shape_is_strict_and_precedes_manifest() {
|
||
use crate::model_capabilities::{resolve, DatabricksV2Route as Manifest};
|
||
|
||
for (model, expected) in [
|
||
(
|
||
"catalog.schema.service",
|
||
DatabricksV2Route::MlflowChatCompletions,
|
||
),
|
||
(
|
||
"catalog.schema.claude-gpt-5",
|
||
DatabricksV2Route::AnthropicMessages,
|
||
),
|
||
(
|
||
"data_tools.goose.kimi-k3",
|
||
DatabricksV2Route::MlflowChatCompletions,
|
||
),
|
||
] {
|
||
assert!(
|
||
crate::model_capabilities::is_databricks_model_service_fqn(model),
|
||
"expected FQN shape: {model}"
|
||
);
|
||
assert_eq!(
|
||
databricks_v2_route(model),
|
||
expected,
|
||
"only a Claude service component may select Anthropic Messages: {model}"
|
||
);
|
||
}
|
||
|
||
let manifest_route =
|
||
|model: &str| match resolve("databricks_v2", model).databricks_v2_wire_route {
|
||
Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses,
|
||
Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages,
|
||
Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => {
|
||
DatabricksV2Route::MlflowChatCompletions
|
||
}
|
||
};
|
||
for model in [
|
||
"catalog.schema",
|
||
"catalog..service",
|
||
".schema.service",
|
||
"catalog.schema.",
|
||
"catalog.schema.service.extra",
|
||
"catalog/schema/service",
|
||
"catalog.schema service",
|
||
] {
|
||
assert!(
|
||
!crate::model_capabilities::is_databricks_model_service_fqn(model),
|
||
"unexpected FQN shape: {model}"
|
||
);
|
||
assert_eq!(
|
||
databricks_v2_route(model),
|
||
manifest_route(model),
|
||
"malformed/partial IDs must retain manifest routing: {model}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn databricks_v2_dispatch_is_pure_manifest_projection() {
|
||
// Mutation-bypass guard: the dispatch seam must be a pure projection of
|
||
// the manifest's resolved `databricks_v2_wire_route`, with no routing
|
||
// decision of its own. For every known DBv2 model (plus the legacy
|
||
// collision-guard and code-name cases), the seam's wire choice must
|
||
// equal the enum mapping of `resolve(...).databricks_v2_wire_route`.
|
||
//
|
||
// Reintroducing the deleted segment classifier — or any `if
|
||
// model.contains("opus")`-style shortcut that bypasses the manifest —
|
||
// disagrees with the manifest on `goose-opus-5` (segment → Anthropic,
|
||
// manifest → MLflow) and fails this test.
|
||
use crate::model_capabilities::{resolve, DatabricksV2Route as Manifest};
|
||
let expected = |model: &str| match resolve("databricks_v2", model).databricks_v2_wire_route
|
||
{
|
||
Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses,
|
||
Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages,
|
||
Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => {
|
||
DatabricksV2Route::MlflowChatCompletions
|
||
}
|
||
};
|
||
let mut models: Vec<String> =
|
||
crate::model_capabilities::databricks_v2_known_models().to_vec();
|
||
// Uncurated / adversarial names the known-model list does not carry, so
|
||
// the guard covers the exact inputs the legacy classifier misrouted.
|
||
for extra in [
|
||
"goose-opus-5",
|
||
"opus-5",
|
||
"goose-claude-fable-5",
|
||
"consolidated-llama",
|
||
"terraform-coder",
|
||
"corpus-reranker",
|
||
"octopus-model",
|
||
"gpt-opus-5",
|
||
] {
|
||
models.push(extra.to_string());
|
||
}
|
||
for model in &models {
|
||
assert_eq!(
|
||
databricks_v2_route(model),
|
||
expected(model),
|
||
"dispatch seam diverged from manifest authority for model={model}"
|
||
);
|
||
}
|
||
// The class-F case, stated as a hard fact so the guard's intent is
|
||
// legible: the manifest routes `goose-opus-5` to the MLflow wire, and
|
||
// the seam agrees — the legacy Anthropic answer is gone.
|
||
assert_eq!(
|
||
databricks_v2_route("goose-opus-5"),
|
||
DatabricksV2Route::MlflowChatCompletions
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_rejects_malformed_function_arguments() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "function_call",
|
||
"call_id": "call_z",
|
||
"name": "t",
|
||
"arguments": "not json {",
|
||
}],
|
||
});
|
||
assert!(matches!(parse_responses(v), Err(AgentError::Llm(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn openai_tool_result_adds_followup_image_user_message() {
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&image_history(),
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
assert_eq!(body["messages"][3]["role"], "tool");
|
||
assert!(body["messages"][3]["content"]
|
||
.as_str()
|
||
.unwrap()
|
||
.contains("provided in the next user message"));
|
||
assert_eq!(body["messages"][4]["role"], "user");
|
||
assert_eq!(body["messages"][4]["content"][0]["type"], "image_url");
|
||
assert_eq!(
|
||
body["messages"][4]["content"][0]["image_url"]["url"],
|
||
"data:image/png;base64,aW1n"
|
||
);
|
||
}
|
||
|
||
/// Regression for Databricks model serving (and any OpenAI-Chat frontend
|
||
/// that translates to Anthropic on the way to the model). Parallel tool
|
||
/// calls where one or more return images previously produced an
|
||
/// interleaved sequence:
|
||
/// role:"tool" (A)
|
||
/// role:"user" (image A)
|
||
/// role:"tool" (B)
|
||
/// role:"user" (image B)
|
||
/// The intervening user message split the run of tool results, so the
|
||
/// translator could no longer fold them into a single Anthropic
|
||
/// `tool_result`-bearing user message — Anthropic then rejected the
|
||
/// request with "tool_use ids were found without tool_result blocks
|
||
/// immediately after". Fix: every `role:"tool"` for a run of adjacent
|
||
/// ToolResults emits contiguously, then a single trailing user message
|
||
/// carries all of the images from the batch.
|
||
#[test]
|
||
fn openai_parallel_image_tool_results_stay_contiguous() {
|
||
let history = vec![
|
||
HistoryItem::User("describe both images".into()),
|
||
HistoryItem::Assistant {
|
||
text: String::new(),
|
||
tool_calls: vec![
|
||
ToolCall {
|
||
provider_id: "toolu_a".into(),
|
||
name: "dev__view_image".into(),
|
||
arguments: serde_json::json!({"source": "a.png"}),
|
||
provider_extra: Default::default(),
|
||
},
|
||
ToolCall {
|
||
provider_id: "toolu_b".into(),
|
||
name: "dev__view_image".into(),
|
||
arguments: serde_json::json!({"source": "b.png"}),
|
||
provider_extra: Default::default(),
|
||
},
|
||
],
|
||
reasoning_details: None,
|
||
},
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "toolu_a".into(),
|
||
content: vec![
|
||
ToolResultContent::Text("10×10, 70 B (image/png from a.png)".into()),
|
||
ToolResultContent::Image {
|
||
data: "aaa".into(),
|
||
mime_type: "image/png".into(),
|
||
},
|
||
],
|
||
is_error: false,
|
||
}),
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "toolu_b".into(),
|
||
content: vec![
|
||
ToolResultContent::Text("10×10, 70 B (image/png from b.png)".into()),
|
||
ToolResultContent::Image {
|
||
data: "bbb".into(),
|
||
mime_type: "image/png".into(),
|
||
},
|
||
],
|
||
is_error: false,
|
||
}),
|
||
];
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&history,
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
// [0] system, [1] user, [2] assistant(tool_calls), [3] tool A, [4] tool B, [5] user(images)
|
||
assert_eq!(messages.len(), 6, "messages: {messages:#?}");
|
||
assert_eq!(messages[3]["role"], "tool");
|
||
assert_eq!(messages[3]["tool_call_id"], "toolu_a");
|
||
assert_eq!(
|
||
messages[4]["role"], "tool",
|
||
"tool results must stay adjacent; intervening user message breaks Databricks/Anthropic pairing"
|
||
);
|
||
assert_eq!(messages[4]["tool_call_id"], "toolu_b");
|
||
assert_eq!(messages[5]["role"], "user");
|
||
let imgs = messages[5]["content"].as_array().unwrap();
|
||
assert_eq!(imgs.len(), 2);
|
||
assert_eq!(imgs[0]["image_url"]["url"], "data:image/png;base64,aaa");
|
||
assert_eq!(imgs[1]["image_url"]["url"], "data:image/png;base64,bbb");
|
||
}
|
||
|
||
// ---- prompt caching (cache_control) body-shape tests ----
|
||
|
||
#[test]
|
||
fn anthropic_body_stamps_cache_control_when_enabled() {
|
||
let body = anthropic_body(
|
||
&cfg(Provider::DatabricksV2),
|
||
"sys",
|
||
&[
|
||
HistoryItem::User("hello".into()),
|
||
HistoryItem::Assistant {
|
||
text: "hi".into(),
|
||
tool_calls: vec![],
|
||
reasoning_details: None,
|
||
},
|
||
HistoryItem::User("more".into()),
|
||
],
|
||
&[],
|
||
"databricks-claude-opus-5",
|
||
None,
|
||
"databricks_v2",
|
||
);
|
||
// Static prefix: system promoted to a structured block carrying the marker.
|
||
assert_eq!(body["system"][0]["type"], "text");
|
||
assert_eq!(body["system"][0]["text"], "sys");
|
||
assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral");
|
||
// Leapfrog: the last block of the last TWO messages is marked; earlier
|
||
// ones are not. Three distinct user/assistant/user turns → messages[1]
|
||
// and messages[2] marked, messages[0] clean.
|
||
let msgs = body["messages"].as_array().unwrap();
|
||
assert_eq!(msgs.len(), 3);
|
||
let tail_block = |m: &Value| m["content"].as_array().unwrap().last().unwrap().clone();
|
||
assert_eq!(tail_block(&msgs[2])["cache_control"]["type"], "ephemeral");
|
||
assert_eq!(tail_block(&msgs[1])["cache_control"]["type"], "ephemeral");
|
||
assert!(
|
||
tail_block(&msgs[0]).get("cache_control").is_none(),
|
||
"only the last two messages carry a breakpoint"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_single_message_stamps_only_one_breakpoint() {
|
||
// With a single message there is no second turn to leapfrog to; the
|
||
// checked_sub(2) index is skipped rather than panicking.
|
||
let body = anthropic_body(
|
||
&cfg(Provider::DatabricksV2),
|
||
"sys",
|
||
&[HistoryItem::User("hello".into())],
|
||
&[],
|
||
"databricks-claude-opus-5",
|
||
None,
|
||
"databricks_v2",
|
||
);
|
||
let msgs = body["messages"].as_array().unwrap();
|
||
assert_eq!(msgs.len(), 1);
|
||
assert_eq!(
|
||
msgs[0]["content"].as_array().unwrap().last().unwrap()["cache_control"]["type"],
|
||
"ephemeral"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_no_cache_control_when_disabled() {
|
||
let mut c = cfg(Provider::DatabricksV2);
|
||
c.prompt_caching = false;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"sys",
|
||
&[HistoryItem::User("hello".into())],
|
||
&[],
|
||
"databricks-claude-opus-5",
|
||
None,
|
||
"databricks_v2",
|
||
);
|
||
// system stays a bare string; no marker anywhere.
|
||
assert_eq!(body["system"], "sys");
|
||
let last_block = &body["messages"][0]["content"][0];
|
||
assert!(last_block.get("cache_control").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_empty_system_stays_string_even_when_caching() {
|
||
// An empty system prompt must not become an empty text block —
|
||
// Anthropic rejects those. Caching is still applied to the tail.
|
||
let body = anthropic_body(
|
||
&cfg(Provider::DatabricksV2),
|
||
"",
|
||
&[HistoryItem::User("hello".into())],
|
||
&[],
|
||
"databricks-claude-opus-5",
|
||
None,
|
||
"databricks_v2",
|
||
);
|
||
assert_eq!(body["system"], "");
|
||
assert_eq!(
|
||
body["messages"][0]["content"][0]["cache_control"]["type"],
|
||
"ephemeral"
|
||
);
|
||
}
|
||
|
||
// ---- ThinkingEffort body-shape tests ----
|
||
|
||
#[test]
|
||
fn anthropic_body_omits_thinking_when_effort_none() {
|
||
let body = anthropic_body(
|
||
&cfg(Provider::Anthropic),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
None,
|
||
"anthropic",
|
||
);
|
||
assert!(
|
||
body.get("thinking").is_none(),
|
||
"thinking must be absent when effort is None"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_emits_thinking_when_effort_high() {
|
||
// claude-3.x model → manual budget_tokens shape.
|
||
// Use max_output_tokens = 4096 so budget fits: headroom = 4096 - 1024 = 3072.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 4096;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-3-7-sonnet-20250219",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
assert_eq!(body["thinking"]["type"], "enabled");
|
||
// budget_tokens = min(32768, 4096-1024) = 3072
|
||
assert_eq!(body["thinking"]["budget_tokens"], 3072);
|
||
assert!(body.get("output_config").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_omits_thinking_when_max_output_too_small() {
|
||
// max_output_tokens = 2047: headroom = 2047 - 1024 = 1023 < 1024 → omit thinking.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 2047;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-3-7-sonnet-20250219",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
assert!(
|
||
body.get("thinking").is_none(),
|
||
"thinking must be omitted when max_output_tokens leaves < 1024 for budget"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_emits_thinking_at_boundary_2048() {
|
||
// max_output_tokens = 2048: headroom = 2048 - 1024 = 1024 ≥ 1024 → emit.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 2048;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-3-7-sonnet-20250219",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
let t = body
|
||
.get("thinking")
|
||
.expect("thinking must be present at boundary 2048");
|
||
assert_eq!(t["budget_tokens"], 1024); // min(32768, 2048-1024)
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_emits_thinking_high_uncapped_when_budget_fits() {
|
||
// When max_output_tokens is large enough, budget_tokens is not capped.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 65_536;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-3-7-sonnet-20250219",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
assert_eq!(body["thinking"]["budget_tokens"], 32_768);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_emits_thinking_low_budget() {
|
||
// Low budget (1024 tokens) exactly fits when max_output_tokens = 2048.
|
||
// headroom = 2048 - 1024 = 1024; min(1024, 1024) = 1024 ≥ 1024 → emit.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 2048;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-3-7-sonnet-20250219",
|
||
Some(ThinkingEffort::Low),
|
||
"anthropic",
|
||
);
|
||
// Low budget (1024) fits exactly at the boundary — emitted without capping.
|
||
assert_eq!(body["thinking"]["budget_tokens"], 1024);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_emits_adaptive_thinking_for_opus_4() {
|
||
// Adaptive Claude (claude-opus-4-6/4.7/4.8) → thinking:{type:"adaptive"} + output_config.effort.
|
||
// Note: Opus 4.5 is NOT adaptive — it uses manual budget.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 32_768;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-opus-4-7",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
assert_eq!(
|
||
body["thinking"]["type"], "adaptive",
|
||
"thinking must be {{type:adaptive}} for claude-opus-4-7"
|
||
);
|
||
assert_eq!(body["output_config"]["effort"], "high");
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_emits_manual_budget_for_opus_4_5() {
|
||
// Opus 4.5 uses manual budget (effort page: "uses manual thinking").
|
||
// max_output_tokens = 32768; headroom = 32768 - 1024 = 31744; min(32768, 31744) = 31744.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 32_768;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-opus-4-5",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
assert_eq!(body["thinking"]["type"], "enabled");
|
||
assert_eq!(body["thinking"]["budget_tokens"], 31_744); // min(32768, 32768-1024)
|
||
assert!(
|
||
body.get("output_config").is_none(),
|
||
"output_config must be absent for claude-opus-4-5 (manual budget)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_omits_both_fields_for_unrecognized_model() {
|
||
// Non-Anthropic models (gpt-5, llama, etc.) → omit both fields rather than guess.
|
||
let body = anthropic_body(
|
||
&cfg(Provider::Anthropic),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"gpt-4o",
|
||
Some(ThinkingEffort::High),
|
||
"anthropic",
|
||
);
|
||
assert!(body.get("thinking").is_none(), "thinking must be absent");
|
||
assert!(
|
||
body.get("output_config").is_none(),
|
||
"output_config must be absent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openai_body_omits_reasoning_effort_when_none() {
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
assert!(
|
||
body.get("reasoning_effort").is_none(),
|
||
"reasoning_effort must be absent when effort is None"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openai_body_emits_reasoning_effort_medium() {
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::Medium),
|
||
);
|
||
assert_eq!(body["reasoning_effort"], "medium");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_omits_reasoning_when_effort_none() {
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
assert!(
|
||
body.get("reasoning").is_none(),
|
||
"reasoning must be absent when effort is None"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_emits_reasoning_effort_low() {
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::Low),
|
||
);
|
||
assert_eq!(body["reasoning"]["effort"], "low");
|
||
// summary defaults to "auto" when effort is set.
|
||
assert_eq!(body["reasoning"]["summary"], "auto");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_summary_present_iff_effort_set() {
|
||
// effort set → reasoning object present with both effort and summary.
|
||
let body_with_effort = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::Medium),
|
||
);
|
||
assert!(
|
||
body_with_effort.get("reasoning").is_some(),
|
||
"reasoning must be present when effort is set"
|
||
);
|
||
assert_eq!(body_with_effort["reasoning"]["effort"], "medium");
|
||
assert_eq!(body_with_effort["reasoning"]["summary"], "auto");
|
||
|
||
// effort None → reasoning object entirely absent.
|
||
let body_no_effort = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
assert!(
|
||
body_no_effort.get("reasoning").is_none(),
|
||
"reasoning must be absent when effort is None"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_emits_configured_summary_mode() {
|
||
let mut cfg = cfg_responses();
|
||
cfg.thinking_summary = ThinkingSummary::Detailed;
|
||
let body = responses_body(
|
||
&cfg,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::High),
|
||
);
|
||
assert_eq!(body["reasoning"]["effort"], "high");
|
||
assert_eq!(
|
||
body["reasoning"]["summary"], "detailed",
|
||
"configured summary mode must be forwarded to reasoning object"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_concise_summary_mode() {
|
||
let mut cfg = cfg_responses();
|
||
cfg.thinking_summary = ThinkingSummary::Concise;
|
||
let body = responses_body(
|
||
&cfg,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::Low),
|
||
);
|
||
assert_eq!(body["reasoning"]["summary"], "concise");
|
||
}
|
||
|
||
#[test]
|
||
fn effective_model_overrides_cfg_model_in_anthropic_body() {
|
||
let body = anthropic_body(
|
||
&cfg(Provider::Anthropic),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"override-model",
|
||
None,
|
||
"anthropic",
|
||
);
|
||
assert_eq!(body["model"], "override-model");
|
||
}
|
||
|
||
#[test]
|
||
fn effective_model_overrides_cfg_model_in_openai_body() {
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"override-model",
|
||
None,
|
||
);
|
||
assert_eq!(body["model"], "override-model");
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_opus_4_8_xhigh_emits_xhigh_effort() {
|
||
// Body-shape regression: xhigh on Opus 4.8 must emit output_config.effort="xhigh".
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 32_768;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-opus-4-8",
|
||
Some(ThinkingEffort::XHigh),
|
||
"anthropic",
|
||
);
|
||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||
assert_eq!(body["output_config"]["effort"], "xhigh");
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_body_opus_4_8_max_emits_max_effort() {
|
||
// Body-shape regression: max on Opus 4.8 must emit output_config.effort="max".
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 32_768;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-opus-4-8",
|
||
Some(ThinkingEffort::Max),
|
||
"anthropic",
|
||
);
|
||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||
assert_eq!(body["output_config"]["effort"], "max");
|
||
}
|
||
|
||
#[test]
|
||
fn openai_body_emits_xhigh_effort() {
|
||
// xhigh is a valid OpenAI effort value — must pass through.
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::XHigh),
|
||
);
|
||
assert_eq!(body["reasoning_effort"], "xhigh");
|
||
}
|
||
|
||
#[test]
|
||
fn openai_body_emits_none_effort() {
|
||
// none is a valid OpenAI effort value.
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::None),
|
||
);
|
||
assert_eq!(body["reasoning_effort"], "none");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_emits_xhigh_effort() {
|
||
// xhigh is a valid Responses API effort value.
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::XHigh),
|
||
);
|
||
assert_eq!(body["reasoning"]["effort"], "xhigh");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_body_emits_minimal_effort() {
|
||
// minimal is a valid Responses API effort value.
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
Some(ThinkingEffort::Minimal),
|
||
);
|
||
assert_eq!(body["reasoning"]["effort"], "minimal");
|
||
}
|
||
|
||
// ---- DatabricksV2 route-aware effort normalization (body-level assertions) ----
|
||
//
|
||
// The DBv2 `complete()` dispatch applies `normalize_effort_for_databricks_v2` /
|
||
// `normalize_effort_for_anthropic_route` before calling body builders. These tests
|
||
// verify the body shape that results from the already-normalized effort values — i.e.,
|
||
// they confirm the body builders correctly serialize the values the dispatch passes them.
|
||
|
||
#[test]
|
||
fn dbv2_openai_route_max_effort_clamped_to_xhigh_in_responses_body() {
|
||
// DBv2 GPT-5.5 route: max → clamped to xhigh by normalize_effort_for_databricks_v2
|
||
// before reaching responses_body. gpt-5.5 supports xhigh so the final value is xhigh.
|
||
let clamped =
|
||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.5");
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"gpt-5.5",
|
||
Some(clamped),
|
||
);
|
||
assert_eq!(
|
||
body["reasoning"]["effort"], "xhigh",
|
||
"DBv2 GPT-5.5 route: max must be clamped to xhigh before responses_body"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dbv2_openai_route_max_effort_passes_through_for_gpt5_6() {
|
||
let normalized =
|
||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.6-sol");
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"gpt-5.6-sol",
|
||
Some(normalized),
|
||
);
|
||
assert_eq!(
|
||
body["reasoning"]["effort"], "max",
|
||
"DBv2 GPT-5.6 route must serialize max to the Responses API"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dbv2_mlflow_route_max_effort_clamped_to_xhigh_in_openai_body() {
|
||
// DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_databricks_v2.
|
||
// Unknown models pass through after the max→xhigh clamp.
|
||
let clamped =
|
||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "llama-4");
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"llama-4",
|
||
Some(clamped),
|
||
);
|
||
assert_eq!(
|
||
body["reasoning_effort"], "xhigh",
|
||
"DBv2 MLflow route: max must be clamped to xhigh before openai_body"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dbv2_openai_route_none_minimal_pass_through_in_responses_body() {
|
||
// Verify that supported values pass through for the respective model families.
|
||
// gpt-5.5 supports none (but not minimal); gpt-5 base supports minimal (but not none).
|
||
let none_normalized =
|
||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::None, "gpt-5.5");
|
||
assert_eq!(
|
||
none_normalized,
|
||
ThinkingEffort::None,
|
||
"OpenAI normalizer must not touch none for gpt-5.5"
|
||
);
|
||
let minimal_normalized =
|
||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Minimal, "gpt-5");
|
||
assert_eq!(
|
||
minimal_normalized,
|
||
ThinkingEffort::Minimal,
|
||
"OpenAI normalizer must not touch minimal for gpt-5 base"
|
||
);
|
||
let body = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"gpt-5.5",
|
||
Some(none_normalized),
|
||
);
|
||
assert_eq!(
|
||
body["reasoning"]["effort"], "none",
|
||
"DBv2 GPT-5.5 route: none must be emitted as-is"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dbv2_claude_route_none_effort_omits_thinking_fields() {
|
||
// DBv2 Claude route: none → normalize_effort_for_anthropic_route returns None → omit.
|
||
let normalized = crate::config::normalize_effort_for_anthropic_route(ThinkingEffort::None);
|
||
assert_eq!(
|
||
normalized, None,
|
||
"Anthropic normalizer must return None for ThinkingEffort::None"
|
||
);
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 32_768;
|
||
let body = anthropic_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"claude-opus-4-8",
|
||
normalized, // None → omit thinking fields
|
||
"anthropic",
|
||
);
|
||
assert!(
|
||
body.get("thinking").is_none(),
|
||
"DBv2 Claude route: none effort must omit thinking fields"
|
||
);
|
||
assert!(
|
||
body.get("output_config").is_none(),
|
||
"DBv2 Claude route: none effort must omit output_config"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dbv2_route_switch_max_body_level_simulation() {
|
||
// Body-level simulation of a session/set_model switch from a Claude model to a GPT-5
|
||
// model when thinking_effort=max. Calls body builders and normalizers directly (not
|
||
// through the ACP session/set_model path or DatabricksV2 dispatch) to verify the
|
||
// correct output shape for each side of the route switch.
|
||
// Before the switch: Claude route → max passes through as Anthropic "max".
|
||
// After the switch: GPT-5 route → max clamped to xhigh.
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.max_output_tokens = 32_768;
|
||
|
||
// Before switch: claude-opus-4-8 with effort=max → adaptive shape, effort="max"
|
||
let (thinking_before, oc_before) = crate::config::anthropic_thinking_config(
|
||
"anthropic",
|
||
"claude-opus-4-8",
|
||
ThinkingEffort::Max,
|
||
32_768,
|
||
);
|
||
assert_eq!(thinking_before.unwrap()["type"], "adaptive");
|
||
assert_eq!(oc_before.unwrap()["effort"], "max");
|
||
|
||
// After switch to GPT-5.5 route: normalize max → xhigh for responses_body
|
||
// (gpt-5.5 supports xhigh, so the clamp result is xhigh, not further reduced)
|
||
let clamped =
|
||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.5");
|
||
assert_eq!(clamped, ThinkingEffort::XHigh);
|
||
let body_after = responses_body(
|
||
&cfg_responses(),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"gpt-5.5",
|
||
Some(clamped),
|
||
);
|
||
assert_eq!(
|
||
body_after["reasoning"]["effort"], "xhigh",
|
||
"After set_model to GPT-5.5: max must be clamped to xhigh"
|
||
);
|
||
}
|
||
|
||
/// Regression: a connection that is accepted and then dropped before any
|
||
/// HTTP response bytes are written surfaces as a reqwest request-class
|
||
/// error (not `is_connect()`, not `is_timeout()`). The retry predicate
|
||
/// must recognize it; otherwise transient TLS/h2/proxy hiccups bubble
|
||
/// out of the agent as `transport: error sending request ...`.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_retries_on_dropped_connection_before_response() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let n = accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
if n == 0 {
|
||
// First attempt: read the request, then drop the socket
|
||
// without writing a response. reqwest surfaces this as
|
||
// a request-class error (is_request() == true).
|
||
let mut tmp = [0u8; 4096];
|
||
let _ = sock.read(&mut tmp).await;
|
||
drop(sock);
|
||
continue;
|
||
}
|
||
// Subsequent attempts: serve a tiny JSON body.
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
let body = "{\"ok\":true}";
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let out = post(
|
||
&client,
|
||
&url,
|
||
&serde_json::json!({}),
|
||
Duration::from_secs(5),
|
||
|b| b,
|
||
)
|
||
.await
|
||
.expect("post should succeed after retry");
|
||
assert_eq!(out, serde_json::json!({ "ok": true }));
|
||
assert!(
|
||
accepts.load(Ordering::SeqCst) >= 2,
|
||
"server should have seen at least 2 connection attempts, saw {}",
|
||
accepts.load(Ordering::SeqCst)
|
||
);
|
||
}
|
||
|
||
/// A 499 response is retried and the call succeeds on the second attempt.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_retries_499_and_succeeds() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let n = accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
// Read the full request headers before responding.
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
if n == 0 {
|
||
// First attempt: respond with 499.
|
||
let resp = "HTTP/1.1 499 Client Closed Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
continue;
|
||
}
|
||
// Subsequent attempts: 200 OK with a tiny JSON body.
|
||
let body = "{\"ok\":true}";
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let out = post(
|
||
&client,
|
||
&url,
|
||
&serde_json::json!({}),
|
||
Duration::from_secs(5),
|
||
|b| b,
|
||
)
|
||
.await
|
||
.expect("post should succeed after 499 retry");
|
||
assert_eq!(out, serde_json::json!({ "ok": true }));
|
||
assert!(
|
||
accepts.load(Ordering::SeqCst) >= 2,
|
||
"server must see at least 2 attempts (got {})",
|
||
accepts.load(Ordering::SeqCst)
|
||
);
|
||
}
|
||
|
||
/// When all MAX_RETRIES attempts return 499 the error includes "exhausted retries".
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_exhausts_retries_on_persistent_499() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
// Read the full request headers before responding.
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
// Always 499.
|
||
let resp = "HTTP/1.1 499 Client Closed Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = post(
|
||
&client,
|
||
&url,
|
||
&serde_json::json!({}),
|
||
Duration::from_secs(5),
|
||
|b| b,
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
match &err {
|
||
PostError::Agent(AgentError::Llm(msg)) => {
|
||
assert!(
|
||
msg.contains("exhausted retries") && msg.contains("499"),
|
||
"expected 'exhausted retries' + '499' in error, got: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("cumulative") && msg.contains("3 attempts"),
|
||
"expected cumulative duration + exact attempt count, got: {msg}"
|
||
);
|
||
}
|
||
other => panic!("expected PostError::Agent(AgentError::Llm), got: {other:?}"),
|
||
}
|
||
assert_eq!(
|
||
accepts.load(Ordering::SeqCst),
|
||
MAX_RETRIES,
|
||
"server must see exactly MAX_RETRIES attempts — 499 must be retried"
|
||
);
|
||
}
|
||
|
||
/// Regression (write-compressor, tb21-twins-1): a provider returning
|
||
/// HTTP 200 with a *truncated* JSON body (cleanly closed, correct
|
||
/// framing, unparseable content) previously surfaced as a terminal
|
||
/// `AgentError::Llm("json: EOF while parsing a value ...")` on the very
|
||
/// first attempt, killing the agent turn. A malformed body is transient
|
||
/// upstream trouble and must be retried like a 5xx. Re-sending is
|
||
/// tool-safe: nothing was parsed, so no tool call was extracted from the
|
||
/// bad body, and the retry replays the identical completion request.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_retries_malformed_json_body_and_succeeds() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let n = accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
if n == 0 {
|
||
// First attempt: 200 OK with a truncated JSON document.
|
||
// Content-Length matches the bytes actually sent, so the
|
||
// body read completes cleanly — the fault is purely that
|
||
// the JSON is cut off mid-value.
|
||
let body = "{\"choices\":[{\"mess";
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
continue;
|
||
}
|
||
// Subsequent attempts: complete valid JSON.
|
||
let body = "{\"ok\":true}";
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let out = post(
|
||
&client,
|
||
&url,
|
||
&serde_json::json!({}),
|
||
Duration::from_secs(5),
|
||
|b| b,
|
||
)
|
||
.await
|
||
.expect("post should succeed after retrying the malformed body");
|
||
assert_eq!(out, serde_json::json!({ "ok": true }));
|
||
assert_eq!(
|
||
accepts.load(Ordering::SeqCst),
|
||
2,
|
||
"server must see exactly 2 attempts (malformed body retried once)"
|
||
);
|
||
}
|
||
|
||
/// A persistently malformed 200 body exhausts MAX_RETRIES and surfaces
|
||
/// the terminal error with the `json:` detail plus cumulative
|
||
/// duration/attempt count — never an early first-attempt death.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_exhausts_retries_on_persistent_malformed_json() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
let body = "{\"choices\":[{\"mess";
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = post(
|
||
&client,
|
||
&url,
|
||
&serde_json::json!({}),
|
||
Duration::from_secs(5),
|
||
|b| b,
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
match &err {
|
||
PostError::Agent(AgentError::Llm(msg)) => {
|
||
assert!(
|
||
msg.contains("json:"),
|
||
"expected the json parse detail, got: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("cumulative") && msg.contains("3 attempts"),
|
||
"expected cumulative duration + exact attempt count, got: {msg}"
|
||
);
|
||
}
|
||
other => panic!("expected PostError::Agent(AgentError::Llm), got: {other:?}"),
|
||
}
|
||
assert_eq!(
|
||
accepts.load(Ordering::SeqCst),
|
||
MAX_RETRIES,
|
||
"server must see exactly MAX_RETRIES attempts — malformed bodies must be retried"
|
||
);
|
||
}
|
||
|
||
/// Same regression coverage for `openrouter_post`, which carries its own
|
||
/// retry loop: a truncated 200 body on the first attempt is retried and
|
||
/// the call succeeds on the second.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_retries_malformed_json_body_and_succeeds() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let n = accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
if n == 0 {
|
||
let body = "{\"choices\":[{\"mess";
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
continue;
|
||
}
|
||
let body = r#"{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}"#;
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let out = openrouter_post(&client, &url, &json!({}), "key", Duration::from_secs(5))
|
||
.await
|
||
.expect("openrouter_post should succeed after retrying the malformed body");
|
||
assert!(out.is_object(), "expected a JSON object: {out:?}");
|
||
assert_eq!(
|
||
accepts.load(Ordering::SeqCst),
|
||
2,
|
||
"server must see exactly 2 attempts (malformed body retried once)"
|
||
);
|
||
}
|
||
|
||
/// A body-read timeout on the first attempt triggers a retry under an
|
||
/// escalated budget, and the call succeeds on the second attempt.
|
||
///
|
||
/// The stub sends HTTP 200 headers for the first request, writes a partial
|
||
/// body, then stalls long enough to exhaust the base timeout (1 s). On the
|
||
/// second request it sends a complete response immediately. The test asserts
|
||
/// that `post()` returns success and that the server saw exactly 2 requests.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_body_read_timeout_retries_with_escalated_budget() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/v1/x", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let n = accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
|
||
// Drain the incoming request headers on every attempt.
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
|
||
if n == 0 {
|
||
// First attempt: declare a 64-byte body, send only 4 bytes,
|
||
// then stall for 3 s — long enough to outlast the 1 s
|
||
// base timeout and trigger a body-read timeout.
|
||
let _ = sock
|
||
.write_all(
|
||
b"HTTP/1.1 200 OK\r\n\
|
||
Content-Type: application/json\r\n\
|
||
Content-Length: 64\r\n\
|
||
\r\n\
|
||
{\"s",
|
||
)
|
||
.await;
|
||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||
// Connection closes here; the client has already timed out.
|
||
continue;
|
||
}
|
||
|
||
// Second attempt: complete a valid JSON response immediately.
|
||
let body = r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}"#;
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
// No client-level timeout — per-request timeout is applied inside post().
|
||
let client = Client::builder().build().unwrap();
|
||
let out = post(
|
||
&client,
|
||
&url,
|
||
&serde_json::json!({"model": "x"}),
|
||
// Small base timeout so the body stall triggers quickly.
|
||
Duration::from_secs(1),
|
||
|b| b,
|
||
)
|
||
.await
|
||
.expect("post should succeed on the second attempt after body-read timeout");
|
||
assert!(out.is_object(), "expected a JSON object response: {out:?}");
|
||
assert_eq!(
|
||
accepts.load(Ordering::SeqCst),
|
||
2,
|
||
"server must see exactly 2 requests (body-read timeout retry)"
|
||
);
|
||
}
|
||
|
||
/// `openrouter_post`: a body-read timeout on the first attempt retries under
|
||
/// an escalated budget and succeeds on the second attempt.
|
||
///
|
||
/// Same shape as `post_body_read_timeout_retries_with_escalated_budget` —
|
||
/// the stub stalls mid-body on the first request, then returns a complete
|
||
/// response immediately on the second.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_body_read_timeout_retries_with_escalated_budget() {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}/chat/completions", listener.local_addr().unwrap());
|
||
let accepts = Arc::new(AtomicU32::new(0));
|
||
let accepts_srv = accepts.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let n = accepts_srv.fetch_add(1, Ordering::SeqCst);
|
||
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
|
||
if n == 0 {
|
||
// First attempt: declare 64-byte body, send 4 bytes, stall.
|
||
let _ = sock
|
||
.write_all(
|
||
b"HTTP/1.1 200 OK\r\n\
|
||
Content-Type: application/json\r\n\
|
||
Content-Length: 64\r\n\
|
||
\r\n\
|
||
{\"c",
|
||
)
|
||
.await;
|
||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||
continue;
|
||
}
|
||
|
||
// Second attempt: complete valid OpenRouter JSON response.
|
||
let body = r#"{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}"#;
|
||
let resp = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let client = Client::builder().build().unwrap();
|
||
let out = openrouter_post(&client, &url, &json!({}), "key", Duration::from_secs(1))
|
||
.await
|
||
.expect("openrouter_post should succeed on the second attempt after body-read timeout");
|
||
assert!(out.is_object(), "expected a JSON object: {out:?}");
|
||
assert_eq!(
|
||
accepts.load(Ordering::SeqCst),
|
||
2,
|
||
"server must see exactly 2 requests (body-read timeout retry)"
|
||
);
|
||
}
|
||
|
||
/// `terminal_llm_error` below `STALL_NOTICE_THRESHOLD` carries the detail
|
||
/// and attempt count but no stall-specific text — this is the common case
|
||
/// (a handful of quick retries), not an outage.
|
||
#[test]
|
||
fn terminal_llm_error_below_threshold_carries_detail_no_stall_claim() {
|
||
let err = terminal_llm_error(Duration::from_secs(2), 3, "exhausted retries: 499: body");
|
||
match err {
|
||
AgentError::Llm(msg) => {
|
||
assert!(msg.contains("cumulative 2s"), "must carry duration: {msg}");
|
||
assert!(
|
||
msg.contains("3 attempts"),
|
||
"must carry attempt count: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("exhausted retries") && msg.contains("499"),
|
||
"must preserve detail: {msg}"
|
||
);
|
||
}
|
||
other => panic!("expected Llm, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Above `STALL_NOTICE_THRESHOLD`, the error still carries the same
|
||
/// detail/duration/count — the threshold only gates the extra
|
||
/// `tracing::warn!` (not separately assertable here), not the error text.
|
||
/// Singular "1 attempt" (not "1 attempts") confirms the pluralization.
|
||
#[test]
|
||
fn terminal_llm_error_above_threshold_uses_singular_attempt() {
|
||
let err = terminal_llm_error(Duration::from_secs(301), 1, "transport: connection reset");
|
||
match err {
|
||
AgentError::Llm(msg) => {
|
||
assert!(
|
||
msg.contains("cumulative 301s"),
|
||
"must carry duration: {msg}"
|
||
);
|
||
assert!(msg.contains("1 attempt"), "must carry count: {msg}");
|
||
assert!(
|
||
!msg.contains("1 attempts"),
|
||
"singular for count of 1: {msg}"
|
||
);
|
||
}
|
||
other => panic!("expected Llm, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Captures `tracing::warn!` events emitted during a scoped subscriber
|
||
/// so a test can assert on the stall-notice threshold without a real
|
||
/// sleep or a global logger.
|
||
struct StallWarnCapture {
|
||
count: Arc<std::sync::atomic::AtomicUsize>,
|
||
}
|
||
|
||
struct StallWarnVisitor {
|
||
saw_cumulative_stall: bool,
|
||
saw_attempts: bool,
|
||
}
|
||
|
||
impl tracing::field::Visit for StallWarnVisitor {
|
||
fn record_debug(&mut self, field: &tracing::field::Field, _value: &dyn std::fmt::Debug) {
|
||
match field.name() {
|
||
"cumulative_stall" => self.saw_cumulative_stall = true,
|
||
"attempts" => self.saw_attempts = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
|
||
if field.name() == "attempts" {
|
||
self.saw_attempts = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for StallWarnCapture {
|
||
fn on_event(
|
||
&self,
|
||
event: &tracing::Event<'_>,
|
||
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||
) {
|
||
if *event.metadata().level() != tracing::Level::WARN {
|
||
return;
|
||
}
|
||
let mut visitor = StallWarnVisitor {
|
||
saw_cumulative_stall: false,
|
||
saw_attempts: false,
|
||
};
|
||
event.record(&mut visitor);
|
||
if visitor.saw_cumulative_stall && visitor.saw_attempts {
|
||
self.count.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runs `f` under a scoped subscriber that only counts `WARN` events
|
||
/// carrying both `cumulative_stall` and `attempts` fields — the shape
|
||
/// `terminal_llm_error`'s stall notice emits. Returns that count.
|
||
fn count_stall_warnings(f: impl FnOnce()) -> usize {
|
||
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let subscriber = tracing_subscriber::registry().with(StallWarnCapture {
|
||
count: count.clone(),
|
||
});
|
||
tracing::subscriber::with_default(subscriber, f);
|
||
count.load(Ordering::SeqCst)
|
||
}
|
||
|
||
/// Below `STALL_NOTICE_THRESHOLD`, `terminal_llm_error` must not emit the
|
||
/// stall warning at all — otherwise every ordinary retry-exhaustion would
|
||
/// falsely read as an outage. This is mutation-sensitive: deleting the
|
||
/// threshold branch, weakening `>=` to `>` at the boundary, or an
|
||
/// always-warn mutation are all caught by pairing this with the 300s case
|
||
/// below.
|
||
#[test]
|
||
fn terminal_llm_error_below_threshold_emits_no_stall_warning() {
|
||
let warnings = count_stall_warnings(|| {
|
||
let _ = terminal_llm_error(Duration::from_secs(299), 3, "exhausted retries: 499");
|
||
});
|
||
assert_eq!(warnings, 0, "no stall warning below STALL_NOTICE_THRESHOLD");
|
||
}
|
||
|
||
/// At `STALL_NOTICE_THRESHOLD`, `terminal_llm_error` must emit exactly
|
||
/// one stall warning carrying the duration and attempt count — the
|
||
/// never-warn mutation is caught here; combined with the 299s case above,
|
||
/// a deleted branch or an always-warn mutation are caught by whichever
|
||
/// assertion it violates.
|
||
#[test]
|
||
fn terminal_llm_error_at_threshold_emits_one_stall_warning() {
|
||
let warnings = count_stall_warnings(|| {
|
||
let _ = terminal_llm_error(Duration::from_secs(300), 5, "transport: connection reset");
|
||
});
|
||
assert_eq!(
|
||
warnings, 1,
|
||
"exactly one stall warning with duration+attempts fields at threshold"
|
||
);
|
||
}
|
||
|
||
// ---- escalated_timeout (pure-function tests) ----------------------------
|
||
|
||
/// No prior timeouts → budget is base unchanged.
|
||
#[test]
|
||
fn escalated_timeout_zero_failures_returns_base() {
|
||
let base = Duration::from_secs(240);
|
||
assert_eq!(
|
||
escalated_timeout(base, 0),
|
||
base,
|
||
"0 timeout failures must return base unchanged"
|
||
);
|
||
}
|
||
|
||
/// One prior timeout → budget doubles.
|
||
#[test]
|
||
fn escalated_timeout_one_failure_doubles() {
|
||
let base = Duration::from_secs(240);
|
||
assert_eq!(
|
||
escalated_timeout(base, 1),
|
||
Duration::from_secs(480),
|
||
"1 timeout failure must double the base to 480s"
|
||
);
|
||
}
|
||
|
||
/// Two prior timeouts → budget quadruples.
|
||
#[test]
|
||
fn escalated_timeout_two_failures_quadruples() {
|
||
let base = Duration::from_secs(240);
|
||
assert_eq!(
|
||
escalated_timeout(base, 2),
|
||
Duration::from_secs(960),
|
||
"2 timeout failures must quadruple the base to 960s"
|
||
);
|
||
}
|
||
|
||
/// At three prior timeouts (base 240 s, 240×8 = 1920 s) the cap kicks in
|
||
/// and the result is clamped to ESCALATION_TIMEOUT_CAP (1200 s).
|
||
#[test]
|
||
fn escalated_timeout_three_failures_capped_at_1200s() {
|
||
let base = Duration::from_secs(240);
|
||
assert_eq!(
|
||
escalated_timeout(base, 3),
|
||
Duration::from_secs(1200),
|
||
"3 timeout failures with 240s base must be capped at 1200s"
|
||
);
|
||
}
|
||
|
||
/// When base already exceeds the cap, the cap is raised to base (we never
|
||
/// shrink the operator-configured budget).
|
||
#[test]
|
||
fn escalated_timeout_base_above_cap_is_never_shrunk() {
|
||
let base = Duration::from_secs(1500);
|
||
// All scaled values (1×, 2×, 4×, …) are ≥ base, and the effective cap
|
||
// is max(ESCALATION_TIMEOUT_CAP, base) = 1500s, so they're all clamped
|
||
// to 1500s.
|
||
assert_eq!(
|
||
escalated_timeout(base, 0),
|
||
Duration::from_secs(1500),
|
||
"base 1500s at 0 failures must stay 1500s"
|
||
);
|
||
assert_eq!(
|
||
escalated_timeout(base, 1),
|
||
Duration::from_secs(1500),
|
||
"base 1500s at 1 failure must be capped at 1500s (not truncated to 1200s)"
|
||
);
|
||
}
|
||
|
||
// ---- timeout_message (pure-function tests, no network) ------------------
|
||
|
||
/// Connect timeout (is_connect=true) wins regardless of phase and shows
|
||
/// the LLM_CONNECT_TIMEOUT value — never the read-timeout text.
|
||
#[test]
|
||
fn timeout_message_connect_true_shows_connect_timeout() {
|
||
let llm = std::time::Duration::from_secs(240);
|
||
for phase in [TimeoutPhase::Transport, TimeoutPhase::BodyRead] {
|
||
let msg = timeout_message(true, llm, phase);
|
||
assert!(
|
||
msg.starts_with("connect timeout:"),
|
||
"is_connect=true must start with 'connect timeout:': {msg}"
|
||
);
|
||
// The configured connect timeout (10s) must appear verbatim.
|
||
assert!(
|
||
msg.contains("10s"),
|
||
"connect timeout must include the 10s configured value: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("read timeout"),
|
||
"connect timeout must not mention 'read timeout': {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"),
|
||
"connect timeout must not reference the read-timeout config knob: {msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Transport read-timeout (is_connect=false, Transport phase) shows the
|
||
/// configured llm_timeout value and the config-knob hint.
|
||
#[test]
|
||
fn timeout_message_transport_phase_shows_read_timeout_and_duration() {
|
||
let llm = std::time::Duration::from_secs(240);
|
||
let msg = timeout_message(false, llm, TimeoutPhase::Transport);
|
||
assert!(
|
||
msg.starts_with("read timeout:"),
|
||
"transport read-timeout must start with 'read timeout:': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("240s"),
|
||
"transport read-timeout must include the 240s configured value: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"),
|
||
"transport read-timeout must reference the config knob: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("connect timeout"),
|
||
"transport read-timeout must not say 'connect timeout': {msg}"
|
||
);
|
||
}
|
||
|
||
/// Body-read timeout (BodyRead phase) says "response did not complete" and
|
||
/// shows the per-request timeout value and the config-knob hint.
|
||
///
|
||
/// With per-request total timeouts, a body-stall fires the same total-budget
|
||
/// timer as a transport stall — the message reflects that the entire request
|
||
/// (not just a read-idle window) expired.
|
||
#[test]
|
||
fn timeout_message_body_read_phase_says_did_not_complete_and_duration() {
|
||
let per_request = std::time::Duration::from_secs(300);
|
||
let msg = timeout_message(false, per_request, TimeoutPhase::BodyRead);
|
||
assert!(
|
||
msg.starts_with("request timed out:"),
|
||
"body-read timeout must start with 'request timed out:': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("did not complete"),
|
||
"body-read timeout must say 'did not complete': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("300s"),
|
||
"body-read timeout must include the 300s per-request value: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"),
|
||
"body-read timeout must reference the config knob: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("read timeout"),
|
||
"body-read timeout must not say 'read timeout': {msg}"
|
||
);
|
||
}
|
||
|
||
/// A non-default duration threads through correctly — verifies the value
|
||
/// is not hard-coded anywhere in the pure function.
|
||
#[test]
|
||
fn timeout_message_duration_is_not_hardcoded() {
|
||
let msg = timeout_message(
|
||
false,
|
||
std::time::Duration::from_secs(600),
|
||
TimeoutPhase::Transport,
|
||
);
|
||
assert!(
|
||
msg.contains("600s"),
|
||
"transport read-timeout must reflect the supplied 600s value: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("240s"),
|
||
"must not hard-code 240s when 600s was supplied: {msg}"
|
||
);
|
||
}
|
||
|
||
// ---- classify_transport_error / classify_body_read_error (reqwest integration) --
|
||
|
||
/// A real loopback read-timeout must produce a message rooted at "read
|
||
/// timeout:" that contains the configured value — and must NOT use reqwest's
|
||
/// opaque "error sending request" string.
|
||
///
|
||
/// This is the one test that requires real network I/O (loopback only) to
|
||
/// verify that reqwest actually sets is_timeout() for the scenario in which
|
||
/// Buzz agents stall (server connected but emitting no bytes).
|
||
#[tokio::test]
|
||
async fn classify_transport_error_read_timeout_is_loopback_verified() {
|
||
use tokio::net::TcpListener;
|
||
|
||
let llm_timeout = std::time::Duration::from_millis(50);
|
||
// Bind and never accept — TCP connect succeeds, no bytes follow.
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let addr = listener.local_addr().unwrap();
|
||
let _listener = listener; // keep alive so connect succeeds
|
||
|
||
let client = reqwest::Client::builder()
|
||
.read_timeout(llm_timeout)
|
||
.build()
|
||
.unwrap();
|
||
|
||
let err = client
|
||
.get(format!("http://{addr}/"))
|
||
.send()
|
||
.await
|
||
.expect_err("must time out");
|
||
|
||
// Preconditions: verify reqwest's classification before asserting our output.
|
||
assert!(
|
||
err.is_timeout(),
|
||
"precondition: reqwest must report is_timeout"
|
||
);
|
||
assert!(
|
||
!err.is_connect(),
|
||
"precondition: read timeout must not set is_connect"
|
||
);
|
||
|
||
let msg = classify_transport_error(&err, llm_timeout);
|
||
assert!(
|
||
msg.starts_with("read timeout:"),
|
||
"read timeout must start with 'read timeout:': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("50ms"),
|
||
"read timeout must include the configured 50ms value: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"),
|
||
"read timeout must name the config knob: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("error sending request"),
|
||
"read timeout must not use the opaque reqwest string: {msg}"
|
||
);
|
||
}
|
||
|
||
/// Non-timeout transport errors preserve the original reqwest error text.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn classify_transport_error_non_timeout_preserves_reqwest_text() {
|
||
use tokio::net::TcpListener;
|
||
|
||
// Accept-then-close: keep the listener alive so the endpoint stays
|
||
// owned throughout, spawn a task that accepts exactly one connection
|
||
// and immediately drops the socket. Produces a deterministic
|
||
// non-timeout reqwest error (request-class, not is_timeout()) while
|
||
// the test holds exclusive ownership of the address — no released-port
|
||
// race possible.
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let addr = listener.local_addr().unwrap();
|
||
tokio::spawn(async move {
|
||
if let Ok((sock, _)) = listener.accept().await {
|
||
drop(sock); // close immediately, no response written
|
||
}
|
||
});
|
||
|
||
let client = reqwest::Client::builder()
|
||
.connect_timeout(std::time::Duration::from_millis(200))
|
||
.build()
|
||
.unwrap();
|
||
|
||
let err = client
|
||
.get(format!("http://{addr}/"))
|
||
.send()
|
||
.await
|
||
.expect_err("must fail: server closes connection before response");
|
||
|
||
assert!(
|
||
!err.is_timeout(),
|
||
"precondition: connection-closed is not a timeout: {err}"
|
||
);
|
||
|
||
assert_eq!(
|
||
classify_transport_error(&err, std::time::Duration::from_secs(240)),
|
||
format!("transport: {err}")
|
||
);
|
||
}
|
||
|
||
/// A body-read timeout fires after headers arrive but before the body is
|
||
/// complete. A loopback server sends an HTTP 200 with a declared content-
|
||
/// length larger than the payload it actually delivers; the client reads
|
||
/// one chunk, then stalls until the per-request total timeout fires.
|
||
///
|
||
/// Asserts the exact wording, configured duration, and config-knob hint.
|
||
/// Also covers the non-timeout fallback via classify_body_read_error.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn classify_body_read_error_timeout_says_did_not_complete() {
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let per_request_timeout = std::time::Duration::from_millis(100);
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let addr = listener.local_addr().unwrap();
|
||
|
||
// Server: accept once, send headers + one body chunk, then hang.
|
||
tokio::spawn(async move {
|
||
if let Ok((mut sock, _)) = listener.accept().await {
|
||
// Consume the request.
|
||
let mut buf = [0u8; 512];
|
||
let _ = sock.read(&mut buf).await;
|
||
// Declare 1 KiB body, send 4 bytes, then do nothing.
|
||
let _ = sock
|
||
.write_all(
|
||
b"HTTP/1.1 200 OK\r\n\
|
||
Content-Type: application/json\r\n\
|
||
Content-Length: 1024\r\n\
|
||
\r\n\
|
||
test",
|
||
)
|
||
.await;
|
||
// Hold the connection open so the client times out rather than
|
||
// seeing EOF.
|
||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||
}
|
||
});
|
||
|
||
// Use a client-level read_timeout to produce a body-stall error; in
|
||
// production we use per-request .timeout(), but the error classification
|
||
// is the same — reqwest sets is_timeout() in both cases.
|
||
let client = reqwest::Client::builder()
|
||
.read_timeout(per_request_timeout)
|
||
.build()
|
||
.unwrap();
|
||
|
||
let resp = client
|
||
.get(format!("http://{addr}/"))
|
||
.send()
|
||
.await
|
||
.expect("headers must arrive before timeout");
|
||
|
||
// Consume the response body — this is where the timeout fires.
|
||
let err = resp.bytes().await.expect_err("body read must time out");
|
||
|
||
assert!(
|
||
err.is_timeout(),
|
||
"precondition: reqwest must report is_timeout for body stall"
|
||
);
|
||
|
||
// ---- classify_body_read_error: timeout path ----
|
||
let msg = classify_body_read_error(&err, per_request_timeout);
|
||
assert!(
|
||
msg.starts_with("request timed out:"),
|
||
"body-read timeout must start with 'request timed out:': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("did not complete"),
|
||
"body-read timeout must say 'did not complete': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("100ms"),
|
||
"body-read timeout must include the per-request 100ms value: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"),
|
||
"body-read timeout must reference the config knob: {msg}"
|
||
);
|
||
|
||
// ---- classify_body_read_error: non-timeout fallback (pure, no I/O) ----
|
||
// We can't produce a real non-timeout body error without real I/O, but
|
||
// the pure-function path is identical to classify_transport_error's
|
||
// non-timeout fallback and is covered by the pure tests above.
|
||
}
|
||
|
||
// ---- usage / input-token extraction -------------------------------------
|
||
|
||
#[test]
|
||
fn parse_anthropic_sums_input_and_cache_tokens() {
|
||
// input_tokens alone excludes cached tokens; the inclusive total must
|
||
// sum all three so a cache-heavy turn can't undercount the budget.
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {
|
||
"input_tokens": 100,
|
||
"cache_read_input_tokens": 900,
|
||
"cache_creation_input_tokens": 50,
|
||
"output_tokens": 7
|
||
}
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.input_tokens, Some(1050));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_input_tokens_only() {
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {"input_tokens": 42, "output_tokens": 3}
|
||
});
|
||
assert_eq!(parse_anthropic(v).unwrap().input_tokens, Some(42));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_missing_usage_is_none() {
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}]
|
||
});
|
||
assert_eq!(parse_anthropic(v).unwrap().input_tokens, None);
|
||
}
|
||
|
||
/// A Gemini reply as the Databricks MLflow route actually returns it:
|
||
/// block-array `content`, and a `thoughtSignature` beside `function`.
|
||
fn gemini_choice() -> Value {
|
||
json!({"choices": [{"finish_reason": "tool_calls", "message": {
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "weighing it"}]},
|
||
{"type": "text", "text": "391"}
|
||
],
|
||
"tool_calls": [{
|
||
"id": "get_weather", "type": "function", "thoughtSignature": "SIG-A",
|
||
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
|
||
}]
|
||
}}]})
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_reads_text_out_of_a_block_array() {
|
||
// Before this, `as_str()` on the array yielded "" and the model's answer
|
||
// was discarded with no error at all.
|
||
let r = parse_openai(gemini_choice()).unwrap();
|
||
assert_eq!(r.text, "391");
|
||
assert_eq!(r.reasoning, "weighing it");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_still_reads_a_plain_string_content() {
|
||
let v = json!({"choices": [{"finish_reason": "stop", "message": {
|
||
"role": "assistant", "content": "plain"}}]});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.text, "plain");
|
||
assert_eq!(r.reasoning, "");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_keeps_unmodelled_tool_call_fields() {
|
||
let r = parse_openai(gemini_choice()).unwrap();
|
||
let extra = &r.tool_calls[0].provider_extra;
|
||
assert_eq!(extra.get("thoughtSignature"), Some(&json!("SIG-A")));
|
||
// `id`/`type`/`function` are modelled, so they must not be duplicated
|
||
// into the passthrough — they would be re-emitted twice.
|
||
assert!(!extra.contains_key("id"));
|
||
assert!(!extra.contains_key("type"));
|
||
assert!(!extra.contains_key("function"));
|
||
}
|
||
|
||
#[test]
|
||
fn openai_body_replays_the_signature_beside_function_not_inside_it() {
|
||
// Position is what the gateway checks: nested inside `function{}` it is
|
||
// rejected with the same 400 as a missing signature.
|
||
let r = parse_openai(gemini_choice()).unwrap();
|
||
let history = vec![HistoryItem::Assistant {
|
||
text: r.text.clone(),
|
||
tool_calls: r.tool_calls.clone(),
|
||
reasoning_details: None,
|
||
}];
|
||
let body = openai_body(
|
||
&cfg(Provider::DatabricksV2),
|
||
"sys",
|
||
&history,
|
||
&[],
|
||
"databricks-gemini-3-6-flash",
|
||
None,
|
||
);
|
||
let call = &body["messages"][1]["tool_calls"][0];
|
||
assert_eq!(call["thoughtSignature"], json!("SIG-A"));
|
||
assert!(call["function"].get("thoughtSignature").is_none());
|
||
assert_eq!(call["function"]["name"], json!("get_weather"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_makes_duplicate_tool_call_ids_unique() {
|
||
// Gemini returns the function name as the id, so parallel calls to one
|
||
// function collide and their results become indistinguishable.
|
||
let v = json!({"choices": [{"finish_reason": "tool_calls", "message": {
|
||
"role": "assistant", "content": "",
|
||
"tool_calls": [
|
||
{"id": "get_weather", "type": "function",
|
||
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}},
|
||
{"id": "get_weather", "type": "function",
|
||
"function": {"name": "get_weather", "arguments": "{\"city\":\"Rome\"}"}}
|
||
]}}]});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.tool_calls[0].provider_id, "get_weather");
|
||
assert_eq!(r.tool_calls[1].provider_id, "get_weather-2");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_uses_prompt_tokens() {
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
|
||
"usage": {"prompt_tokens": 123, "completion_tokens": 4, "total_tokens": 127}
|
||
});
|
||
assert_eq!(parse_openai(v).unwrap().input_tokens, Some(123));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_databricks_prompt_tokens_already_inclusive() {
|
||
// Databricks' MLflow route uses the OpenAI chat wire format
|
||
// (prompt_tokens) but ALSO reports the flat Anthropic-style
|
||
// cache_read_input_tokens. prompt_tokens is already inclusive of that
|
||
// slice, so the total is prompt_tokens alone — summing double-counts.
|
||
// Values are the live databricks-glm-5-2 response (2026-07-28), where
|
||
// prompt_tokens + completion_tokens == total_tokens proves inclusivity.
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
|
||
"usage": {
|
||
"prompt_tokens": 13320,
|
||
"completion_tokens": 30,
|
||
"total_tokens": 13350,
|
||
"cache_read_input_tokens": 13312,
|
||
"prompt_tokens_details": {"cached_tokens": 13312}
|
||
}
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(
|
||
r.input_tokens,
|
||
Some(13320),
|
||
"prompt_tokens is the inclusive total"
|
||
);
|
||
assert_eq!(r.cached_input_tokens, Some(13312));
|
||
assert!(
|
||
r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap(),
|
||
"the cached slice is a subset of the input total"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_missing_usage_is_none() {
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]
|
||
});
|
||
assert_eq!(parse_openai(v).unwrap().input_tokens, None);
|
||
}
|
||
|
||
// ── total_tokens parsing ───────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn parse_openai_chat_total_tokens_present_is_read() {
|
||
// Chat Completions: `usage.total_tokens` is a genuine provider total.
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
|
||
"usage": {"prompt_tokens": 100, "completion_tokens": 25, "total_tokens": 125}
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.total_tokens, Some(125));
|
||
assert_eq!(r.input_tokens, Some(100));
|
||
assert_eq!(r.output_tokens, Some(25));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_chat_total_tokens_absent_is_none() {
|
||
// Chat Completions without `total_tokens` → None, not a derived sum.
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
|
||
"usage": {"prompt_tokens": 100, "completion_tokens": 25}
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.total_tokens, None);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_total_tokens_present_is_read() {
|
||
// Responses API: `usage.total_tokens` is a genuine provider total.
|
||
let v = serde_json::json!({
|
||
"output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}],
|
||
"status": "completed",
|
||
"usage": {"input_tokens": 80, "output_tokens": 20, "total_tokens": 100}
|
||
});
|
||
let r = parse_responses(v).unwrap();
|
||
assert_eq!(r.total_tokens, Some(100));
|
||
assert_eq!(r.input_tokens, Some(80));
|
||
assert_eq!(r.output_tokens, Some(20));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_total_tokens_absent_is_none() {
|
||
// Responses API without `total_tokens` → None.
|
||
let v = serde_json::json!({
|
||
"output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}],
|
||
"status": "completed",
|
||
"usage": {"input_tokens": 80, "output_tokens": 20}
|
||
});
|
||
let r = parse_responses(v).unwrap();
|
||
assert_eq!(r.total_tokens, None);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_total_tokens_always_none() {
|
||
// Anthropic reports only category counts; NIP-AM forbids deriving a total.
|
||
// total_tokens must always be None regardless of what the response contains —
|
||
// including if a future Anthropic API version unexpectedly adds total_tokens.
|
||
let v = serde_json::json!({
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"stop_reason": "end_turn",
|
||
"usage": {
|
||
"input_tokens": 100,
|
||
"cache_read_input_tokens": 50,
|
||
"cache_creation_input_tokens": 0,
|
||
"output_tokens": 30,
|
||
// Unexpected field: parse_anthropic must ignore this and return None.
|
||
"total_tokens": 180
|
||
}
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert!(
|
||
r.total_tokens.is_none(),
|
||
"Anthropic must never supply a total_tokens value"
|
||
);
|
||
// Verify other fields still parse correctly.
|
||
assert_eq!(r.input_tokens, Some(150)); // inclusive sum with cache
|
||
assert_eq!(r.output_tokens, Some(30));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_reads_nested_cached_tokens() {
|
||
// The shape vanilla OpenAI actually returns, captured from a live
|
||
// /chat/completions probe on gpt-5.6-luna: `prompt_tokens` is already
|
||
// inclusive and the cache split is nested one level down. Reading only
|
||
// flat keys left the discount unclaimed while the total looked correct,
|
||
// which is why this went unnoticed.
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "OK"}}],
|
||
"usage": {
|
||
"prompt_tokens": 5229,
|
||
"completion_tokens": 4,
|
||
"total_tokens": 5233,
|
||
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 5226,
|
||
"cache_write_tokens": 0}
|
||
}
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.input_tokens, Some(5229), "total must stay inclusive");
|
||
assert_eq!(r.cached_input_tokens, Some(5226));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_cache_write_round_reports_zero_cached() {
|
||
// First request of a cold prefix: the provider writes the cache and
|
||
// serves nothing from it. `Some(0)` not `None` — the split was reported,
|
||
// it was simply zero, and a consumer must be able to tell the two apart.
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "OK"}}],
|
||
"usage": {
|
||
"prompt_tokens": 5229,
|
||
"completion_tokens": 4,
|
||
"prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 5226}
|
||
}
|
||
});
|
||
assert_eq!(parse_openai(v).unwrap().cached_input_tokens, Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_no_cache_detail_is_none() {
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
|
||
"usage": {"prompt_tokens": 123, "completion_tokens": 4}
|
||
});
|
||
assert_eq!(parse_openai(v).unwrap().cached_input_tokens, None);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_prefers_flat_anthropic_spelling_over_nested() {
|
||
// Databricks reports both shapes for the same quantity. Take one, never
|
||
// the sum, or the cached slice double-counts. cache_read (800) is a
|
||
// subset of the inclusive prompt_tokens (1000), as it must be.
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
|
||
"usage": {
|
||
"prompt_tokens": 1000,
|
||
"completion_tokens": 4,
|
||
"cache_read_input_tokens": 800,
|
||
"prompt_tokens_details": {"cached_tokens": 800}
|
||
}
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.input_tokens, Some(1000));
|
||
assert_eq!(r.cached_input_tokens, Some(800));
|
||
assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_reports_cache_read_as_cached() {
|
||
// Anthropic's `input_tokens` EXCLUDES cached, so the inclusive total is
|
||
// a sum -- but the cached slice must still be a subset of that total.
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {
|
||
"input_tokens": 100,
|
||
"output_tokens": 7,
|
||
"cache_read_input_tokens": 900,
|
||
"cache_creation_input_tokens": 50
|
||
}
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.input_tokens, Some(1050));
|
||
assert_eq!(r.cached_input_tokens, Some(900));
|
||
assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_reads_nested_cached_tokens() {
|
||
// The Responses API nests the same figure under a different key than
|
||
// /chat/completions does.
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "hi"}]
|
||
}],
|
||
"usage": {
|
||
"input_tokens": 4000,
|
||
"output_tokens": 9,
|
||
"input_tokens_details": {"cached_tokens": 3584}
|
||
}
|
||
});
|
||
let r = parse_responses(v).unwrap();
|
||
assert_eq!(r.input_tokens, Some(4000));
|
||
assert_eq!(r.cached_input_tokens, Some(3584));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_uses_input_tokens() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "hi"}]
|
||
}],
|
||
"usage": {"input_tokens": 321, "output_tokens": 9, "total_tokens": 330}
|
||
});
|
||
assert_eq!(parse_responses(v).unwrap().input_tokens, Some(321));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_responses_missing_usage_is_none() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "hi"}]
|
||
}]
|
||
});
|
||
assert_eq!(parse_responses(v).unwrap().input_tokens, None);
|
||
}
|
||
|
||
#[test]
|
||
fn sum_usage_empty_object_is_none() {
|
||
// A `usage` object present but carrying none of the requested fields
|
||
// is "no usable reading" -> None, not Some(0).
|
||
let v = serde_json::json!({"usage": {"output_tokens": 5}});
|
||
assert_eq!(sum_usage(&v, &["input_tokens", "prompt_tokens"]), None);
|
||
}
|
||
|
||
#[test]
|
||
fn sum_usage_exact_single_field() {
|
||
let v = serde_json::json!({"usage": {"input_tokens": 42}});
|
||
assert_eq!(
|
||
sum_usage(&v, &["input_tokens"]),
|
||
Some(SumUsageResult::Exact(42))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sum_usage_exact_two_fields() {
|
||
let v = serde_json::json!({"usage": {"input_tokens": 100, "cache_read_input_tokens": 50}});
|
||
assert_eq!(
|
||
sum_usage(&v, &["input_tokens", "cache_read_input_tokens"]),
|
||
Some(SumUsageResult::Exact(150))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sum_usage_overflow_two_fields_signals_overflow() {
|
||
// Reproducer from Thufir's finding: input_tokens = u64::MAX, cache_read = 1.
|
||
// Must return Overflow, not Exact(u64::MAX).
|
||
let v = serde_json::json!({
|
||
"usage": {
|
||
"input_tokens": u64::MAX,
|
||
"cache_read_input_tokens": 1_u64,
|
||
"cache_creation_input_tokens": 0_u64
|
||
}
|
||
});
|
||
assert_eq!(
|
||
sum_usage(
|
||
&v,
|
||
&[
|
||
"input_tokens",
|
||
"cache_read_input_tokens",
|
||
"cache_creation_input_tokens"
|
||
]
|
||
),
|
||
Some(SumUsageResult::Overflow)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_input_overflow_sets_flag_and_clears_value() {
|
||
// The inclusive sum overflows; input_tokens must be None and the flag set.
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {
|
||
"input_tokens": u64::MAX,
|
||
"cache_read_input_tokens": 1_u64,
|
||
"cache_creation_input_tokens": 0_u64,
|
||
"output_tokens": 7
|
||
}
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.input_tokens, None, "overflowed value must be discarded");
|
||
assert!(r.input_tokens_overflowed, "overflow flag must be set");
|
||
// output_tokens unaffected by input overflow
|
||
assert_eq!(r.output_tokens, Some(7));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_normal_sum_does_not_set_flag() {
|
||
// Normal (non-overflow) path: flag stays false.
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {
|
||
"input_tokens": 100_u64,
|
||
"cache_read_input_tokens": 900_u64,
|
||
"cache_creation_input_tokens": 50_u64,
|
||
"output_tokens": 7
|
||
}
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.input_tokens, Some(1050));
|
||
assert!(!r.input_tokens_overflowed);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_anthropic_absent_usage_does_not_set_flag() {
|
||
// Absent usage: flag stays false, input_tokens stays None.
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}]
|
||
});
|
||
let r = parse_anthropic(v).unwrap();
|
||
assert_eq!(r.input_tokens, None);
|
||
assert!(!r.input_tokens_overflowed);
|
||
}
|
||
|
||
/// A token source whose `bearer()` always hands back the same stale
|
||
/// token and whose `refresh_now()` mints a distinct fresh one, counting
|
||
/// each refresh. Lets a test assert exactly how many forced refreshes a
|
||
/// `post_openai` call provoked.
|
||
struct CountingAuth {
|
||
refreshes: std::sync::atomic::AtomicU32,
|
||
}
|
||
|
||
#[async_trait::async_trait]
|
||
impl TokenSource for CountingAuth {
|
||
async fn bearer(&self) -> Result<String, AgentError> {
|
||
Ok("stale".into())
|
||
}
|
||
async fn refresh_now(&self, _rejected: &str) -> Result<String, AgentError> {
|
||
self.refreshes
|
||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||
Ok("fresh".into())
|
||
}
|
||
}
|
||
|
||
/// Stub that answers `reject_status` to any request carrying `Bearer
|
||
/// stale` and 200 to `Bearer fresh`. When `always_reject` is set it rejects
|
||
/// unconditionally, simulating a token the refresh can never satisfy.
|
||
/// Counts requests so a test can assert "one retry, not a loop".
|
||
async fn spawn_auth_stub(
|
||
always_reject: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||
reject_status: u16,
|
||
) -> String {
|
||
use std::sync::atomic::Ordering;
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let reject_line = match reject_status {
|
||
401 => "401 Unauthorized",
|
||
403 => "403 Forbidden",
|
||
other => panic!("unsupported reject_status {other}"),
|
||
};
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let always_reject = always_reject.clone();
|
||
tokio::spawn(async move {
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(k) => buf.extend_from_slice(&tmp[..k]),
|
||
}
|
||
}
|
||
let head = String::from_utf8_lossy(&buf).to_ascii_lowercase();
|
||
let stale = head.contains("authorization: bearer stale");
|
||
let resp = if always_reject.load(Ordering::SeqCst) || stale {
|
||
format!(
|
||
"HTTP/1.1 {reject_line}\r\nContent-Length: 11\r\n\
|
||
Connection: close\r\n\r\ntoken stale"
|
||
)
|
||
} else {
|
||
let body = "{\"ok\":true}";
|
||
format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body,
|
||
)
|
||
};
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
});
|
||
}
|
||
});
|
||
url
|
||
}
|
||
|
||
fn llm_with(auth: Arc<dyn TokenSource>) -> Llm {
|
||
Llm {
|
||
http: Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap(),
|
||
auto_upgraded: std::sync::atomic::AtomicBool::new(false),
|
||
auth,
|
||
}
|
||
}
|
||
|
||
/// A single 401 forces exactly one refresh, the retry with the fresh
|
||
/// token succeeds, and a *later* call gets its own refresh — proving the
|
||
/// one-shot guard is per-call, not stored on the source.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_openai_refreshes_once_per_call_on_401() {
|
||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||
|
||
let always_401 = Arc::new(AtomicBool::new(false));
|
||
let base = spawn_auth_stub(always_401, 401).await;
|
||
let auth = Arc::new(CountingAuth {
|
||
refreshes: AtomicU32::new(0),
|
||
});
|
||
let llm = llm_with(auth.clone());
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base;
|
||
|
||
let out = llm
|
||
.post_openai(&c, "/v1/x", &json!({}), "model")
|
||
.await
|
||
.expect("retry with fresh token should succeed");
|
||
assert_eq!(out, json!({ "ok": true }));
|
||
assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1, "one refresh");
|
||
|
||
// Second call's 401 must trigger its own refresh — the guard cannot
|
||
// be a stored flag that an earlier turn already tripped.
|
||
let out2 = llm
|
||
.post_openai(&c, "/v1/x", &json!({}), "model")
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(out2, json!({ "ok": true }));
|
||
assert_eq!(
|
||
auth.refreshes.load(Ordering::SeqCst),
|
||
2,
|
||
"later call gets its own retry"
|
||
);
|
||
}
|
||
|
||
/// A persistent 401 (even the refreshed token is rejected) propagates as
|
||
/// `LlmAuth` after exactly one refresh — no infinite loop.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_openai_persistent_401_propagates_after_one_retry() {
|
||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||
|
||
let always_401 = Arc::new(AtomicBool::new(true));
|
||
let base = spawn_auth_stub(always_401, 401).await;
|
||
let auth = Arc::new(CountingAuth {
|
||
refreshes: AtomicU32::new(0),
|
||
});
|
||
let llm = llm_with(auth.clone());
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base;
|
||
|
||
let err = llm
|
||
.post_openai(&c, "/v1/x", &json!({}), "model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, PostError::Agent(AgentError::LlmAuth(_))),
|
||
"got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
auth.refreshes.load(Ordering::SeqCst),
|
||
1,
|
||
"exactly one refresh, then propagate"
|
||
);
|
||
}
|
||
|
||
/// A 403 is treated as refreshable: a persistent 403 forces exactly one
|
||
/// refresh-and-retry, then propagates as `LlmAuth`. Proves a revoked-token
|
||
/// 403 takes the same recovery path as a 401, bounded by the per-call guard.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_openai_persistent_403_propagates_after_one_retry() {
|
||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||
|
||
let always_403 = Arc::new(AtomicBool::new(true));
|
||
let base = spawn_auth_stub(always_403, 403).await;
|
||
let auth = Arc::new(CountingAuth {
|
||
refreshes: AtomicU32::new(0),
|
||
});
|
||
let llm = llm_with(auth.clone());
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base;
|
||
|
||
let err = llm
|
||
.post_openai(&c, "/v1/x", &json!({}), "model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, PostError::Agent(AgentError::LlmAuth(_))),
|
||
"got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
auth.refreshes.load(Ordering::SeqCst),
|
||
1,
|
||
"403 refreshes exactly once, then propagates"
|
||
);
|
||
}
|
||
|
||
/// A recoverable 403 (stale token 403s, fresh token 200s) forces exactly
|
||
/// one refresh and the retry succeeds — proving a 403 enters the refresh
|
||
/// path and a refreshed token clears it, the stale-token-403 recovery case.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_openai_refreshes_once_on_403() {
|
||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||
|
||
let always_403 = Arc::new(AtomicBool::new(false));
|
||
let base = spawn_auth_stub(always_403, 403).await;
|
||
let auth = Arc::new(CountingAuth {
|
||
refreshes: AtomicU32::new(0),
|
||
});
|
||
let llm = llm_with(auth.clone());
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base;
|
||
|
||
let out = llm
|
||
.post_openai(&c, "/v1/x", &json!({}), "model")
|
||
.await
|
||
.expect("retry with fresh token should clear the 403");
|
||
assert_eq!(out, json!({ "ok": true }));
|
||
assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1, "one refresh");
|
||
}
|
||
|
||
/// The default `refresh_now()` on a static source returns the static
|
||
/// token unchanged — a key that can't refresh still answers harmlessly.
|
||
#[tokio::test]
|
||
async fn static_token_source_refresh_now_returns_static_token() {
|
||
let src = StaticTokenSource::new("static-key");
|
||
assert_eq!(src.refresh_now("rejected").await.unwrap(), "static-key");
|
||
}
|
||
|
||
// ── Output-token parsing tests ──────────────────────────────────────────
|
||
|
||
/// `parse_anthropic` extracts `output_tokens` from the usage object.
|
||
#[test]
|
||
fn parse_anthropic_output_tokens() {
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {"input_tokens": 42, "output_tokens": 7}
|
||
});
|
||
assert_eq!(parse_anthropic(v).unwrap().output_tokens, Some(7));
|
||
}
|
||
|
||
/// `parse_anthropic` returns `None` for `output_tokens` when usage is absent.
|
||
#[test]
|
||
fn parse_anthropic_output_tokens_missing_usage_is_none() {
|
||
let v = serde_json::json!({
|
||
"stop_reason": "end_turn",
|
||
"content": [{"type": "text", "text": "hi"}]
|
||
});
|
||
assert_eq!(parse_anthropic(v).unwrap().output_tokens, None);
|
||
}
|
||
|
||
/// `parse_openai` maps `completion_tokens` to `output_tokens`.
|
||
#[test]
|
||
fn parse_openai_output_tokens_from_completion_tokens() {
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
|
||
"usage": {"prompt_tokens": 123, "completion_tokens": 4, "total_tokens": 127}
|
||
});
|
||
assert_eq!(parse_openai(v).unwrap().output_tokens, Some(4));
|
||
}
|
||
|
||
/// `parse_openai` returns `None` for `output_tokens` when usage is absent.
|
||
#[test]
|
||
fn parse_openai_output_tokens_missing_usage_is_none() {
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]
|
||
});
|
||
assert_eq!(parse_openai(v).unwrap().output_tokens, None);
|
||
}
|
||
|
||
/// `parse_responses` extracts `output_tokens` from the usage object.
|
||
#[test]
|
||
fn parse_responses_output_tokens() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "hi"}]
|
||
}],
|
||
"usage": {"input_tokens": 321, "output_tokens": 9, "total_tokens": 330}
|
||
});
|
||
assert_eq!(parse_responses(v).unwrap().output_tokens, Some(9));
|
||
}
|
||
|
||
/// `parse_responses` returns `None` for `output_tokens` when usage is absent.
|
||
#[test]
|
||
fn parse_responses_output_tokens_missing_usage_is_none() {
|
||
let v = serde_json::json!({
|
||
"status": "completed",
|
||
"output": [{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "hi"}]
|
||
}]
|
||
});
|
||
assert_eq!(parse_responses(v).unwrap().output_tokens, None);
|
||
}
|
||
|
||
// ---- A3: OpenRouter body-shape tests ----
|
||
|
||
fn tools_vec() -> Vec<ToolDef> {
|
||
vec![ToolDef {
|
||
name: "dev__shell".into(),
|
||
description: "run a shell command".into(),
|
||
input_schema: serde_json::json!({
|
||
"type": "object",
|
||
"properties": {"command": {"type": "string"}},
|
||
}),
|
||
}]
|
||
}
|
||
|
||
#[test]
|
||
fn openrouter_body_tools_with_effort() {
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.thinking_effort = Some(ThinkingEffort::High);
|
||
let mut body = openai_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&tools_vec(),
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(
|
||
&mut body,
|
||
c.thinking_effort,
|
||
"anthropic/claude-opus-4-7",
|
||
true,
|
||
);
|
||
assert_eq!(body["reasoning"]["effort"], "high");
|
||
// `openai_body` is always called with `effort=None` on the OpenRouter
|
||
// path (line 151): OpenRouter uses its own `reasoning` object, not the
|
||
// OpenAI-style `reasoning_effort` field. Verify the field is structurally
|
||
// absent — not merely removed by a no-op cleanup.
|
||
assert!(
|
||
body.get("reasoning_effort").is_none(),
|
||
"reasoning_effort must never appear on the OpenRouter body path: \
|
||
openai_body is called with effort=None, so the field is never emitted"
|
||
);
|
||
assert!(
|
||
body.get("provider").is_none(),
|
||
"provider.require_parameters hard-404s models that do not advertise \
|
||
every parameter we send"
|
||
);
|
||
assert!(!body["tools"].as_array().unwrap().is_empty());
|
||
assert_eq!(
|
||
body["max_tokens"], 1024,
|
||
"token limit must carry openai_body's value under OpenRouter's spelling"
|
||
);
|
||
assert!(
|
||
body.get("max_completion_tokens").is_none(),
|
||
"max_completion_tokens is the OpenAI-native spelling; OpenRouter reads max_tokens"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openrouter_body_tools_no_effort() {
|
||
let c = cfg(Provider::OpenRouter);
|
||
let mut body = openai_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&tools_vec(),
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
|
||
assert!(
|
||
body.get("reasoning").is_none(),
|
||
"reasoning must be absent when effort is None"
|
||
);
|
||
assert!(
|
||
body.get("reasoning_effort").is_none(),
|
||
"reasoning_effort must never appear on the OpenRouter body path"
|
||
);
|
||
assert!(
|
||
body.get("provider").is_none(),
|
||
"tools alone must not add a provider routing filter"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openrouter_body_empty_tools_with_effort() {
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.thinking_effort = Some(ThinkingEffort::Medium);
|
||
let mut body = openai_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(
|
||
&mut body,
|
||
c.thinking_effort,
|
||
"anthropic/claude-opus-4-7",
|
||
true,
|
||
);
|
||
assert_eq!(body["reasoning"]["effort"], "medium");
|
||
assert!(
|
||
body.get("provider").is_none(),
|
||
"83 of 274 tools-capable models do not advertise `reasoning`; filtering on \
|
||
it would 404 them instead of answering without reasoning"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openrouter_body_empty_tools_no_effort() {
|
||
let c = cfg(Provider::OpenRouter);
|
||
let mut body = openai_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
|
||
assert!(body.get("reasoning").is_none());
|
||
assert!(
|
||
body.get("provider").is_none(),
|
||
"no body shape adds a provider routing filter"
|
||
);
|
||
}
|
||
|
||
/// `BUZZ_AGENT_PROMPT_CACHING=0` must reach the OpenRouter `anthropic/*`
|
||
/// route too, not just the native Anthropic Messages routes. The switch and
|
||
/// this route landed in separate changes, so nothing but this test stops the
|
||
/// gate from being dropped and the kill switch silently becoming a no-op on
|
||
/// a route that emits Anthropic-dialect breakpoints.
|
||
#[test]
|
||
fn openrouter_body_caching_disabled_emits_no_cache_control() {
|
||
let c = cfg(Provider::OpenRouter);
|
||
let mut body = openai_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&tools_vec(),
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", false);
|
||
assert!(
|
||
!body.to_string().contains("cache_control"),
|
||
"BUZZ_AGENT_PROMPT_CACHING=0 must suppress every breakpoint: {body}"
|
||
);
|
||
// The switch is scoped to caching — the routing-contract mutations that
|
||
// make the request serveable at all must still be applied.
|
||
assert_eq!(
|
||
body.get("max_tokens").and_then(Value::as_u64),
|
||
Some(u64::from(c.max_output_tokens)),
|
||
"the max_tokens rename is not part of the caching gate"
|
||
);
|
||
}
|
||
|
||
/// The paired positive case: with caching on, the same body does carry
|
||
/// breakpoints. Without this twin, a mutation that hard-disabled caching
|
||
/// outright would still leave the test above green.
|
||
#[test]
|
||
fn openrouter_body_caching_enabled_emits_cache_control() {
|
||
let c = cfg(Provider::OpenRouter);
|
||
let mut body = openai_body(
|
||
&c,
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&tools_vec(),
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
|
||
assert!(
|
||
body.to_string().contains("cache_control"),
|
||
"caching on must still emit breakpoints: {body}"
|
||
);
|
||
}
|
||
|
||
/// The rename moves an existing value; it never invents a token limit for a
|
||
/// body that did not carry one.
|
||
#[test]
|
||
fn openrouter_body_without_token_limit_gains_none() {
|
||
let mut body = json!({ "model": "vendor/model", "messages": [] });
|
||
apply_openrouter_mutations(&mut body, None, "vendor/model", true);
|
||
assert!(body.get("max_tokens").is_none());
|
||
assert!(body.get("max_completion_tokens").is_none());
|
||
}
|
||
|
||
/// The summary body reserves `max_output_tokens` for visible text by
|
||
/// granting reasoning a separate, equal budget on top and excluding it
|
||
/// from the response. Without the separation, a reasoning model can spend
|
||
/// the entire cap thinking and length-stop with empty `content`, which
|
||
/// `summarize()` reports as an empty summary and the handoff degrades to
|
||
/// lossy truncation.
|
||
#[test]
|
||
fn openrouter_summary_budgets_reasoning_separately_and_carries_no_provider() {
|
||
let body = openrouter_summary_body(
|
||
"anthropic/claude-opus-4-7",
|
||
"summarize",
|
||
"text to summarize",
|
||
1024,
|
||
);
|
||
assert_eq!(body["model"], "anthropic/claude-opus-4-7");
|
||
assert_eq!(body["messages"][0]["role"], "system");
|
||
assert_eq!(body["messages"][1]["content"], "text to summarize");
|
||
assert_eq!(
|
||
body["max_tokens"], 2048,
|
||
"total cap must cover the text budget plus the reasoning budget"
|
||
);
|
||
assert_eq!(
|
||
body["reasoning"]["max_tokens"], 1024,
|
||
"reasoning gets its own budget so it cannot starve the summary text"
|
||
);
|
||
assert_eq!(
|
||
body["reasoning"]["exclude"], true,
|
||
"reasoning must not be included in the response; summarize() reads only content"
|
||
);
|
||
assert!(
|
||
body["reasoning"].get("effort").is_none(),
|
||
"budget-based cap only; effort stays unset for the summary call"
|
||
);
|
||
assert!(
|
||
body.get("max_completion_tokens").is_none(),
|
||
"summary body must use OpenRouter's token-limit spelling"
|
||
);
|
||
assert!(
|
||
body.get("provider").is_none(),
|
||
"summary body must not carry provider"
|
||
);
|
||
}
|
||
|
||
/// The token-limit rename belongs to `apply_openrouter_mutations`, not to
|
||
/// `openai_body` — which OpenAI and Databricks also use, and where
|
||
/// `max_completion_tokens` is the correct spelling.
|
||
#[test]
|
||
fn openai_body_keeps_max_completion_tokens_when_unmutated() {
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenAi),
|
||
"system",
|
||
&[HistoryItem::User("hi".into())],
|
||
&[],
|
||
"model",
|
||
None,
|
||
);
|
||
assert_eq!(body["max_completion_tokens"], 1024);
|
||
assert!(body.get("max_tokens").is_none());
|
||
}
|
||
|
||
// ---- A5: error-inside-200 ----
|
||
|
||
#[test]
|
||
fn parse_openai_error_inside_200_returns_error() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "error",
|
||
"error": {
|
||
"code": 503,
|
||
"message": "No endpoints found that support tool use"
|
||
}
|
||
}]
|
||
});
|
||
let err = parse_openai(v).unwrap_err();
|
||
match &err {
|
||
AgentError::Llm(s) => {
|
||
assert!(s.contains("provider error (503)"), "got: {s}");
|
||
assert!(s.contains("No endpoints found"), "got: {s}");
|
||
}
|
||
_ => panic!("expected AgentError::Llm, got: {err:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_error_inside_200_accepts_string_code() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "error",
|
||
"error": {
|
||
"code": "insufficient_quota",
|
||
"message": "quota exceeded"
|
||
}
|
||
}]
|
||
});
|
||
let err = parse_openai(v).unwrap_err();
|
||
match &err {
|
||
AgentError::Llm(s) => assert!(
|
||
s.contains("provider error (insufficient_quota)"),
|
||
"got: {s}"
|
||
),
|
||
_ => panic!("expected AgentError::Llm, got: {err:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_error_inside_200_surfaces_error_type() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "error",
|
||
"error": {
|
||
"code": 429,
|
||
"message": "Rate limit exceeded",
|
||
"metadata": { "error_type": "rate_limit_exceeded" }
|
||
}
|
||
}]
|
||
});
|
||
let err = parse_openai(v).unwrap_err();
|
||
match &err {
|
||
AgentError::Llm(s) => {
|
||
assert!(s.contains("429"), "got: {s}");
|
||
assert!(s.contains("rate_limit_exceeded"), "got: {s}");
|
||
}
|
||
_ => panic!("expected AgentError::Llm, got: {err:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_normal_stop_not_affected_by_error_check() {
|
||
let v = serde_json::json!({
|
||
"choices": [{"finish_reason": "stop", "message": {"content": "hello"}}],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.text, "hello");
|
||
assert_eq!(r.stop, ProviderStop::EndTurn);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_tool_calls_not_affected_by_error_check() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "tool_calls",
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {"name": "test", "arguments": "{}"}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert_eq!(r.stop, ProviderStop::ToolUse);
|
||
assert_eq!(r.tool_calls.len(), 1);
|
||
}
|
||
|
||
// ---- A6: OpenRouter retry classification ----
|
||
|
||
#[test]
|
||
fn classify_429_rate_limit_body_retry_after_is_ignored() {
|
||
// M1: no documented body-level retry field, so a `retry_after` key
|
||
// inside `error.metadata` is inert — only the HTTP header (passed
|
||
// separately) can produce a delay.
|
||
let body =
|
||
r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded","retry_after":2.5}}}"#;
|
||
match classify_openrouter_error(429, body, None) {
|
||
OpenRouterErrorClass::Retryable(None) => {}
|
||
other => panic!("expected Retryable(None), got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_429_rate_limit_without_retry_after() {
|
||
let body = r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded"}}}"#;
|
||
match classify_openrouter_error(429, body, None) {
|
||
OpenRouterErrorClass::Retryable(None) => {}
|
||
other => panic!("expected Retryable(None), got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_429_prefers_http_header_over_body() {
|
||
// Body-level retry hints are no longer parsed (M1: undocumented field);
|
||
// the HTTP header is the only source, and it's honored when present.
|
||
let body = r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded"}}}"#;
|
||
let header = Some(Duration::from_secs(3));
|
||
match classify_openrouter_error(429, body, header) {
|
||
OpenRouterErrorClass::Retryable(Some(d)) => {
|
||
assert_eq!(d, Duration::from_secs(3), "HTTP header must be honored");
|
||
}
|
||
other => panic!("expected Retryable with header delay, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_502_provider_unavailable() {
|
||
let body = r#"{"error":{"metadata":{"error_type":"provider_unavailable"}}}"#;
|
||
match classify_openrouter_error(502, body, None) {
|
||
OpenRouterErrorClass::Retryable(None) => {}
|
||
other => panic!("expected Retryable(None) for 502, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_503_provider_overloaded_with_retry_after() {
|
||
// No documented body-level retry field (M1); the header is the only
|
||
// source `classify_openrouter_error` consults.
|
||
let body = r#"{"error":{"metadata":{"error_type":"provider_overloaded"}}}"#;
|
||
let header = Some(Duration::from_secs(5));
|
||
match classify_openrouter_error(503, body, header) {
|
||
OpenRouterErrorClass::Retryable(Some(d)) => {
|
||
assert_eq!(d, Duration::from_secs(5));
|
||
}
|
||
other => panic!("expected Retryable with delay, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_503_untyped_is_unknown() {
|
||
let body = r#"{"error":{"message":"No endpoints found"}}"#;
|
||
match classify_openrouter_error(503, body, None) {
|
||
OpenRouterErrorClass::Unknown => {}
|
||
other => panic!("expected Unknown for untyped 503, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_500_untyped_is_unknown() {
|
||
match classify_openrouter_error(500, r#"{"error":{"message":"internal"}}"#, None) {
|
||
OpenRouterErrorClass::Unknown => {}
|
||
other => panic!("expected Unknown for 500, got: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_retry_after_header_valid() {
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::RETRY_AFTER, "5".parse().unwrap());
|
||
assert_eq!(
|
||
parse_retry_after_header(&headers),
|
||
Some(Duration::from_secs(5))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_retry_after_header_zero_rejected() {
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::RETRY_AFTER, "0".parse().unwrap());
|
||
assert_eq!(parse_retry_after_header(&headers), None);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_retry_after_header_over_cap_clamped() {
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(reqwest::header::RETRY_AFTER, "3601".parse().unwrap());
|
||
assert_eq!(
|
||
parse_retry_after_header(&headers),
|
||
Some(Duration::from_secs(RETRY_AFTER_CAP_SECS)),
|
||
"over-cap hints clamp to the ceiling rather than being dropped"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_retry_after_header_at_cap_unclamped() {
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(
|
||
reqwest::header::RETRY_AFTER,
|
||
RETRY_AFTER_CAP_SECS.to_string().parse().unwrap(),
|
||
);
|
||
assert_eq!(
|
||
parse_retry_after_header(&headers),
|
||
Some(Duration::from_secs(RETRY_AFTER_CAP_SECS))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_retry_after_header_missing() {
|
||
let headers = reqwest::header::HeaderMap::new();
|
||
assert_eq!(parse_retry_after_header(&headers), None);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_retry_after_header_non_numeric_ignored() {
|
||
let mut headers = reqwest::header::HeaderMap::new();
|
||
headers.insert(
|
||
reqwest::header::RETRY_AFTER,
|
||
"Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
|
||
);
|
||
assert_eq!(parse_retry_after_header(&headers), None);
|
||
}
|
||
|
||
// ---- A7: Anthropic cache_control with mixed content ----
|
||
|
||
#[test]
|
||
fn anthropic_cache_control_mixed_text_tool_image_history() {
|
||
let history = vec![
|
||
HistoryItem::User("first question".into()),
|
||
HistoryItem::Assistant {
|
||
text: String::new(),
|
||
tool_calls: vec![ToolCall {
|
||
provider_id: "toolu_1".into(),
|
||
name: "dev__view_image".into(),
|
||
arguments: serde_json::json!({"source": "x.png"}),
|
||
provider_extra: Default::default(),
|
||
}],
|
||
reasoning_details: None,
|
||
},
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "toolu_1".into(),
|
||
content: vec![
|
||
ToolResultContent::Text("10×10 image".into()),
|
||
ToolResultContent::Image {
|
||
data: "aW1n".into(),
|
||
mime_type: "image/png".into(),
|
||
},
|
||
],
|
||
is_error: false,
|
||
}),
|
||
HistoryItem::User("second question about the image".into()),
|
||
HistoryItem::User("third question".into()),
|
||
];
|
||
let mut body = openai_body(
|
||
&cfg(Provider::OpenRouter),
|
||
"system",
|
||
&history,
|
||
&tools_vec(),
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
|
||
// System message should have cache_control
|
||
let system = &messages[0];
|
||
assert_eq!(system["content"][0]["cache_control"]["type"], "ephemeral");
|
||
|
||
// Image-batch user messages (containing image_url blocks) must NOT have cache_control
|
||
let image_user_msgs: Vec<_> = messages
|
||
.iter()
|
||
.filter(|m| {
|
||
m.get("role").and_then(Value::as_str) == Some("user")
|
||
&& m.get("content")
|
||
.and_then(Value::as_array)
|
||
.map(|a| {
|
||
a.iter()
|
||
.any(|b| b.get("type").and_then(Value::as_str) == Some("image_url"))
|
||
})
|
||
.unwrap_or(false)
|
||
})
|
||
.collect();
|
||
assert!(
|
||
!image_user_msgs.is_empty(),
|
||
"should have image user messages"
|
||
);
|
||
for img_msg in &image_user_msgs {
|
||
let content = img_msg["content"].as_array().unwrap();
|
||
for block in content {
|
||
assert!(
|
||
block.get("cache_control").is_none(),
|
||
"image-only user message must not receive cache_control"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Exactly 2 text user messages should have cache_control (skipping image-only ones)
|
||
let cached_text_count = messages
|
||
.iter()
|
||
.filter(|m| {
|
||
m.get("role").and_then(Value::as_str) == Some("user")
|
||
&& m.get("content")
|
||
.and_then(Value::as_array)
|
||
.map(|a| {
|
||
a.iter().any(|b| {
|
||
b.get("type").and_then(Value::as_str) == Some("text")
|
||
&& b.get("cache_control").is_some()
|
||
})
|
||
})
|
||
.unwrap_or(false)
|
||
})
|
||
.count();
|
||
assert_eq!(
|
||
cached_text_count, 2,
|
||
"exactly 2 text user messages should have cache_control"
|
||
);
|
||
|
||
// Last tool def should have cache_control
|
||
let tools = body["tools"].as_array().unwrap();
|
||
let last_tool = tools.last().unwrap();
|
||
assert_eq!(last_tool["function"]["cache_control"]["type"], "ephemeral");
|
||
}
|
||
|
||
#[test]
|
||
fn anthropic_cache_control_image_only_user_does_not_consume_slot() {
|
||
// An image-only user message between two text user messages must not
|
||
// consume a cache breakpoint slot — both text messages should get cached.
|
||
let history = vec![
|
||
HistoryItem::User("text message one".into()),
|
||
HistoryItem::Assistant {
|
||
text: String::new(),
|
||
tool_calls: vec![ToolCall {
|
||
provider_id: "toolu_1".into(),
|
||
name: "dev__view_image".into(),
|
||
arguments: serde_json::json!({"source": "x.png"}),
|
||
provider_extra: Default::default(),
|
||
}],
|
||
reasoning_details: None,
|
||
},
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "toolu_1".into(),
|
||
content: vec![ToolResultContent::Image {
|
||
data: "aW1n".into(),
|
||
mime_type: "image/png".into(),
|
||
}],
|
||
is_error: false,
|
||
}),
|
||
HistoryItem::User("text message two".into()),
|
||
HistoryItem::User("text message three".into()),
|
||
];
|
||
let mut body = openai_body(
|
||
&cfg(Provider::OpenRouter),
|
||
"system",
|
||
&history,
|
||
&[],
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
|
||
// Count text user messages that got cache_control
|
||
let cached_text_count = messages
|
||
.iter()
|
||
.filter(|m| {
|
||
m.get("role").and_then(Value::as_str) == Some("user")
|
||
&& m.get("content")
|
||
.and_then(Value::as_array)
|
||
.map(|a| a.iter().any(|b| b.get("cache_control").is_some()))
|
||
.unwrap_or(false)
|
||
})
|
||
.count();
|
||
assert_eq!(
|
||
cached_text_count, 2,
|
||
"image-only user messages must not consume a cache breakpoint slot"
|
||
);
|
||
}
|
||
|
||
// ---- A9: reasoning_details round-trip ----
|
||
|
||
#[test]
|
||
fn parse_openai_with_reasoning_details_captures_array() {
|
||
let details = serde_json::json!([
|
||
{"type": "thinking", "content": "Let me consider..."},
|
||
{"type": "thinking", "content": "The answer is 42."}
|
||
]);
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "tool_calls",
|
||
"message": {
|
||
"content": "",
|
||
"reasoning_details": details,
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {"name": "test", "arguments": "{}"}
|
||
}]
|
||
}
|
||
}],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||
});
|
||
let r = parse_openai_with_reasoning_details(v).unwrap();
|
||
assert_eq!(r.reasoning_details, Some(details));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_with_reasoning_details_none_when_absent() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "stop",
|
||
"message": {"content": "hello"}
|
||
}]
|
||
});
|
||
let r = parse_openai_with_reasoning_details(v).unwrap();
|
||
assert!(
|
||
r.reasoning_details.is_none(),
|
||
"reasoning_details must be None when not in response"
|
||
);
|
||
}
|
||
|
||
/// M2: a malformed `reasoning_details` shape (null or a bare object,
|
||
/// rather than the documented array) must be omitted at the parse
|
||
/// boundary, not stored and later replayed into the next request.
|
||
#[test]
|
||
fn parse_openai_with_reasoning_details_omits_non_array_shapes() {
|
||
let null_shape = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "stop",
|
||
"message": {"content": "hello", "reasoning_details": null}
|
||
}]
|
||
});
|
||
let r = parse_openai_with_reasoning_details(null_shape).unwrap();
|
||
assert!(
|
||
r.reasoning_details.is_none(),
|
||
"null reasoning_details must be omitted, not stored as Some(Null)"
|
||
);
|
||
|
||
let object_shape = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "stop",
|
||
"message": {
|
||
"content": "hello",
|
||
"reasoning_details": {"type": "thinking", "content": "not an array"}
|
||
}
|
||
}]
|
||
});
|
||
let r = parse_openai_with_reasoning_details(object_shape).unwrap();
|
||
assert!(
|
||
r.reasoning_details.is_none(),
|
||
"a bare-object reasoning_details must be omitted, not stored as Some(object)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_openai_plain_never_captures_reasoning_details() {
|
||
let v = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "stop",
|
||
"message": {
|
||
"content": "hello",
|
||
"reasoning_details": [{"type": "thinking", "content": "hmm"}]
|
||
}
|
||
}]
|
||
});
|
||
let r = parse_openai(v).unwrap();
|
||
assert!(
|
||
r.reasoning_details.is_none(),
|
||
"plain parse_openai must never capture reasoning_details (OpenAI/Databricks regression)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_details_two_request_round_trip() {
|
||
let details = serde_json::json!([
|
||
{"type": "thinking", "content": "Step 1: analyze the request."},
|
||
{"type": "thinking", "content": "Step 2: call the tool."}
|
||
]);
|
||
// Request 1: model returns a tool call with reasoning_details
|
||
let response1 = serde_json::json!({
|
||
"choices": [{
|
||
"finish_reason": "tool_calls",
|
||
"message": {
|
||
"content": "",
|
||
"reasoning_details": details,
|
||
"tool_calls": [{
|
||
"id": "call_abc",
|
||
"type": "function",
|
||
"function": {"name": "dev__shell", "arguments": "{\"command\":\"ls\"}"}
|
||
}]
|
||
}
|
||
}],
|
||
"usage": {"prompt_tokens": 50, "completion_tokens": 20}
|
||
});
|
||
let r1 = parse_openai_with_reasoning_details(response1).unwrap();
|
||
assert_eq!(r1.reasoning_details, Some(details.clone()));
|
||
|
||
// Build history as the agent would: assistant turn with reasoning_details,
|
||
// followed by a tool result.
|
||
let history = vec![
|
||
HistoryItem::User("run ls".into()),
|
||
HistoryItem::Assistant {
|
||
text: String::new(),
|
||
tool_calls: r1.tool_calls,
|
||
reasoning_details: r1.reasoning_details,
|
||
},
|
||
HistoryItem::ToolResult(ToolResult {
|
||
provider_id: "call_abc".into(),
|
||
content: vec![ToolResultContent::Text("file.txt".into())],
|
||
is_error: false,
|
||
}),
|
||
];
|
||
|
||
// Request 2: build the body for the continuation
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenRouter),
|
||
"system",
|
||
&history,
|
||
&[],
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
|
||
// The assistant message must carry the identical reasoning_details array
|
||
let assistant_msg = messages
|
||
.iter()
|
||
.find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
|
||
.expect("assistant message must exist");
|
||
assert_eq!(
|
||
assistant_msg["reasoning_details"], details,
|
||
"reasoning_details must be replayed byte-for-byte on the assistant message"
|
||
);
|
||
|
||
// The assistant message must appear BEFORE the tool result
|
||
let assistant_idx = messages
|
||
.iter()
|
||
.position(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
|
||
.unwrap();
|
||
let tool_idx = messages
|
||
.iter()
|
||
.position(|m| m.get("role").and_then(Value::as_str) == Some("tool"))
|
||
.unwrap();
|
||
assert!(
|
||
assistant_idx < tool_idx,
|
||
"assistant with reasoning_details must precede tool result"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_details_none_emits_no_field_in_body() {
|
||
let history = vec![
|
||
HistoryItem::User("hello".into()),
|
||
HistoryItem::Assistant {
|
||
text: "hi back".into(),
|
||
tool_calls: Vec::new(),
|
||
reasoning_details: None,
|
||
},
|
||
];
|
||
let body = openai_body(
|
||
&cfg(Provider::OpenRouter),
|
||
"system",
|
||
&history,
|
||
&[],
|
||
"anthropic/claude-opus-4-7",
|
||
None,
|
||
);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
let assistant_msg = messages
|
||
.iter()
|
||
.find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
|
||
.expect("assistant message must exist");
|
||
assert!(
|
||
assistant_msg.get("reasoning_details").is_none(),
|
||
"assistant with None reasoning_details must not emit the field"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_details_charged_to_estimated_bytes() {
|
||
let details = serde_json::json!([
|
||
{"type": "thinking", "content": "A long chain of reasoning tokens here."}
|
||
]);
|
||
let with = HistoryItem::Assistant {
|
||
text: "text".into(),
|
||
tool_calls: Vec::new(),
|
||
reasoning_details: Some(details.clone()),
|
||
};
|
||
let without = HistoryItem::Assistant {
|
||
text: "text".into(),
|
||
tool_calls: Vec::new(),
|
||
reasoning_details: None,
|
||
};
|
||
assert!(
|
||
with.estimated_bytes() > without.estimated_bytes(),
|
||
"reasoning_details must contribute to estimated_bytes"
|
||
);
|
||
assert!(
|
||
with.context_pressure_bytes() > without.context_pressure_bytes(),
|
||
"reasoning_details must contribute to context_pressure_bytes"
|
||
);
|
||
let details_size = serde_json::to_vec(&details).unwrap().len();
|
||
assert_eq!(
|
||
with.estimated_bytes() - without.estimated_bytes(),
|
||
details_size,
|
||
"reasoning_details contribution must equal its serialized size"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_details_not_replayed_in_anthropic_body() {
|
||
let history = vec![
|
||
HistoryItem::User("hi".into()),
|
||
HistoryItem::Assistant {
|
||
text: "ok".into(),
|
||
tool_calls: Vec::new(),
|
||
reasoning_details: Some(
|
||
serde_json::json!([{"type": "thinking", "content": "hmm"}]),
|
||
),
|
||
},
|
||
];
|
||
let body = anthropic_body(
|
||
&cfg(Provider::Anthropic),
|
||
"system",
|
||
&history,
|
||
&[],
|
||
"claude-opus-4-7",
|
||
None,
|
||
"anthropic",
|
||
);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
let assistant = messages
|
||
.iter()
|
||
.find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
|
||
.unwrap();
|
||
assert!(
|
||
assistant.get("reasoning_details").is_none(),
|
||
"anthropic_body must not replay reasoning_details"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_details_not_replayed_in_responses_body() {
|
||
let history = vec![
|
||
HistoryItem::User("hi".into()),
|
||
HistoryItem::Assistant {
|
||
text: "ok".into(),
|
||
tool_calls: Vec::new(),
|
||
reasoning_details: Some(
|
||
serde_json::json!([{"type": "thinking", "content": "hmm"}]),
|
||
),
|
||
},
|
||
];
|
||
let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None);
|
||
let body_str = serde_json::to_string(&body).unwrap();
|
||
assert!(
|
||
!body_str.contains("reasoning_details"),
|
||
"responses_body must not replay reasoning_details"
|
||
);
|
||
}
|
||
|
||
// ---- T4: openrouter_post transport-level regressions ----
|
||
//
|
||
// These stub an HTTP server directly and drive `openrouter_post` (not the
|
||
// classifier in isolation), proving the retry/attempt-accounting and
|
||
// header behavior the classifier-only tests above cannot see.
|
||
|
||
/// One canned response: status, body, and any extra headers (e.g.
|
||
/// `Retry-After`) to send back for a single request.
|
||
struct CannedResponse {
|
||
status: u16,
|
||
body: String,
|
||
extra_headers: Vec<(String, String)>,
|
||
}
|
||
|
||
impl CannedResponse {
|
||
fn new(status: u16, body: &str) -> Self {
|
||
Self {
|
||
status,
|
||
body: body.into(),
|
||
extra_headers: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn with_header(mut self, name: &str, value: &str) -> Self {
|
||
self.extra_headers.push((name.into(), value.into()));
|
||
self
|
||
}
|
||
}
|
||
|
||
fn status_line(status: u16) -> &'static str {
|
||
match status {
|
||
200 => "200 OK",
|
||
400 => "400 Bad Request",
|
||
413 => "413 Payload Too Large",
|
||
401 => "401 Unauthorized",
|
||
402 => "402 Payment Required",
|
||
403 => "403 Forbidden",
|
||
404 => "404 Not Found",
|
||
429 => "429 Too Many Requests",
|
||
499 => "499 Client Closed Request",
|
||
500 => "500 Internal Server Error",
|
||
502 => "502 Bad Gateway",
|
||
503 => "503 Service Unavailable",
|
||
_ => panic!("unsupported status {status} in test stub"),
|
||
}
|
||
}
|
||
|
||
/// Spawns a stub HTTP server that pops one `CannedResponse` per request
|
||
/// (repeating the last one once the queue is exhausted, so an
|
||
/// over-budget attempt count is visible rather than hanging), and
|
||
/// captures each request's raw header block for header-attribution
|
||
/// assertions. Returns (url, captured_header_blocks, attempt_counter).
|
||
async fn spawn_openrouter_stub(
|
||
responses: Vec<CannedResponse>,
|
||
) -> (
|
||
String,
|
||
Arc<Mutex<Vec<String>>>,
|
||
Arc<std::sync::atomic::AtomicU32>,
|
||
) {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
|
||
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||
let attempts = Arc::new(AtomicU32::new(0));
|
||
let captured_clone = captured.clone();
|
||
let attempts_clone = attempts.clone();
|
||
tokio::spawn(async move {
|
||
loop {
|
||
let (mut sock, _) = match listener.accept().await {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
let queue = queue.clone();
|
||
let captured = captured_clone.clone();
|
||
let attempts = attempts_clone.clone();
|
||
tokio::spawn(async move {
|
||
let mut buf = Vec::new();
|
||
let mut tmp = [0u8; 4096];
|
||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => return,
|
||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||
}
|
||
if buf.len() > 1_000_000 {
|
||
return;
|
||
}
|
||
}
|
||
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
||
let header_str = String::from_utf8_lossy(&buf[..header_end]).into_owned();
|
||
// Drain any remaining body per Content-Length so `Connection:
|
||
// close` doesn't race the client's write.
|
||
let content_length: usize = header_str
|
||
.lines()
|
||
.find_map(|line| {
|
||
line.to_ascii_lowercase()
|
||
.strip_prefix("content-length:")
|
||
.and_then(|v| v.trim().parse().ok())
|
||
})
|
||
.unwrap_or(0);
|
||
let mut body_len = buf.len() - header_end;
|
||
while body_len < content_length {
|
||
match sock.read(&mut tmp).await {
|
||
Ok(0) | Err(_) => break,
|
||
Ok(n) => body_len += n,
|
||
}
|
||
}
|
||
captured.lock().await.push(header_str);
|
||
attempts.fetch_add(1, Ordering::SeqCst);
|
||
|
||
let mut q = queue.lock().await;
|
||
let canned = if q.len() > 1 {
|
||
q.pop_front().unwrap()
|
||
} else {
|
||
// Repeat the final canned response so a test bug that
|
||
// over-retries produces a visible extra attempt
|
||
// instead of a hung connection.
|
||
let last = q.front().unwrap();
|
||
CannedResponse {
|
||
status: last.status,
|
||
body: last.body.clone(),
|
||
extra_headers: last.extra_headers.clone(),
|
||
}
|
||
};
|
||
drop(q);
|
||
|
||
let mut resp = format!(
|
||
"HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n",
|
||
status_line(canned.status),
|
||
canned.body.len()
|
||
);
|
||
for (name, value) in &canned.extra_headers {
|
||
resp.push_str(&format!("{name}: {value}\r\n"));
|
||
}
|
||
resp.push_str("Connection: close\r\n\r\n");
|
||
resp.push_str(&canned.body);
|
||
let _ = sock.write_all(resp.as_bytes()).await;
|
||
let _ = sock.shutdown().await;
|
||
});
|
||
}
|
||
});
|
||
(url, captured, attempts)
|
||
}
|
||
|
||
/// Wren's rider: assert on the error emerging from `complete()` for the
|
||
/// OpenRouter path, not from `openrouter_post`. The bug was the `?` in the
|
||
/// provider arm, which is invisible from below — a low-level test can see
|
||
/// the classification but not whether the arm returns it into the
|
||
/// convergence mapper. The regression test has to cross the layer that had
|
||
/// the bug.
|
||
///
|
||
/// Two claims here: the variant is `LlmContextExceeded` (so the agent loop
|
||
/// can recover), and the message carries the `(model)` stamp (so the arm
|
||
/// reaches the mapper at all). Measured before the fix: variant was correct
|
||
/// but UNSTAMPED, which is exactly the bypass Wren named.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_context_400_is_typed_and_stamped_through_complete() {
|
||
let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
400,
|
||
r#"{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}"#,
|
||
)])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.base_url = url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::LlmContextExceeded(_)),
|
||
"OpenRouter context-window 400 must classify as LlmContextExceeded, got: {err:?}"
|
||
);
|
||
let text = err.to_string();
|
||
assert!(
|
||
text.contains("or-model-xyz"),
|
||
"OpenRouter arm must return into the convergence mapper so the model stamp is \
|
||
applied; got: {text}"
|
||
);
|
||
}
|
||
|
||
/// Same two claims on the Anthropic arm — the other `?` Wren named, and the
|
||
/// other terminal's provider phrasing ("prompt is too long").
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn anthropic_context_400_is_typed_and_stamped_through_complete() {
|
||
let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 400,
|
||
body: json!({"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 300000 tokens > 200000 maximum"}}),
|
||
}])
|
||
.await;
|
||
let mut c = cfg(Provider::Anthropic);
|
||
c.base_url = base_url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "claude-probe-model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::LlmContextExceeded(_)),
|
||
"Anthropic context-window 400 must classify as LlmContextExceeded, got: {err:?}"
|
||
);
|
||
let text = err.to_string();
|
||
assert!(
|
||
text.contains("claude-probe-model"),
|
||
"Anthropic arm must return into the convergence mapper so the model stamp is \
|
||
applied; got: {text}"
|
||
);
|
||
}
|
||
|
||
/// Negative arm for the OpenRouter terminal: an ordinary 400 must stay
|
||
/// `AgentError::Llm`. Paired with the positive above, this is what proves
|
||
/// the matcher — not the status alone — is doing the classification. The
|
||
/// body deliberately quotes "tokens" and "model", the words a loose matcher
|
||
/// would key on.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_ordinary_400_stays_plain_llm_error() {
|
||
let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
400,
|
||
r#"{"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}"#,
|
||
)])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.base_url = url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::Llm(_)),
|
||
"an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}"
|
||
);
|
||
}
|
||
|
||
/// Negative arm for the shared `post()` terminal (OpenAI/Databricks), the
|
||
/// second of the two `!status.is_success()` sites. Same body as the
|
||
/// OpenRouter negative so the two terminals are compared on equal input.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openai_ordinary_400_stays_plain_llm_error() {
|
||
let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 400,
|
||
body: json!({"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}),
|
||
}])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base_url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "gpt-probe-model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::Llm(_)),
|
||
"an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}"
|
||
);
|
||
}
|
||
|
||
/// Positive arm for the shared `post()` terminal: OpenAI's machine-readable
|
||
/// `context_length_exceeded` code classifies as recoverable.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openai_context_400_is_typed_through_complete() {
|
||
let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 400,
|
||
body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}),
|
||
}])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base_url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "gpt-probe-model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::LlmContextExceeded(_)),
|
||
"OpenAI context-window 400 must classify as LlmContextExceeded, got: {err:?}"
|
||
);
|
||
assert!(
|
||
err.to_string().contains("gpt-probe-model"),
|
||
"expected the convergence mapper's model stamp, got: {err}"
|
||
);
|
||
}
|
||
|
||
/// A context-window 400 must NOT trip the Responses-API auto-upgrade. True
|
||
/// by construction — `try_upgrade` matches only `AgentError::Llm` and the
|
||
/// typed variant can never reach it — but asserted because the guarantee
|
||
/// lives in a pattern match one refactor away from widening, and a silent
|
||
/// sticky upgrade would reroute every later OpenAI call for the process.
|
||
///
|
||
/// `openai_api = Auto` is load-bearing in BOTH arms: `try_upgrade` is only
|
||
/// consulted under `Auto` (`llm.rs:587`), so with the test helper's default
|
||
/// `Chat` the upgrade path is disabled outright and the negative below would
|
||
/// pass without observing anything. The control caught exactly that.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn context_400_does_not_trip_responses_upgrade() {
|
||
let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 400,
|
||
body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","code":"context_length_exceeded"}}),
|
||
}])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base_url;
|
||
c.openai_api = OpenAiApi::Auto;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "gpt-probe-model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, AgentError::LlmContextExceeded(_)));
|
||
assert!(
|
||
!llm.auto_upgraded.load(Ordering::Relaxed),
|
||
"a context-window 400 must not latch the Responses-API upgrade"
|
||
);
|
||
// Positive control: the same helper DOES latch on a genuine
|
||
// "use the Responses API" error, so the negative above is a real
|
||
// observation and not a probe that can never fire.
|
||
let (base_url2, _c2) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 400,
|
||
body: json!({"error":{"message":"This model is only supported in /v1/responses"}}),
|
||
}])
|
||
.await;
|
||
let mut c2 = cfg(Provider::OpenAi);
|
||
c2.base_url = base_url2;
|
||
c2.openai_api = OpenAiApi::Auto;
|
||
let llm2 = Llm::new(&c2).unwrap();
|
||
let _ = complete_model(&llm2, &c2, "gpt-probe-model").await;
|
||
assert!(
|
||
llm2.auto_upgraded.load(Ordering::Relaxed),
|
||
"control: a genuine Responses-API error must latch the upgrade"
|
||
);
|
||
}
|
||
|
||
/// The `status == 400` conjunct is load-bearing, not belt-and-braces: the
|
||
/// recovery ladder is only a correct response to an INPUT-SIZE rejection.
|
||
/// A 403 whose body happens to quote context-window prose (a guardrail
|
||
/// echoing the request, say) is a permission failure — shrinking history
|
||
/// cannot fix it, so classifying it as recoverable would burn the whole
|
||
/// recovery budget on three doomed summarize round-trips and turn a clear
|
||
/// immediate error into a slow one.
|
||
///
|
||
/// 413 (Payload Too Large) is the right probe status, and picking it took a
|
||
/// measurement: my first attempt used 403, which BOTH ladders intercept
|
||
/// earlier (shared `post()` maps 401/403 to `LlmAuth`; `openrouter_post()`
|
||
/// has its own 403 arm), so those probes never reached the classification
|
||
/// site at all and the mutant with the conjunct deleted survived them. 413
|
||
/// is intercepted by neither ladder, so it reaches the same
|
||
/// `!status.is_success()` terminal the 400 does — and it is the most
|
||
/// plausible real-world carrier of size prose on a non-400. One arm per
|
||
/// terminal site.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openai_413_with_context_prose_is_not_recoverable() {
|
||
let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 413,
|
||
body: json!({"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}),
|
||
}])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base_url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "gpt-probe-model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::Llm(_)),
|
||
"only a 400 may classify as a context overflow; a 413 must stay terminal, got: \
|
||
{err:?}"
|
||
);
|
||
}
|
||
|
||
/// Same claim at the OpenRouter terminal, which has its own status ladder.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_413_with_context_prose_is_not_recoverable() {
|
||
let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
413,
|
||
r#"{"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}"#,
|
||
)])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.base_url = url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err();
|
||
assert!(
|
||
matches!(err, AgentError::Llm(_)),
|
||
"only a 400 may classify as a context overflow; a 413 must stay terminal, got: \
|
||
{err:?}"
|
||
);
|
||
}
|
||
|
||
/// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs)
|
||
/// must NOT be classified as `LlmAuth`: refreshing a static key returns
|
||
/// the identical key, so retrying would just waste a duplicate request.
|
||
/// Exactly one attempt, plain `AgentError::Llm` with the body preserved.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_403_single_attempt_not_auth_error() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
403,
|
||
r#"{"error":{"message":"model flagged by moderation"}}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::Llm(s) if s.contains("403") && s.contains("model flagged by moderation")),
|
||
"403 must surface as AgentError::Llm with status+body, not LlmAuth: got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
1,
|
||
"403 must not be retried (a refreshed static key is identical)"
|
||
);
|
||
}
|
||
|
||
/// A 402 short-circuits on the first attempt: no retry, one request,
|
||
/// the actionable credits-exhausted message.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_402_single_attempt_short_circuit() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
402,
|
||
r#"{"error":{"message":"payment required"}}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::Llm(s) if s.contains("credits exhausted")),
|
||
"got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
1,
|
||
"402 must not be retried"
|
||
);
|
||
}
|
||
|
||
/// A 404 whose body is OpenRouter's parameter-routing rejection is NOT a
|
||
/// missing model: the id is valid and no endpoint behind it can serve the
|
||
/// request shape. It must surface the actionable routing message rather than
|
||
/// `LlmModelNotFound`, which sends the user hunting a model-name typo.
|
||
/// Body text is the one OpenRouter actually returned in the live probe run.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_404_no_endpoints_found_is_parameter_routing_error() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
404,
|
||
r#"{"error":{"message":"No endpoints found that can handle the requested parameters. To learn more about provider routing, visit: https://openrouter.ai/docs/guides/routing/provider-selection","code":404}}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")),
|
||
"parameter-routing 404 must not be reported as a missing model: got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
1,
|
||
"404 must not be retried"
|
||
);
|
||
}
|
||
|
||
/// A provider's explicit image-capability rejection is a recoverable typed
|
||
/// error, not a missing model. The agent loop uses this signal to remove the
|
||
/// image from history before retrying the next LLM round.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_404_unsupported_image_is_typed_and_not_retried() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
404,
|
||
r#"{"error":{"message":"No endpoints found that support image input"}}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")),
|
||
"image rejection must reach the history-recovery path: got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
1,
|
||
"a deterministic capability rejection must not be retried"
|
||
);
|
||
}
|
||
|
||
/// OpenAI-compatible text-only deployments report the image rejection as a
|
||
/// 400, not OpenRouter's 404 — Crusoe serverless GLM answers
|
||
/// `"crusoeai/GLM-5.2-NVFP4 is not a multimodal model"` to every request
|
||
/// whose history contains an image. Before the 400 arm existed, this fell
|
||
/// through to terminal `AgentError::Llm`: the image stayed in history and
|
||
/// every later call in the session failed identically (measured live:
|
||
/// 8 wedged benchmark trials, 40 min of doomed retries each). Asserted
|
||
/// through `complete()` so the arm's return path into the convergence
|
||
/// mapper is covered, same doctrine as the context-400 tests above.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openai_400_unsupported_image_is_typed_through_complete() {
|
||
let (base_url, captured) = spawn_sequence_stub(vec![StubHttpResponse {
|
||
status: 400,
|
||
body: json!({"error":{"message":"crusoeai/GLM-5.2-NVFP4 is not a multimodal model","type":"invalid_request_error"}}),
|
||
}])
|
||
.await;
|
||
let mut c = cfg(Provider::OpenAi);
|
||
c.base_url = base_url;
|
||
let llm = Llm::new(&c).unwrap();
|
||
let err = complete_model(&llm, &c, "gpt-probe-model")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("not a multimodal model")),
|
||
"a text-only deployment's 400 must reach the history-recovery path: got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
captured.lock().await.len(),
|
||
1,
|
||
"a deterministic capability rejection must not be retried"
|
||
);
|
||
}
|
||
|
||
/// Same 400-shaped rejection at the OpenRouter terminal, which has its own
|
||
/// status ladder: a BYOK/passthrough upstream can surface the provider's
|
||
/// own 400 body instead of OpenRouter's 404 routing error.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_400_unsupported_image_is_typed_and_not_retried() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
400,
|
||
r#"{"error":{"message":"crusoeai/GLM-5.2-NVFP4 is not a multimodal model"}}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("not a multimodal model")),
|
||
"image rejection must reach the history-recovery path: got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
1,
|
||
"a deterministic capability rejection must not be retried"
|
||
);
|
||
}
|
||
|
||
/// Every other 404 still maps to `LlmModelNotFound`, including one that
|
||
/// shares the `No endpoints found` prefix but is about the model rather than
|
||
/// the parameters — the discriminator is narrow enough that a genuinely
|
||
/// unavailable model keeps its own error kind (Desktop renders
|
||
/// model-not-found differently from a generic LLM failure).
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_404_unknown_model_stays_model_not_found() {
|
||
let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
404,
|
||
r#"{"error":{"message":"No endpoints found for vendor/nonexistent-model.","code":404}}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::LlmModelNotFound(s) if s.contains("404") && s.contains("vendor/nonexistent-model")),
|
||
"a model-level 404 must stay LlmModelNotFound: got {err:?}"
|
||
);
|
||
}
|
||
|
||
/// A 429 with `Retry-After: 1` sleeps for that duration before the retry
|
||
/// succeeds — proving the header value is actually honored, not just
|
||
/// classified.
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn openrouter_post_429_honors_retry_after_header() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![
|
||
CannedResponse::new(429, r#"{"error":{"message":"rate limited"}}"#)
|
||
.with_header("Retry-After", "1"),
|
||
CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
|
||
])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(30))
|
||
.build()
|
||
.unwrap();
|
||
let before = std::time::Instant::now();
|
||
let out = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.expect("second attempt succeeds");
|
||
assert_eq!(out["choices"][0]["message"]["content"], "ok");
|
||
assert!(
|
||
before.elapsed() >= Duration::from_secs(1),
|
||
"must sleep at least the Retry-After hint"
|
||
);
|
||
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||
}
|
||
|
||
// ---- retry_delay_for_429 (pure-function tests, no network) ---------------
|
||
|
||
/// A huge server-advertised Retry-After is capped at RETRY_AFTER_CAP_SECS.
|
||
/// This test proves the cap is actually wired into the loop's only delay
|
||
/// computation (not just a dead constant), because `openrouter_post`'s 429
|
||
/// arm calls `parse_retry_after_header` which delegates to this function.
|
||
#[test]
|
||
fn retry_delay_for_429_huge_value_is_capped() {
|
||
let d = retry_delay_for_429(Some("999999")).unwrap();
|
||
assert_eq!(
|
||
d,
|
||
Duration::from_secs(RETRY_AFTER_CAP_SECS),
|
||
"999999s must be capped to {RETRY_AFTER_CAP_SECS}s"
|
||
);
|
||
}
|
||
|
||
/// A small value below the cap is returned as-is.
|
||
#[test]
|
||
fn retry_delay_for_429_small_value_honored() {
|
||
let d = retry_delay_for_429(Some("3")).unwrap();
|
||
assert_eq!(d, Duration::from_secs(3), "3s must be honored verbatim");
|
||
}
|
||
|
||
/// A value equal to the cap is returned unchanged (boundary).
|
||
#[test]
|
||
fn retry_delay_for_429_exact_cap_is_not_truncated() {
|
||
let d = retry_delay_for_429(Some(&RETRY_AFTER_CAP_SECS.to_string())).unwrap();
|
||
assert_eq!(
|
||
d,
|
||
Duration::from_secs(RETRY_AFTER_CAP_SECS),
|
||
"exact cap must not be truncated"
|
||
);
|
||
}
|
||
|
||
/// Missing header returns None → caller falls back to backoff.
|
||
#[test]
|
||
fn retry_delay_for_429_missing_header_is_none() {
|
||
assert!(
|
||
retry_delay_for_429(None).is_none(),
|
||
"absent header must return None"
|
||
);
|
||
}
|
||
|
||
/// Garbage / non-numeric header returns None.
|
||
#[test]
|
||
fn retry_delay_for_429_garbage_header_is_none() {
|
||
assert!(
|
||
retry_delay_for_429(Some("not-a-number")).is_none(),
|
||
"unparseable header must return None"
|
||
);
|
||
}
|
||
|
||
/// Zero is treated as absent (no-op: don't sleep 0 seconds).
|
||
#[test]
|
||
fn retry_delay_for_429_zero_is_none() {
|
||
assert!(
|
||
retry_delay_for_429(Some("0")).is_none(),
|
||
"zero Retry-After must return None (no-op sleep)"
|
||
);
|
||
}
|
||
|
||
/// An untyped 503 (no `error.metadata.error_type`) exhausts all
|
||
/// `MAX_RETRIES` attempts, then returns the actionable routing message —
|
||
/// proving attempt accounting terminates rather than retrying forever.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_untyped_503_exhausts_retries_into_actionable_message() {
|
||
let canned = CannedResponse::new(503, r#"{"error":{"message":"no capacity"}}"#);
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![canned]).await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(30))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")),
|
||
"got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
MAX_RETRIES,
|
||
"must exhaust exactly MAX_RETRIES attempts, no more"
|
||
);
|
||
}
|
||
|
||
/// Attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`) are on the
|
||
/// actual wire request, not merely asserted against a body fixture.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_sends_attribution_headers() {
|
||
let (url, captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
200,
|
||
r#"{"choices":[{"message":{"content":"ok"}}]}"#,
|
||
)])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.expect("200 succeeds");
|
||
let headers = captured.lock().await;
|
||
let header_str = headers
|
||
.first()
|
||
.expect("one request captured")
|
||
.to_lowercase();
|
||
assert!(
|
||
header_str.contains("http-referer: https://github.com/block/buzz"),
|
||
"got: {header_str}"
|
||
);
|
||
assert!(
|
||
header_str.contains("x-openrouter-title: buzz"),
|
||
"got: {header_str}"
|
||
);
|
||
}
|
||
|
||
/// A 499 response is retried and the call succeeds on the second attempt,
|
||
/// mirroring the shared `post()` path (#2175).
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_retries_499_then_succeeds() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![
|
||
CannedResponse::new(499, ""),
|
||
CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
|
||
])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(30))
|
||
.build()
|
||
.unwrap();
|
||
let out = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.expect("retry after 499 should succeed");
|
||
assert_eq!(out["choices"][0]["message"]["content"], "ok");
|
||
assert_eq!(
|
||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||
2,
|
||
"exactly one 499 retry"
|
||
);
|
||
}
|
||
|
||
/// A 502 without `provider_unavailable` still retries (the classifier's
|
||
/// unconditional-retry branch), then succeeds on attempt 2 — proving the
|
||
/// generic 502 path isn't accidentally routed to `Unknown`/terminal.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_502_retries_then_succeeds() {
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![
|
||
CannedResponse::new(502, r#"{"error":{"message":"bad gateway"}}"#),
|
||
CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
|
||
])
|
||
.await;
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(30))
|
||
.build()
|
||
.unwrap();
|
||
let out = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.expect("retry succeeds");
|
||
assert_eq!(out["choices"][0]["message"]["content"], "ok");
|
||
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||
}
|
||
|
||
/// A 200 response whose body is truncated mid-stream (connection closed
|
||
/// before the full Content-Length is delivered) must surface the error
|
||
/// through `terminal_llm_error`, not a bare `AgentError::Llm("read: …")` —
|
||
/// the caller needs cumulative duration and attempt-count context to diagnose
|
||
/// an upstream that silently drops connections.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn openrouter_post_truncated_200_body_wraps_in_terminal_error() {
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
use tokio::net::TcpListener;
|
||
|
||
// Spawn a stub that sends a 200 with Content-Length > actual body,
|
||
// then closes the connection — reqwest sees a truncated stream.
|
||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||
tokio::spawn(async move {
|
||
if let Ok((mut sock, _)) = listener.accept().await {
|
||
let mut buf = [0u8; 4096];
|
||
// Read until end-of-headers, ignore body
|
||
loop {
|
||
match sock.read(&mut buf).await {
|
||
Ok(0) | Err(_) => break,
|
||
Ok(n) => {
|
||
if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Claim 1 MB of body, send only 10 bytes, then close.
|
||
let _ = sock
|
||
.write_all(
|
||
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||
Content-Length: 1048576\r\nConnection: close\r\n\r\n\
|
||
{truncated",
|
||
)
|
||
.await;
|
||
let _ = sock.shutdown().await;
|
||
}
|
||
});
|
||
|
||
let http = Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let err = openrouter_post(
|
||
&http,
|
||
&format!("{url}/x"),
|
||
&json!({}),
|
||
"key",
|
||
Duration::from_secs(5),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::Llm(s) if s.contains("body read")),
|
||
"truncated body must surface as AgentError::Llm with 'body read': got {err:?}"
|
||
);
|
||
assert!(
|
||
matches!(&err, AgentError::Llm(s) if s.contains("cumulative")),
|
||
"body-read error must include terminal_llm_error's cumulative context: got {err:?}"
|
||
);
|
||
}
|
||
|
||
/// A `TokenSource` whose `refresh_now` always returns the identical token —
|
||
/// models a static API key whose bytes never change on refresh.
|
||
struct StaticAuth {
|
||
token: String,
|
||
}
|
||
|
||
#[async_trait::async_trait]
|
||
impl TokenSource for StaticAuth {
|
||
async fn bearer(&self) -> Result<String, AgentError> {
|
||
Ok(self.token.clone())
|
||
}
|
||
async fn refresh_now(&self, _rejected: &str) -> Result<String, AgentError> {
|
||
Ok(self.token.clone()) // static: same bytes every time
|
||
}
|
||
}
|
||
|
||
/// A `TokenSource` that returns a stale token from `bearer()` and a
|
||
/// distinct fresh token from `refresh_now()`, modelling a PKCE OAuth source.
|
||
struct MintingAuth {
|
||
stale: String,
|
||
fresh: String,
|
||
refreshes: std::sync::atomic::AtomicU32,
|
||
}
|
||
|
||
#[async_trait::async_trait]
|
||
impl TokenSource for MintingAuth {
|
||
async fn bearer(&self) -> Result<String, AgentError> {
|
||
Ok(self.stale.clone())
|
||
}
|
||
async fn refresh_now(&self, _rejected: &str) -> Result<String, AgentError> {
|
||
self.refreshes
|
||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||
Ok(self.fresh.clone())
|
||
}
|
||
}
|
||
|
||
/// A static key 401: `refresh_now` returns the same bytes — the second
|
||
/// wire request would be byte-identical, so the retry must be skipped.
|
||
/// Exactly one wire request reaches the server.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_openrouter_static_key_401_single_attempt_no_retry() {
|
||
use std::sync::atomic::Ordering;
|
||
|
||
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
|
||
401,
|
||
r#"{"error":{"message":"invalid api key"}}"#,
|
||
)])
|
||
.await;
|
||
let auth = Arc::new(StaticAuth {
|
||
token: "static-key".into(),
|
||
});
|
||
let llm = llm_with(auth);
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.base_url = url;
|
||
|
||
let err = llm.post_openrouter(&c, &json!({})).await.unwrap_err();
|
||
assert!(
|
||
matches!(&err, AgentError::LlmAuth(s) if s.contains("static key rejected")),
|
||
"static 401 must surface as LlmAuth with 'static key rejected': got {err:?}"
|
||
);
|
||
assert_eq!(
|
||
attempts.load(Ordering::SeqCst),
|
||
1,
|
||
"exactly one wire request — no duplicate retry for a static key"
|
||
);
|
||
}
|
||
|
||
/// A minting-source 401: `refresh_now` produces a distinct fresh token, so
|
||
/// the retry is legitimate. The stub accepts the fresh token's second
|
||
/// request with 200 and exactly two wire requests reach the server.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn post_openrouter_minting_source_401_retries_with_fresh_token() {
|
||
use std::sync::atomic::Ordering;
|
||
|
||
// Stub: always 401 for bearer "stale", 200 for anything else.
|
||
// We repurpose `spawn_auth_stub` here: it rejects `Bearer stale`,
|
||
// accepts `Bearer fresh`.
|
||
let always_401 = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let base = spawn_auth_stub(always_401, 401).await;
|
||
|
||
let auth = Arc::new(MintingAuth {
|
||
stale: "stale".into(),
|
||
fresh: "fresh".into(),
|
||
refreshes: std::sync::atomic::AtomicU32::new(0),
|
||
});
|
||
let llm = llm_with(auth.clone());
|
||
let mut c = cfg(Provider::OpenRouter);
|
||
c.base_url = base;
|
||
|
||
let result = llm.post_openrouter(&c, &json!({})).await;
|
||
// `spawn_auth_stub` returns `{"ok":true}` on success.
|
||
assert!(
|
||
result.is_ok(),
|
||
"minting-source retry should succeed: {result:?}"
|
||
);
|
||
assert_eq!(
|
||
auth.refreshes.load(Ordering::SeqCst),
|
||
1,
|
||
"exactly one refresh"
|
||
);
|
||
}
|
||
}
|