diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 0c4a988..c4a4f9a 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -144,6 +144,11 @@ struct PlatformSpec { /// (tts/embeddings/image). Availability still requires the LIVE listing; /// this only drops listing noise, never adds models. restrict_to_enriched: bool, + /// Path (relative to base) to hit for API-key VALIDATION at login, when + /// the listing endpoint can't validate. OpenRouter's `/models` is public + /// (200 for any key), so a bad key would false-accept at login; its + /// `/key` endpoint 401s properly. `None` = validate against `/models`. + key_validation_path: Option<&'static str>, /// 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 @@ -171,6 +176,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -194,6 +200,7 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -217,6 +224,7 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -245,6 +253,7 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: true, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -273,6 +282,7 @@ const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::XApiKey, restrict_to_enriched: false, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -301,6 +311,7 @@ const DEEPSEEK_SPEC: PlatformSpec = PlatformSpec { chat_compat: PlatformChatCompat::DeepSeek, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -329,6 +340,7 @@ const GROQ_SPEC: PlatformSpec = PlatformSpec { // The listing carries whisper/tts entries; keep tool-calling chat // models only. restrict_to_enriched: true, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -359,6 +371,7 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec { // The listing carries embed/moderation/OCR entries; keep tool-calling // chat models only. restrict_to_enriched: true, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -388,6 +401,7 @@ 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, + key_validation_path: None, strip_listing_id_prefix: None, }; @@ -417,9 +431,41 @@ const GOOGLE_SPEC: PlatformSpec = PlatformSpec { // The compat listing carries embedding/tts/image models; keep // tool-calling enrichment-known chat models only. restrict_to_enriched: true, + key_validation_path: None, strip_listing_id_prefix: Some("models/"), }; +/// Base-URL override for OpenRouter (dev/test escape hatch). +pub const OPENROUTER_BASE_URL_ENV: &str = "KIGI_OPENROUTER_BASE_URL"; + +const OPENROUTER_SPEC: PlatformSpec = PlatformSpec { + id: "openrouter", + display_name: "OpenRouter", + base_url: BaseUrlSource::EnvOr { + env: OPENROUTER_BASE_URL_ENV, + // Note /api/v1, not /v1. + default: "https://openrouter.ai/api/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["OPENROUTER_API_KEY"], + vendor: "OpenRouter", + console_host: Some("openrouter.ai"), + login_label: Some("OpenRouter (API key)"), + // OpenRouter's public /models serves context_length for every model, so + // enrichment is neither needed nor fetched (verified live: 340/340 carry + // a top-level context_length). Not a models.dev provider here. + models_dev_id: None, + wire_serves_metadata: true, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + restrict_to_enriched: false, + key_validation_path: Some("/key"), + strip_listing_id_prefix: None, +}; + /// The platform registry. Platforms are compiled-in spec rows; there is no /// dynamic provider registration (PRD F2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -444,12 +490,14 @@ pub enum PlatformId { Fireworks, /// Google Gemini platform API (API key, OpenAI-compatibility shim). Google, + /// OpenRouter meta-provider (API key, wire-served metadata). + OpenRouter, } 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; 10] = [ + pub const ALL: [PlatformId; 11] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -460,6 +508,7 @@ impl PlatformId { Self::Mistral, Self::Fireworks, Self::Google, + Self::OpenRouter, ]; /// The registry row backing this platform (single source of per-platform @@ -476,6 +525,7 @@ impl PlatformId { Self::Mistral => &MISTRAL_SPEC, Self::Fireworks => &FIREWORKS_SPEC, Self::Google => &GOOGLE_SPEC, + Self::OpenRouter => &OPENROUTER_SPEC, } } @@ -572,6 +622,13 @@ impl PlatformId { self.spec().strip_listing_id_prefix } + /// Path (relative to base) for API-key validation at login. Defaults to + /// `/models`; a platform whose listing is public (OpenRouter) overrides + /// it with an auth-requiring endpoint so a bad key can't false-accept. + pub fn key_validation_path(self) -> &'static str { + self.spec().key_validation_path.unwrap_or("/models") + } + /// Model-listing endpoint shape + headers. pub fn listing(self) -> ListingDialect { self.spec().listing @@ -1116,6 +1173,24 @@ mod tests { ); } + /// OpenRouter's `/models` is public, so its key validation must target + /// an auth-requiring endpoint; every other platform validates against + /// the default `/models`. + #[test] + fn only_openrouter_overrides_the_validation_path() { + assert_eq!(PlatformId::OpenRouter.key_validation_path(), "/key"); + for p in PlatformId::ALL { + if p != PlatformId::OpenRouter { + assert_eq!( + p.key_validation_path(), + "/models", + "{} must validate against /models", + p.as_str() + ); + } + } + } + /// 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. @@ -1163,9 +1238,10 @@ mod tests { PlatformId::Mistral => 7, PlatformId::Fireworks => 8, PlatformId::Google => 9, + PlatformId::OpenRouter => 10, } } - const VARIANT_COUNT: usize = 10; // update together with `ordinal` + const VARIANT_COUNT: usize = 11; // 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 c339b96..a9b041c 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -394,7 +394,11 @@ pub(crate) async fn authenticate_platform_api_key( let Some(key) = key else { return Err(auth_err(missing_platform_key_error(platform))); }; - let url = format!("{}/models", platform.base_url().trim_end_matches('/')); + let url = format!( + "{}{}", + platform.base_url().trim_end_matches('/'), + platform.key_validation_path() + ); let request = match platform.key_header() { kigi_models::PlatformKeyHeader::Bearer => crate::http::shared_client() .get(&url) @@ -598,7 +602,8 @@ mod tests { "groq", "mistral", "fireworks", - "google" + "google", + "openrouter" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -631,7 +636,8 @@ mod tests { "groq", "mistral", "fireworks", - "google" + "google", + "openrouter" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -657,7 +663,8 @@ mod tests { "groq", "mistral", "fireworks", - "google" + "google", + "openrouter" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -686,7 +693,8 @@ mod tests { "groq", "mistral", "fireworks", - "google" + "google", + "openrouter" ] ); assert_eq!(default_id(&built), None); @@ -816,4 +824,63 @@ mod tests { "the key must never leak into errors" ); } + + /// OpenRouter's `/models` is PUBLIC (200 for any key), so validation must + /// hit its auth-requiring `/key` endpoint instead — otherwise a bad key + /// false-accepts at login. The mock serves `/models` 200 always; a bad + /// key must still be rejected (proving `/models` is NOT what's validated). + #[tokio::test] + #[serial] + async fn openrouter_validates_against_key_endpoint_not_public_models() { + use wiremock::matchers::{method, path}; + let server = wiremock::MockServer::start().await; + // Public listing: 200 for anyone. If validation used this, a bad key + // would pass. + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "data": [] })), + ) + .mount(&server) + .await; + // Auth-required key endpoint: 401 for a bad key. + wiremock::Mock::given(method("GET")) + .and(path("/key")) + .respond_with(wiremock::ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::OPENROUTER_BASE_URL_ENV, &server.uri()); + let err = + authenticate_platform_api_key(kigi_models::PlatformId::OpenRouter, Some("sk-or-bad")) + .await + .expect_err("a bad key must be rejected via /key, not accepted via /models"); + assert_eq!( + err.message, + "Invalid API key for openrouter \u{2014} check your key on openrouter.ai" + ); + } + + /// A valid OpenRouter key: `/key` returns 200 → accepted. + #[tokio::test] + #[serial] + async fn openrouter_valid_key_succeeds_via_key_endpoint() { + use wiremock::matchers::{header, method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/key")) + .and(header("Authorization", "Bearer sk-or-good")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "data": { "label": "k" } })), + ) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::OPENROUTER_BASE_URL_ENV, &server.uri()); + authenticate_platform_api_key(kigi_models::PlatformId::OpenRouter, Some("sk-or-good")) + .await + .expect("200 from /key must validate the key"); + } } diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index b359651..6491b5a 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1505,6 +1505,98 @@ mod tests { ); } + /// OpenRouter-cycle e2e: wire_serves_metadata=true — the listing itself + /// carries `context_length`, so context comes from the WIRE with NO + /// enrichment fetch and NO restriction (all models kept). The models.dev + /// refresh is disabled to prove it is never consulted. Slashed ids + /// round-trip; Passthrough dialect. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn openrouter_wire_metadata_needs_no_enrichment_and_keeps_all() { + 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 or-1")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "data": [ + { "id": "anthropic/claude-opus-4.8", "context_length": 1000000, + "supported_parameters": ["reasoning_effort", "tools"] }, + { "id": "openai/gpt-5.5", "context_length": 400000, + "supported_parameters": ["tools"] } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::OPENROUTER_BASE_URL_ENV, + platform_server.uri(), + ); + // Kill switch: proves enrichment is never fetched for a wire-served + // platform (any attempt would need this URL). + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + "0", + ); + 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::OpenRouter, + "or-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![ + "openrouter/anthropic/claude-opus-4.8", + "openrouter/openai/gpt-5.5" + ], + "no restriction — all listed models kept; slashed ids in the key" + ); + let opus = &result.models[0]; + assert_eq!( + opus.context_window.get(), + 1_000_000, + "context window comes from the wire listing, not enrichment" + ); + assert_eq!( + opus.model, "anthropic/claude-opus-4.8", + "the native slashed id rides the wire" + ); + assert_eq!( + kigi_models::parse_managed_model_key(opus.id.as_deref().unwrap()), + Some(( + kigi_models::PlatformId::OpenRouter, + "anthropic/claude-opus-4.8" + )) + ); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(opus); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("or-1".into()), + base_url: opus.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-shell/src/kimi_import.rs b/crates/codegen/kigi-shell/src/kimi_import.rs index 3e36343..4ed374d 100644 --- a/crates/codegen/kigi-shell/src/kimi_import.rs +++ b/crates/codegen/kigi-shell/src/kimi_import.rs @@ -611,7 +611,7 @@ model = "kimi-for-coding" max_context_size = 262144 [models.my-openai] -provider = "openrouter" +provider = "customllm" model = "gpt-x" max_context_size = 128000 @@ -620,9 +620,9 @@ type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-kimi-secret" -[providers.openrouter] +[providers.customllm] type = "openai_legacy" -base_url = "https://openrouter.ai/api/v1" +base_url = "https://llm.example.test/v1" api_key = "sk-or-secret" "#, ) @@ -711,7 +711,7 @@ api_key = "sk-or-secret" let m = &plan.custom_models[0]; assert_eq!(m.alias, "my-openai"); assert_eq!(m.model, "gpt-x"); - assert_eq!(m.base_url, "https://openrouter.ai/api/v1"); + assert_eq!(m.base_url, "https://llm.example.test/v1"); assert_eq!(m.api_key.as_deref(), Some("sk-or-secret")); assert_eq!(m.context_window, Some(128_000)); @@ -850,7 +850,7 @@ api_key = "sk-ms" assert_eq!(model["model"].as_str().unwrap(), "gpt-x"); assert_eq!( model["base_url"].as_str().unwrap(), - "https://openrouter.ai/api/v1" + "https://llm.example.test/v1" ); assert_eq!(model["api_key"].as_str().unwrap(), "sk-or-secret"); assert_eq!(model["context_window"].as_integer().unwrap(), 128_000); diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index 2689e25..d625dd3 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(), 11, "10 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 12, "11 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 {:?}", @@ -6964,7 +6964,14 @@ pub(crate) mod tests { label: "Google Gemini (API key)".into(), } ); - assert_eq!(items[10], PendingMenuItem::Quit); + assert_eq!( + items[10], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter), + label: "OpenRouter (API key)".into(), + } + ); + assert_eq!(items[11], 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 { @@ -6975,7 +6982,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 11, + 12, "xai.api_key / cached_token must not add rows" ); }