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 @@ -55,6 +55,12 @@ BUZZ_AGENT_PROVIDER=databricks \
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
DATABRICKS_MODEL=goose-claude-4-6-sonnet \
./target/release/buzz-agent

# Or Gemini via Google's OpenAI-compatible surface
BUZZ_AGENT_PROVIDER=gemini \
GEMINI_API_KEY=AIza... \
GEMINI_MODEL=gemini-2.5-pro \
./target/release/buzz-agent
```

That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr.
Expand Down Expand Up @@ -129,7 +135,7 @@ 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`, `gemini`, `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 @@ -138,6 +144,9 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `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_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
| `GEMINI_API_KEY` | — | Required when provider=gemini. |
| `GEMINI_MODEL` | — | Required when provider=gemini (unless `BUZZ_AGENT_MODEL` is set, which overrides it). |
| `GEMINI_BASE_URL` | `https://generativelanguage.googleapis.com/v1beta/openai` | Google's OpenAI-compatible surface. Chat Completions is always used; native `generateContent` is not. |
| `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 @@ -169,10 +178,13 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| 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 |
| Gemini | `gemini` | `POST {base}/chat/completions` | gemini-2.5-pro, gemini-2.5-flash |
| 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` without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=gemini` without `GEMINI_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.

`provider=gemini` targets Google's OpenAI-compatible surface (`GEMINI_BASE_URL`, default `https://generativelanguage.googleapis.com/v1beta/openai`) and always speaks **Chat Completions** (`POST {base}/chat/completions`) with a static `GEMINI_API_KEY` bearer — it never routes to `/responses`, and native `generateContent` is not used.

`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).

Expand Down
1 change: 1 addition & 0 deletions crates/buzz-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ pub(crate) fn push_hook_outputs_as_tool_results(
provider_id: provider_id.clone(),
name: tool_name,
arguments: serde_json::json!({}),
extra_content: None,
}],
});
history.push(HistoryItem::ToolResult(ToolResult {
Expand Down
113 changes: 103 additions & 10 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,10 +658,20 @@ pub const HANDOFF_MAX_TOOL_NAMES: usize = 20;
const DEFAULT_SYSTEM_PROMPT: &str =
"You are buzz-agent. Use the provided tools to act. Tool calls are your only output.";

/// Default base URL for `Provider::Gemini`: Google's OpenAI-compatible surface.
/// Chat Completions and `/models` live under this prefix. Overridable via
/// `GEMINI_BASE_URL`. Native `generateContent` is intentionally not used.
pub(crate) const GEMINI_DEFAULT_BASE_URL: &str =
"https://generativelanguage.googleapis.com/v1beta/openai";

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Provider {
Anthropic,
OpenAi,
/// Google Gemini via its OpenAI-compatible surface. Reuses the OpenAI Chat
/// Completions body builder/parser and a static `GEMINI_API_KEY` bearer;
/// only the env var names and default base URL differ from `OpenAi`.
Gemini,
/// 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 @@ -740,6 +750,7 @@ impl Config {
env("BUZZ_AGENT_PROVIDER").as_deref(),
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
env("GEMINI_API_KEY").as_deref(),
)?;

// Universal model override — takes priority over provider-specific model
Expand Down Expand Up @@ -775,6 +786,16 @@ impl Config {
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?,
),
// Gemini's OpenAI-compatible surface is Chat Completions only, so the
// API shape is pinned to Chat rather than Auto (there is no /responses
// endpoint to upgrade to on generativelanguage.googleapis.com).
Provider::Gemini => (
req("GEMINI_API_KEY")?,
resolve_model(buzz_agent_model.as_deref(), env("GEMINI_MODEL").as_deref())
.ok_or_else(|| "config: GEMINI_MODEL required".to_string())?,
env_or("GEMINI_BASE_URL", GEMINI_DEFAULT_BASE_URL),
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 @@ -983,6 +1004,7 @@ fn resolve_provider(
requested: Option<&str>,
anthropic_key: Option<&str>,
openai_key: Option<&str>,
gemini_key: Option<&str>,
) -> Result<Provider, String> {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
Expand All @@ -996,6 +1018,10 @@ fn resolve_provider(
"openai" | "openai-compat" => Err(
"config: OPENAI_COMPAT_API_KEY required".into(),
),
"gemini" if present_nonempty(gemini_key) => Ok(Provider::Gemini),
"gemini" => Err(
"config: GEMINI_API_KEY required".into(),
),
"databricks" => Ok(Provider::Databricks),
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
_ => Err(format!(
Expand All @@ -1004,7 +1030,7 @@ fn resolve_provider(
}
}
None => Err(
"config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, databricks)".into(),
"config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, gemini, databricks)".into(),
),
}
}
Expand Down Expand Up @@ -1215,29 +1241,42 @@ 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
);
assert_eq!(
resolve_provider(Some("gemini"), None, None, Some("AIza-key")).unwrap(),
Provider::Gemini
);
// Case-insensitive, like the other providers.
assert_eq!(
resolve_provider(Some("Gemini"), None, None, Some("AIza-key")).unwrap(),
Provider::Gemini
);
}

#[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}");

// Whitespace-only Gemini key is treated as absent.
let err = resolve_provider(Some("gemini"), None, None, Some(" ")).unwrap_err();
assert!(err.contains("GEMINI_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 +1286,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 @@ -1299,6 +1338,37 @@ mod tests {
assert!(result.is_none());
}

#[test]
fn gemini_default_base_url_is_openai_compat_surface() {
// The default base must point at Google's OpenAI-compatible surface
// (v1beta/openai), not the native generateContent API. from_env() feeds
// this const to env_or("GEMINI_BASE_URL", …).
assert_eq!(
GEMINI_DEFAULT_BASE_URL,
"https://generativelanguage.googleapis.com/v1beta/openai"
);
// Sanity: the compat surface must not be treated as a first-party OpenAI
// host, or Auto-mode dispatch would try to upgrade it to /responses.
assert!(!is_openai_host(GEMINI_DEFAULT_BASE_URL));
}

#[test]
fn gemini_model_resolution_prefers_buzz_agent_model_override() {
// from_env() resolves Gemini's model as
// resolve_model(BUZZ_AGENT_MODEL, GEMINI_MODEL): the universal override wins.
assert_eq!(
resolve_model(Some("gemini-2.5-pro"), Some("gemini-1.5-flash")).as_deref(),
Some("gemini-2.5-pro"),
"BUZZ_AGENT_MODEL must override GEMINI_MODEL"
);
// Falls back to GEMINI_MODEL when no universal override is set.
assert_eq!(
resolve_model(None, Some("gemini-1.5-flash")).as_deref(),
Some("gemini-1.5-flash"),
"GEMINI_MODEL must be used when BUZZ_AGENT_MODEL is unset"
);
}

#[test]
fn parse_thinking_effort_round_trips_all_values() {
for (raw, expected) in [
Expand Down Expand Up @@ -1922,6 +1992,29 @@ mod tests {
}
}

#[test]
fn validate_accepts_all_efforts_for_gemini() {
// Gemini rides the OpenAI-shaped Chat path, so effort validation must
// behave exactly like OpenAI: none/minimal are NOT rejected at startup
// (only pure Anthropic rejects them); availability is normalized at
// request-build time via normalize_effort_for_openai_route.
for effort in [
ThinkingEffort::None,
ThinkingEffort::Minimal,
ThinkingEffort::Low,
ThinkingEffort::Medium,
ThinkingEffort::High,
ThinkingEffort::XHigh,
ThinkingEffort::Max,
] {
let cfg = make_config_for_validation(Provider::Gemini, Some(effort));
assert!(
cfg.validate().is_ok(),
"Gemini must accept {effort:?} at startup, consistent with the OpenAI Chat path"
);
}
}

#[test]
fn validate_accepts_all_efforts_for_databricks() {
// Legacy Databricks effort support is model-dependent and normalized at request build time.
Expand Down
Loading
Loading