feat(agent): add Gemini as a first-class LLM provider

Adds `gemini` as a first-class buzz-agent provider using Google's
OpenAI-compatible surface, so it reuses the existing Chat Completions
request/response path rather than introducing a second serializer/parser.

Provider contract (RESEARCH/GEMINI_PROVIDER_API_CONTRACT.md):
- Provider id `gemini`; env vars `GEMINI_API_KEY`, `GEMINI_MODEL`, optional
  `GEMINI_BASE_URL` (default https://generativelanguage.googleapis.com/v1beta/openai).
- Static `Authorization: Bearer $GEMINI_API_KEY`; `/models` discovery reuses the
  OpenAI-compatible path with the same bearer and key-redacted errors.
- API shape pinned to Chat Completions — never Responses or native
  generateContent (there is no /responses endpoint on the Google host).

Changes:
- buzz-agent config.rs: `Provider::Gemini` variant, `resolve_provider` +
  `from_env` arms reading GEMINI_* with the Google default base URL, API pinned
  to Chat. Extracted `GEMINI_DEFAULT_BASE_URL` constant.
- buzz-agent llm.rs: route Gemini through the OpenAI Chat arms and the static
  bearer token source. Extracted `should_use_responses` so a Chat-pinned
  provider can never be routed to /responses regardless of host/auto-upgrade.
- desktop agent_models.rs: provider-aware base-URL/key resolution so `/models`
  discovery reads GEMINI_BASE_URL/GEMINI_API_KEY; Gemini ids pass through the
  OpenAI text-model filter unchanged.
- desktop readiness.rs: require GEMINI_API_KEY and accept GEMINI_MODEL fallback.
- frontend personaDialogPickers.tsx: provider option, credential mapping, and
  explicit-model requirement.

Tests: new coverage in config.rs, llm.rs, agent_models_tests.rs, readiness.rs,
and editAgentProviderDiscovery.test.mjs. Full local gates green: buzz-agent +
desktop Tauri unit tests, desktop TS tests, typecheck, biome, clippy
(workspace + Tauri), rustfmt.

Co-authored-by: Atish Patel <atish@squareup.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Atish Patel <atish@squareup.com>
This commit is contained in:
Atish Patel
2026-07-17 17:29:27 -05:00
parent 7f769b2439
commit fb4a727a50
13 changed files with 503 additions and 36 deletions

View File

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

View File

@@ -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.
@@ -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
@@ -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())
@@ -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) => {
@@ -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!(
@@ -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(),
),
}
}
@@ -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}");
}
@@ -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"));
}
@@ -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 [
@@ -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.

View File

@@ -89,7 +89,7 @@ impl Llm {
.await?;
parse_anthropic(v)
}
Provider::OpenAi | Provider::Databricks => {
Provider::OpenAi | Provider::Databricks | Provider::Gemini => {
self.openai_request(cfg, effective_model, |use_responses| {
// Normalize effort for model-specific availability. Startup no longer rejects
// `max` for pure OpenAI/Databricks; this per-model table is the single authority
@@ -178,7 +178,7 @@ impl Llm {
});
Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text)
}
Provider::OpenAi | Provider::Databricks => {
Provider::OpenAi | Provider::Databricks | Provider::Gemini => {
let r = self
.openai_request(cfg, effective_model, |use_responses| {
if use_responses {
@@ -274,9 +274,11 @@ impl Llm {
where
F: FnMut(bool) -> (Value, OpenAiParse) + Send,
{
let use_responses = self.auto_upgraded.load(Ordering::Relaxed)
|| matches!(cfg.openai_api, OpenAiApi::Responses)
|| matches!(cfg.openai_api, OpenAiApi::Auto) && is_openai_host(&cfg.base_url);
let use_responses = should_use_responses(
cfg.openai_api,
&cfg.base_url,
self.auto_upgraded.load(Ordering::Relaxed),
);
if use_responses {
let (b, p) = build(true);
@@ -387,6 +389,26 @@ impl Llm {
}
}
/// Decide whether an OpenAI-family request targets `/responses` instead of
/// `/chat/completions`.
///
/// `OpenAiApi::Chat` is authoritative: a Chat-pinned provider (e.g. Gemini's
/// OpenAI-compatible surface, which has no `/responses` endpoint) can NEVER be
/// routed to `/responses`, regardless of the `auto_upgraded` latch or the host
/// heuristic. This is what keeps Gemini dispatch on Chat Completions even if the
/// auto-upgrade heuristics change later.
///
/// `Responses` always uses `/responses`. `Auto` upgrades once the provider has
/// signalled it (the `auto_upgraded` latch, set only under `Auto`) or when the
/// base URL is a first-party OpenAI host.
fn should_use_responses(api: OpenAiApi, base_url: &str, auto_upgraded: bool) -> bool {
match api {
OpenAiApi::Chat => false,
OpenAiApi::Responses => true,
OpenAiApi::Auto => auto_upgraded || is_openai_host(base_url),
}
}
fn anthropic_body(
cfg: &Config,
system_prompt: &str,
@@ -1121,13 +1143,15 @@ where
/// never read for Anthropic requests (those go through `post_anthropic` with
/// `x-api-key`), but Llm holds one to keep the field non-`Option`.
/// - `Provider::OpenAi`: a static source over `OPENAI_COMPAT_API_KEY`.
/// - `Provider::Gemini`: a static source over `GEMINI_API_KEY`, sent as the
/// `Authorization: Bearer` token on Gemini's OpenAI-compatible surface.
/// - `Provider::Databricks`: if `DATABRICKS_TOKEN` is set, a static source.
/// Otherwise a `PkceOAuthTokenSource` pointed at the workspace's OIDC
/// discovery URL. First request without a cached token triggers a browser
/// flow; subsequent requests use the cache + refresh transparently.
pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
match cfg.provider {
Provider::Anthropic | Provider::OpenAi => {
Provider::Anthropic | Provider::OpenAi | Provider::Gemini => {
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
}
Provider::Databricks | Provider::DatabricksV2 => {
@@ -1206,6 +1230,54 @@ mod tests {
}
}
#[test]
fn chat_pinned_never_uses_responses_regardless_of_host_or_latch() {
// Gemini (and any Chat-pinned provider) must stay on /chat/completions.
// Neither a first-party OpenAI host nor a set auto-upgrade latch may flip
// it to /responses — Chat is authoritative. This guards Gemini dispatch
// against future changes to the host/auto heuristics.
for base in [
"https://generativelanguage.googleapis.com/v1beta/openai",
"https://api.openai.com/v1", // even a real OpenAI host stays on Chat
] {
for auto_upgraded in [false, true] {
assert!(
!should_use_responses(OpenAiApi::Chat, base, auto_upgraded),
"Chat must never route to /responses (base={base}, auto_upgraded={auto_upgraded})"
);
}
}
}
#[test]
fn responses_pinned_always_uses_responses() {
assert!(should_use_responses(
OpenAiApi::Responses,
"http://example.invalid",
false
));
}
#[test]
fn auto_upgrades_on_openai_host_or_latch_only() {
// Auto: /responses only when the latch is set or the base is an OpenAI host.
assert!(should_use_responses(
OpenAiApi::Auto,
"https://api.openai.com/v1",
false
));
assert!(should_use_responses(
OpenAiApi::Auto,
"http://example.invalid",
true
));
assert!(!should_use_responses(
OpenAiApi::Auto,
"http://example.invalid",
false
));
}
fn image_history() -> Vec<HistoryItem> {
vec![
HistoryItem::User("describe the image".into()),

View File

@@ -442,7 +442,11 @@ const overrides = new Map([
// (if let Some(provider_update) = input.provider { record.provider = provider_update; }).
// +8: harness_override thread-through in update_managed_agent so a deliberate
// Custom pin routes to update_time_agent_command_override (comment + call).
["src-tauri/src/commands/agent_models.rs", 1079],
// +15: gemini provider — is_gemini_provider + provider-aware base-URL/api-key
// resolution (openai_compatible_base_url_env / _api_key_env) so Gemini reuses
// the OpenAI-compatible /models discovery path with GEMINI_BASE_URL/GEMINI_API_KEY.
// Load-bearing feature growth; queued to split with the rest of this list.
["src-tauri/src/commands/agent_models.rs", 1095],
// global-agent-config: get_agent_config_surface / write_agent_config_field /
// put_agent_session_config commands + GlobalAgentConfig serde types. New file
// in this PR; queued to split with the command module refactor.

View File

@@ -319,20 +319,54 @@ fn is_openai_compatible_provider(provider: Option<&str>) -> bool {
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("openai" | "openai-compat")
Some("openai" | "openai-compat" | "gemini")
)
}
fn is_gemini_provider(provider: Option<&str>) -> bool {
provider
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
== Some("gemini")
}
/// Base-URL env var + default for an OpenAI-compatible provider. Gemini reuses
/// the discovery path but reads `GEMINI_BASE_URL`, defaulting to Google's
/// OpenAI-compatible surface.
fn openai_compatible_base_url_env(provider: Option<&str>) -> (&'static str, &'static str) {
if is_gemini_provider(provider) {
(
"GEMINI_BASE_URL",
"https://generativelanguage.googleapis.com/v1beta/openai",
)
} else {
("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1")
}
}
/// API-key env var for an OpenAI-compatible provider (Gemini uses `GEMINI_API_KEY`).
fn openai_compatible_api_key_env(provider: Option<&str>) -> &'static str {
if is_gemini_provider(provider) {
"GEMINI_API_KEY"
} else {
"OPENAI_COMPAT_API_KEY"
}
}
#[cfg(test)]
fn openai_compatible_models_url(env: &BTreeMap<String, String>) -> String {
let base_url = env_value(env, "OPENAI_COMPAT_BASE_URL")
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
fn openai_compatible_models_url(env: &BTreeMap<String, String>, provider: Option<&str>) -> String {
let (key, default) = openai_compatible_base_url_env(provider);
let base_url = env_value(env, key).unwrap_or_else(|| default.to_string());
format!("{}/models", base_url.trim_end_matches('/'))
}
fn openai_compatible_models_url_for_discovery(env: &BTreeMap<String, String>) -> String {
let base_url = env_or_process_value(env, "OPENAI_COMPAT_BASE_URL")
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
fn openai_compatible_models_url_for_discovery(
env: &BTreeMap<String, String>,
provider: Option<&str>,
) -> String {
let (key, default) = openai_compatible_base_url_env(provider);
let base_url = env_or_process_value(env, key).unwrap_or_else(|| default.to_string());
format!("{}/models", base_url.trim_end_matches('/'))
}
@@ -490,17 +524,18 @@ async fn discover_openai_compatible_models(
return Ok(None);
}
let api_key_env = openai_compatible_api_key_env(provider);
let api_key = if relay_mesh {
crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string()
} else {
env_or_process_value(env, "OPENAI_COMPAT_API_KEY")
.ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())?
env_or_process_value(env, api_key_env)
.ok_or_else(|| format!("config: {api_key_env} required"))?
};
let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key);
let redaction_env = redaction_env_with_value(env, api_key_env, &api_key);
let url = if relay_mesh {
format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL)
} else {
openai_compatible_models_url_for_discovery(env)
openai_compatible_models_url_for_discovery(env, provider)
};
let response = client
.get(&url)

View File

@@ -100,11 +100,60 @@ fn openai_compat_model_normalization_preserves_provider_specific_ids() {
#[test]
fn openai_models_url_uses_openai_default_base_url() {
assert_eq!(
openai_compatible_models_url(&BTreeMap::new()),
openai_compatible_models_url(&BTreeMap::new(), Some("openai")),
"https://api.openai.com/v1/models"
);
}
#[test]
fn gemini_models_url_uses_google_openai_compatible_default_base_url() {
assert_eq!(
openai_compatible_models_url(&BTreeMap::new(), Some("gemini")),
"https://generativelanguage.googleapis.com/v1beta/openai/models"
);
}
#[test]
fn gemini_models_url_honors_gemini_base_url_override() {
let env = BTreeMap::from([(
"GEMINI_BASE_URL".to_string(),
"https://proxy.example/v1beta/openai/".to_string(),
)]);
assert_eq!(
openai_compatible_models_url(&env, Some("gemini")),
"https://proxy.example/v1beta/openai/models"
);
}
#[test]
fn gemini_is_openai_compatible_but_not_filtered_like_openai() {
// Routed through the OpenAI-compatible discovery path...
assert!(is_openai_compatible_provider(Some("gemini")));
assert!(is_gemini_provider(Some("gemini")));
// ...but Gemini model IDs must pass through unfiltered (only pure "openai"
// applies the GPT text-model filter).
let models = normalize_openai_compatible_models(
OpenAiModelListResponse {
data: vec![
OpenAiModelListItem {
id: "gemini-2.5-flash".to_string(),
created: Some(2),
},
OpenAiModelListItem {
id: "gemini-2.5-pro".to_string(),
created: Some(1),
},
],
},
Some("gemini"),
);
let ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
assert_eq!(
ids,
vec!["gemini-2.5-flash".to_string(), "gemini-2.5-pro".to_string()]
);
}
#[test]
fn anthropic_models_url_uses_anthropic_default_base_url() {
assert_eq!(

View File

@@ -242,6 +242,7 @@ impl AgentReadiness {
/// provider-specific credentials are required:
/// - `anthropic` → `ANTHROPIC_API_KEY`
/// - `openai` → `OPENAI_COMPAT_API_KEY`
/// - `gemini` → `GEMINI_API_KEY`
/// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional —
/// OAuth PKCE is the fallback)
/// * **claude**: a successful `claude auth status` probe.
@@ -329,6 +330,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
}
Some("anthropic") => Some("ANTHROPIC_MODEL"),
Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"),
Some("gemini") => Some("GEMINI_MODEL"),
_ => None,
};
let model_present = effective
@@ -363,6 +365,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
key: "OPENAI_COMPAT_API_KEY".to_string(),
});
}
Some("gemini")
if env_key_missing("GEMINI_API_KEY") => {
missing.push(Requirement::EnvKey {
key: "GEMINI_API_KEY".to_string(),
});
}
Some("databricks") | Some("databricks_v2") | Some("databricks-v2")
// DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional
// (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143).
@@ -582,6 +590,50 @@ mod tests {
}));
}
#[test]
fn buzz_agent_missing_gemini_key_returns_not_ready() {
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "gemini"),
("BUZZ_AGENT_MODEL", "gemini-2.5-flash"),
]),
);
let result = agent_readiness(&env);
assert!(!result.is_ready());
assert!(result.requirements().contains(&Requirement::EnvKey {
key: "GEMINI_API_KEY".to_string()
}));
}
#[test]
fn buzz_agent_gemini_with_all_fields_is_ready() {
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "gemini"),
("BUZZ_AGENT_MODEL", "gemini-2.5-flash"),
("GEMINI_API_KEY", "AIza-test"),
]),
);
assert!(agent_readiness(&env).is_ready());
}
#[test]
fn buzz_agent_gemini_model_fallback_env_key_is_ready() {
// GEMINI_MODEL (not BUZZ_AGENT_MODEL) should satisfy the model requirement,
// mirroring the provider-specific fallback for the other providers.
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "gemini"),
("GEMINI_MODEL", "gemini-2.5-pro"),
("GEMINI_API_KEY", "AIza-test"),
]),
);
assert!(agent_readiness(&env).is_ready());
}
#[test]
fn buzz_agent_anthropic_with_all_fields_is_ready() {
let env = make_env(

View File

@@ -19,6 +19,20 @@ export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet<string> = new Set([
"databricks",
]);
/**
* Provider ids that only the `buzz-agent` runtime can drive. Goose keys off
* `GOOSE_PROVIDER`/`GOOSE_MODEL` plus its own config and its readiness map
* (`readiness::goose_requirements`) only knows anthropic/openai/databricks —
* it has no `gemini` arm. Offering Gemini for Goose would advertise a provider
* the Goose backend never checks and demand a `GEMINI_API_KEY` it never reads
* (frontend/backend drift). These ids are suppressed from the picker and from
* credential requirements for every runtime except `buzz-agent`; a value
* already persisted with one of them still renders via the `(current)` tail.
*/
export const BUZZ_AGENT_ONLY_PROVIDER_IDS: ReadonlySet<string> = new Set([
"gemini",
]);
export const PERSONA_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
export const PERSONA_FIELD_CONTROL_CLASS =
@@ -36,6 +50,7 @@ const KNOWN_LLM_PROVIDER_IDS = [
"anthropic",
"databricks",
"databricks_v2",
"gemini",
"openai",
"openai-compat",
] as const;
@@ -91,6 +106,10 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
secretEnvVar: "OPENAI_COMPAT_API_KEY",
},
gemini: {
requiredEnvKeys: ["GEMINI_API_KEY"],
secretEnvVar: "GEMINI_API_KEY",
},
databricks: {
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
requiredEnvKeys: ["DATABRICKS_HOST"],
@@ -116,6 +135,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [
{ id: "anthropic", label: "Anthropic" },
{ id: "openai", label: "OpenAI" },
{ id: "openai-compat", label: "OpenAI-compatible" },
{ id: "gemini", label: "Gemini" },
{ id: "relay-mesh", label: "Buzz shared compute" },
{ id: "databricks", label: "Databricks" },
{ id: "databricks_v2", label: "Databricks v2" },
@@ -158,7 +178,17 @@ export function requiredCredentialEnvKeys(
if (normalizedRuntime !== "buzz-agent" && normalizedRuntime !== "goose") {
return [];
}
const config = PROVIDER_CREDENTIAL_CONFIG[provider.trim().toLowerCase()];
const normalizedProvider = provider.trim().toLowerCase();
// buzz-agent-only providers (e.g. gemini) are unsupported by Goose, so they
// require no credentials there — keep this in lockstep with the picker gate
// in getPersonaProviderOptions so options and requirements never drift.
if (
normalizedRuntime !== "buzz-agent" &&
BUZZ_AGENT_ONLY_PROVIDER_IDS.has(normalizedProvider)
) {
return [];
}
const config = PROVIDER_CREDENTIAL_CONFIG[normalizedProvider];
return config?.requiredEnvKeys ?? [];
}
@@ -254,7 +284,8 @@ export function providerRequiresExplicitModel(
return (
trimmedProvider === "anthropic" ||
trimmedProvider === "openai" ||
trimmedProvider === "openai-compat"
trimmedProvider === "openai-compat" ||
trimmedProvider === "gemini"
);
}
@@ -342,9 +373,18 @@ export function getPersonaProviderOptions(
const defaultProviderOptions = [
{ id: "", label: getDefaultLlmProviderLabel(runtimeId, globalProvider) },
];
// Suppress buzz-agent-only providers (e.g. gemini) for every other runtime.
// A value already persisted with one still renders via the `(current)` tail
// below, so an existing agent never loses its saved selection.
const isBuzzAgentRuntime = runtimeId.trim() === "buzz-agent";
const runtimeVisibleOptions = isBuzzAgentRuntime
? PERSONA_LLM_PROVIDER_OPTIONS
: PERSONA_LLM_PROVIDER_OPTIONS.filter(
(o) => !BUZZ_AGENT_ONLY_PROVIDER_IDS.has(o.id),
);
const filteredOptions = hideProviderIds?.size
? PERSONA_LLM_PROVIDER_OPTIONS.filter((o) => !hideProviderIds.has(o.id))
: PERSONA_LLM_PROVIDER_OPTIONS;
? runtimeVisibleOptions.filter((o) => !hideProviderIds.has(o.id))
: runtimeVisibleOptions;
const options = [...defaultProviderOptions, ...filteredOptions];
if (
trimmedProvider.length === 0 ||

View File

@@ -69,6 +69,8 @@ function providerModelEnvKey(provider: string): string | null {
case "openai":
case "openai-compat":
return "OPENAI_COMPAT_MODEL";
case "gemini":
return "GEMINI_MODEL";
default:
return null;
}

View File

@@ -1407,6 +1407,25 @@ test("global model fallback resolves the selected provider model env", () => {
);
});
test("global model fallback resolves the Gemini provider model env", () => {
const bakedEnv = [
{ key: "GEMINI_MODEL", value: "gemini-2.5-pro", masked: false },
];
assert.equal(getGlobalModelFallback(bakedEnv, "gemini"), "gemini-2.5-pro");
});
test("global model fallback gives saved Gemini env precedence over build env", () => {
const bakedEnv = [
{ key: "GEMINI_MODEL", value: "gemini-2.5-flash", masked: false },
];
assert.equal(
getGlobalModelFallback(bakedEnv, "gemini", {
GEMINI_MODEL: "gemini-2.5-pro",
}),
"gemini-2.5-pro",
);
});
test("global model fallback gives saved provider env precedence over build env", () => {
const bakedEnv = [
{ key: "ANTHROPIC_MODEL", value: "build-model", masked: false },

View File

@@ -6,6 +6,7 @@ import {
getPersonaProviderOptions,
requiredCredentialEnvKeys,
isMissingRequiredDropdownField,
providerRequiresExplicitModel,
} from "./agentConfigOptions.tsx";
import {
computeEditAgentFormValidity,
@@ -56,7 +57,7 @@ test("editAgent_providerFieldHidden_forBlankRuntime", () => {
// ── Provider dropdown options for EditAgentProviderField ────────────────────
//
// The provider dropdown contains the well-known providers
// (databricks, databricks_v2, anthropic, openai, openai-compat) plus a
// (databricks, databricks_v2, anthropic, openai, openai-compat, gemini) plus a
// default-provider fallback entry so users can clear a saved provider.
//
// On OSS builds, Databricks v1 ("databricks") is shown alongside v2 so OSS
@@ -111,6 +112,64 @@ test("editAgent_providerOptions_includesDatabricksV1AsCurrentEvenWhenHidden", ()
);
});
test("editAgent_providerOptions_includesGemini", () => {
const options = getPersonaProviderOptions("", "buzz-agent");
const ids = options.map((o) => o.id);
assert.ok(ids.includes("gemini"), "gemini must be present in the dropdown");
});
test("editAgent_gemini_requiresApiKeyAndExplicitModel", () => {
// Gemini's credential is GEMINI_API_KEY, and (like OpenAI/Anthropic) it has
// no auto/default model, so an explicit model must be chosen.
assert.deepEqual(requiredCredentialEnvKeys("buzz-agent", "gemini"), [
"GEMINI_API_KEY",
]);
assert.ok(
providerRequiresExplicitModel("gemini"),
"gemini must require an explicit model",
);
});
// ── Gemini is buzz-agent-only — must not be advertised for Goose ─────────────
//
// Goose's readiness map (readiness::goose_requirements) has no `gemini` arm,
// so offering Gemini for Goose would demand a GEMINI_API_KEY the Goose backend
// never reads (frontend/backend drift). The picker and credential-requirement
// helpers gate Gemini to buzz-agent; a value already persisted with `gemini`
// still renders via the `(current)` tail so an existing agent never loses it.
test("editAgent_providerOptions_excludesGeminiForGoose", () => {
const options = getPersonaProviderOptions("", "goose");
const ids = options.map((o) => o.id);
assert.ok(
!ids.includes("gemini"),
"gemini must not be offered for the goose runtime",
);
});
test("editAgent_providerOptions_includesGeminiAsCurrentForGooseWhenSaved", () => {
// A record already persisted with provider="gemini" on goose must still show
// its saved value via the (current) tail even though gemini is not offered.
const options = getPersonaProviderOptions("gemini", "goose");
const ids = options.map((o) => o.id);
assert.ok(
ids.includes("gemini"),
"a saved gemini value must render as the current provider on goose",
);
});
test("editAgent_gemini_requiresNoCredentialsForGoose", () => {
// Goose can't drive gemini, so it requires no gemini credentials there.
assert.deepEqual(requiredCredentialEnvKeys("goose", "gemini"), []);
});
test("editAgent_gooseSupportedProviderCredentialsUnchanged", () => {
// The gate is narrow: providers Goose does support keep their credentials.
assert.deepEqual(requiredCredentialEnvKeys("goose", "anthropic"), [
"ANTHROPIC_API_KEY",
]);
});
test("editAgent_providerOptions_includesDefaultEntry", () => {
const options = getPersonaProviderOptions("", "buzz-agent");
// The first entry is the default (empty id) — clearing back to runtime default.

View File

@@ -25,6 +25,27 @@ test("model discovery status names missing OpenAI-compatible credentials", () =>
assert.match(status?.message ?? "", /OpenAI models/);
});
test("model discovery status names missing Gemini credentials", () => {
const status = formatModelDiscoveryErrorStatus(
new Error("config: GEMINI_API_KEY required"),
"gemini",
);
assert.equal(status?.tone, "warning");
assert.match(status?.message ?? "", /Gemini API key/);
assert.match(status?.message ?? "", /Gemini models/);
});
test("model discovery status labels Gemini in the generic fallback", () => {
const status = formatModelDiscoveryErrorStatus(
new Error("gemini discovery hit an unexpected transport error"),
"gemini",
);
assert.equal(status?.tone, "warning");
assert.match(status?.message ?? "", /Gemini/);
});
test("Buzz shared compute names the empty state and next action", () => {
const status = formatModelDiscoveryErrorStatus(
new Error("no Buzz shared compute serving members are available"),

View File

@@ -25,6 +25,8 @@ function providerObjectLabel(provider: string): string {
return "OpenAI";
case "openai-compat":
return "OpenAI-compatible";
case "gemini":
return "Gemini";
default:
return provider.trim() || "this provider";
}
@@ -101,6 +103,13 @@ export function formatModelDiscoveryErrorStatus(
};
}
if (message.includes("GEMINI_API_KEY required")) {
return {
message: "Enter a Gemini API key to load Gemini models.",
tone: "warning",
};
}
if (
message.includes("DATABRICKS_HOST required") ||
message.includes("DATABRICKS_MODEL required") ||