From 7a2cd8a7260d44d5a57ecf2ab435b7e8d3142da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 21 Jul 2026 11:47:47 -0400 Subject: [PATCH] Add Google Gemini platform via OpenAI-compat endpoint (provider 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10th registry row: id "google", GEMINI_API_KEY > auth.json "google" scope, https://generativelanguage.googleapis.com/v1beta/openai (Gemini's OpenAI-compatibility shim) with KIGI_GOOGLE_BASE_URL override, Bearer, OpenAI listing + ChatCompletions + Passthrough, enrichment-backed metadata (models_dev_id google), tool-calling listing restriction. Review caught a ship-blocking defect: Gemini's compat /models returns -PREFIXED ids (confirmed via Google's own cookbook), but the models.dev snapshot keys and the chat endpoint use the BARE id. Without normalization the enrichment lookup misses and restrict_to_enriched silently empties the Gemini catalog (login works, zero models selectable). A doc WebFetch had hidden this — the compat docs show bare INPUT ids to retrieve/chat but never print the list OUTPUT. Fix: new spec field strip_listing_id_prefix, applied in the fetch before filter/enrich/keying. Google sets Some("models/") (defensive: a no-op if an id is already bare, so correct regardless of the live shape); all other rows None. The e2e now feeds the REAL prefixed listing ids and asserts they survive as the bare managed key google/gemini-2.5-pro with the bare id on the wire (chat rejects the prefix); a registry test pins the config. Note: models.dev models Gemini reasoning as budget_tokens (not effort-type), so no auto effort-menu — models still fully work; reasoning is dynamic. Gemini compat applies default safety filters (no BLOCK_NONE). --- crates/codegen/kigi-models/src/lib.rs | 82 ++++++++++++- .../kigi-shell/src/agent/auth_method.rs | 12 +- .../kigi-shell/src/agent/models_fetch.rs | 110 ++++++++++++++++++ crates/codegen/kigi-tui/src/app/app_view.rs | 13 ++- 4 files changed, 208 insertions(+), 9 deletions(-) diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 6aff086..0c4a988 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -144,6 +144,14 @@ struct PlatformSpec { /// (tts/embeddings/image). Availability still requires the LIVE listing; /// this only drops listing noise, never adds models. restrict_to_enriched: bool, + /// A prefix to strip from each live-listing model id before filtering, + /// enrichment lookup, and managed-key formation. Google's OpenAI-compat + /// `/models` returns `models/`-prefixed ids while its chat endpoint (and + /// the models.dev snapshot) use the bare id — stripping canonicalizes to + /// the bare form. `None` = no stripping (the id is used verbatim). The + /// strip is a no-op when the prefix is absent, so it is safe even if a + /// listing returns some ids already bare. + strip_listing_id_prefix: Option<&'static str>, } const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { @@ -163,6 +171,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + strip_listing_id_prefix: None, }; const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { @@ -185,6 +194,7 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + strip_listing_id_prefix: None, }; const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { @@ -207,6 +217,7 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + strip_listing_id_prefix: None, }; /// Base-URL override for OpenAI (dev/test escape hatch). @@ -234,6 +245,7 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: true, + strip_listing_id_prefix: None, }; /// Base-URL override for Anthropic (dev/test escape hatch). @@ -261,6 +273,7 @@ const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::XApiKey, restrict_to_enriched: false, + strip_listing_id_prefix: None, }; /// Base-URL override for DeepSeek (dev/test escape hatch). @@ -288,6 +301,7 @@ const DEEPSEEK_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::DeepSeek, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + strip_listing_id_prefix: None, }; /// Base-URL override for Groq (dev/test escape hatch). @@ -315,6 +329,7 @@ const GROQ_SPEC: PlatformSpec = PlatformSpec { // The listing carries whisper/tts entries; keep tool-calling chat // models only. restrict_to_enriched: true, + strip_listing_id_prefix: None, }; /// Base-URL override for Mistral (dev/test escape hatch). @@ -344,6 +359,7 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec { // The listing carries embed/moderation/OCR entries; keep tool-calling // chat models only. restrict_to_enriched: true, + strip_listing_id_prefix: None, }; /// Base-URL override for Fireworks (dev/test escape hatch). @@ -372,6 +388,36 @@ const FIREWORKS_SPEC: PlatformSpec = PlatformSpec { // The inference /models listing can include embedding/non-chat models; // keep tool-calling enrichment-known models only. restrict_to_enriched: true, + strip_listing_id_prefix: None, +}; + +/// Base-URL override for Google Gemini (dev/test escape hatch). +pub const GOOGLE_BASE_URL_ENV: &str = "KIGI_GOOGLE_BASE_URL"; + +const GOOGLE_SPEC: PlatformSpec = PlatformSpec { + id: "google", + display_name: "Google Gemini", + base_url: BaseUrlSource::EnvOr { + env: GOOGLE_BASE_URL_ENV, + // Gemini's OpenAI-compatibility shim (bare model ids, Bearer key). + default: "https://generativelanguage.googleapis.com/v1beta/openai", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["GEMINI_API_KEY"], + vendor: "Google", + console_host: Some("aistudio.google.com"), + login_label: Some("Google Gemini (API key)"), + models_dev_id: Some("google"), + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + // The compat listing carries embedding/tts/image models; keep + // tool-calling enrichment-known chat models only. + restrict_to_enriched: true, + strip_listing_id_prefix: Some("models/"), }; /// The platform registry. Platforms are compiled-in spec rows; there is no @@ -396,12 +442,14 @@ pub enum PlatformId { Mistral, /// Fireworks AI platform API (API key, OpenAI-compatible ChatCompletions). Fireworks, + /// Google Gemini platform API (API key, OpenAI-compatibility shim). + Google, } impl PlatformId { /// All platforms, in catalog precedence order: the subscription channel /// first so "default model = first list item" favors it when present. - pub const ALL: [PlatformId; 9] = [ + pub const ALL: [PlatformId; 10] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -411,6 +459,7 @@ impl PlatformId { Self::Groq, Self::Mistral, Self::Fireworks, + Self::Google, ]; /// The registry row backing this platform (single source of per-platform @@ -426,6 +475,7 @@ impl PlatformId { Self::Groq => &GROQ_SPEC, Self::Mistral => &MISTRAL_SPEC, Self::Fireworks => &FIREWORKS_SPEC, + Self::Google => &GOOGLE_SPEC, } } @@ -516,6 +566,12 @@ impl PlatformId { self.spec().restrict_to_enriched } + /// Prefix to strip from live-listing model ids before filter/enrich/key + /// (e.g. Google's `models/`). `None` = use the id verbatim. + pub fn strip_listing_id_prefix(self) -> Option<&'static str> { + self.spec().strip_listing_id_prefix + } + /// Model-listing endpoint shape + headers. pub fn listing(self) -> ListingDialect { self.spec().listing @@ -1060,6 +1116,27 @@ mod tests { ); } + /// Google's compat listing returns `models/`-prefixed ids; the spec + /// must declare the strip so they canonicalize to the bare snapshot + /// form. Every other platform uses ids verbatim. + #[test] + fn only_google_strips_a_listing_id_prefix() { + assert_eq!( + PlatformId::Google.strip_listing_id_prefix(), + Some("models/") + ); + for p in PlatformId::ALL { + if p != PlatformId::Google { + assert_eq!( + p.strip_listing_id_prefix(), + None, + "{} must use listing ids verbatim", + p.as_str() + ); + } + } + } + #[test] fn platform_ids_round_trip() { for p in PlatformId::ALL { @@ -1085,9 +1162,10 @@ mod tests { PlatformId::Groq => 6, PlatformId::Mistral => 7, PlatformId::Fireworks => 8, + PlatformId::Google => 9, } } - const VARIANT_COUNT: usize = 9; // update together with `ordinal` + const VARIANT_COUNT: usize = 10; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index f1a4a37..c339b96 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -597,7 +597,8 @@ mod tests { "deepseek", "groq", "mistral", - "fireworks" + "fireworks", + "google" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -629,7 +630,8 @@ mod tests { "deepseek", "groq", "mistral", - "fireworks" + "fireworks", + "google" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -654,7 +656,8 @@ mod tests { "deepseek", "groq", "mistral", - "fireworks" + "fireworks", + "google" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -682,7 +685,8 @@ mod tests { "deepseek", "groq", "mistral", - "fireworks" + "fireworks", + "google" ] ); assert_eq!(default_id(&built), None); diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index fb8d3f1..b359651 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -309,6 +309,19 @@ fn fetch_one_platform_models( })? } }; + // Canonicalize listing ids before filtering/enrichment/keying. Google's + // OpenAI-compat `/models` returns `models/`-prefixed ids while its chat + // endpoint and the models.dev snapshot use the bare id — without this the + // enrichment lookup misses and `restrict_to_enriched` would empty the + // catalog. No-op for platforms with no configured prefix. + let mut data = data; + if let Some(prefix) = platform.strip_listing_id_prefix() { + for wire in &mut data { + if let Some(bare) = wire.id.strip_prefix(prefix) { + wire.id = bare.to_string(); + } + } + } let total = data.len(); let mut filtered = kigi_models::filter_allowed_models(platform, data); if filtered.len() != total { @@ -1395,6 +1408,103 @@ mod tests { ); } + /// Google/Gemini-cycle e2e: the OpenAI-compat listing returns + /// `models/`-PREFIXED ids (Google's real shape), which the Google spec's + /// `strip_listing_id_prefix` canonicalizes to the bare form the models.dev + /// snapshot + chat endpoint use — WITHOUT the strip, restrict_to_enriched + /// would silently drop every Gemini model. Embedding pollution is + /// restricted away; Passthrough dialect; bare id on the wire. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn google_compat_listing_strips_prefix_restricts_and_maps_passthrough() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .and(wiremock::matchers::header("Authorization", "Bearer gk-1")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "data": [ + // Real Gemini compat shape: `models/`-prefixed ids. + { "id": "models/gemini-2.5-pro", "object": "model" }, + { "id": "models/gemini-embedding-001", "object": "model" } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + let modelsdev_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "google": { "models": { + "gemini-2.5-pro": { + "limit": {"context": 1048576, "output": 65536}, + "tool_call": true + }, + "gemini-embedding-001": { "limit": {"context": 2048} } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::GOOGLE_BASE_URL_ENV, + platform_server.uri(), + ); + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + format!("{}/api.json", modelsdev_server.uri()), + ); + let _mdev_cache = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV, + cache_dir.path(), + ); + let endpoints = crate::agent::config::EndpointsConfig::default(); + let keys = crate::agent::models::PlatformApiKeys::test_single( + kigi_models::PlatformId::Google, + "gk-1", + ); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &keys) + }) + .await + .unwrap() + .expect("fetch must succeed"); + assert_eq!( + result + .models + .iter() + .map(|m| m.id.as_deref().unwrap_or_default()) + .collect::>(), + vec!["google/gemini-2.5-pro"], + "prefix stripped → matches bare snapshot → kept as the bare \ + managed key; embedding (not tool-calling) dropped" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 1_048_576, + "enrichment matched the bare id" + ); + assert_eq!(entry.max_completion_tokens, Some(65_536)); + assert_eq!( + entry.model, "gemini-2.5-pro", + "the BARE Gemini id rides the wire (chat rejects the models/ prefix)" + ); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("gk-1".into()), + base_url: entry.base_url.clone(), + auth_type: kigi_chat_state::AuthType::ApiKey, + auth_scheme: Default::default(), + }; + let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None); + assert_eq!( + cfg.chat_compat, + kigi_sampling_types::ChatCompat::Passthrough + ); + } + #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index a7ae0b1..2689e25 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -6894,7 +6894,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 10, "9 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 11, "10 login rows + Quit, got {items:?}"); assert!( matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), "row 0 must be the OAuth login, got {:?}", @@ -6957,7 +6957,14 @@ pub(crate) mod tests { label: "Fireworks AI (API key)".into(), } ); - assert_eq!(items[9], PendingMenuItem::Quit); + assert_eq!( + items[9], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Google), + label: "Google Gemini (API key)".into(), + } + ); + assert_eq!(items[10], PendingMenuItem::Quit); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -6968,7 +6975,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 10, + 11, "xai.api_key / cached_token must not add rows" ); }