From fb4a727a507bd2f0434e1ee09e624939740716fa Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Fri, 17 Jul 2026 17:29:27 -0500 Subject: [PATCH] feat(agent): add Gemini as a first-class LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.8 (1M context) Signed-off-by: Atish Patel --- crates/buzz-agent/README.md | 16 ++- crates/buzz-agent/src/config.rs | 113 ++++++++++++++++-- crates/buzz-agent/src/llm.rs | 84 ++++++++++++- desktop/scripts/check-file-sizes.mjs | 6 +- .../src-tauri/src/commands/agent_models.rs | 57 +++++++-- .../src/commands/agent_models_tests.rs | 51 +++++++- .../src-tauri/src/managed_agents/readiness.rs | 52 ++++++++ .../features/agents/ui/agentConfigOptions.tsx | 48 +++++++- .../src/features/agents/ui/bakedEnvHelpers.ts | 2 + .../ui/createAgentLocalModeGate.test.mjs | 19 +++ .../ui/editAgentProviderDiscovery.test.mjs | 61 +++++++++- .../ui/personaModelDiscoveryStatus.test.mjs | 21 ++++ .../agents/ui/personaModelDiscoveryStatus.ts | 9 ++ 13 files changed, 503 insertions(+), 36 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index c9b70429a1..1f63b76b46 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -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). diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f06849c7d1..bcb49cb697 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -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 { 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. diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 8a774fdf24..d3e3e73d2b 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -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, 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 { vec![ HistoryItem::User("describe the image".into()), diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1963acc3bc..a87554013b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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. diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 97772b12a5..d2e2cea9c6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -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 { - 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, 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 { - 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, + 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) diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..31be8f4f7e 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -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::>(); + 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!( diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..328d4fcece 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -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 { } 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 { 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( diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5272f559bc..190571e60a 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -19,6 +19,20 @@ export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet = 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 = 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 || diff --git a/desktop/src/features/agents/ui/bakedEnvHelpers.ts b/desktop/src/features/agents/ui/bakedEnvHelpers.ts index 40b3328256..86531464f9 100644 --- a/desktop/src/features/agents/ui/bakedEnvHelpers.ts +++ b/desktop/src/features/agents/ui/bakedEnvHelpers.ts @@ -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; } diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs index 32fc7cf582..5f53b4d0f1 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -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 }, diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 8b1003e951..f931d56c56 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -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. diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs index bf21bc92ff..5a4bac7041 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs @@ -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"), diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index 38138429f8..052a3a3469 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -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") ||