mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
Reference in New Issue
Block a user