Files
buzz/crates/buzz-agent/src/config.rs
Kalvin C 48884848f5 fix: route databricks claude fqns to anthropic messages (#7829)
Fix Databricks V2 Unity Catalog Claude service routing without erasing
verified model capabilities.

- preserve exact provider-qualified record precedence for
`data_workflow_tools.goose.goose-claude-opus-5-5`, including adaptive
thinking, explicit `output_config.effort` (default medium), and signed
native tool continuation
- route other boundary-matched `claude-*` service components to
Anthropic Messages while preserving the full FQN and advertising no
unverified effort controls
- keep GPT-5+ service components on OpenAI Responses and other uncurated
FQNs on MLflow Chat Completions; catalog/schema text does not affect
routing
- keep Rust and TypeScript resolver precedence aligned and regenerate
the 168-vector normative corpus

Validation at `1204f1eacff17ebe05eb2bba06b5eee210e21c25`:
- `cargo test -p buzz-agent`
- `pnpm -C desktop test` — 6,628 passed
- `pnpm -C desktop typecheck`
- `cargo fmt --all -- --check`
- `cargo clippy -p buzz-agent --all-targets -- -D warnings`
- `just test-unit` — passed on isolated rerun
- pre-push file-size, desktop check, desktop typecheck, desktop tests,
and Tauri checks passed; the repository-wide Rust lane exited without
identifying a failing test in two concurrent hook runs, while its
isolated `just test-unit` rerun passed

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-09-23 12:09:27 -07:00

2518 lines
101 KiB
Rust

use std::time::Duration;
pub const PROTOCOL_VERSION: u32 = 2;
/// Reasoning/thinking effort level for providers that support it.
///
/// Set via `BUZZ_AGENT_THINKING_EFFORT` (`none|minimal|low|medium|high|xhigh|max`).
/// When unset the provider's default behaviour is preserved — no thinking
/// config is sent in the request body.
///
/// Provider support (doc-verified, July 2025):
/// - **Anthropic adaptive**: `low|medium|high|xhigh|max` (model-dependent; see `anthropic_thinking_config`).
/// `none`/`minimal` are not Anthropic values — rejected at startup.
/// - **Anthropic manual budget** (claude-3*, opus-4-5): `low|medium|high`; `xhigh`/`max` clamp to high budget.
/// - **OpenAI Responses / Chat Completions**: effort support is model-dependent and normalized at
/// request time; `max` is valid for documented max-supporting families such as GPT-5.6.
/// - **Databricks**: routed by model family (Claude → Anthropic mapping, GPT-5 → Responses, MLflow → Chat).
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "lowercase")]
pub enum ThinkingEffort {
None,
Minimal,
Low,
Medium,
High,
XHigh,
Max,
}
impl ThinkingEffort {
/// Map level to an Anthropic `budget_tokens` value for legacy Claude 3.x / Opus 4.5 models.
/// `XHigh` and `Max` clamp to the high budget value; the answer-room reserve of 1024 tokens
/// is applied separately in `anthropic_thinking_config`.
pub fn anthropic_budget_tokens(self) -> u32 {
match self {
ThinkingEffort::Low => 1_024,
ThinkingEffort::Medium => 8_192,
ThinkingEffort::High | ThinkingEffort::XHigh | ThinkingEffort::Max => 32_768,
// None/Minimal are not valid for Anthropic (rejected at startup); treat as zero
// defensively so a misconfigured call doesn't accidentally enable thinking.
ThinkingEffort::None | ThinkingEffort::Minimal => 0,
}
}
/// Map level to an OpenAI `reasoning.effort` / `reasoning_effort` string.
pub fn openai_effort_str(self) -> &'static str {
match self {
ThinkingEffort::None => "none",
ThinkingEffort::Minimal => "minimal",
ThinkingEffort::Low => "low",
ThinkingEffort::Medium => "medium",
ThinkingEffort::High => "high",
ThinkingEffort::XHigh => "xhigh",
ThinkingEffort::Max => "max",
}
}
/// Map level to an Anthropic `output_config.effort` string.
/// Returns the level string if supported, or the highest supported level for the model.
/// Caller must apply model-level clamping via `clamp_for_anthropic_adaptive`.
pub fn anthropic_effort_str(self) -> &'static str {
match self {
ThinkingEffort::Low => "low",
ThinkingEffort::Medium => "medium",
ThinkingEffort::High => "high",
ThinkingEffort::XHigh => "xhigh",
ThinkingEffort::Max => "max",
// None/Minimal are rejected at startup for Anthropic; defensive fallback.
ThinkingEffort::None | ThinkingEffort::Minimal => "low",
}
}
}
/// Resolve the nearest supported effort level for a given OpenAI model.
///
/// When the requested effort is not in the model's supported set, falls back to the
/// nearest supported level using this preference order:
///
/// - `none` ↔ `minimal` are each other's first fallback (the none/minimal split across
/// model families means the "closest analogue" is the other form before jumping to `low`).
/// - Above that pair: upward clamp first, then downward (prefer more thinking over less).
/// - `xhigh` falls back to `high` when not supported (no model skips from `high` to `xhigh`).
/// - `max` passes through for model families whose table includes it; otherwise it resolves to
/// the nearest supported level.
///
/// Logs a `warn!` on every substitution.
fn resolve_openai_effort(
model: &str,
requested: ThinkingEffort,
supported: &[ThinkingEffort],
) -> ThinkingEffort {
if supported.contains(&requested) {
return requested;
}
// Build a candidate list ordered by preference: the "other" form of none/minimal first,
// then the levels sorted nearest to requested (ascending distance).
let candidates: Vec<ThinkingEffort> = {
// none ↔ minimal are each other's first fallback.
let peer = match requested {
ThinkingEffort::None => Some(ThinkingEffort::Minimal),
ThinkingEffort::Minimal => Some(ThinkingEffort::None),
_ => None,
};
// All supported values sorted by distance (abs diff in ordinal), upward ties win.
let mut by_dist: Vec<ThinkingEffort> = supported.to_vec();
by_dist.sort_by_key(|&e| {
let dist = (e as i32 - requested as i32).unsigned_abs();
// Prefer upward (e > requested) to break ties between equidistant values.
let up = if e >= requested { 0u32 } else { 1 };
(dist, up)
});
// Peer first, then by distance.
let mut result = Vec::new();
if let Some(p) = peer {
if supported.contains(&p) {
result.push(p);
}
}
for e in by_dist {
if !result.contains(&e) {
result.push(e);
}
}
result
};
let resolved = candidates
.into_iter()
.next()
.expect("supported is non-empty");
tracing::warn!(
%model,
requested = requested.openai_effort_str(),
resolved = resolved.openai_effort_str(),
"BUZZ_AGENT_THINKING_EFFORT={} is not supported by this OpenAI model; using nearest supported level",
requested.openai_effort_str(),
);
resolved
}
/// Normalize the effort value for an Anthropic-shaped request body (Messages API).
///
/// Anthropic-shaped bodies (`anthropic_body`) do not have a `none` or `minimal` concept —
/// the thinking block is either present (with a level) or absent. When `none` or `minimal`
/// is configured, we omit the thinking fields entirely and log a warning (omission = provider
/// default; default-on/always-on adaptive models may still think). This handles `DatabricksV2`
/// sessions where the route can switch from GPT to Claude via `session/set_model` after startup.
///
/// Returns `None` to signal "omit thinking fields", or the original effort if it is a valid
/// Anthropic level.
pub fn normalize_effort_for_anthropic_route(effort: ThinkingEffort) -> Option<ThinkingEffort> {
match effort {
ThinkingEffort::None | ThinkingEffort::Minimal => {
tracing::warn!(
requested = effort.openai_effort_str(),
"BUZZ_AGENT_THINKING_EFFORT={} is not expressible as an Anthropic thinking level; \
omitting thinking fields (provider default; default-on/always-on adaptive models may still think)",
effort.openai_effort_str()
);
None
}
other => Some(other),
}
}
/// Normalize the effort value for an OpenAI-shaped request (pure OpenAI or legacy Databricks).
///
/// Resolves the manifest capability record for the actual provider/model and applies
/// `resolve_openai_effort` over its `supported_efforts`. The provider fallback record's
/// effort set encodes the former "unknown model: clamp `max`→`xhigh`, pass the rest"
/// behavior, so unknown models need no special-casing here; adopted exact-record
/// corrections (e.g. `databricks-gpt-5-4-mini` → `[low, medium, high]`) are enforced in
/// production because they live on the resolved `supported_efforts` axis.
///
/// This is the single production authority for `Provider::OpenAi` and `Provider::Databricks`
/// effort normalization.
pub fn normalize_effort_for_provider(
provider: &str,
raw_model: &str,
effort: ThinkingEffort,
) -> ThinkingEffort {
let cap = crate::model_capabilities::resolve(provider, raw_model);
resolve_openai_effort(raw_model, effort, cap.supported_efforts)
}
/// Normalize the effort value for a DatabricksV2 OpenAI-shaped request (Responses / MLflow).
///
/// Reads `normalization_policy` from the manifest record for this raw model id
/// (`provider = "databricks_v2"`) and applies it:
/// - `OpenaiStandard` → resolve against the record's `supported_efforts` (the axis
/// that carries the adopted exact-record corrections).
/// - `OpenaiClampMaxToXhigh` → clamp `max`→`xhigh` with a DBv2-specific warning; resolve
/// any other unsupported value against `supported_efforts`.
/// - `None` → pass the effort through unchanged (Anthropic-routed models,
/// which are normalized by `normalize_effort_for_anthropic_route` and never reach here).
///
/// This is the production authority for DatabricksV2 OpenAI-shaped effort normalization;
/// `normalize_effort_for_provider` covers pure OpenAI and legacy Databricks.
pub fn normalize_effort_for_databricks_v2(
effort: ThinkingEffort,
raw_model: &str,
) -> ThinkingEffort {
use crate::model_capabilities::NormalizationPolicy;
let cap = crate::model_capabilities::resolve("databricks_v2", raw_model);
match cap.normalization_policy {
NormalizationPolicy::OpenaiStandard => {
resolve_openai_effort(raw_model, effort, cap.supported_efforts)
}
NormalizationPolicy::OpenaiClampMaxToXhigh => {
if effort == ThinkingEffort::Max {
tracing::warn!(
requested = "max",
resolved = "xhigh",
model = raw_model,
"BUZZ_AGENT_THINKING_EFFORT=max not confirmed for this DatabricksV2 model; clamping to xhigh"
);
ThinkingEffort::XHigh
} else {
resolve_openai_effort(raw_model, effort, cap.supported_efforts)
}
}
NormalizationPolicy::None => effort,
}
}
/// Build the Anthropic thinking/effort request fields for any manifest-owned provider/model.
///
/// Resolves `thinking_mode` and `supported_efforts` from the manifest record for the
/// effective provider/model and applies them:
/// - `ManualBudget` → `thinking:{type:"enabled", budget_tokens, display:"summarized"}`,
/// with `budget_tokens` clamped to leave at least 1024 answer tokens (both fields omitted
/// when `max_output_tokens` is too small to fit thinking budget + answer headroom).
/// - `Adaptive` → `thinking:{type:"adaptive", display:"summarized"}` +
/// `output_config:{effort}`, with effort clamped down to the highest supported level.
/// - `None` / `OmitFields` → omit both fields (non-thinking model, or unknown/unverified
/// Anthropic name — safer to omit than to guess an unsupported request shape).
///
/// `display:"summarized"` keeps thinking text visible in the observer feed (Anthropic
/// defaults to `display:"omitted"` on the newest models). This is the single production
/// authority for all providers' Anthropic thinking body construction.
pub fn anthropic_thinking_config(
provider: &str,
effective_model: &str,
effort: ThinkingEffort,
max_output_tokens: u32,
) -> (Option<serde_json::Value>, Option<serde_json::Value>) {
use crate::model_capabilities::ThinkingMode;
use serde_json::json;
let cap = crate::model_capabilities::resolve(provider, effective_model);
match cap.thinking_mode {
ThinkingMode::ManualBudget => {
// Manual-budget shape (claude-3*, claude-opus-4-5): budget_tokens clamped to
// fit within max_output_tokens while preserving at least MIN_ANSWER_TOKENS.
const MIN_ANSWER_TOKENS: u32 = 1024;
let level_budget = effort.anthropic_budget_tokens();
let headroom = max_output_tokens.saturating_sub(MIN_ANSWER_TOKENS);
let budget = level_budget.min(headroom);
if budget < MIN_ANSWER_TOKENS {
tracing::warn!(
max_output_tokens,
level_budget,
headroom,
model = effective_model,
"BUZZ_AGENT_THINKING_EFFORT: max_output_tokens too small to fit thinking budget + answer headroom; omitting thinking fields"
);
return (None, None);
}
(
Some(
json!({ "type": "enabled", "budget_tokens": budget, "display": "summarized" }),
),
None,
)
}
ThinkingMode::Adaptive => {
// Adaptive shape: clamp effort downward to the highest supported level using the
// manifest's supported_efforts (sorted ascending by validate_manifest).
let clamped = cap
.supported_efforts
.iter()
.rev()
.find(|&&e| e <= effort)
.copied()
.unwrap_or(effort); // effort below the lowest supported; pass through (rare)
if clamped != effort {
tracing::warn!(
model = effective_model,
requested = effort.openai_effort_str(),
clamped = clamped.openai_effort_str(),
"BUZZ_AGENT_THINKING_EFFORT is not available for this model; clamping to highest supported level"
);
}
(
Some(json!({ "type": "adaptive", "display": "summarized" })),
Some(json!({ "effort": clamped.anthropic_effort_str() })),
)
}
ThinkingMode::None | ThinkingMode::OmitFields => {
// Non-thinking model, or unknown/unverified Anthropic name: omit rather than guess.
// A persisted explicit setting can outlive a model switch, so make the
// discarded choice observable even though fresh clients advertise no explicit effort.
if effort != ThinkingEffort::None {
tracing::warn!(
model = effective_model,
requested = effort.openai_effort_str(),
"BUZZ_AGENT_THINKING_EFFORT is unsupported for this unverified Anthropic model; omitting thinking fields"
);
}
(None, None)
}
}
}
/// Reasoning summary mode for the OpenAI Responses API route.
///
/// Controls the `reasoning.summary` field sent alongside `reasoning.effort` in
/// `responses_body`. The Responses API only returns populated `summary` arrays
/// when a summary mode is requested — without it, `summary: []` is returned and
/// the observer feed shows no reasoning text even though the model billed thinking
/// tokens.
///
/// **Responses-route only.** On the Anthropic route, thinking blocks contain the
/// full reasoning text directly (no summary concept); this field is ignored there.
/// On Chat Completions and OpenRouter paths the field is also ignored.
///
/// Set via `BUZZ_AGENT_THINKING_SUMMARY` (`auto|concise|detailed`).
/// Unset/empty → `auto` (the provider chooses the best available summary for the
/// model). Use `detailed` for maximum reasoning visibility in the observer feed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThinkingSummary {
/// Provider selects the best available summary format for the model.
Auto,
/// Shorter summaries — lower token overhead.
Concise,
/// Full-length summaries — maximum reasoning visibility.
Detailed,
}
impl ThinkingSummary {
/// The string value sent in the `reasoning.summary` field.
pub fn as_str(self) -> &'static str {
match self {
ThinkingSummary::Auto => "auto",
ThinkingSummary::Concise => "concise",
ThinkingSummary::Detailed => "detailed",
}
}
}
/// Parse `BUZZ_AGENT_THINKING_SUMMARY`. Pure (env-free) for testability.
///
/// Unset or empty → `Auto` (the safe default that works for all Responses-capable models).
/// Invalid value → startup error.
pub fn parse_thinking_summary(raw: Option<&str>) -> Result<ThinkingSummary, String> {
match raw.map(|s| s.trim().to_ascii_lowercase()).as_deref() {
None | Some("") => Ok(ThinkingSummary::Auto),
Some("auto") => Ok(ThinkingSummary::Auto),
Some("concise") => Ok(ThinkingSummary::Concise),
Some("detailed") => Ok(ThinkingSummary::Detailed),
Some(other) => Err(format!(
"config: BUZZ_AGENT_THINKING_SUMMARY={other} not supported (use auto|concise|detailed)"
)),
}
}
/// Parse `BUZZ_AGENT_THINKING_EFFORT`. Pure (env-free) for testability.
pub fn parse_thinking_effort(raw: Option<&str>) -> Result<Option<ThinkingEffort>, String> {
match raw.map(|s| s.trim().to_ascii_lowercase()).as_deref() {
None | Some("") => Ok(None),
Some("none") => Ok(Some(ThinkingEffort::None)),
Some("minimal") => Ok(Some(ThinkingEffort::Minimal)),
Some("low") => Ok(Some(ThinkingEffort::Low)),
Some("medium") => Ok(Some(ThinkingEffort::Medium)),
Some("high") => Ok(Some(ThinkingEffort::High)),
Some("xhigh") => Ok(Some(ThinkingEffort::XHigh)),
Some("max") => Ok(Some(ThinkingEffort::Max)),
Some(other) => Err(format!(
"config: BUZZ_AGENT_THINKING_EFFORT={other} not supported (use none|minimal|low|medium|high|xhigh|max)"
)),
}
}
pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
pub const MAX_SYSTEM_PROMPT_BYTES: usize = 512 * 1024;
/// Total per-result byte ceiling (text + images). Sized for image-bearing
/// results — view_image can legitimately return multi-MiB base64 payloads.
/// Text is governed by the much smaller `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
pub const MAX_TOOL_RESULT_BYTES: usize = 8 * 1024 * 1024;
/// Default cap on the *text* portion of a single tool result. Oversized text
/// is middle-elided before it enters history; without this, one fat `cat`
/// burns the context window and forces a lossy handoff. 50 KiB matches the
/// shell-output caps in sprout-dev-mcp, goose, and pi; codex defaults to
/// 10 KB. Tunable via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
pub const DEFAULT_TOOL_RESULT_TEXT_BYTES: usize = 50 * 1024;
pub const MAX_TOOL_CALLS_PER_TURN: usize = 64;
pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192;
pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024;
pub const HANDOFF_MAX_TOOL_NAMES: usize = 20;
/// Maximum reactive context-recovery attempts per `run()`. A provider
/// context-window 400 is recoverable — shrink history and retry — but the
/// retry must be bounded: `max_rounds` defaults to `0` (unbounded), so without
/// its own budget a request that stays oversized after every rescue would
/// retry forever. On exhaustion the error surfaces to the caller, which is a
/// visible failure rather than a silent infinite rescue.
pub const MAX_CONTEXT_RECOVERIES_PER_RUN: u32 = 3;
/// Floor for the reactive handoff's history-prompt budget, in bytes. Each
/// recovery attempt halves the budget so the rescue summarize call can escape
/// an overstated `max_context_tokens`, but halving must terminate: below this
/// the prompt can no longer carry a useful summary, so the recovery gives up
/// and surfaces the error instead of issuing ever-smaller doomed requests.
pub const HANDOFF_MIN_PROMPT_BUDGET_BYTES: usize = 4 * 1024;
const DEFAULT_SYSTEM_PROMPT: &str =
"You are buzz-agent. Use the provided tools to act. Tool calls are your only output.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Provider {
Anthropic,
OpenAi,
/// Databricks model serving. Routes to `{base_url}/serving-endpoints/{model}/invocations`
/// with a dynamically-acquired bearer (OAuth 2.0 PKCE, or static `DATABRICKS_TOKEN`).
/// Wire format is OpenAI-chat-compatible — reuses the same body builder and parser.
Databricks,
/// Databricks AI Gateway v2. Routes by model family through the gateway's
/// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths.
DatabricksV2,
/// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible.
OpenRouter,
}
/// Optional visibility filter for the Databricks model catalog.
///
/// Each comma-separated pattern is trimmed and matched against the complete,
/// case-sensitive model id. Only `*` (zero or more characters) and `?` (one
/// character) have wildcard semantics; all other characters are literals.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatabricksModelFilter {
patterns: Vec<String>,
}
impl DatabricksModelFilter {
/// Parse `DATABRICKS_MODEL_FILTER`-style input.
///
/// Unset or whitespace-only input disables filtering. A nonblank value must
/// contain at least one nonblank comma-separated pattern.
pub fn parse(raw: Option<&str>) -> Result<Option<Self>, String> {
let Some(raw) = raw else {
return Ok(None);
};
if raw.trim().is_empty() {
return Ok(None);
}
let patterns: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|pattern| !pattern.is_empty())
.map(str::to_owned)
.collect();
if patterns.is_empty() {
return Err(
"config: DATABRICKS_MODEL_FILTER must contain at least one nonblank pattern".into(),
);
}
Ok(Some(Self { patterns }))
}
/// Return whether the complete model id matches at least one pattern.
pub fn matches(&self, model_id: &str) -> bool {
self.patterns
.iter()
.any(|pattern| glob_matches(pattern, model_id))
}
}
/// Match one full-string `*`/`?` pattern without treating any other character
/// as syntax. The inputs are converted to Unicode scalar values so `?` means
/// one character rather than one UTF-8 byte.
fn glob_matches(pattern: &str, value: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let value: Vec<char> = value.chars().collect();
let mut pattern_index = 0;
let mut value_index = 0;
let mut star_index = None;
let mut star_value_index = 0;
while value_index < value.len() {
match pattern.get(pattern_index) {
Some('?') => {
pattern_index += 1;
value_index += 1;
}
Some('*') => {
star_index = Some(pattern_index);
star_value_index = value_index;
pattern_index += 1;
}
Some(character) if *character == value[value_index] => {
pattern_index += 1;
value_index += 1;
}
_ if star_index.is_some() => {
if let Some(star_index) = star_index {
pattern_index = star_index + 1;
}
star_value_index += 1;
value_index = star_value_index;
}
_ => return false,
}
}
while matches!(pattern.get(pattern_index), Some('*')) {
pattern_index += 1;
}
pattern_index == pattern.len()
}
/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
/// (`auto|chat|responses`); ignored when `provider = Anthropic`. `Auto`
/// picks Responses for `*.openai.com`, Chat Completions otherwise, and
/// permits a one-shot chat→responses upgrade on a "use /v1/responses"
/// provider error.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OpenAiApi {
Chat,
Responses,
Auto,
}
#[derive(Debug, Clone)]
pub struct Config {
pub provider: Provider,
pub system_prompt: String,
pub max_rounds: u32,
pub max_output_tokens: u32,
/// Maximum number of retries after a provider returns a successful but
/// output-truncated response. Zero disables truncation recovery. This is
/// independent of `max_rounds`, which still bounds all successful calls.
pub max_token_recoveries: u32,
pub llm_timeout: Duration,
pub tool_timeout: Duration,
pub mcp_init_timeout: Duration,
pub mcp_max_restart_attempts: u32,
pub mcp_restart_base_ms: u64,
pub mcp_restart_max_ms: u64,
pub max_sessions: usize,
pub max_line_bytes: usize,
pub max_history_bytes: usize,
/// Per-tool-result cap on text content. Oversized text is middle-elided
/// (head + tail kept) before entering history. Images are exempt — they
/// are bounded by [`MAX_TOOL_RESULT_BYTES`] and accounted separately.
/// Set via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`.
pub max_tool_result_text_bytes: usize,
/// Provider context window in tokens used to gate handoff. The handoff
/// fires when the previous request's (cache-summed) input tokens cross the
/// handoff threshold for this budget, before the next request can exceed
/// the window and 400. Default 200_000 — matching Claude 4.x windows;
/// operators lower/raise it for other models. Set via
/// `BUZZ_AGENT_MAX_CONTEXT_TOKENS`.
pub max_context_tokens: u64,
/// Maximum context-handoff attempts permitted within a single
/// `session/prompt` turn. Caps runaway compaction loops inside one turn;
/// does NOT limit handoffs across a session's lifetime — a long-lived
/// session can compact on every successive turn without hitting this bound.
/// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10.
pub max_handoffs: usize,
pub max_parallel_tools: usize,
/// Process-wide cap on simultaneously-outstanding `session/request_permission`
/// asks. Bounds the [`PermissionBroker`](crate::permission::PermissionBroker)
/// correlation map independently of the per-turn tool semaphore (which is
/// fresh per turn) and of `max_sessions` (unbounded by default). Default 32.
/// Set via `BUZZ_AGENT_MAX_PENDING_PERMISSIONS`; validated `>= 1`.
pub max_pending_permissions: usize,
/// Single absolute deadline for a permission ask — shared by broker
/// admission and the response wait, so a saturated call cannot live for two
/// full timeout windows. Default 330s, chosen to outlast the client's 300s
/// auto-deny so the answer (or auto-deny) lands first. Set via
/// `BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`; validated `>= 1`.
pub permission_timeout: Duration,
pub hook_timeout: Duration,
/// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to
/// disable `_Stop` hooks entirely (agent always honors end_turn).
pub stop_max_rejections: u32,
/// Remind the model to publish when a turn is about to end without any
/// recognized attempt to post to Buzz. Default off; opt in per agent with
/// `BUZZ_AGENT_REQUIRE_REPLY=1`.
///
/// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`),
/// then the turn ends regardless. Bounded by the same
/// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on
/// all end-turn objections — at the default 3 both reminders fit; at 1 only
/// one does; at 0 the guard is off with the hooks.
pub require_reply: bool,
/// Hook server allowlist. See [`HookServers`] for variant semantics.
/// Default (env unset/empty) is `None` — hooks are off unless the
/// operator explicitly opts in.
pub hook_servers: HookServers,
/// The effective `DATABRICKS_MODEL_FILTER` value. This is parsed by the
/// caller and passed explicitly so discovery never consults process env.
pub databricks_model_filter: Option<DatabricksModelFilter>,
pub api_key: String,
pub model: String,
pub base_url: String,
pub anthropic_api_version: String,
/// OpenAI endpoint selection. See [`OpenAiApi`].
pub openai_api: OpenAiApi,
pub hints_enabled: bool,
/// Thinking/reasoning effort level. `None` = use provider default (no
/// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`.
pub thinking_effort: Option<ThinkingEffort>,
/// Reasoning summary mode for the OpenAI Responses route. Controls the
/// `reasoning.summary` field emitted alongside `reasoning.effort`; only
/// takes effect when `thinking_effort` is also set. Default `Auto`.
/// Set via `BUZZ_AGENT_THINKING_SUMMARY`. Ignored on Anthropic, Chat
/// Completions, and OpenRouter routes.
pub thinking_summary: ThinkingSummary,
/// Emit Anthropic `cache_control` breakpoints on the stable prefix
/// (tools + system prompt) and the rolling conversation tail. Default on;
/// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Consulted on every route that
/// speaks the Anthropic caching dialect: first-party Anthropic, the
/// DatabricksV2 Claude route, and OpenRouter's `anthropic/*` models. The
/// Databricks gateway does not auto-cache, so without this the surfaced
/// `cache_read_input_tokens` is structurally always 0.
pub prompt_caching: bool,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
let databricks_host = env("DATABRICKS_HOST");
let databricks_model = env("DATABRICKS_MODEL");
let provider = resolve_provider(
env("BUZZ_AGENT_PROVIDER").as_deref(),
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
env("OPENROUTER_API_KEY").as_deref(),
)?;
// Universal model override — takes priority over provider-specific model
// env vars (ANTHROPIC_MODEL, OPENAI_COMPAT_MODEL, DATABRICKS_MODEL) when
// present. Set by the desktop from the persona/record to express explicit
// user intent; provider-specific vars serve as defaults for CLI/standalone use.
let buzz_agent_model = env("BUZZ_AGENT_MODEL");
// OPENAI_COMPAT_API is only read when provider=openai, so a stray
// bad value can't break an Anthropic-only deployment.
//
// Databricks borrows api_key as the *optional* `DATABRICKS_TOKEN` escape
// hatch — empty means "use OAuth PKCE." Legacy Databricks encodes the
// model in the URL path; Databricks v2 keeps it in the request body.
let (api_key, model, base_url, openai_api) = match provider {
Provider::Anthropic => (
req("ANTHROPIC_API_KEY")?,
resolve_model(
buzz_agent_model.as_deref(),
env("ANTHROPIC_MODEL").as_deref(),
)
.ok_or_else(|| "config: ANTHROPIC_MODEL required".to_string())?,
env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
OpenAiApi::Auto, // unused for Anthropic
),
Provider::OpenAi => (
req("OPENAI_COMPAT_API_KEY")?,
resolve_model(
buzz_agent_model.as_deref(),
env("OPENAI_COMPAT_MODEL").as_deref(),
)
.ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?,
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
),
Provider::Databricks | Provider::DatabricksV2 => (
env("DATABRICKS_TOKEN").unwrap_or_default(),
resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref())
.ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?,
databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?,
OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch
),
Provider::OpenRouter => (
req("OPENROUTER_API_KEY")?,
resolve_model(
buzz_agent_model.as_deref(),
env("OPENROUTER_MODEL").as_deref(),
)
.ok_or_else(|| "config: OPENROUTER_MODEL required".to_string())?,
env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
OpenAiApi::Chat, // OpenRouter uses Chat Completions only
),
};
let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) {
(Some(_), Some(_)) => return Err(
"config: BUZZ_AGENT_SYSTEM_PROMPT and BUZZ_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()),
(Some(s), _) => s,
(_, Some(p)) => std::fs::read_to_string(&p).map_err(|e| format!("config: read {p}: {e}"))?,
_ => DEFAULT_SYSTEM_PROMPT.to_owned(),
};
let cfg = Config {
provider,
system_prompt,
api_key,
model,
base_url,
anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"),
openai_api,
max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?,
max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 65_536)?,
max_token_recoveries: parse_env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", 3u32)?,
llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?),
tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 1_260)?),
mcp_init_timeout: Duration::from_secs(parse_env(
"BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS",
30,
)?),
mcp_max_restart_attempts: parse_env("BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS", 3u32)?,
mcp_restart_base_ms: parse_env("BUZZ_AGENT_MCP_RESTART_BASE_MS", 500u64)?,
mcp_restart_max_ms: parse_env("BUZZ_AGENT_MCP_RESTART_MAX_MS", 30_000u64)?,
max_sessions: parse_env("BUZZ_AGENT_MAX_SESSIONS", usize::MAX)?,
max_line_bytes: parse_env("BUZZ_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?,
max_history_bytes: parse_env("BUZZ_AGENT_MAX_HISTORY_BYTES", 16 * 1024 * 1024)?,
max_tool_result_text_bytes: parse_env(
"BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES",
DEFAULT_TOOL_RESULT_TEXT_BYTES,
)?,
max_context_tokens: parse_env("BUZZ_AGENT_MAX_CONTEXT_TOKENS", 200_000u64)?,
max_handoffs: parse_env("BUZZ_AGENT_MAX_HANDOFFS", 10)?,
max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
max_pending_permissions: parse_env("BUZZ_AGENT_MAX_PENDING_PERMISSIONS", 32usize)?,
permission_timeout: Duration::from_secs(parse_env(
"BUZZ_AGENT_PERMISSION_TIMEOUT_SECS",
330u64,
)?),
hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?),
stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
databricks_model_filter: DatabricksModelFilter::parse(
env("DATABRICKS_MODEL_FILTER").as_deref(),
)?,
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
thinking_summary: parse_thinking_summary(
env("BUZZ_AGENT_THINKING_SUMMARY").as_deref(),
)?,
prompt_caching: parse_env("BUZZ_AGENT_PROMPT_CACHING", 1u8)? != 0,
};
cfg.validate()?;
Ok(cfg)
}
/// Construct a minimal `Config` for model-catalog discovery.
///
/// Only the fields used by [`build_token_source`](crate::llm::build_token_source)
/// and the catalog HTTP helpers are meaningful; all others are set to
/// inert defaults. Never call `from_env` for discovery — it requires
/// `DATABRICKS_MODEL` and other fields that are irrelevant here.
pub fn for_discovery(
provider: Provider,
api_key: String,
base_url: String,
databricks_model_filter: Option<DatabricksModelFilter>,
) -> Self {
Self {
provider,
api_key,
base_url,
model: String::new(),
system_prompt: String::new(),
anthropic_api_version: "2023-06-01".into(),
openai_api: OpenAiApi::Chat,
max_rounds: 0,
max_output_tokens: 1,
max_token_recoveries: 0,
llm_timeout: Duration::from_secs(30),
tool_timeout: Duration::from_secs(30),
mcp_init_timeout: Duration::from_secs(30),
mcp_max_restart_attempts: 0,
mcp_restart_base_ms: 0,
mcp_restart_max_ms: 0,
max_sessions: 1,
max_line_bytes: 4 * 1024 * 1024,
max_history_bytes: 16 * 1024 * 1024,
max_tool_result_text_bytes: 50 * 1024,
max_context_tokens: 200_001,
max_handoffs: 0,
max_parallel_tools: 1,
max_pending_permissions: 32,
permission_timeout: Duration::from_secs(330),
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
require_reply: false,
hook_servers: HookServers::None,
databricks_model_filter,
hints_enabled: false,
thinking_effort: None,
thinking_summary: ThinkingSummary::Auto,
prompt_caching: false,
}
}
fn validate(&self) -> Result<(), String> {
const MIN_HISTORY_BYTES: usize = 4096;
const MIN_LINE_BYTES: usize = 1024;
const MIN_TOOL_RESULT_TEXT_BYTES: usize = 1024;
const MIN_TIMEOUT: Duration = Duration::from_secs(1);
if self.max_output_tokens < 1 {
return Err("config: BUZZ_AGENT_MAX_OUTPUT_TOKENS must be >= 1".into());
}
if self.max_context_tokens <= u64::from(self.max_output_tokens) {
return Err(format!(
"config: BUZZ_AGENT_MAX_CONTEXT_TOKENS ({}) must be > BUZZ_AGENT_MAX_OUTPUT_TOKENS ({}) — the context window must leave room for the response",
self.max_context_tokens, self.max_output_tokens
));
}
if self.max_history_bytes < MIN_HISTORY_BYTES {
return Err(format!(
"config: BUZZ_AGENT_MAX_HISTORY_BYTES must be >= {MIN_HISTORY_BYTES}"
));
}
if self.max_history_bytes < MAX_PROMPT_BYTES {
return Err(format!(
"config: BUZZ_AGENT_MAX_HISTORY_BYTES ({}) must be >= MAX_PROMPT_BYTES ({MAX_PROMPT_BYTES})",
self.max_history_bytes
));
}
if self.max_line_bytes < MIN_LINE_BYTES {
return Err(format!(
"config: BUZZ_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}"
));
}
if self.max_tool_result_text_bytes < MIN_TOOL_RESULT_TEXT_BYTES
|| self.max_tool_result_text_bytes > MAX_TOOL_RESULT_BYTES
{
return Err(format!(
"config: BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES must be in {MIN_TOOL_RESULT_TEXT_BYTES}..={MAX_TOOL_RESULT_BYTES}"
));
}
if self.llm_timeout < MIN_TIMEOUT {
return Err("config: BUZZ_AGENT_LLM_TIMEOUT_SECS must be >= 1".into());
}
if self.tool_timeout < MIN_TIMEOUT {
return Err("config: BUZZ_AGENT_TOOL_TIMEOUT_SECS must be >= 1".into());
}
if self.mcp_init_timeout < MIN_TIMEOUT {
return Err("config: BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS must be >= 1".into());
}
if self.max_parallel_tools < 1 {
return Err("config: BUZZ_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into());
}
if self.max_pending_permissions < 1 {
return Err("config: BUZZ_AGENT_MAX_PENDING_PERMISSIONS must be >= 1".into());
}
if self.permission_timeout < MIN_TIMEOUT {
return Err("config: BUZZ_AGENT_PERMISSION_TIMEOUT_SECS must be >= 1".into());
}
if self.mcp_max_restart_attempts < 1 {
return Err("config: BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into());
}
if self.mcp_restart_base_ms < 1 {
return Err("config: BUZZ_AGENT_MCP_RESTART_BASE_MS must be >= 1".into());
}
if self.mcp_restart_max_ms < self.mcp_restart_base_ms {
return Err(
"config: BUZZ_AGENT_MCP_RESTART_MAX_MS must be >= BUZZ_AGENT_MCP_RESTART_BASE_MS"
.into(),
);
}
// Provider-level effort validation (fail-fast, clear error).
// `none`/`minimal` are not Anthropic values — rejected at startup.
//
// OpenAI, Databricks, and DatabricksV2 defer effort validation to request-time routing:
// availability is model-dependent, and `session/set_model` can change the effective model
// after startup. `normalize_effort_for_provider` / `normalize_effort_for_databricks_v2` /
// `normalize_effort_for_anthropic_route` apply route-aware normalization in `llm.rs` when
// building each request.
if let Some(effort) = self.thinking_effort {
let is_pure_anthropic = matches!(self.provider, Provider::Anthropic);
if is_pure_anthropic && matches!(effort, ThinkingEffort::None | ThinkingEffort::Minimal)
{
return Err(format!(
"config: BUZZ_AGENT_THINKING_EFFORT={} is not valid for Anthropic providers \
(allowed: low|medium|high|xhigh|max)",
effort.openai_effort_str()
));
}
}
Ok(())
}
}
fn env(k: &str) -> Option<String> {
std::env::var(k).ok()
}
fn env_or(k: &str, d: &str) -> String {
env(k).unwrap_or_else(|| d.into())
}
fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}
/// Returns the first present value. `explicit_override` (BUZZ_AGENT_MODEL,
/// set by the desktop from the persona/record) wins over `provider_default`
/// (provider-specific env var that may be inherited from the shell).
/// Returns `None` when both are absent so the caller can supply a
/// provider-specific error message.
fn resolve_model(
explicit_override: Option<&str>,
provider_default: Option<&str>,
) -> Option<String> {
explicit_override.or(provider_default).map(str::to_owned)
}
fn present_nonempty(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|s| !s.is_empty())
}
fn resolve_provider(
requested: Option<&str>,
anthropic_key: Option<&str>,
openai_key: Option<&str>,
openrouter_key: Option<&str>,
) -> Result<Provider, String> {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
let normalized = raw.to_ascii_lowercase();
match normalized.as_str() {
"anthropic" if present_nonempty(anthropic_key) => Ok(Provider::Anthropic),
"anthropic" => Err(
"config: ANTHROPIC_API_KEY required".into(),
),
"openai" | "openai-compat" if present_nonempty(openai_key) => Ok(Provider::OpenAi),
"openai" | "openai-compat" => Err(
"config: OPENAI_COMPAT_API_KEY required".into(),
),
"databricks" => Ok(Provider::Databricks),
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
"openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
"openrouter" => Err("config: OPENROUTER_API_KEY required".into()),
_ => Err(format!(
"config: BUZZ_AGENT_PROVIDER={raw} not supported"
)),
}
}
None => Err(
"config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, databricks)".into(),
),
}
}
/// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the
/// caller hands in the raw value.
fn parse_openai_api(raw: Option<&str>) -> Result<OpenAiApi, String> {
match raw.unwrap_or("auto").trim().to_ascii_lowercase().as_str() {
"chat" | "chat-completions" | "chat_completions" => Ok(OpenAiApi::Chat),
"responses" => Ok(OpenAiApi::Responses),
"auto" | "" => Ok(OpenAiApi::Auto),
other => Err(format!(
"config: OPENAI_COMPAT_API={other} not supported (use auto|chat|responses)"
)),
}
}
/// `true` when `base_url` is an official OpenAI host. Hosts on
/// `*.openai.com` get Responses under `Auto`; everything else (vLLM,
/// Ollama, OpenRouter, Block Gateway, …) gets Chat Completions.
/// Lookalike-safe: `api.openai.com.evil.example` returns `false`.
pub fn is_openai_host(base_url: &str) -> bool {
let rest = match base_url
.strip_prefix("https://")
.or_else(|| base_url.strip_prefix("http://"))
{
Some(r) => r,
None => return false,
};
let host = &rest[..rest.find(['/', ':']).unwrap_or(rest.len())];
host == "api.openai.com" || host.ends_with(".openai.com")
}
/// Return the NIP-AM registered billing-authority token for `base_url` when it
/// canonically matches one of the official allowlisted endpoints. Returns `None`
/// for any custom, gateway, or lookalike URL.
///
/// Rules (per NIP-AM publisher behavior):
/// - HTTPS only (no HTTP).
/// - Exact allowlisted host — lookalike-safe: `api.openai.com.evil.example` is rejected.
/// - Default port only (no `:8443` etc.).
/// - No userinfo, query string, or fragment.
/// - Required API base path present and exact (where applicable — OpenRouter requires `/api/v1`).
/// - No path prefix lookalikes (`/api/v10` is not `/api/v1`).
///
/// The wire token itself is the registered bare-host identifier (e.g.
/// `"api.anthropic.com"`), not a URL — the path check is publisher-side only.
///
/// Allowlist (registered values; set extends only by NIP-AM amendment):
/// - `https://api.anthropic.com/` → `"api.anthropic.com"`
/// - `https://api.openai.com/v1` → `"api.openai.com"` (path `/v1` required)
/// - `https://openrouter.ai/api/v1` → `"openrouter.ai"` (path `/api/v1` required)
pub fn pricing_authority(base_url: &str) -> Option<&'static str> {
let parsed = url::Url::parse(base_url).ok()?;
// Require HTTPS only.
if parsed.scheme() != "https" {
return None;
}
// Reject userinfo (username or password present).
if !parsed.username().is_empty() || parsed.password().is_some() {
return None;
}
// Reject query strings and fragments.
if parsed.query().is_some() || parsed.fragment().is_some() {
return None;
}
// Require either no port or the default HTTPS port (443). Both forms are
// equivalent canonical origins; rejecting explicit :443 would create false
// negatives for providers that include the default port in their base URL.
if let Some(port) = parsed.port() {
if port != 443 {
return None;
}
}
let host = parsed.host_str()?.to_ascii_lowercase();
// Normalise trailing slashes for path comparison.
let path = parsed.path().trim_end_matches('/');
match host.as_str() {
"api.anthropic.com" => {
// Anthropic: path must be empty or "/" — no required prefix.
if path.is_empty() {
Some("api.anthropic.com")
} else {
None
}
}
"api.openai.com" => {
// OpenAI: path must be exactly "/v1".
if path == "/v1" {
Some("api.openai.com")
} else {
None
}
}
"openrouter.ai" => {
// OpenRouter: path must be exactly "/api/v1".
if path == "/api/v1" {
Some("openrouter.ai")
} else {
None
}
}
_ => None,
}
}
fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, String>
where
T::Err: std::fmt::Display,
{
env(key)
.map(|v| v.parse().map_err(|e| format!("config: {key}: {e}")))
.unwrap_or(Ok(default))
}
/// Hook-server allowlist parsed from a comma-separated env var.
/// - unset / empty / whitespace-only → `None` (no hooks enabled)
/// - `*` → `All` (every server eligible)
/// - `a,b,c` → `Only(["a","b","c"])`
#[derive(Debug, Clone)]
pub enum HookServers {
None,
All,
Only(Vec<String>),
}
impl HookServers {
/// Returns true iff `name` may receive hook calls.
pub fn allows(&self, name: &str) -> bool {
match self {
HookServers::None => false,
HookServers::All => true,
HookServers::Only(v) => v.iter().any(|s| s == name),
}
}
/// True if no hooks should ever fire — used to short-circuit dispatch.
pub fn is_disabled(&self) -> bool {
matches!(self, HookServers::None)
}
}
fn parse_hook_servers_env(key: &str) -> HookServers {
parse_hook_servers(env(key).as_deref())
}
/// Pure parser exposed for unit tests. `None` (env unset) and `Some("")`
/// (env set but empty) both yield `HookServers::None`.
fn parse_hook_servers(raw: Option<&str>) -> HookServers {
let raw = match raw {
Some(v) => v,
None => return HookServers::None,
};
let names: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
if names.is_empty() {
return HookServers::None;
}
// `*` is the wildcard — only honored when it's the sole entry. A mixed
// value like "*,foo" falls through to `Only(["*","foo"])`; "*" is not a
// legal MCP server name (it can't pass `valid_name`), so it never matches
// an actual server. This avoids silently widening scope on typos.
if names.len() == 1 && names[0] == "*" {
return HookServers::All;
}
HookServers::Only(names)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn databricks_model_filter_unset_and_blank_disable_filtering() {
for raw in [None, Some(""), Some(" ")] {
assert_eq!(DatabricksModelFilter::parse(raw).unwrap(), None);
}
}
#[test]
fn databricks_model_filter_rejects_nonblank_input_without_patterns() {
let error = DatabricksModelFilter::parse(Some(" , , ")).unwrap_err();
assert!(error.contains("DATABRICKS_MODEL_FILTER"), "{error}");
}
#[test]
fn databricks_model_filter_matches_exact_full_string_case_sensitively() {
let filter = DatabricksModelFilter::parse(Some("data_tools.goose.kimi-k3")).unwrap();
assert!(filter.as_ref().unwrap().matches("data_tools.goose.kimi-k3"));
assert!(!filter
.as_ref()
.unwrap()
.matches("prefix.data_tools.goose.kimi-k3"));
assert!(!filter.as_ref().unwrap().matches("data_tools.goose.Kimi-k3"));
}
#[test]
fn databricks_model_filter_matches_star_and_question_mark() {
let filter =
DatabricksModelFilter::parse(Some("databricks-*,data_tools.goose.????-k3")).unwrap();
let filter = filter.as_ref().unwrap();
assert!(filter.matches("databricks-gpt-5"));
assert!(filter.matches("data_tools.goose.kimi-k3"));
assert!(!filter.matches("data_tools.goose.kimi-k33"));
assert!(!filter.matches("other-model"));
}
#[test]
fn databricks_model_filter_trims_multiple_patterns_and_preserves_no_match() {
let filter = DatabricksModelFilter::parse(Some(" first , second-model , third-* "))
.unwrap()
.unwrap();
assert!(filter.matches("first"));
assert!(filter.matches("second-model"));
assert!(filter.matches("third-model"));
assert!(!filter.matches("fourth-model"));
}
#[test]
fn databricks_model_filter_question_mark_matches_one_unicode_character() {
let filter = DatabricksModelFilter::parse(Some("goose-? "))
.unwrap()
.unwrap();
assert!(filter.matches("goose-é"));
assert!(!filter.matches("goose-eé"));
}
#[test]
fn hook_servers_unset_is_none() {
assert!(matches!(parse_hook_servers(None), HookServers::None));
}
#[test]
fn hook_servers_empty_string_is_none() {
assert!(matches!(parse_hook_servers(Some("")), HookServers::None));
}
#[test]
fn hook_servers_whitespace_only_is_none() {
assert!(matches!(
parse_hook_servers(Some(" ,, ,")),
HookServers::None
));
}
#[test]
fn hook_servers_star_is_all() {
assert!(matches!(parse_hook_servers(Some("*")), HookServers::All));
}
#[test]
fn hook_servers_star_with_whitespace_is_all() {
assert!(matches!(
parse_hook_servers(Some(" * ")),
HookServers::All
));
}
#[test]
fn hook_servers_named_list() {
match parse_hook_servers(Some("foo,bar")) {
HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]),
other => panic!("expected Only, got {other:?}"),
}
}
#[test]
fn hook_servers_trims_entries() {
match parse_hook_servers(Some(" foo , bar , ")) {
HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]),
other => panic!("expected Only, got {other:?}"),
}
}
#[test]
fn hook_servers_star_mixed_is_literal() {
// `*,foo` is NOT a wildcard — it's a literal Only(["*","foo"]).
// No real server can be named `*`, so this never matches anything.
match parse_hook_servers(Some("*,foo")) {
HookServers::Only(v) => assert_eq!(v, vec!["*".to_owned(), "foo".to_owned()]),
other => panic!("expected Only, got {other:?}"),
}
}
#[test]
fn hook_servers_allows_matches_named_only() {
let hs = parse_hook_servers(Some("foo,bar"));
assert!(hs.allows("foo"));
assert!(hs.allows("bar"));
assert!(!hs.allows("baz"));
}
#[test]
fn hook_servers_allows_matches_all() {
assert!(parse_hook_servers(Some("*")).allows("anything"));
}
#[test]
fn hook_servers_allows_blocks_when_none() {
assert!(!parse_hook_servers(None).allows("foo"));
}
#[test]
fn hook_servers_star_mixed_does_not_match_real_server() {
let hs = parse_hook_servers(Some("*,foo"));
// The literal "*" entry exists in Only, but no real server can
// be named "*" (rejected by the MCP server name validator).
assert!(hs.allows("foo"));
assert!(!hs.allows("bar"));
// Allowed strictly only as a literal match — defense-in-depth
// expectation for callers.
assert!(hs.allows("*"));
}
#[test]
fn parse_openai_api_values() {
use OpenAiApi::*;
for (raw, want) in [
(None, Ok(Auto)),
(Some("auto"), Ok(Auto)),
(Some(" AUTO "), Ok(Auto)),
(Some(""), Ok(Auto)),
(Some("chat"), Ok(Chat)),
(Some("chat-completions"), Ok(Chat)),
(Some("Responses"), Ok(Responses)),
] {
assert_eq!(parse_openai_api(raw), want, "raw={raw:?}");
}
let err = parse_openai_api(Some("nope")).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API=nope"), "{err}");
}
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(),
Provider::OpenAi
);
}
#[test]
fn resolve_provider_errors_when_requested_provider_key_missing() {
// No fallback — missing key returns an error regardless of Databricks availability.
let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err();
assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}");
let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
}
#[test]
fn resolve_provider_errors_when_provider_env_absent() {
// No implicit inference — absent BUZZ_AGENT_PROVIDER is an error.
let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_requires_databricks_host_and_model_for_fallback() {
// Renamed: verify the explicit databricks provider path works correctly.
// When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless
// of DATABRICKS_HOST/MODEL (those are validated later in from_env()).
assert_eq!(
resolve_provider(Some("databricks"), None, None, None).unwrap(),
Provider::Databricks
);
// Missing key for other providers still errors — no Databricks fallback.
let err = resolve_provider(Some("openai"), None, None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_unsupported_error_preserves_user_casing() {
let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}
#[test]
fn is_openai_host_matrix() {
// Lookalike-safe: `api.openai.com.evil.example` and malformed URLs
// are treated as non-OpenAI (which falls back to Chat Completions).
for (url, want) in [
("https://api.openai.com/v1", true),
("https://api.openai.com", true),
("http://eu.api.openai.com/v1", true),
("http://localhost:11434/v1", false),
("https://openrouter.ai/api/v1", false),
("https://gateway.block.example/v1", false),
("https://api.openai.com.evil.example/v1", false),
("not a url", false),
] {
assert_eq!(is_openai_host(url), want, "url={url}");
}
}
#[test]
fn resolve_model_prefers_explicit_override() {
let result = resolve_model(Some("override-model"), Some("provider-model"));
assert_eq!(result.as_deref(), Some("override-model"));
}
#[test]
fn resolve_model_falls_back_to_provider_default() {
let result = resolve_model(None, Some("provider-model"));
assert_eq!(result.as_deref(), Some("provider-model"));
}
#[test]
fn resolve_model_returns_none_when_both_absent() {
let result = resolve_model(None, None);
assert!(result.is_none());
}
#[test]
fn parse_thinking_effort_round_trips_all_values() {
for (raw, expected) in [
("none", ThinkingEffort::None),
("minimal", ThinkingEffort::Minimal),
("low", ThinkingEffort::Low),
("medium", ThinkingEffort::Medium),
("high", ThinkingEffort::High),
("xhigh", ThinkingEffort::XHigh),
("max", ThinkingEffort::Max),
] {
assert_eq!(
parse_thinking_effort(Some(raw)).unwrap(),
Some(expected),
"raw={raw:?}"
);
}
}
#[test]
fn parse_thinking_effort_none_and_empty_yield_none() {
assert_eq!(parse_thinking_effort(None).unwrap(), None);
assert_eq!(parse_thinking_effort(Some("")).unwrap(), None);
assert_eq!(parse_thinking_effort(Some(" ")).unwrap(), None);
}
#[test]
fn parse_thinking_effort_is_case_insensitive() {
assert_eq!(
parse_thinking_effort(Some("HIGH")).unwrap(),
Some(ThinkingEffort::High)
);
assert_eq!(
parse_thinking_effort(Some(" Medium ")).unwrap(),
Some(ThinkingEffort::Medium)
);
}
#[test]
fn parse_thinking_effort_rejects_unknown_value() {
let err = parse_thinking_effort(Some("extreme")).unwrap_err();
assert!(err.contains("BUZZ_AGENT_THINKING_EFFORT=extreme"), "{err}");
assert!(
err.contains("none|minimal|low|medium|high|xhigh|max"),
"{err}"
);
}
#[test]
fn parse_thinking_summary_round_trips_all_values() {
for (raw, expected) in [
("auto", ThinkingSummary::Auto),
("concise", ThinkingSummary::Concise),
("detailed", ThinkingSummary::Detailed),
] {
assert_eq!(
parse_thinking_summary(Some(raw)).unwrap(),
expected,
"raw={raw:?}"
);
}
}
#[test]
fn parse_thinking_summary_unset_and_empty_yield_auto() {
assert_eq!(parse_thinking_summary(None).unwrap(), ThinkingSummary::Auto);
assert_eq!(
parse_thinking_summary(Some("")).unwrap(),
ThinkingSummary::Auto
);
assert_eq!(
parse_thinking_summary(Some(" ")).unwrap(),
ThinkingSummary::Auto
);
}
#[test]
fn parse_thinking_summary_is_case_insensitive() {
assert_eq!(
parse_thinking_summary(Some("DETAILED")).unwrap(),
ThinkingSummary::Detailed
);
assert_eq!(
parse_thinking_summary(Some(" Concise ")).unwrap(),
ThinkingSummary::Concise
);
}
#[test]
fn parse_thinking_summary_rejects_unknown_value() {
let err = parse_thinking_summary(Some("verbose")).unwrap_err();
assert!(err.contains("BUZZ_AGENT_THINKING_SUMMARY=verbose"), "{err}");
assert!(err.contains("auto|concise|detailed"), "{err}");
}
#[test]
fn thinking_summary_as_str_mapping() {
assert_eq!(ThinkingSummary::Auto.as_str(), "auto");
assert_eq!(ThinkingSummary::Concise.as_str(), "concise");
assert_eq!(ThinkingSummary::Detailed.as_str(), "detailed");
}
#[test]
fn thinking_effort_anthropic_budget_tokens_mapping() {
assert_eq!(ThinkingEffort::Low.anthropic_budget_tokens(), 1_024);
assert_eq!(ThinkingEffort::Medium.anthropic_budget_tokens(), 8_192);
assert_eq!(ThinkingEffort::High.anthropic_budget_tokens(), 32_768);
// XHigh and Max clamp to the high budget value for manual-budget models.
assert_eq!(ThinkingEffort::XHigh.anthropic_budget_tokens(), 32_768);
assert_eq!(ThinkingEffort::Max.anthropic_budget_tokens(), 32_768);
// None/Minimal are rejected at startup for Anthropic; defensive zero.
assert_eq!(ThinkingEffort::None.anthropic_budget_tokens(), 0);
assert_eq!(ThinkingEffort::Minimal.anthropic_budget_tokens(), 0);
}
#[test]
fn thinking_effort_openai_effort_str_mapping() {
assert_eq!(ThinkingEffort::None.openai_effort_str(), "none");
assert_eq!(ThinkingEffort::Minimal.openai_effort_str(), "minimal");
assert_eq!(ThinkingEffort::Low.openai_effort_str(), "low");
assert_eq!(ThinkingEffort::Medium.openai_effort_str(), "medium");
assert_eq!(ThinkingEffort::High.openai_effort_str(), "high");
assert_eq!(ThinkingEffort::XHigh.openai_effort_str(), "xhigh");
assert_eq!(ThinkingEffort::Max.openai_effort_str(), "max");
}
#[test]
fn thinking_effort_anthropic_effort_str_mapping() {
assert_eq!(ThinkingEffort::Low.anthropic_effort_str(), "low");
assert_eq!(ThinkingEffort::Medium.anthropic_effort_str(), "medium");
assert_eq!(ThinkingEffort::High.anthropic_effort_str(), "high");
assert_eq!(ThinkingEffort::XHigh.anthropic_effort_str(), "xhigh");
assert_eq!(ThinkingEffort::Max.anthropic_effort_str(), "max");
// Defensive fallback for invalid Anthropic values (caught at startup validation).
assert_eq!(ThinkingEffort::None.anthropic_effort_str(), "low");
assert_eq!(ThinkingEffort::Minimal.anthropic_effort_str(), "low");
}
#[test]
fn thinking_effort_ord_ordering() {
// PartialOrd/Ord must reflect the ordered hierarchy.
assert!(ThinkingEffort::None < ThinkingEffort::Minimal);
assert!(ThinkingEffort::Minimal < ThinkingEffort::Low);
assert!(ThinkingEffort::Low < ThinkingEffort::Medium);
assert!(ThinkingEffort::Medium < ThinkingEffort::High);
assert!(ThinkingEffort::High < ThinkingEffort::XHigh);
assert!(ThinkingEffort::XHigh < ThinkingEffort::Max);
}
// ---- anthropic_thinking_config helper — per-family tests ----
#[test]
fn anthropic_thinking_config_claude3_emits_budget_tokens() {
// Claude 3.x → `thinking.budget_tokens`; clamped to min(level_budget, max_output - 1024).
// max_output_tokens = 4096: headroom = 4096 - 1024 = 3072; High budget (32768) → 3072.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-3-7-sonnet-20250219",
ThinkingEffort::High,
4096,
);
let t = thinking.expect("thinking field must be present for claude-3");
assert_eq!(t["type"], "enabled");
assert_eq!(t["budget_tokens"], 3072); // capped: min(32768, 4096-1024)
assert!(
output_config.is_none(),
"output_config must be absent for claude-3"
);
}
#[test]
fn anthropic_thinking_config_claude3_omits_thinking_when_max_output_too_small() {
// max_output_tokens = 2047: headroom = 2047 - 1024 = 1023 < 1024 → omit thinking.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-3-7-sonnet-20250219",
ThinkingEffort::High,
2047,
);
assert!(
thinking.is_none(),
"thinking must be omitted when max_output_tokens - 1024 < 1024 (budget would starve answer)"
);
assert!(output_config.is_none());
}
#[test]
fn anthropic_thinking_config_claude3_emits_thinking_at_boundary_2048() {
// max_output_tokens = 2048: headroom = 2048 - 1024 = 1024 ≥ 1024 → emit budget = 1024.
let (thinking, _) = anthropic_thinking_config(
"anthropic",
"claude-3-7-sonnet-20250219",
ThinkingEffort::High,
2048,
);
let t = thinking.expect("thinking must be present when max_output_tokens = 2048");
assert_eq!(t["budget_tokens"], 1024); // min(32768, 2048-1024) = 1024
}
#[test]
fn anthropic_thinking_config_claude3_budget_uncapped_when_fits() {
// High budget fits comfortably under a large max_output_tokens.
let (thinking, _) = anthropic_thinking_config(
"anthropic",
"claude-3-7-sonnet-20250219",
ThinkingEffort::High,
65_536,
);
let t = thinking.unwrap();
assert_eq!(t["budget_tokens"], 32_768);
}
#[test]
fn anthropic_thinking_config_opus_4_8_emits_adaptive_and_effort() {
// Opus 4.8 — adaptive family. Requires thinking:{type:"adaptive"} to enable thinking.
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-opus-4-8", ThinkingEffort::High, 32_768);
let t = thinking.expect("thinking must be present for claude-opus-4-8");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-opus-4-8");
assert_eq!(oc["effort"], "high");
}
#[test]
fn anthropic_thinking_config_opus_4_7_emits_adaptive_and_effort() {
// Opus 4.7 — adaptive family.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-opus-4-7",
ThinkingEffort::Medium,
32_768,
);
let t = thinking.expect("thinking must be present for claude-opus-4-7");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-opus-4-7");
assert_eq!(oc["effort"], "medium");
}
#[test]
fn anthropic_thinking_config_sonnet_5_emits_adaptive_and_effort() {
// Sonnet 5 — adaptive family.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-sonnet-5-20250901",
ThinkingEffort::Low,
32_768,
);
let t = thinking.expect("thinking must be present for claude-sonnet-5");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-sonnet-5");
assert_eq!(oc["effort"], "low");
}
#[test]
fn anthropic_thinking_config_sonnet_4_6_emits_adaptive_and_effort() {
// Sonnet 4.6 — adaptive family. Docs explicitly list "Combine effort with adaptive thinking."
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-sonnet-4-6",
ThinkingEffort::High,
32_768,
);
let t = thinking.expect("thinking must be present for claude-sonnet-4-6");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-sonnet-4-6");
assert_eq!(oc["effort"], "high");
}
#[test]
fn anthropic_thinking_config_opus_4_5_emits_manual_budget() {
// Opus 4.5 — manual budget (NOT adaptive; effort page: "uses manual thinking").
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::High, 65_536);
let t = thinking.expect("thinking must be present for claude-opus-4-5");
assert_eq!(t["type"], "enabled");
assert_eq!(t["budget_tokens"], 32_768); // High budget fits under 65536
assert!(
output_config.is_none(),
"output_config must be absent for claude-opus-4-5 (manual budget)"
);
}
#[test]
fn anthropic_thinking_config_opus_4_5_budget_capped() {
// Opus 4.5 manual budget is clamped to min(level_budget, max_output_tokens - 1024).
// max_output_tokens = 4096: headroom = 4096 - 1024 = 3072; High budget (32768) → 3072.
let (thinking, _) =
anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::High, 4096);
let t = thinking.unwrap();
assert_eq!(t["budget_tokens"], 3072); // min(32768, 4096-1024)
}
#[test]
fn anthropic_thinking_config_opus_4_5_omits_thinking_when_max_output_1025() {
// max_output_tokens = 1025: headroom = 1025 - 1024 = 1 < 1024 → omit thinking.
let (thinking, _) =
anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::High, 1025);
assert!(
thinking.is_none(),
"thinking must be omitted when max_output_tokens - 1024 < 1024"
);
}
#[test]
fn anthropic_thinking_config_manual_budget_low_emits_1024_when_fits() {
// Low budget (1024 tokens) exactly fits when max_output_tokens = 2048.
// headroom = 2048 - 1024 = 1024; min(1024, 1024) = 1024 ≥ 1024 → emit.
let (thinking, _) = anthropic_thinking_config(
"anthropic",
"claude-3-7-sonnet-20250219",
ThinkingEffort::Low,
2048,
);
let t = thinking.expect("Low budget (1024) must be emitted when max_output_tokens = 2048");
assert_eq!(t["budget_tokens"], 1024);
}
#[test]
fn anthropic_thinking_config_unknown_claude_omits_both_fields() {
// An unknown/future "claude-*" name that is not in the allowlist → omit both fields.
// This prevents sending an unverified shape to an unrecognized model.
// Includes Opus 4.9 (future version), which is NOT in the doc-verified adaptive list.
for model in &[
"claude-haiku-4-5",
"claude-sonnet-4-5",
"claude-unknown-9-1",
"claude-future-model",
"claude-opus-4-9",
] {
let (thinking, output_config) =
anthropic_thinking_config("anthropic", model, ThinkingEffort::High, 32_768);
assert!(
thinking.is_none(),
"thinking must be absent for unverified claude model: {model}"
);
assert!(
output_config.is_none(),
"output_config must be absent for unverified claude model: {model}"
);
}
}
#[test]
fn anthropic_thinking_config_non_claude_omits_both_fields() {
// Non-Anthropic model names (gpt-5, llama, etc.) → omit both fields.
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "gpt-4o-mini", ThinkingEffort::High, 32_768);
assert!(
thinking.is_none(),
"thinking must be absent for non-claude model"
);
assert!(
output_config.is_none(),
"output_config must be absent for non-claude model"
);
}
#[test]
fn anthropic_thinking_config_databricks_prefix_stripped_for_claude3() {
// Databricks gateway prefixes like "databricks-claude-3-..." must be stripped.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"databricks-claude-3-5-sonnet",
ThinkingEffort::Low,
8_192,
);
let t = thinking.expect("thinking must be present after stripping databricks- prefix");
assert_eq!(t["type"], "enabled");
assert!(output_config.is_none());
}
#[test]
fn anthropic_thinking_config_databricks_prefix_stripped_for_opus_4_7() {
// Databricks gateway prefix stripping applies to adaptive Claude families too.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"databricks-claude-opus-4-7",
ThinkingEffort::High,
32_768,
);
let t = thinking
.expect("thinking:{type:adaptive} must be present for databricks-claude-opus-4-7");
assert_eq!(t["type"], "adaptive");
let oc =
output_config.expect("output_config must be present for databricks-claude-opus-4-7");
assert_eq!(oc["effort"], "high");
}
#[test]
fn anthropic_thinking_config_databricks_prefix_stripped_for_opus_4_8() {
// Databricks gateway prefix stripping applies to Opus 4.8 too.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"databricks-claude-opus-4-8",
ThinkingEffort::Medium,
32_768,
);
let t = thinking
.expect("thinking:{type:adaptive} must be present for databricks-claude-opus-4-8");
assert_eq!(t["type"], "adaptive");
let oc =
output_config.expect("output_config must be present for databricks-claude-opus-4-8");
assert_eq!(oc["effort"], "medium");
}
#[test]
fn anthropic_thinking_config_goose_prefix_stripped_for_fable_5() {
// "goose-" catalog prefix must be stripped so goose-claude-fable-5 routes to
// the adaptive + xhigh/max bucket, not the "unknown model → (None, None)" path.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"goose-claude-fable-5",
ThinkingEffort::Max,
32_768,
);
let t =
thinking.expect("thinking:{type:adaptive} must be present for goose-claude-fable-5");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for goose-claude-fable-5");
assert_eq!(oc["effort"], "max");
}
#[test]
fn anthropic_thinking_config_goose_prefix_stripped_for_sonnet_5() {
// Adaptive xhigh model via goose- prefix.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"goose-claude-sonnet-5",
ThinkingEffort::XHigh,
32_768,
);
let t =
thinking.expect("thinking:{type:adaptive} must be present for goose-claude-sonnet-5");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for goose-claude-sonnet-5");
assert_eq!(oc["effort"], "xhigh");
}
#[test]
fn anthropic_thinking_config_arbitrary_prefix_stripped_for_opus_4_7() {
// team-x-claude-opus-4-7: first claude- token at index 7 → strips "team-x-"
// Verifies the arbitrary-prefix normalization reaches anthropic_thinking_config
// end-to-end: UI exposes max as valid, and runtime must honor it.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"team-x-claude-opus-4-7",
ThinkingEffort::Max,
32_768,
);
let t =
thinking.expect("thinking:{type:adaptive} must be present for team-x-claude-opus-4-7");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for team-x-claude-opus-4-7");
assert_eq!(oc["effort"], "max");
}
// ---- anthropic_thinking_config: display:"summarized" in all enabled shapes ----
#[test]
fn anthropic_thinking_config_adaptive_emits_display_summarized() {
// Adaptive families (Opus 4.7, Sonnet 5, Fable 5, etc.) must include
// display:"summarized" so thinking text is returned, not omitted.
for model in &[
"claude-opus-4-7",
"claude-opus-4-8",
"claude-sonnet-5-20250901",
"claude-fable-5",
"claude-mythos-5",
] {
let (thinking, _) =
anthropic_thinking_config("anthropic", model, ThinkingEffort::High, 32_768);
let t = thinking
.unwrap_or_else(|| panic!("thinking must be present for adaptive model {model}"));
assert_eq!(
t["display"], "summarized",
"display:summarized must be present for adaptive model {model}: got {t}"
);
}
}
#[test]
fn anthropic_thinking_config_manual_budget_emits_display_summarized() {
// Manual-budget families (claude-3.x, opus-4-5) must also include
// display:"summarized" so thinking text is returned.
for model in &["claude-3-7-sonnet-20250219", "claude-opus-4-5"] {
let (thinking, _) =
anthropic_thinking_config("anthropic", model, ThinkingEffort::High, 65_536);
let t = thinking.unwrap_or_else(|| {
panic!("thinking must be present for manual-budget model {model}")
});
assert_eq!(
t["display"], "summarized",
"display:summarized must be present for manual-budget model {model}: got {t}"
);
}
}
#[test]
fn anthropic_thinking_config_omitted_when_no_thinking_has_no_display_field() {
// Models that don't produce a thinking field at all should have no display key.
let (thinking, _) = anthropic_thinking_config(
"anthropic",
"claude-haiku-4-5",
ThinkingEffort::High,
32_768,
);
assert!(
thinking.is_none(),
"thinking must be absent for unknown model"
);
}
// ---- anthropic_thinking_config — xhigh/max body-shape assertions ----
#[test]
fn anthropic_thinking_config_opus_4_8_xhigh_emits_xhigh_effort() {
// Opus 4.8 supports xhigh; output_config.effort must be "xhigh".
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-opus-4-8",
ThinkingEffort::XHigh,
32_768,
);
let t = thinking.expect("thinking must be present for claude-opus-4-8");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-opus-4-8");
assert_eq!(oc["effort"], "xhigh");
}
#[test]
fn anthropic_thinking_config_opus_4_8_max_emits_max_effort() {
// Opus 4.8 supports max; output_config.effort must be "max".
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-opus-4-8", ThinkingEffort::Max, 32_768);
let t = thinking.expect("thinking must be present for claude-opus-4-8");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-opus-4-8");
assert_eq!(oc["effort"], "max");
}
#[test]
fn anthropic_thinking_config_opus_4_7_xhigh_emits_xhigh_effort() {
// Opus 4.7 supports xhigh.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-opus-4-7",
ThinkingEffort::XHigh,
32_768,
);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
let oc = output_config.unwrap();
assert_eq!(oc["effort"], "xhigh");
}
#[test]
fn anthropic_thinking_config_opus_4_6_xhigh_clamps_to_high() {
// Opus 4.6 does NOT support xhigh → clamp to high.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-opus-4-6",
ThinkingEffort::XHigh,
32_768,
);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
let oc = output_config.unwrap();
assert_eq!(
oc["effort"], "high",
"xhigh must clamp to high for claude-opus-4-6"
);
}
#[test]
fn anthropic_thinking_config_opus_4_6_max_passes_through() {
// Opus 4.6 supports max — passes through without clamping.
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-opus-4-6", ThinkingEffort::Max, 32_768);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
let oc = output_config.unwrap();
assert_eq!(oc["effort"], "max");
}
#[test]
fn anthropic_thinking_config_manual_bucket_xhigh_clamps_to_high_budget() {
// Manual-budget models (claude-3*, opus-4-5): xhigh clamps to high budget (32_768).
for model in &["claude-3-7-sonnet-20250219", "claude-opus-4-5"] {
let (thinking, output_config) =
anthropic_thinking_config("anthropic", model, ThinkingEffort::XHigh, 65_536);
let t = thinking.expect("thinking must be present");
assert_eq!(t["type"], "enabled");
assert_eq!(
t["budget_tokens"], 32_768,
"xhigh must clamp to high budget for manual model {model}"
);
assert!(output_config.is_none());
}
}
#[test]
fn anthropic_thinking_config_manual_bucket_max_clamps_to_high_budget() {
// Manual-budget models: max also clamps to high budget (32_768).
let (thinking, _) =
anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::Max, 65_536);
let t = thinking.unwrap();
assert_eq!(t["type"], "enabled");
assert_eq!(t["budget_tokens"], 32_768);
}
// ---- provider-level validation tests ----
/// Build a minimal Config with the given provider and thinking_effort, bypassing from_env().
/// Uses `Config::for_discovery` as a base and patches the fields we care about.
fn make_config_for_validation(
provider: Provider,
thinking_effort: Option<ThinkingEffort>,
) -> Config {
let mut cfg =
Config::for_discovery(provider, "key".into(), "https://example.com".into(), None);
cfg.model = "some-model".into();
cfg.thinking_effort = thinking_effort;
// for_discovery sets max_output_tokens=1 and max_context_tokens=200_001 which satisfies
// the context > output constraint. Adjust to something valid for further checks.
cfg.max_output_tokens = 1024;
cfg.max_context_tokens = 200_000 + 1024;
// Restore mandatory positive values that for_discovery zeroes out.
cfg.mcp_max_restart_attempts = 1;
cfg.mcp_restart_base_ms = 1;
cfg.mcp_restart_max_ms = 1;
cfg.max_parallel_tools = 1;
cfg.llm_timeout = Duration::from_secs(1);
cfg.tool_timeout = Duration::from_secs(1);
cfg.mcp_init_timeout = Duration::from_secs(1);
cfg
}
#[test]
fn validate_rejects_none_effort_for_anthropic() {
let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::None));
let err = cfg.validate().unwrap_err();
assert!(
err.contains("BUZZ_AGENT_THINKING_EFFORT=none"),
"error must name the value: {err}"
);
assert!(
err.contains("not valid for Anthropic"),
"error must name the provider: {err}"
);
assert!(
err.contains("low|medium|high|xhigh|max"),
"error must name allowed values: {err}"
);
}
#[test]
fn validate_rejects_minimal_effort_for_anthropic() {
let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::Minimal));
let err = cfg.validate().unwrap_err();
assert!(err.contains("BUZZ_AGENT_THINKING_EFFORT=minimal"), "{err}");
assert!(err.contains("not valid for Anthropic"), "{err}");
}
#[test]
fn validate_accepts_all_efforts_for_databricks_v2() {
// DatabricksV2 dispatches across Anthropic/OpenAI/MLflow routes at request build time.
// No effort value is invalid for all three routes — startup rejects none.
for effort in [
ThinkingEffort::None,
ThinkingEffort::Minimal,
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
ThinkingEffort::XHigh,
ThinkingEffort::Max,
] {
let cfg = make_config_for_validation(Provider::DatabricksV2, Some(effort));
assert!(
cfg.validate().is_ok(),
"DatabricksV2 must accept {effort:?} at startup (route-aware normalization at request build)"
);
}
}
#[test]
fn validate_accepts_all_efforts_for_openai() {
// OpenAI effort support is model-dependent and normalized at request build time.
for effort in [
ThinkingEffort::None,
ThinkingEffort::Minimal,
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
ThinkingEffort::XHigh,
ThinkingEffort::Max,
] {
let cfg = make_config_for_validation(Provider::OpenAi, Some(effort));
assert!(
cfg.validate().is_ok(),
"OpenAI must accept {effort:?} at startup (route-aware normalization at request build)"
);
}
}
#[test]
fn validate_accepts_all_efforts_for_databricks() {
// Legacy Databricks effort support is model-dependent and normalized at request build time.
for effort in [
ThinkingEffort::None,
ThinkingEffort::Minimal,
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
ThinkingEffort::XHigh,
ThinkingEffort::Max,
] {
let cfg = make_config_for_validation(Provider::Databricks, Some(effort));
assert!(
cfg.validate().is_ok(),
"Databricks must accept {effort:?} at startup (route-aware normalization at request build)"
);
}
}
#[test]
fn validate_accepts_xhigh_for_anthropic() {
// xhigh is valid for Anthropic providers — model-level clamping is dynamic.
let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::XHigh));
assert!(
cfg.validate().is_ok(),
"xhigh must be accepted at startup for Anthropic"
);
}
#[test]
fn validate_accepts_max_for_anthropic() {
// max is valid for Anthropic providers.
let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::Max));
assert!(cfg.validate().is_ok(), "max must be accepted for Anthropic");
}
#[test]
fn validate_accepts_xhigh_for_openai() {
// xhigh is valid for OpenAI providers (server-validated per-model).
let cfg = make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::XHigh));
assert!(cfg.validate().is_ok(), "xhigh must be accepted for OpenAI");
}
#[test]
fn validate_accepts_none_and_minimal_for_openai() {
// none/minimal are valid OpenAI effort values.
let cfg_none = make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::None));
assert!(
cfg_none.validate().is_ok(),
"none must be accepted for OpenAI"
);
let cfg_minimal =
make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::Minimal));
assert!(
cfg_minimal.validate().is_ok(),
"minimal must be accepted for OpenAI"
);
}
// ---- normalize_effort_for_databricks_v2 (F1 exact-record corrections) ----
#[test]
fn normalize_effort_for_databricks_v2_gpt_5_5_xhigh_clamps_to_high() {
// F1 correction: databricks-gpt-5-5 supported_efforts = [low, medium, high].
// XHigh is outside the supported set → nearest supported is High.
assert_eq!(
normalize_effort_for_databricks_v2(ThinkingEffort::XHigh, "databricks-gpt-5-5"),
ThinkingEffort::High,
"databricks-gpt-5-5 XHigh must clamp to High (F1 correction: supported=[low,medium,high])"
);
}
#[test]
fn normalize_effort_for_databricks_v2_gpt_5_5_none_clamps_to_low() {
// F1 correction: databricks-gpt-5-5 supported_efforts = [low, medium, high].
// None is outside the set → nearest supported is Low.
assert_eq!(
normalize_effort_for_databricks_v2(ThinkingEffort::None, "databricks-gpt-5-5"),
ThinkingEffort::Low,
"databricks-gpt-5-5 None must clamp to Low (F1 correction: supported=[low,medium,high])"
);
}
#[test]
fn normalize_effort_for_databricks_v2_gpt_5_5_in_range_passes_through() {
// Values within the corrected set must pass through unchanged.
for effort in [
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
] {
assert_eq!(
normalize_effort_for_databricks_v2(effort, "databricks-gpt-5-5"),
effort,
"databricks-gpt-5-5 {effort:?} is in supported set, must pass through"
);
}
}
#[test]
fn normalize_effort_for_databricks_v2_gpt_5_6_sol_max_passes_through() {
// databricks-gpt-5-6-sol F1 adoption: [low, medium, high, max] — max is supported.
assert_eq!(
normalize_effort_for_databricks_v2(ThinkingEffort::Max, "databricks-gpt-5-6-sol"),
ThinkingEffort::Max,
"databricks-gpt-5-6-sol Max must pass through (F1: supported includes max)"
);
}
#[test]
fn normalize_effort_for_uncurated_claude_fqn_is_a_noop() {
let model = "catalog.schema.claude-sonnet-custom";
assert!(crate::model_capabilities::resolve("databricks_v2", model)
.supported_efforts
.is_empty());
for effort in [
ThinkingEffort::None,
ThinkingEffort::Minimal,
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
ThinkingEffort::XHigh,
ThinkingEffort::Max,
] {
assert_eq!(
normalize_effort_for_databricks_v2(effort, model),
effort,
"Anthropic-routed FQNs must never enter OpenAI effort resolution"
);
}
}
// ---- normalize_effort_for_anthropic_route ----
#[test]
fn normalize_anthropic_route_none_yields_none() {
assert_eq!(
normalize_effort_for_anthropic_route(ThinkingEffort::None),
None,
"none must yield None (omit thinking fields)"
);
}
#[test]
fn normalize_anthropic_route_minimal_yields_none() {
assert_eq!(
normalize_effort_for_anthropic_route(ThinkingEffort::Minimal),
None,
"minimal must yield None (omit thinking fields)"
);
}
#[test]
fn normalize_anthropic_route_passes_through_valid_values() {
for effort in [
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
ThinkingEffort::XHigh,
ThinkingEffort::Max,
] {
assert_eq!(
normalize_effort_for_anthropic_route(effort),
Some(effort),
"normalize_effort_for_anthropic_route must pass through {effort:?}"
);
}
}
// ---- F2: Fable 5 / Mythos 5 / Mythos Preview adaptive thinking ----
#[test]
fn anthropic_thinking_config_fable_5_emits_adaptive_and_effort() {
// Fable 5 — always-on adaptive thinking.
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-fable-5", ThinkingEffort::High, 32_768);
let t = thinking.expect("thinking must be present for claude-fable-5");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-fable-5");
assert_eq!(oc["effort"], "high");
}
#[test]
fn anthropic_thinking_config_mythos_5_emits_adaptive_and_effort() {
// Mythos 5 — always-on adaptive thinking.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-mythos-5",
ThinkingEffort::Medium,
32_768,
);
let t = thinking.expect("thinking must be present for claude-mythos-5");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-mythos-5");
assert_eq!(oc["effort"], "medium");
}
#[test]
fn anthropic_thinking_config_mythos_preview_emits_adaptive_and_effort() {
// Mythos Preview — Always on adaptive thinking.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-mythos-preview",
ThinkingEffort::Low,
32_768,
);
let t = thinking.expect("thinking must be present for claude-mythos-preview");
assert_eq!(t["type"], "adaptive");
let oc = output_config.expect("output_config must be present for claude-mythos-preview");
assert_eq!(oc["effort"], "low");
}
#[test]
fn anthropic_thinking_config_fable_5_xhigh_emits_xhigh() {
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-fable-5", ThinkingEffort::XHigh, 32_768);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
assert_eq!(output_config.unwrap()["effort"], "xhigh");
}
#[test]
fn anthropic_thinking_config_mythos_5_xhigh_emits_xhigh() {
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-mythos-5",
ThinkingEffort::XHigh,
32_768,
);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
assert_eq!(output_config.unwrap()["effort"], "xhigh");
}
#[test]
fn anthropic_thinking_config_mythos_preview_xhigh_clamps_to_high() {
// Mythos Preview does NOT support xhigh → clamp to high.
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-mythos-preview",
ThinkingEffort::XHigh,
32_768,
);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
assert_eq!(
output_config.unwrap()["effort"],
"high",
"xhigh must clamp to high for claude-mythos-preview"
);
}
#[test]
fn anthropic_thinking_config_fable_5_max_passes_through() {
let (thinking, output_config) =
anthropic_thinking_config("anthropic", "claude-fable-5", ThinkingEffort::Max, 32_768);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
assert_eq!(output_config.unwrap()["effort"], "max");
}
#[test]
fn anthropic_thinking_config_mythos_preview_max_passes_through() {
let (thinking, output_config) = anthropic_thinking_config(
"anthropic",
"claude-mythos-preview",
ThinkingEffort::Max,
32_768,
);
let t = thinking.unwrap();
assert_eq!(t["type"], "adaptive");
assert_eq!(output_config.unwrap()["effort"], "max");
}
#[test]
fn resolve_provider_openrouter_with_key() {
assert_eq!(
resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(),
Provider::OpenRouter
);
}
#[test]
fn resolve_provider_openrouter_missing_key() {
let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err();
assert!(err.contains("OPENROUTER_API_KEY"));
}
// ── pricing_authority: canonical URL → bare-host registry token ──────────
#[test]
fn pricing_authority_anthropic_returns_registry_token() {
assert_eq!(
pricing_authority("https://api.anthropic.com/"),
Some("api.anthropic.com")
);
// No trailing slash
assert_eq!(
pricing_authority("https://api.anthropic.com"),
Some("api.anthropic.com")
);
}
#[test]
fn pricing_authority_openai_requires_v1_path() {
assert_eq!(
pricing_authority("https://api.openai.com/v1"),
Some("api.openai.com")
);
// With trailing slash
assert_eq!(
pricing_authority("https://api.openai.com/v1/"),
Some("api.openai.com")
);
// Root-only: no path → must return None
assert_eq!(pricing_authority("https://api.openai.com/"), None);
assert_eq!(pricing_authority("https://api.openai.com"), None);
// Wrong path
assert_eq!(pricing_authority("https://api.openai.com/v2"), None);
}
#[test]
fn pricing_authority_openrouter_requires_api_v1_path() {
assert_eq!(
pricing_authority("https://openrouter.ai/api/v1"),
Some("openrouter.ai")
);
assert_eq!(
pricing_authority("https://openrouter.ai/api/v1/"),
Some("openrouter.ai")
);
assert_eq!(pricing_authority("https://openrouter.ai/"), None);
assert_eq!(pricing_authority("https://openrouter.ai"), None);
}
#[test]
fn pricing_authority_rejects_http_scheme() {
assert_eq!(pricing_authority("http://api.anthropic.com/"), None);
assert_eq!(pricing_authority("http://api.openai.com/v1"), None);
}
#[test]
fn pricing_authority_accepts_explicit_default_port() {
// Explicit :443 is the default HTTPS port — both omitted and explicit
// forms resolve to the same canonical origin and must both be accepted.
assert_eq!(
pricing_authority("https://api.anthropic.com:443/"),
Some("api.anthropic.com"),
"explicit :443 must be accepted for Anthropic"
);
assert_eq!(
pricing_authority("https://api.openai.com:443/v1"),
Some("api.openai.com"),
"explicit :443 must be accepted for OpenAI"
);
assert_eq!(
pricing_authority("https://openrouter.ai:443/api/v1"),
Some("openrouter.ai"),
"explicit :443 must be accepted for OpenRouter"
);
// Non-default port must still be rejected.
assert_eq!(pricing_authority("https://api.openai.com:8080/v1"), None);
}
#[test]
fn pricing_authority_rejects_userinfo() {
assert_eq!(
pricing_authority("https://user:pass@api.anthropic.com/"),
None
);
}
#[test]
fn pricing_authority_rejects_query_and_fragment() {
assert_eq!(
pricing_authority("https://api.anthropic.com/?debug=1"),
None
);
assert_eq!(
pricing_authority("https://api.anthropic.com/#section"),
None
);
assert_eq!(pricing_authority("https://api.openai.com/v1?key=1"), None);
}
#[test]
fn pricing_authority_rejects_lookalike_hosts() {
// Subdomain: must NOT match
assert_eq!(
pricing_authority("https://subdomain.api.anthropic.com/"),
None
);
// Superset: must NOT match
assert_eq!(pricing_authority("https://notapi.anthropic.com/"), None);
assert_eq!(
pricing_authority("https://api.anthropic.com.evil.com/"),
None
);
// Path-prefix lookalike for OpenAI: /v1extra must not match /v1
assert_eq!(pricing_authority("https://api.openai.com/v1extra"), None);
}
#[test]
fn pricing_authority_unknown_host_returns_none() {
assert_eq!(pricing_authority("https://api.databricks.com/v1"), None);
assert_eq!(pricing_authority("https://custom.llm.corp/v1"), None);
}
#[test]
fn default_tool_timeout_is_1260_seconds() {
// Lock the production default so accidental regressions are caught.
// This value must remain >= buzz-dev-mcp's MAX_TIMEOUT_MS (1_200s) to
// give every shell(timeout_ms=1_200_000) call time to complete before
// buzz-agent kills the MCP server. See PR #7185 for the full budget chain.
//
// 1_260s is the literal default passed to parse_env in Config::from_env().
// Update here if and only if you update that literal; the test name makes
// "grep for old value" reliable.
const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 1_260;
const {
// Shell cap (1_200_000 ms = 1_200s) must fit inside the agent timeout.
assert!(
1_200u64 <= DEFAULT_TOOL_TIMEOUT_SECS,
"agent tool timeout must be >= dev-mcp shell cap (1200s)"
);
}
}
}