Skip to content
Open
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
16 changes: 14 additions & 2 deletions crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ OPENROUTER_API_KEY=sk-or-v1-... \
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \
./target/release/buzz-agent

# Or Venice AI
BUZZ_AGENT_PROVIDER=venice \
VENICE_API_KEY=vapi-... \
VENICE_MODEL=zai-org-glm-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 @@ -135,7 +141,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro

| Variable | Default | Notes |
|---|---|---|
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `venice`, `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` | |
Expand All @@ -147,6 +153,9 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `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` | |
| `VENICE_API_KEY` | — | Required when provider=venice. |
| `VENICE_MODEL` | — | Required when provider=venice. |
| `VENICE_BASE_URL` | `https://api.venice.ai/api/v1` | Venice uses Chat Completions; Buzz disables Venice's additional system prompt. |
| `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 @@ -239,15 +248,18 @@ lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md).
| Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder |
| 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) |
| Venice AI | `venice` | `POST {base}/chat/completions` | tools-capable text models advertised by Venice's model catalog |
| 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`, `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.
If a keyed provider is selected without its matching credential (`ANTHROPIC_API_KEY`, `OPENAI_COMPAT_API_KEY`, `OPENROUTER_API_KEY`, or `VENICE_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=venice` always uses Venice's stable Chat Completions API. Buzz sends `venice_parameters.include_venice_system_prompt=false` so the configured agent prompt remains authoritative, and preserves Venice `reasoning_details` across tool-call rounds.

`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` is set on the request when reasoning effort is configured. The request deliberately carries no `provider.require_parameters` filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise `reasoning`, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead.
Expand Down
46 changes: 35 additions & 11 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,8 @@ const DEFAULT_SYSTEM_PROMPT: &str =
pub enum Provider {
Anthropic,
OpenAi,
/// Venice AI. Uses the OpenAI-compatible Chat Completions wire format.
Venice,
/// 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.
Expand Down Expand Up @@ -769,6 +771,7 @@ impl Config {
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
env("OPENROUTER_API_KEY").as_deref(),
env("VENICE_API_KEY").as_deref(),
)?;

// Universal model override — takes priority over provider-specific model
Expand Down Expand Up @@ -804,6 +807,13 @@ impl Config {
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
),
Provider::Venice => (
req("VENICE_API_KEY")?,
resolve_model(buzz_agent_model.as_deref(), env("VENICE_MODEL").as_deref())
.ok_or_else(|| "config: VENICE_MODEL required".to_string())?,
env_or("VENICE_BASE_URL", "https://api.venice.ai/api/v1"),
OpenAiApi::Chat,
),
Provider::Databricks | Provider::DatabricksV2 => (
env("DATABRICKS_TOKEN").unwrap_or_default(),
resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref())
Expand Down Expand Up @@ -1029,6 +1039,7 @@ fn resolve_provider(
anthropic_key: Option<&str>,
openai_key: Option<&str>,
openrouter_key: Option<&str>,
venice_key: Option<&str>,
) -> Result<Provider, String> {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
Expand All @@ -1046,6 +1057,8 @@ fn resolve_provider(
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
"openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
"openrouter" => Err("config: OPENROUTER_API_KEY required".into()),
"venice" if present_nonempty(venice_key) => Ok(Provider::Venice),
"venice" => Err("config: VENICE_API_KEY required".into()),
_ => Err(format!(
"config: BUZZ_AGENT_PROVIDER={raw} not supported"
)),
Expand Down Expand Up @@ -1263,29 +1276,30 @@ mod tests {
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(),
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None, None).unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(),
resolve_provider(Some("openai"), None, Some("sk-openai"), None, 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();
let err = resolve_provider(Some("anthropic"), None, 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();
let err =
resolve_provider(Some("openai-compat"), None, Some(" "), None, 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();
let err = resolve_provider(None, None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}

Expand All @@ -1295,19 +1309,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, None).unwrap(),
resolve_provider(Some("databricks"), None, 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();
let err = resolve_provider(Some("openai"), None, None, None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
let err = resolve_provider(None, None, None, None).unwrap_err();
let err = resolve_provider(None, 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();
let err = resolve_provider(Some("OpenAIish"), None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}

Expand Down Expand Up @@ -2753,14 +2767,24 @@ mod tests {
#[test]
fn resolve_provider_openrouter_with_key() {
assert_eq!(
resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(),
resolve_provider(Some("openrouter"), None, None, Some("sk-or-123"), None,).unwrap(),
Provider::OpenRouter
);
}

#[test]
fn resolve_provider_openrouter_missing_key() {
let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err();
let err = resolve_provider(Some("openrouter"), None, None, None, None).unwrap_err();
assert!(err.contains("OPENROUTER_API_KEY"));
}

#[test]
fn resolve_provider_venice_requires_its_own_key() {
assert_eq!(
resolve_provider(Some("venice"), None, None, None, Some("vapi-key")).unwrap(),
Provider::Venice
);
let err = resolve_provider(Some("venice"), None, None, None, None).unwrap_err();
assert!(err.contains("VENICE_API_KEY"));
}
}
63 changes: 47 additions & 16 deletions crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ impl Llm {
let v = self.post_openrouter(cfg, &body).await?;
parse_openai_with_reasoning_details(v)
}
Provider::OpenAi | Provider::Databricks => {
Provider::OpenAi | Provider::Venice | Provider::Databricks => {
self.openai_request(
cfg,
effective_model,
Expand All @@ -183,9 +183,14 @@ impl Llm {
parse_responses as OpenAiParse,
)
} else {
let parse = if cfg.provider == Provider::Venice {
parse_openai_with_reasoning_details as OpenAiParse
} else {
parse_openai as OpenAiParse
};
(
openai_body(cfg, system_prompt, history, tools, request_model, e),
parse_openai as OpenAiParse,
parse,
)
}
},
Expand Down Expand Up @@ -270,7 +275,7 @@ impl Llm {
let v = self.post_openrouter(cfg, &body).await?;
Ok(parse_openai(v)?.text)
}
Provider::OpenAi | Provider::Databricks => {
Provider::OpenAi | Provider::Venice | Provider::Databricks => {
let r = self
.openai_request(
cfg,
Expand All @@ -288,18 +293,19 @@ impl Llm {
parse_responses as OpenAiParse,
)
} else {
(
json!({
"model": request_model,
"stream": false,
"max_completion_tokens": max_output_tokens,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
}),
parse_openai as OpenAiParse,
)
let mut body = json!({
"model": request_model,
"stream": false,
"max_completion_tokens": max_output_tokens,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
});
if cfg.provider == Provider::Venice {
apply_venice_mutations(&mut body);
}
(body, parse_openai as OpenAiParse)
}
},
)
Expand Down Expand Up @@ -862,6 +868,12 @@ fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec<Value> {
.collect()
}

fn apply_venice_mutations(body: &mut Value) {
body["venice_parameters"] = json!({
"include_venice_system_prompt": false,
});
}

fn openai_body(
cfg: &Config,
system_prompt: &str,
Expand Down Expand Up @@ -951,6 +963,9 @@ fn openai_body(
.collect();
let mut body = json!({ "model": effective_model, "stream": false,
"max_completion_tokens": cfg.max_output_tokens, "messages": messages });
if cfg.provider == Provider::Venice {
apply_venice_mutations(&mut body);
}
if let Some(e) = effort {
body["reasoning_effort"] = json!(e.openai_effort_str());
}
Expand Down Expand Up @@ -1948,7 +1963,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig {
/// flow; subsequent requests use the cache + refresh transparently.
pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
match cfg.provider {
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => {
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter | Provider::Venice => {
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
}
Provider::Databricks | Provider::DatabricksV2 => {
Expand Down Expand Up @@ -3757,6 +3772,22 @@ mod tests {
);
}

#[test]
fn venice_body_disables_provider_system_prompt() {
let body = openai_body(
&cfg(Provider::Venice),
"system",
&[HistoryItem::User("hi".into())],
&[],
"zai-org-glm-5",
None,
);
assert_eq!(
body["venice_parameters"]["include_venice_system_prompt"],
false
);
}

#[test]
fn openai_body_emits_reasoning_effort_medium() {
let body = openai_body(
Expand Down
64 changes: 60 additions & 4 deletions crates/buzz-agent/tests/fake_llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,40 @@ struct Harness {

impl Harness {
async fn spawn(base_url: &str) -> Self {
Self::spawn_for_provider(
base_url,
"openai",
"OPENAI_COMPAT_API_KEY",
"OPENAI_COMPAT_MODEL",
"OPENAI_COMPAT_BASE_URL",
)
.await
}

async fn spawn_venice(base_url: &str) -> Self {
Self::spawn_for_provider(
base_url,
"venice",
"VENICE_API_KEY",
"VENICE_MODEL",
"VENICE_BASE_URL",
)
.await
}

async fn spawn_for_provider(
base_url: &str,
provider: &str,
api_key_env: &str,
model_env: &str,
base_url_env: &str,
) -> Self {
let bin = env!("CARGO_BIN_EXE_buzz-agent");
let mut cmd = tokio::process::Command::new(bin);
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("OPENAI_COMPAT_BASE_URL", base_url)
cmd.env("BUZZ_AGENT_PROVIDER", provider)
.env(api_key_env, "test")
.env(model_env, "fake-model")
.env(base_url_env, base_url)
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
.env("BUZZ_AGENT_MAX_ROUNDS", "4")
Expand Down Expand Up @@ -303,6 +331,34 @@ async fn text_only_end_turn() {
h.shutdown().await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn venice_uses_chat_completions_body_and_disables_provider_prompt() {
let (url, captures) = spawn_capturing_fake_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn_venice(&url).await;
let sid = init_session(&mut h).await;
let prompt_id = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{ "type": "text", "text": "hi" }],
}),
)
.await;
let response = h.recv_until(|value| value["id"] == prompt_id).await;
assert_eq!(response["result"]["stopReason"], "end_turn");

let requests = captures.lock().await;
assert_eq!(requests.len(), 1);
assert!(requests[0]["messages"].is_array());
assert_eq!(
requests[0]["venice_parameters"]["include_venice_system_prompt"],
false
);
drop(requests);
h.shutdown().await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_call_then_end_turn() {
// Round 1: tool call (will fail with "unknown tool" since no MCP registered).
Expand Down
Loading