feat(desktop): support Goose's native Google Gemini provider

Goose drives Gemini through its own `google` provider (native
`streamGenerateContent`, `x-goog-api-key`), authenticated with
GOOGLE_API_KEY — distinct from buzz-agent's OpenAI-compatible `gemini`
provider (GEMINI_API_KEY). Offer `google` (labelled "Google Gemini") in
the provider picker for the Goose runtime only, and make Goose readiness
require GOOGLE_API_KEY for it.

- agentConfigOptions.tsx: GOOSE_ONLY_PROVIDER_IDS = {google}; add google
  to the provider list, credential config, KNOWN_LLM_PROVIDER_IDS, and
  providerRequiresExplicitModel. Picker/credential gating is symmetric —
  gemini is buzz-agent-only, google is goose-only — and a persisted value
  still renders via the `(current)` tail.
- readiness.rs: goose `google` requires GOOGLE_API_KEY from the env layer
  only. Goose keeps the key in its own secret store, never in
  config.yaml, so the file config cannot silence it. buzz-agent is
  unchanged.
- config_bridge/goose.rs: verify the persisted `active_provider: google`
  + `providers.google.model` path parses to provider/model and never
  surfaces GOOGLE_API_KEY from the file.
- readiness_tests.rs: the accumulated `mod tests` stays extracted here
  (agent_models precedent) so readiness.rs remains under the default
  file-size ceiling; check-file-sizes.mjs override retargeted.

No silent GEMINI_API_KEY->GOOGLE_API_KEY migration — the two contracts
stay independent.

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-19 22:48:19 -05:00
co-authored by Claude Opus 4.8
parent e573b8f3e1
commit e77fdcb085
6 changed files with 1318 additions and 1065 deletions
+6 -1
View File
@@ -173,7 +173,12 @@ const overrides = new Map([
// Windows Doctor install fix: cli_install_commands_windows field added to test stubs.
// team-instructions-first-class: ManagedAgentRecord fixture gains the new
// team_id field (+1 line).
["src-tauri/src/managed_agents/readiness.rs", 1765],
// gemini-goose-provider: the accumulated `mod tests` module was extracted to
// readiness_tests.rs (following the agent_models_tests.rs precedent) so the
// production file (readiness.rs) is back under the default ceiling. The
// historical test debt — plus the goose `google` (native Gemini) provider
// readiness coverage — now lives in that test-only file.
["src-tauri/src/managed_agents/readiness_tests.rs", 1125],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
@@ -199,6 +199,27 @@ providers:
);
}
#[test]
fn parse_nested_google_provider() {
// Persisted `active_provider: google` with `providers.google.model` —
// Goose's native Gemini provider. The bridge surfaces provider + model
// but must NOT parse or infer the GOOGLE_API_KEY secret (Goose keeps it
// in its own secret store, not config.yaml).
let yaml = r#"
active_provider: google
providers:
google:
model: gemini-2.5-pro
"#;
let cfg = parse_goose_config(yaml).unwrap();
assert_eq!(cfg.provider.as_deref(), Some("google"));
assert_eq!(cfg.model.as_deref(), Some("gemini-2.5-pro"));
assert!(
!cfg.extra.contains_key("GOOGLE_API_KEY"),
"the file bridge must never surface GOOGLE_API_KEY from config.yaml"
);
}
#[test]
fn non_databricks_provider_uses_provider_host_key() {
let yaml = r#"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,18 @@ export const BUZZ_AGENT_ONLY_PROVIDER_IDS: ReadonlySet<string> = new Set([
"gemini",
]);
/**
* Provider ids that only the `goose` runtime can drive. Goose registers a
* native Google Gemini provider under the id `google`, authenticated with
* `GOOGLE_API_KEY` (distinct from buzz-agent's OpenAI-compatible `gemini` /
* `GEMINI_API_KEY` contract — see `BUZZ_AGENT_ONLY_PROVIDER_IDS`). buzz-agent
* has no `google` adapter, so offering it there would advertise a provider the
* buzz-agent backend never checks. These ids are suppressed from the picker and
* from credential requirements for every runtime except `goose`; a value
* already persisted with one of them still renders via the `(current)` tail.
*/
export const GOOSE_ONLY_PROVIDER_IDS: ReadonlySet<string> = new Set(["google"]);
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 =
@@ -51,6 +63,7 @@ const KNOWN_LLM_PROVIDER_IDS = [
"databricks",
"databricks_v2",
"gemini",
"google",
"openai",
"openai-compat",
] as const;
@@ -110,6 +123,12 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
requiredEnvKeys: ["GEMINI_API_KEY"],
secretEnvVar: "GEMINI_API_KEY",
},
google: {
// Goose's native Gemini provider. GOOGLE_HOST is optional (defaults to the
// Google AI host), so only GOOGLE_API_KEY is required.
requiredEnvKeys: ["GOOGLE_API_KEY"],
secretEnvVar: "GOOGLE_API_KEY",
},
databricks: {
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
requiredEnvKeys: ["DATABRICKS_HOST"],
@@ -136,6 +155,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [
{ id: "openai", label: "OpenAI" },
{ id: "openai-compat", label: "OpenAI-compatible" },
{ id: "gemini", label: "Gemini" },
{ id: "google", label: "Google Gemini" },
{ id: "relay-mesh", label: "Buzz shared compute" },
{ id: "databricks", label: "Databricks" },
{ id: "databricks_v2", label: "Databricks v2" },
@@ -179,15 +199,23 @@ export function requiredCredentialEnvKeys(
return [];
}
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.
// Runtime-scoped providers require no credentials on other runtimes — keep
// this in lockstep with the picker gate in getPersonaProviderOptions so
// options and requirements never drift.
// buzz-agent-only providers (e.g. gemini) are unsupported by Goose.
if (
normalizedRuntime !== "buzz-agent" &&
BUZZ_AGENT_ONLY_PROVIDER_IDS.has(normalizedProvider)
) {
return [];
}
// goose-only providers (e.g. google) are unsupported by buzz-agent.
if (
normalizedRuntime !== "goose" &&
GOOSE_ONLY_PROVIDER_IDS.has(normalizedProvider)
) {
return [];
}
const config = PROVIDER_CREDENTIAL_CONFIG[normalizedProvider];
return config?.requiredEnvKeys ?? [];
}
@@ -285,7 +313,8 @@ export function providerRequiresExplicitModel(
trimmedProvider === "anthropic" ||
trimmedProvider === "openai" ||
trimmedProvider === "openai-compat" ||
trimmedProvider === "gemini"
trimmedProvider === "gemini" ||
trimmedProvider === "google"
);
}
@@ -373,15 +402,19 @@ 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),
);
// Suppress runtime-scoped providers for runtimes that can't drive them:
// buzz-agent-only (e.g. gemini) is hidden everywhere but buzz-agent, and
// goose-only (e.g. google) is hidden everywhere but goose. A value already
// persisted with one still renders via the `(current)` tail below, so an
// existing agent never loses its saved selection.
const trimmedRuntimeId = runtimeId.trim();
const isBuzzAgentRuntime = trimmedRuntimeId === "buzz-agent";
const isGooseRuntime = trimmedRuntimeId === "goose";
const runtimeVisibleOptions = PERSONA_LLM_PROVIDER_OPTIONS.filter((o) => {
if (BUZZ_AGENT_ONLY_PROVIDER_IDS.has(o.id)) return isBuzzAgentRuntime;
if (GOOSE_ONLY_PROVIDER_IDS.has(o.id)) return isGooseRuntime;
return true;
});
const filteredOptions = hideProviderIds?.size
? runtimeVisibleOptions.filter((o) => !hideProviderIds.has(o.id))
: runtimeVisibleOptions;
@@ -170,6 +170,46 @@ test("editAgent_gooseSupportedProviderCredentialsUnchanged", () => {
]);
});
// ── Google Gemini is goose-only — must not be advertised for buzz-agent ──────
//
// Goose registers a native Gemini provider under the id `google`, authenticated
// with GOOGLE_API_KEY (distinct from buzz-agent's OpenAI-compatible `gemini` /
// GEMINI_API_KEY). buzz-agent has no `google` adapter, so the picker and
// credential-requirement helpers gate `google` to the goose runtime.
test("editAgent_providerOptions_includesGoogleForGoose", () => {
const options = getPersonaProviderOptions("", "goose");
const ids = options.map((o) => o.id);
assert.ok(
ids.includes("google"),
"google (Goose's native Gemini provider) must be offered for goose",
);
});
test("editAgent_providerOptions_excludesGoogleForBuzzAgent", () => {
const options = getPersonaProviderOptions("", "buzz-agent");
const ids = options.map((o) => o.id);
assert.ok(
!ids.includes("google"),
"google must not be offered for the buzz-agent runtime",
);
});
test("editAgent_google_requiresGoogleApiKeyAndExplicitModelForGoose", () => {
assert.deepEqual(requiredCredentialEnvKeys("goose", "google"), [
"GOOGLE_API_KEY",
]);
assert.ok(
providerRequiresExplicitModel("google"),
"google must require an explicit model",
);
});
test("editAgent_google_requiresNoCredentialsForBuzzAgent", () => {
// buzz-agent can't drive google, so it requires no google credentials there.
assert.deepEqual(requiredCredentialEnvKeys("buzz-agent", "google"), []);
});
test("editAgent_providerOptions_includesDefaultEntry", () => {
const options = getPersonaProviderOptions("", "buzz-agent");
// The first entry is the default (empty id) — clearing back to runtime default.