Files
buzz/crates/buzz-agent/src/llm.rs
Tyler 081f805d5e feat(agent): optional reply guard reminds a silent turn to publish (#3763)
## Why

A Buzz agent's assistant text and reasoning are never shown to anyone —
only what it posts through the CLI. A turn that runs fifteen tool calls
and never publishes is a silent failure: the requester waits on a result
that was produced and thrown away.

This adds an optional reminder at the end-of-turn gate, off by default.

Tyler asked for it in buzz-mesh; plan iterated to **9.5/10 with @Wren**
(Minimalness 9.7, Elegance 9.5, Correctness 9.3).

## What

`BUZZ_AGENT_REQUIRE_REPLY=1` (default off, per-agent opt-in). A turn
about to end with no recognized attempt to post gets a reminder and is
rerolled. **At most two, then the turn ends regardless** — the guard
catches accidental omission, it does not compel speech. The reminder
text explicitly licenses silence so it cannot fight the base prompt's
"silence is usually correct."

**This is not a new MCP hook.** `RunCtx::run` *is* the turn, so the two
per-turn locals need no plumbing, and every tool call already passes
through it with arguments visible. The objection is appended at the
existing `_Stop` gate and rides `push_hook_outputs_as_tool_results`, so
the model receives it as a lower-trust tool result with `{hook, server,
text}` attribution. No new trust path, no new lifecycle event, no
dev-mcp or CLI protocol change.

Earlier revisions of this plan needed four crates (a `_UserPromptSubmit`
hook, a marker file, a `buzz-cli` change, dev-mcp state). Tyler pointed
out the agent already knows both facts; that deleted all of it. Net
runtime change is ~35 lines in `agent.rs` + ~4 in `config.rs`.

### Recognition contract

A registered non-hook tool whose qualified name ends in `__shell`, whose
`command` argument contains `messages send` or `reactions add`.

- **The `__` separator is exact, not approximate.** Given `has()` +
`!is_hook()`, `ends_with("__shell")` is *provably equivalent* to a bare
name of `shell`: registration forbids `__` in server and bare names
(`mcp.rs:227,268`) and qnames are `{server}__{bare}`, so a trailing
`__shell` could only straddle the separator if the bare name began with
`_` — which `is_hook` excludes. Without the separator, `powershell` and
`noshell` would match.
- **Reads the structured `command` field**, not serialized arguments, so
a `description` that quotes a send cannot disarm the guard, and a
non-string `command` is rejected rather than coerced.
- **Detects an attempt, not a successful publish.** A failed send
already returns non-zero exit and error JSON — louder than this
reminder. The variable is named `buzz_reply_call_seen` so the code can't
pretend otherwise.
- **Checked after the per-turn tool-call cap**, since a discarded call
never ran.
- `messages send` also covers `messages send-diff`. Reactions count
because the base prompt directs agents to react rather than post a bare
acknowledgement.

**Known limits, both deliberate and documented:** a command assembled at
runtime (`$CMD`) or hidden in a wrapper script is missed; text that
merely quotes a send (`echo "buzz messages send"`) matches. Missing a
real post is the expensive direction and substring matching is the
forgiving one there. Neither edge is pinned by a test, so the matcher
stays free to improve.

### Budget

Reminders share `BUZZ_AGENT_STOP_MAX_REJECTIONS`, the existing outer cap
on every end-turn objection. Default 3 fits both; at 1 only one fits; at
0 the guard is off with the hooks. A round carrying both a hook
objection and a reminder costs one rejection and delivers both texts. An
independent budget would either violate that bound or need a second
arbitration rule.

## Prior art

- **#3467** (closed) built the same detector one layer up in `buzz-acp`
for a different remedy. None of its symbols are on main — this borrows
its permission to be coarse, but reads structured data that ACP didn't
have.
- **#3648** (open) detects turns with *no output at all*; a turn with
fifteen tool calls and no post counts as output there, so it does not
cover this case.
- **#3741** (merged) is mesh-only.

## Testing

**14 new tests.** 4 unit tests on the matcher; 10 integration tests
through the ACP wire harness: off by default, `=0` still off, opted-in
silent → exactly 2 reminders then `end_turn`, registered `fake__shell`
send → 0 reminders, hallucinated `fake__shell` → still reminded, publish
call truncated past the 64-call cap → still reminded, budget 1 → 1
reminder, budget 0 → off, combined `_Stop` hook objection + reminder →
one round both texts and after 2 reminders the hook objection continues
alone, unparseable `=true` → startup error naming the key.

**10 mutation checks, each breaking a specific named test** — neutralize
the nag cap, stop sharing the budget, neutralize `buzz_reply_call_seen`,
drop `has`/`is_hook`, ignore the flag, drop the `__`, drop `reactions
add`, read serialized args, move detection before truncation.

`tests/bin/fake_mcp.rs` gains `FAKE_MCP_SHELL_TOOL=1`: it previously
exposed no tool with a bare name of `shell`, so the satisfied-guard path
was untestable.

Full `cargo test -p buzz-agent` green at 9e0ae1f04; clippy `-D warnings`
and `cargo fmt --check` clean.

**Unrelated flake found:**
`cancelled_turn_with_usage_emits_notification_before_response`
(`tests/fake_llm.rs`) is timing-sensitive. Under 10 loaded cores it
fails **2/20 on this branch and 1/20 at unmodified
`origin/main@02be413b8`** — pre-existing, not caused by this change
(which is inert without the env var). Flagging so it isn't misattributed
to the next PR that's open when CI hits it.

## Docs

`crates/buzz-agent/README.md` is the primary home (env var, recognition
contract, limits, budget interaction). `docs/MCP_DRIVEN_HOOKS.md` gets a
short cross-reference explaining this is *not* a hook — otherwise
readers hunt for a `_ReplyGuard` tool that doesn't exist.

---------

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
2026-07-31 07:02:48 -04:00

6557 lines
253 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use reqwest::Client;
use serde_json::{json, Map, Value};
use tokio::sync::Mutex;
use tokio::time::Instant;
use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource};
use crate::config::{
is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_openai_route,
Config, OpenAiApi, Provider, ThinkingEffort,
};
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);
const MESH_VIRTUAL_MODEL_ID: &str = "mesh";
const MESH_AUTO_MODEL_ID: &str = "auto";
const MESH_AUTO_CATALOG_TTL: std::time::Duration = std::time::Duration::from_secs(5);
const MESH_AUTO_CATALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
const MESH_AUTO_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
const MESH_AUTO_ENABLE_OBSERVATIONS: u8 = 2;
const MESH_MOA_UNAVAILABLE_MESSAGE: &str = "MoA requires ≥2 models available in the mesh";
/// 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 MeshCatalogObservation {
Available,
Unavailable,
Unknown,
}
fn mesh_catalog_supports_collective(catalog: &Value) -> Option<bool> {
let models = catalog.get("data").and_then(Value::as_array)?;
let mut has_virtual_mesh = false;
let mut physical_models = BTreeSet::new();
for id in models
.iter()
.filter_map(|model| model.get("id").and_then(Value::as_str))
.map(str::trim)
.filter(|id| !id.is_empty())
{
if id == MESH_VIRTUAL_MODEL_ID {
has_virtual_mesh = true;
} else if id != MESH_AUTO_MODEL_ID {
physical_models.insert(id.replace("@main", ""));
}
}
Some(has_virtual_mesh && physical_models.len() >= 2)
}
fn looks_like_unstructured_tool_call(text: &str) -> bool {
let text = text.trim_start().to_ascii_lowercase();
text.starts_with("<|tool_call")
|| text.starts_with("<tool_call")
|| text.starts_with("[tool_call]")
}
#[derive(Debug, Default)]
struct MeshAutoState {
last_checked: Option<Instant>,
consecutive_available: u8,
collective_enabled: bool,
cooldown_until: Option<Instant>,
}
#[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,
/// Hysteretic view of whether mesh-llm currently advertises its virtual
/// Mixture-of-Agents model. A TTL, confirmation count, and failure cooldown
/// let long-running agents adapt without bouncing as peers briefly flap.
mesh_auto_state: Mutex<MeshAutoState>,
/// 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>,
}
impl Llm {
pub fn new(cfg: &Config) -> Result<Self, AgentError> {
let http = Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(cfg.llm_timeout)
.build()
.map_err(|e| AgentError::Llm(format!("http: {e}")))?;
let auth = build_token_source(cfg)?;
Ok(Self {
http,
auto_upgraded: AtomicBool::new(false),
mesh_auto_state: Mutex::new(MeshAutoState::default()),
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 result = match cfg.provider {
Provider::Anthropic => {
let v = self
.post_anthropic(
cfg,
&anthropic_body(
cfg,
system_prompt,
history,
tools,
effective_model,
effort,
),
)
.await?;
parse_anthropic(v)
}
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,
);
let v = self.post_openrouter(cfg, &body).await?;
parse_openai_with_reasoning_details(v)
}
Provider::OpenAi | Provider::Databricks => {
self.openai_request(
cfg,
effective_model,
!tools.is_empty(),
|use_responses, request_model| {
// Normalize effort for model-specific availability. Startup no longer rejects
// `max` for pure OpenAI/Databricks; this per-model table is the single authority
// — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped
// models, and still applies corrections like none→minimal on the gpt-5 base.
let e =
effort.map(|ef| normalize_effort_for_openai_route(ef, request_model));
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 against the per-model table.
let e =
effort.map(|ef| normalize_effort_for_openai_route(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),
parse_anthropic as OpenAiParse,
)
}
DatabricksV2Route::MlflowChatCompletions => {
// MLflow Chat path (OpenAI-shaped): normalize effort against the per-model table.
let e =
effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model));
(
openai_body(cfg, system_prompt, history, tools, effective_model, e),
parse_openai as OpenAiParse,
)
}
})
.await
}
};
// 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.
result.map_err(|e| match e {
AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")),
AgentError::LlmModelNotFound(s) => {
AgentError::LlmModelNotFound(format!("({effective_model}) {s}"))
}
other => other,
})
}
pub async fn summarize(
&self,
cfg: &Config,
system_prompt: &str,
user_prompt: &str,
max_output_tokens: u32,
effective_model: &str,
) -> Result<String, AgentError> {
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,
false,
|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 => (
json!({
"model": effective_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)
}
}
}
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, false, |r| {
r.header("x-api-key", &cfg.api_key)
.header("anthropic-version", &cfg.anthropic_api_version)
})
.await
.map_err(PostError::into_agent)
}
/// OpenAI dispatch with Buzz's relay-mesh `auto` policy layered over the
/// normal endpoint selection. When enabled, a live virtual `mesh` model is
/// preferred; if the mesh contracts between discovery and inference, retry
/// the same request once through the router's ordinary `auto` model.
async fn openai_request<F>(
&self,
cfg: &Config,
effective_model: &str,
tools_supplied: bool,
mut build: F,
) -> Result<LlmResponse, AgentError>
where
F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send,
{
let request_model = self.resolve_openai_model(cfg, effective_model).await;
let adaptive_mesh =
effective_model == MESH_AUTO_MODEL_ID && request_model == MESH_VIRTUAL_MODEL_ID;
let first = self
.openai_request_for_model(cfg, &request_model, &mut build)
.await;
match first {
Err(PostError::MeshFallback(detail)) if adaptive_mesh => {
self.cool_down_collective().await;
tracing::warn!(
configured_model = effective_model,
attempted_model = MESH_VIRTUAL_MODEL_ID,
fallback_model = MESH_AUTO_MODEL_ID,
provider_message = detail,
"relay-mesh auto: collective request failed; retrying once with auto"
);
self.openai_request_for_model(cfg, MESH_AUTO_MODEL_ID, &mut build)
.await
.map_err(PostError::into_agent)
}
Ok(response)
if adaptive_mesh
&& tools_supplied
&& response.tool_calls.is_empty()
&& looks_like_unstructured_tool_call(&response.text) =>
{
self.cool_down_collective().await;
tracing::warn!(
configured_model = effective_model,
attempted_model = MESH_VIRTUAL_MODEL_ID,
fallback_model = MESH_AUTO_MODEL_ID,
"relay-mesh auto: collective response emitted unstructured tool markup; retrying once with auto"
);
self.openai_request_for_model(cfg, MESH_AUTO_MODEL_ID, &mut build)
.await
.map_err(PostError::into_agent)
}
Ok(response) => Ok(response),
Err(error) => Err(error.into_agent()),
}
}
async fn cool_down_collective(&self) {
let now = Instant::now();
let mut state = self.mesh_auto_state.lock().await;
state.last_checked = Some(now);
state.consecutive_available = 0;
state.collective_enabled = false;
state.cooldown_until = Some(now + MESH_AUTO_COOLDOWN);
}
/// Resolve the model for one OpenAI-family request. Explicit model choices
/// are never changed. Relay-mesh `auto` dynamically follows the short-lived
/// `/models` catalog so long-running agents can adopt or leave MoA without
/// being restarted.
async fn resolve_openai_model(&self, cfg: &Config, effective_model: &str) -> String {
if cfg.provider != Provider::OpenAi
|| !cfg.prefer_mesh_for_auto
|| effective_model != MESH_AUTO_MODEL_ID
{
return effective_model.to_string();
}
let mut state = self.mesh_auto_state.lock().await;
let now = Instant::now();
if let Some(cooldown_until) = state.cooldown_until {
if now < cooldown_until {
return MESH_AUTO_MODEL_ID.to_string();
}
state.cooldown_until = None;
}
if state
.last_checked
.is_some_and(|checked_at| checked_at.elapsed() < MESH_AUTO_CATALOG_TTL)
{
return if state.collective_enabled {
MESH_VIRTUAL_MODEL_ID.to_string()
} else {
MESH_AUTO_MODEL_ID.to_string()
};
}
let observation = self.observe_mesh_virtual_model(cfg).await;
let checked_at = Instant::now();
state.last_checked = Some(checked_at);
match observation {
MeshCatalogObservation::Available => {
state.consecutive_available = state.consecutive_available.saturating_add(1);
if state.consecutive_available >= MESH_AUTO_ENABLE_OBSERVATIONS {
state.collective_enabled = true;
}
}
MeshCatalogObservation::Unavailable => {
if state.collective_enabled {
state.cooldown_until = Some(checked_at + MESH_AUTO_COOLDOWN);
}
state.consecutive_available = 0;
state.collective_enabled = false;
}
// A failed catalog check is not evidence that a previously stable
// mesh disappeared. Preserve the last confirmed state; inference
// itself remains authoritative and has the narrow 503 fallback.
MeshCatalogObservation::Unknown => {}
}
let request_model = if state.collective_enabled {
MESH_VIRTUAL_MODEL_ID
} else {
MESH_AUTO_MODEL_ID
};
tracing::debug!(
configured_model = effective_model,
request_model,
"relay-mesh auto: resolved request model from live catalog"
);
request_model.to_string()
}
async fn observe_mesh_virtual_model(&self, cfg: &Config) -> MeshCatalogObservation {
let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
let bearer = match self.auth.bearer().await {
Ok(bearer) => bearer,
Err(error) => {
tracing::debug!(
%error,
"relay-mesh auto: catalog auth unavailable; preserving last confirmed route"
);
return MeshCatalogObservation::Unknown;
}
};
let response = match self
.http
.get(&url)
.bearer_auth(bearer)
.timeout(MESH_AUTO_CATALOG_TIMEOUT)
.send()
.await
{
Ok(response) => response,
Err(error) => {
tracing::debug!(
%error,
"relay-mesh auto: catalog probe failed; preserving last confirmed route"
);
return MeshCatalogObservation::Unknown;
}
};
if !response.status().is_success() {
tracing::debug!(
status = %response.status(),
"relay-mesh auto: catalog probe was not successful; preserving last confirmed route"
);
return MeshCatalogObservation::Unknown;
}
match response.json::<Value>().await {
Ok(catalog) => {
let Some(available) = mesh_catalog_supports_collective(&catalog) else {
tracing::debug!(
"relay-mesh auto: catalog response has no data array; preserving last confirmed route"
);
return MeshCatalogObservation::Unknown;
};
if available {
MeshCatalogObservation::Available
} else {
MeshCatalogObservation::Unavailable
}
}
Err(error) => {
tracing::debug!(
%error,
"relay-mesh auto: invalid catalog response; preserving last confirmed route"
);
MeshCatalogObservation::Unknown
}
}
}
/// 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_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_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_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_for_model(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,
effective_model == MESH_VIRTUAL_MODEL_ID,
|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).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>,
) -> 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);
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(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())
.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()));
if let Some(details) = reasoning_details {
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() });
}
if !tools_json.is_empty() {
body["tools"] = Value::Array(tools_json);
body["tool_choice"] = json!("auto");
}
body
}
/// 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")
}
/// OpenAI-family code names that appear as their own segment in a Databricks v2
/// endpoint name (the GPT-5 launch aliases). The `gpt` family itself is matched
/// separately by segment prefix so `gpt`, `gpt5`, and the `gpt` of a split
/// `gpt-5` all qualify.
const DATABRICKS_V2_OPENAI_CODE_NAMES: &[&str] = &["sol", "luna", "terra"];
/// Anthropic (Claude) family and release code names that appear as their own
/// segment in a Databricks v2 endpoint name — the `claude` prefix, the family
/// names (`opus`, `sonnet`, `haiku`), and the release code names (`mythos`,
/// `fable`). Getting a Claude model onto the Anthropic Messages route is what
/// lets it carry a `cache_control` breakpoint; an endpoint that matches none of
/// these falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt
/// caching is structurally impossible and the discount is silently lost.
const DATABRICKS_V2_CLAUDE_NAMES: &[&str] =
&["claude", "opus", "sonnet", "haiku", "mythos", "fable"];
/// Split a Databricks v2 endpoint name into its lowercase alphanumeric segments,
/// breaking on any non-alphanumeric delimiter (`-`, `_`, `.`, `/`, …). E.g.
/// `Databricks-Claude-Opus-5` -> `["databricks", "claude", "opus", "5"]`.
fn model_name_segments(model: &str) -> Vec<String> {
model
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|s| !s.is_empty())
.map(str::to_ascii_lowercase)
.collect()
}
fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route {
// The v2 catalog exposes no family field, so the wire format is inferred
// from the endpoint name. Discovery deliberately keeps arbitrary custom
// aliases, so we match whole name *segments* rather than raw substrings: a
// substring test would misroute unrelated names — `consolidated-llama`
// (`sol`), `terraform-coder` (`terra`), `corpus-reranker`/`octopus-model`
// (`opus`) — onto a wire whose request shape their backend can't parse,
// turning a caching optimization into a hard request/parse failure. Segment
// matching still accepts real prefixed names like `goose-opus-5`.
let segments = model_name_segments(model);
let has_named_segment =
|names: &[&str]| segments.iter().any(|seg| names.contains(&seg.as_str()));
// `gpt` family: any segment beginning with `gpt` — covers `gpt`, `gpt5`, and
// the `gpt` segment of a split `gpt-5`, without matching mid-word.
let is_gpt_family = segments.iter().any(|seg| seg.starts_with("gpt"));
// OpenAI is checked before Claude so a name carrying both markers resolves
// to the OpenAI wire (preserving the prior `gpt-5`-first precedence).
if is_gpt_family || has_named_segment(DATABRICKS_V2_OPENAI_CODE_NAMES) {
DatabricksV2Route::OpenAiResponses
} else if has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) {
DatabricksV2Route::AnthropicMessages
} else {
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 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") => {
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(),
)?);
}
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"]);
let output_tokens = sum_usage(&v, &["output_tokens"]);
// 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 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"]);
Ok(LlmResponse {
text,
tool_calls,
stop,
input_tokens,
cached_input_tokens,
output_tokens,
total_tokens,
reasoning,
reasoning_details: 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") => ProviderStop::MaxTokens,
Some("refusal" | "content_filter") => ProviderStop::Refusal,
_ => ProviderStop::Other,
}
}
/// 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".
fn sum_usage(v: &Value, fields: &[&str]) -> Option<u64> {
let usage = v.get("usage")?;
let mut total: u64 = 0;
let mut saw_any = false;
for f in fields {
if let Some(n) = usage.get(*f).and_then(Value::as_u64) {
total = total.saturating_add(n);
saw_any = true;
}
}
saw_any.then_some(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.
fn anthropic_input_tokens(v: &Value) -> Option<u64> {
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"])
}
/// 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);
}
}
// Anthropic's replay shape is fully modelled, so nothing to keep.
Some("tool_use") => tool_calls.push(make_tool_call(
str_field(b, "id"),
str_field(b, "name"),
b.get("input").cloned().unwrap_or(Value::Null),
Default::default(),
)?),
_ => {}
}
}
}
let input_tokens = anthropic_input_tokens(&v);
let output_tokens = sum_usage(&v, &["output_tokens"]);
// 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"], &[]);
Ok(LlmResponse {
text,
tool_calls,
stop,
input_tokens,
cached_input_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,
reasoning_details: 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 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"]);
let cached_input_tokens = openai_chat_cached_tokens(&v);
// 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"]);
Ok(LlmResponse {
text,
tool_calls,
stop,
input_tokens,
cached_input_tokens,
output_tokens,
total_tokens,
reasoning,
reasoning_details: 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;
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()
}
/// 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 that preserves a mesh-specific MoA failure as a
/// typed signal. This covers both pre-inference eligibility rejection and a
/// structured `moa_failure` from workers/reducers. It is consumed inside
/// `openai_request`: an adaptive `auto` call falls back once, while an explicit
/// `mesh` call is surfaced as the ordinary LLM error callers already
/// understand.
#[derive(Debug)]
enum PostError {
Agent(AgentError),
MeshFallback(String),
}
impl PostError {
fn into_agent(self) -> AgentError {
match self {
Self::Agent(error) => error,
Self::MeshFallback(detail) => AgentError::Llm(detail),
}
}
}
impl From<AgentError> for PostError {
fn from(error: AgentError) -> Self {
Self::Agent(error)
}
}
fn is_mesh_moa_unavailable_body(body: &str) -> bool {
serde_json::from_str::<Value>(body)
.ok()
.and_then(|value| {
value
.pointer("/error/message")
.and_then(Value::as_str)
.map(str::to_owned)
})
.as_deref()
== Some(MESH_MOA_UNAVAILABLE_MESSAGE)
}
fn is_mesh_moa_failure_body(body: &str) -> bool {
serde_json::from_str::<Value>(body)
.ok()
.and_then(|value| {
value
.pointer("/error/type")
.and_then(Value::as_str)
.map(str::to_owned)
})
.as_deref()
== Some("moa_failure")
}
async fn post<F>(
http: &Client,
url: &str,
body: &Value,
detect_mesh_fallback: bool,
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();
for attempt in 0..MAX_RETRIES {
let resp = match apply(
http.post(url)
.header("content-type", "application/json")
.body(body_bytes.clone()),
)
.send()
.await
{
Ok(r) => r,
Err(e) => {
if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) {
tracing::warn!(
attempt = attempt + 1,
max_attempts = MAX_RETRIES,
error = %e,
"llm: transport error, retrying"
);
backoff_with_jitter(attempt).await;
continue;
}
return Err(PostError::Agent(terminal_llm_error(
call_start.elapsed(),
attempt + 1,
&format!("transport: {e}"),
)));
}
};
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;
let mesh_fallback = detect_mesh_fallback
&& (status == reqwest::StatusCode::SERVICE_UNAVAILABLE
&& is_mesh_moa_unavailable_body(&body)
|| status.is_server_error() && is_mesh_moa_failure_body(&body));
if mesh_fallback {
return Err(PostError::MeshFallback(format!("{status}: {body}")));
}
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 {
return Err(PostError::Agent(AgentError::LlmModelNotFound(format!(
"{status}: {}",
read_error_body(resp).await
))));
}
if !status.is_success() {
return Err(PostError::Agent(AgentError::Llm(format!(
"{status}: {}",
read_error_body(resp).await
))));
}
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 {
return Err(PostError::Agent(AgentError::Llm(format!(
"response exceeded {MAX_LLM_RESPONSE_BYTES} bytes"
))));
}
buf.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(e) => {
return Err(PostError::Agent(terminal_llm_error(
call_start.elapsed(),
attempt + 1,
&format!("body read: {e}"),
)));
}
}
}
return serde_json::from_slice(&buf)
.map_err(|e| PostError::Agent(AgentError::Llm(format!("json: {e}"))));
}
unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)");
}
/// 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())));
}
let discovery_url = format!(
"{}/oidc/.well-known/oauth-authorization-server",
cfg.base_url.trim_end_matches('/')
);
let pkce = PkceOAuthConfig {
discovery_url,
client_id: DATABRICKS_CLIENT_ID.into(),
scopes: DATABRICKS_OAUTH_SCOPES
.iter()
.map(|s| (*s).into())
.collect(),
cache_namespace: "databricks".into(),
cache_dir_override: None,
};
Ok(PkceOAuthTokenSource::new(pkce)?)
}
}
}
/// 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 `reasoning` (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 {
json!({
"model": effective_model,
"stream": false,
"max_tokens": max_output_tokens,
"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*
/// `Client::timeout` (`cfg.llm_timeout`, default 240s) — 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 connect/response timeout.
const RETRY_AFTER_CAP_SECS: u64 = 60;
fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option<std::time::Duration> {
let val = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
let secs: u64 = val.trim().parse().ok()?;
(secs > 0).then(|| std::time::Duration::from_secs(secs.min(RETRY_AFTER_CAP_SECS)))
}
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,
) -> 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();
for attempt in 0..MAX_RETRIES {
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())
.send()
.await
{
Ok(r) => r,
Err(e) => {
if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) {
tracing::warn!(
attempt = attempt + 1,
max_attempts = MAX_RETRIES,
error = %e,
"llm: openrouter transport error, retrying"
);
backoff_with_jitter(attempt).await;
continue;
}
return Err(terminal_llm_error(
call_start.elapsed(),
attempt + 1,
&format!("transport: {e}"),
));
}
};
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 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() {
return Err(AgentError::Llm(format!(
"{status}: {}",
read_error_body(resp).await
)));
}
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) => {
return Err(terminal_llm_error(
call_start.elapsed(),
attempt + 1,
&format!("body read: {e}"),
))
}
}
}
return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(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 {
use super::*;
use crate::config::{Config, HookServers, OpenAiApi, Provider};
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,
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,
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
require_reply: false,
hook_servers: HookServers::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,
prefer_mesh_for_auto: false,
hints_enabled: true,
thinking_effort: None,
prompt_caching: true,
}
}
#[derive(Debug, Clone)]
struct CapturedHttpRequest {
method: String,
path: 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",
}
}),
}
}
fn moa_failure(status: u16, code: &str, message: &str) -> Self {
Self {
status,
body: json!({
"choices": [{
"finish_reason": "error",
"message": { "content": message, "role": "assistant" },
}],
"error": {
"message": message,
"type": "moa_failure",
"code": code,
},
"model": "mesh",
}),
}
}
}
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 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, 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",
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 model_catalog(ids: &[&str]) -> Value {
json!({
"object": "list",
"data": ids
.iter()
.map(|id| json!({ "id": id, "object": "model" }))
.collect::<Vec<_>>(),
})
}
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
}
async fn complete_model_with_tool(
llm: &Llm,
cfg: &Config,
model: &str,
) -> Result<LlmResponse, AgentError> {
llm.complete(
cfg,
"system",
&[HistoryItem::User("add two numbers".into())],
&[ToolDef {
name: "add_numbers".into(),
description: "Add two numbers".into(),
input_schema: json!({
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"}
},
"required": ["a", "b"]
}),
}],
model,
)
.await
}
async fn expire_mesh_catalog_check(llm: &Llm) {
llm.mesh_auto_state.lock().await.last_checked =
Some(Instant::now() - MESH_AUTO_CATALOG_TTL);
}
async fn expire_mesh_auto_cooldown(llm: &Llm) {
let mut state = llm.mesh_auto_state.lock().await;
state.last_checked = Some(Instant::now() - MESH_AUTO_CATALOG_TTL);
state.cooldown_until = Some(Instant::now() - std::time::Duration::from_secs(1));
}
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()
}
#[tokio::test]
async fn mesh_auto_requires_two_stable_catalog_observations() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("direct")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("collective")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
assert_eq!(
complete_model(&llm, &config, "auto").await.unwrap().text,
"direct"
);
expire_mesh_catalog_check(&llm).await;
assert_eq!(
complete_model(&llm, &config, "auto").await.unwrap().text,
"collective"
);
let requests = captured.lock().await;
assert_eq!(posted_models(&requests), vec!["auto", "mesh"]);
assert_eq!(
requests
.iter()
.filter(|request| request.path == "/v1/models")
.count(),
2
);
}
#[tokio::test]
async fn mesh_auto_does_not_enable_while_second_model_flaps() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("one")),
StubHttpResponse::ok(model_catalog(&["model-a"])),
StubHttpResponse::ok(chat_response("two")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("three")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("four")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
for _ in 0..4 {
complete_model(&llm, &config, "auto").await.unwrap();
expire_mesh_catalog_check(&llm).await;
}
let requests = captured.lock().await;
assert_eq!(
posted_models(&requests),
vec!["auto", "auto", "auto", "mesh"]
);
}
#[test]
fn collective_catalog_requires_two_distinct_physical_models() {
assert_eq!(mesh_catalog_supports_collective(&json!({})), None);
assert_eq!(
mesh_catalog_supports_collective(&model_catalog(&[])),
Some(false)
);
assert_eq!(
mesh_catalog_supports_collective(&model_catalog(&["model-a", "mesh"])),
Some(false)
);
assert_eq!(
mesh_catalog_supports_collective(&model_catalog(&[
"org/model@main:Q4",
"org/model:Q4",
"mesh"
])),
Some(false),
"two spellings of one model are not collective capacity"
);
assert_eq!(
mesh_catalog_supports_collective(&model_catalog(&["model-a", "model-b", "mesh"])),
Some(true)
);
}
#[tokio::test]
async fn mesh_auto_tracks_models_appearing_disappearing_and_reappearing() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::ok(model_catalog(&[])),
StubHttpResponse::ok(chat_response("zero")),
StubHttpResponse::ok(model_catalog(&["model-a", "mesh"])),
StubHttpResponse::ok(chat_response("one")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("two-first-observation")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("two-stable")),
StubHttpResponse::ok(model_catalog(&["model-a", "mesh"])),
StubHttpResponse::ok(chat_response("contracted")),
StubHttpResponse::ok(model_catalog(&[])),
StubHttpResponse::ok(chat_response("empty-again")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("rejoin-first-observation")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("rejoined-stable")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
for call in 0..8 {
if call == 5 {
expire_mesh_auto_cooldown(&llm).await;
} else if call > 0 {
expire_mesh_catalog_check(&llm).await;
}
complete_model(&llm, &config, "auto").await.unwrap();
}
let requests = captured.lock().await;
assert_eq!(
posted_models(&requests),
vec!["auto", "auto", "auto", "mesh", "auto", "auto", "auto", "mesh"]
);
assert_eq!(
requests
.iter()
.filter(|request| request.path == "/v1/models")
.count(),
8
);
}
#[tokio::test]
async fn mesh_auto_falls_back_once_and_cools_down_when_mesh_contracts() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("warmup")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::error(503, MESH_MOA_UNAVAILABLE_MESSAGE),
StubHttpResponse::ok(chat_response("fallback")),
StubHttpResponse::ok(chat_response("cooldown")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
complete_model(&llm, &config, "auto").await.unwrap();
expire_mesh_catalog_check(&llm).await;
assert_eq!(
complete_model(&llm, &config, "auto").await.unwrap().text,
"fallback"
);
assert_eq!(
complete_model(&llm, &config, "auto").await.unwrap().text,
"cooldown"
);
let requests = captured.lock().await;
assert_eq!(
posted_models(&requests),
vec!["auto", "mesh", "auto", "auto"]
);
assert_eq!(
requests
.iter()
.filter(|request| request.path == "/v1/models")
.count(),
2,
"cooldown request must not re-probe the catalog"
);
}
#[tokio::test]
async fn mesh_auto_falls_back_once_when_moa_reducer_fails() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("warmup")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::moa_failure(
502,
"all_reducers_failed",
"Reducer failed: unsupported chat template",
),
StubHttpResponse::ok(chat_response("fallback")),
StubHttpResponse::ok(chat_response("cooldown")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
complete_model(&llm, &config, "auto").await.unwrap();
expire_mesh_catalog_check(&llm).await;
assert_eq!(
complete_model(&llm, &config, "auto").await.unwrap().text,
"fallback"
);
assert_eq!(
complete_model(&llm, &config, "auto").await.unwrap().text,
"cooldown"
);
let requests = captured.lock().await;
assert_eq!(
posted_models(&requests),
vec!["auto", "mesh", "auto", "auto"]
);
assert_eq!(
requests
.iter()
.filter(|request| request.path == "/v1/models")
.count(),
2,
"mesh-specific failure must enter cooldown without re-probing"
);
}
#[tokio::test]
async fn mesh_auto_retries_unstructured_tool_markup_through_auto() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response("warmup")),
StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])),
StubHttpResponse::ok(chat_response(
"<|tool_call>call:add_numbers{a:17,b:25}<tool_call|>",
)),
StubHttpResponse::ok(chat_response("safe fallback")),
StubHttpResponse::ok(chat_response("cooldown")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
complete_model(&llm, &config, "auto").await.unwrap();
expire_mesh_catalog_check(&llm).await;
assert_eq!(
complete_model_with_tool(&llm, &config, "auto")
.await
.unwrap()
.text,
"safe fallback"
);
assert_eq!(
complete_model_with_tool(&llm, &config, "auto")
.await
.unwrap()
.text,
"cooldown"
);
let requests = captured.lock().await;
assert_eq!(
posted_models(&requests),
vec!["auto", "mesh", "auto", "auto"]
);
assert_eq!(
requests
.iter()
.filter(|request| request.path == "/v1/models")
.count(),
2,
"pseudo tool markup must enter cooldown without another catalog probe"
);
}
#[tokio::test]
async fn mesh_auto_catalog_failure_fails_open_to_auto() {
let (base_url, captured) = spawn_sequence_stub(vec![
StubHttpResponse::error(500, "catalog unavailable"),
StubHttpResponse::ok(chat_response("direct")),
])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
complete_model(&llm, &config, "auto").await.unwrap();
let requests = captured.lock().await;
assert_eq!(posted_models(&requests), vec!["auto"]);
}
#[tokio::test]
async fn generic_openai_auto_does_not_probe_or_select_mesh() {
let (base_url, captured) =
spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("provider auto"))]).await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = false;
let llm = Llm::new(&config).unwrap();
complete_model(&llm, &config, "auto").await.unwrap();
let requests = captured.lock().await;
assert_eq!(posted_models(&requests), vec!["auto"]);
assert!(requests.iter().all(|request| request.path != "/v1/models"));
}
#[tokio::test]
async fn explicit_models_are_never_rewritten_or_fallback_retried() {
let (base_url, captured) = spawn_sequence_stub(vec![StubHttpResponse::error(
503,
MESH_MOA_UNAVAILABLE_MESSAGE,
)])
.await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
let error = complete_model(&llm, &config, "mesh").await.unwrap_err();
assert!(error.to_string().contains(MESH_MOA_UNAVAILABLE_MESSAGE));
let requests = captured.lock().await;
assert_eq!(posted_models(&requests), vec!["mesh"]);
assert!(requests.iter().all(|request| request.path != "/v1/models"));
}
#[tokio::test]
async fn explicit_real_model_bypasses_mesh_auto_policy() {
let (base_url, captured) =
spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("explicit"))]).await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
config.prefer_mesh_for_auto = true;
let llm = Llm::new(&config).unwrap();
let response = complete_model(&llm, &config, "model-a").await.unwrap();
assert_eq!(response.text, "explicit");
let requests = captured.lock().await;
assert_eq!(posted_models(&requests), vec!["model-a"]);
assert!(requests.iter().all(|request| request.path != "/v1/models"));
}
#[test]
fn mesh_unavailable_classifier_requires_exact_openai_error_message() {
assert!(is_mesh_moa_unavailable_body(
&StubHttpResponse::error(503, MESH_MOA_UNAVAILABLE_MESSAGE)
.body
.to_string()
));
assert!(!is_mesh_moa_unavailable_body(
&StubHttpResponse::error(503, "some other outage")
.body
.to_string()
));
}
#[test]
fn mesh_failure_classifier_requires_structured_moa_failure_type() {
assert!(is_mesh_moa_failure_body(
&StubHttpResponse::moa_failure(
502,
"all_reducers_failed",
"Reducer failed: unsupported chat template",
)
.body
.to_string()
));
assert!(!is_mesh_moa_failure_body(
&StubHttpResponse::error(502, "some other bad gateway")
.body
.to_string()
));
}
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,
);
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": [],
});
let r = parse_responses(v).unwrap();
assert_eq!(r.stop, ProviderStop::MaxTokens);
}
#[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_routes_by_model_family() {
use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses};
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",
),
("gpt-4o", OpenAiResponses, "/ai-gateway/openai/v1/responses"),
// The intentional dashless `gpt5` spelling still routes to OpenAI.
("gpt5", 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",
),
(
"databricks-terra",
OpenAiResponses,
"/ai-gateway/openai/v1/responses",
),
// Anthropic-shaped: the claude prefix, the family names, and the
// release code names — each must reach the cache-capable route even
// when the endpoint name omits the literal "claude".
(
"databricks-claude-opus-4-7",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"goose-opus-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-sonnet-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-haiku-4-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-mythos-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-fable-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
// Case-insensitive.
(
"Databricks-Claude-Opus-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
// Unrecognised names still fall through to the MLflow chat route.
(
"custom-tool-model",
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
(
"databricks-gemini-3-pro",
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.
// Each of these embeds a marker (`sol`, `terra`, `opus`) mid-word
// and must stay on the MLflow fallback, not adopt a wire its
// backend can't parse.
(
"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_for_model(model);
assert_eq!(got, route, "model={model}");
assert_eq!(databricks_v2_path(got), path, "model={model}");
}
}
#[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,
);
// 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,
);
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,
);
// 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,
);
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,
);
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),
);
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),
);
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),
);
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),
);
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),
);
// 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),
);
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),
);
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),
);
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");
}
#[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,
);
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),
);
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),
);
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_openai_route` /
// `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_openai_route
// before reaching responses_body. gpt-5.5 supports xhigh so the final value is xhigh.
let clamped =
crate::config::normalize_effort_for_openai_route(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_openai_route(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_openai_route.
// Unknown models pass through after the max→xhigh clamp.
let clamped =
crate::config::normalize_effort_for_openai_route(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_openai_route(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_openai_route(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
);
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(
"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_openai_route(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!({}), false, |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!({}), false, |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!({}), false, |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"
);
}
/// `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"
);
}
// ---- 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);
}
/// 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),
mesh_auto_state: Mutex::new(MeshAutoState::default()),
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());
}
#[test]
fn openrouter_summary_carries_neither_reasoning_nor_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"], 1024);
assert!(
body.get("max_completion_tokens").is_none(),
"summary body must use OpenRouter's token-limit spelling"
);
assert!(
body.get("reasoning").is_none(),
"summary body must not carry reasoning"
);
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,
);
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",
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)
}
/// 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")
.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")
.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")
.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"
);
}
/// 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")
.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")
.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);
}
/// A `Retry-After` far beyond `RETRY_AFTER_CAP_SECS` must not stall the
/// retry loop for anywhere near its advertised duration — proving the
/// cap is enforced end-to-end in `openrouter_post`'s actual sleep, not
/// merely in the isolated `parse_retry_after_header` unit tests above.
/// Runs on a paused clock so a real 999999s wait would hang the test
/// instead of silently passing.
#[tokio::test(start_paused = true)]
async fn openrouter_post_429_retry_sleep_capped_despite_huge_retry_after() {
let (url, _captured, attempts) = spawn_openrouter_stub(vec![
CannedResponse::new(429, r#"{"error":{"message":"rate limited"}}"#)
.with_header("Retry-After", "999999"),
CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
])
.await;
let http = Client::builder().build().unwrap();
let before = tokio::time::Instant::now();
let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
.await
.expect("second attempt succeeds");
assert_eq!(out["choices"][0]["message"]["content"], "ok");
assert!(
before.elapsed() <= Duration::from_secs(RETRY_AFTER_CAP_SECS + 5),
"retry sleep must be clamped to RETRY_AFTER_CAP_SECS ({RETRY_AFTER_CAP_SECS}s), \
not the header's 999999s: elapsed {:?}",
before.elapsed()
);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
}
/// 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")
.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")
.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")
.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")
.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")
.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"
);
}
}