Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
HTTPS
Anthropic Messages API
or any OpenAI-compat
(vLLM, llama.cpp, OpenRouter,
Anthropic Messages API,
OpenRouter, or any OpenAI-compat
(vLLM, llama.cpp, Databricks,
Block Gateway, Ollama, …)
```

Expand All @@ -50,6 +50,12 @@ OPENAI_COMPAT_MODEL=gpt-5 \
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
./target/release/buzz-agent

# Or OpenRouter
BUZZ_AGENT_PROVIDER=openrouter \
OPENROUTER_API_KEY=sk-or-v1-... \
OPENROUTER_MODEL=anthropic/claude-sonnet-4-5 \
./target/release/buzz-agent

# Or Databricks model serving via OAuth 2.0 PKCE
BUZZ_AGENT_PROVIDER=databricks \
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
Expand Down Expand Up @@ -129,15 +135,18 @@ Everything is environment variables. No flags, no config files. (We are a subpro

| Variable | Default | Notes |
|---|---|---|
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, Ollama, etc. |
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
| `OPENROUTER_API_KEY` | — | Required when provider=openrouter. |
| `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4-5`. |
| `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | |
| `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
Expand Down Expand Up @@ -167,17 +176,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| vLLM | `openai` | `POST {base}/chat/completions` | any tool-calling model |
| llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF |
| Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder |
| OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route |
| Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude |
| OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) |
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 |

If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.

`provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format).

By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway).

`provider=openrouter` is first-class, not routed through `provider=openai`: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top —

- `reasoning.effort` and `provider.require_parameters` are set on the request when reasoning effort or tools are configured, so OpenRouter only routes to endpoints that actually honor them.
- The response's `reasoning_details` array (opaque extended-thinking payload) is captured and replayed byte-for-byte on the next turn's assistant message, so multi-turn tool use keeps the model's chain-of-thought.
- `anthropic/*` models get Anthropic-style `cache_control` breakpoints injected on the system message and the last two user messages.
- Retryable statuses (429 and typed `provider_overloaded` 503) honor the documented `Retry-After` header (clamped to a small ceiling — see `RETRY_AFTER_CAP_SECS` in `llm.rs` — since the sleep happens outside `BUZZ_AGENT_LLM_TIMEOUT_SECS`); 502 and untyped 503 retry with jittered backoff instead. `401` is treated as an expired/invalid key and refreshed once, while `402` (no credits) and `403` (guardrail/moderation/permission) fail immediately without retry.

`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.

## MCP Servers
Expand Down
76 changes: 76 additions & 0 deletions crates/buzz-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ impl RunCtx<'_> {
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: Vec::new(),
reasoning_details: response.reasoning_details.clone(),
});
let stop = map_stop(response.stop);
// Only gate genuine end_turn — don't override max_tokens/refusal.
Expand Down Expand Up @@ -249,6 +250,7 @@ impl RunCtx<'_> {
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: calls.clone(),
reasoning_details: response.reasoning_details,
});

if let Some(stop) = self.execute_calls(&calls).await {
Expand Down Expand Up @@ -680,6 +682,7 @@ pub(crate) fn push_hook_outputs_as_tool_results(
name: tool_name,
arguments: serde_json::json!({}),
}],
reasoning_details: None,
});
history.push(HistoryItem::ToolResult(ToolResult {
provider_id,
Expand Down Expand Up @@ -744,3 +747,76 @@ fn map_stop(p: ProviderStop) -> StopReason {
ProviderStop::Refusal => StopReason::Refusal,
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

/// A9 regression: `reasoning_details` contributes real bytes to
/// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a
/// history item carrying a large opaque reasoning array must actually
/// drive `truncate_history` eviction — not be silently invisible to the
/// sizing gate that decides what survives a turn.
#[test]
fn truncate_history_evicts_oldest_turn_with_reasoning_details() {
let big_reasoning = json!([{ "type": "reasoning.text", "text": "x".repeat(400) }]);
let mut history = vec![
HistoryItem::User("first question".into()),
HistoryItem::Assistant {
text: "first answer".into(),
tool_calls: vec![],
reasoning_details: Some(big_reasoning),
},
HistoryItem::User("second question".into()),
HistoryItem::Assistant {
text: "second answer".into(),
tool_calls: vec![],
reasoning_details: None,
},
];

let total_before: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
// Budget below the total but above the second (smaller) turn alone,
// so only the oldest user+assistant pair — the one carrying
// reasoning_details — must be dropped.
let max_bytes = total_before - 100;
assert!(
max_bytes > 0,
"test fixture must leave room to evict only one turn"
);

truncate_history(&mut history, max_bytes);

assert_eq!(
history.len(),
2,
"the oldest user+assistant turn (with reasoning_details) must be evicted"
);
assert!(matches!(&history[0], HistoryItem::User(s) if s == "second question"));
assert!(
matches!(&history[1], HistoryItem::Assistant { text, .. } if text == "second answer")
);
let total_after: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
assert!(total_after <= max_bytes);
}

#[test]
fn truncate_history_noop_when_under_budget() {
let mut history = vec![
HistoryItem::User("hi".into()),
HistoryItem::Assistant {
text: "hello".into(),
tool_calls: vec![],
reasoning_details: None,
},
];
let original_len = history.len();
truncate_history(&mut history, 1_000_000);
assert_eq!(
history.len(),
original_len,
"under budget must not evict anything"
);
}
}
51 changes: 42 additions & 9 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,8 @@ pub enum Provider {
/// 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,
}

/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
Expand Down Expand Up @@ -740,6 +742,7 @@ impl Config {
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
Expand Down Expand Up @@ -782,6 +785,16 @@ impl Config {
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(
Expand Down Expand Up @@ -983,6 +996,7 @@ 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) => {
Expand All @@ -998,6 +1012,8 @@ fn resolve_provider(
),
"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"
)),
Expand Down Expand Up @@ -1215,29 +1231,29 @@ mod tests {
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(),
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(),
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).unwrap_err();
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(" ")).unwrap_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).unwrap_err();
let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}

Expand All @@ -1247,19 +1263,19 @@ mod tests {
// 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).unwrap(),
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).unwrap_err();
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).unwrap_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).unwrap_err();
let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}

Expand Down Expand Up @@ -2647,6 +2663,9 @@ mod tests {
if p == "databricks" {
return openai_result(&m);
}
if p == "openrouter" {
return (ALL_7.to_vec(), Some("medium"));
}
// openai-compat, unknown, empty → all-7, default medium.
(ALL_7.to_vec(), Some("medium"))
}
Expand Down Expand Up @@ -2698,4 +2717,18 @@ mod tests {
);
}
}

#[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"));
}
}
6 changes: 5 additions & 1 deletion crates/buzz-agent/src/handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,11 @@ fn push_history_snippet(out: &mut String, item: &HistoryItem) {
out.push_str(s);
out.push('\n');
}
HistoryItem::Assistant { text, tool_calls } => {
HistoryItem::Assistant {
text,
tool_calls,
reasoning_details: _,
} => {
out.push_str("[assistant] ");
if !text.is_empty() {
out.push_str(text);
Expand Down
Loading
Loading