diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index c2b17fe..c8e84cd 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -563,6 +563,40 @@ const NVIDIA_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +/// Base-URL override for Vercel AI Gateway (dev/test escape hatch). +pub const VERCEL_BASE_URL_ENV: &str = "KIGI_VERCEL_BASE_URL"; + +const VERCEL_SPEC: PlatformSpec = PlatformSpec { + id: "vercel-ai-gateway", + display_name: "Vercel AI Gateway", + base_url: BaseUrlSource::EnvOr { + env: VERCEL_BASE_URL_ENV, + default: "https://ai-gateway.vercel.sh/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["AI_GATEWAY_API_KEY"], + vendor: "Vercel", + console_host: Some("vercel.com"), + login_label: Some("Vercel AI Gateway (API key)"), + models_dev_id: Some("vercel"), + // Vercel's /models serves rich metadata but under `context_window` (not + // the WireModel `context_length`), so take context from models.dev + // enrichment instead; restrict to tool-calling chat models (the gateway + // lists embedding/image/rerank types too). Ids are creator/model, + // byte-matching the models.dev "vercel" keys. + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + // /models is PUBLIC (200 for any key), so validate against /credits, + // which 401s for a bad key. + key_validation_path: Some("/credits"), + strip_listing_id_prefix: None, + restrict_to_enriched: true, +}; + /// 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)] @@ -595,12 +629,14 @@ pub enum PlatformId { Cerebras, /// NVIDIA NIM platform API (API key, OpenAI-compatible ChatCompletions). Nvidia, + /// Vercel AI Gateway (API key, wire-listed with models.dev enrichment). + Vercel, } 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; 14] = [ + pub const ALL: [PlatformId; 15] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -615,6 +651,7 @@ impl PlatformId { Self::Together, Self::Cerebras, Self::Nvidia, + Self::Vercel, ]; /// The registry row backing this platform (single source of per-platform @@ -635,6 +672,7 @@ impl PlatformId { Self::Together => &TOGETHER_SPEC, Self::Cerebras => &CEREBRAS_SPEC, Self::Nvidia => &NVIDIA_SPEC, + Self::Vercel => &VERCEL_SPEC, } } @@ -1318,14 +1356,16 @@ mod tests { assert!(parse_openai_listing("{").is_err()); } - /// OpenRouter's `/models` is public, so its key validation must target - /// an auth-requiring endpoint; every other platform validates against - /// the default `/models`. + /// Providers whose `/models` listing is public must validate keys + /// against an auth-requiring endpoint; every other platform validates + /// against the default `/models`. #[test] - fn only_openrouter_overrides_the_validation_path() { + fn public_listing_providers_override_the_validation_path() { assert_eq!(PlatformId::OpenRouter.key_validation_path(), "/key"); + assert_eq!(PlatformId::Vercel.key_validation_path(), "/credits"); + let overrides = [PlatformId::OpenRouter, PlatformId::Vercel]; for p in PlatformId::ALL { - if p != PlatformId::OpenRouter { + if !overrides.contains(&p) { assert_eq!( p.key_validation_path(), "/models", @@ -1387,9 +1427,10 @@ mod tests { PlatformId::Together => 11, PlatformId::Cerebras => 12, PlatformId::Nvidia => 13, + PlatformId::Vercel => 14, } } - const VARIANT_COUNT: usize = 14; // update together with `ordinal` + const VARIANT_COUNT: usize = 15; // 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 d2e60e8..72e6279 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -606,7 +606,8 @@ mod tests { "openrouter", "together", "cerebras", - "nvidia" + "nvidia", + "vercel-ai-gateway" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -643,7 +644,8 @@ mod tests { "openrouter", "together", "cerebras", - "nvidia" + "nvidia", + "vercel-ai-gateway" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -673,7 +675,8 @@ mod tests { "openrouter", "together", "cerebras", - "nvidia" + "nvidia", + "vercel-ai-gateway" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -706,7 +709,8 @@ mod tests { "openrouter", "together", "cerebras", - "nvidia" + "nvidia", + "vercel-ai-gateway" ] ); assert_eq!(default_id(&built), None); @@ -874,6 +878,38 @@ mod tests { ); } + /// Vercel's `/models` is public too; validation must hit `/credits` + /// (401s for a bad key). A bad key is rejected even though `/models` + /// would 200. + #[tokio::test] + #[serial] + async fn vercel_validates_against_credits_endpoint_not_public_models() { + use wiremock::matchers::{method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "data": [] })), + ) + .mount(&server) + .await; + wiremock::Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(wiremock::ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::VERCEL_BASE_URL_ENV, &server.uri()); + let err = authenticate_platform_api_key(kigi_models::PlatformId::Vercel, Some("vg-bad")) + .await + .expect_err("a bad key must be rejected via /credits, not accepted via /models"); + assert_eq!( + err.message, + "Invalid API key for vercel-ai-gateway \u{2014} check your key on vercel.com" + ); + } + /// A valid OpenRouter key: `/key` returns 200 → accepted. #[tokio::test] #[serial] diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index e23af4c..b914109 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1897,6 +1897,103 @@ mod tests { ); } + /// Vercel-cycle e2e: the gateway lists creator/model ids (matching the + /// models.dev "vercel" keys); enrichment supplies context (the wire uses + /// `context_window`, not the WireModel `context_length`); the restriction + /// drops non-chat types; slashed id; Passthrough dialect. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn vercel_gateway_enriches_restricts_and_maps_passthrough() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + // Vercel serves context under `context_window` (ignored by + // WireModel) — enrichment supplies the real context. + serde_json::json!({ "data": [ + // A DISTINCT (wrong) context_window that WireModel ignores — + // so asserting the enrichment value below proves the source. + { "id": "openai/gpt-5.5", "object": "model", + "type": "language", "context_window": 999 }, + { "id": "voyage/rerank-2.5", "object": "model", "type": "embedding" } + ]}), + )) + .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!({ "vercel": { "models": { + "openai/gpt-5.5": { + "limit": {"context": 400000, "output": 128000}, + "tool_call": true + }, + "voyage/rerank-2.5": { "limit": {"context": 32000} } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::VERCEL_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::Vercel, + "vg-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!["vercel-ai-gateway/openai/gpt-5.5"], + "rerank (not tool-calling) dropped; creator/model id kept under the platform key" + ); + let entry = &result.models[0]; + assert_eq!( + entry.context_window.get(), + 400_000, + "context comes from enrichment (wire used context_window, not context_length)" + ); + assert_eq!(entry.max_completion_tokens, Some(128_000)); + assert_eq!( + entry.model, "openai/gpt-5.5", + "the creator/model id rides the wire" + ); + let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); + let creds = crate::agent::config::ResolvedCredentials { + api_key: Some("vg-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 7f6716b..d3a550f 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(), 15, "14 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 16, "15 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 {:?}", @@ -6992,7 +6992,14 @@ pub(crate) mod tests { label: "NVIDIA NIM (API key)".into(), } ); - assert_eq!(items[14], PendingMenuItem::Quit); + assert_eq!( + items[14], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Vercel), + label: "Vercel AI Gateway (API key)".into(), + } + ); + assert_eq!(items[15], 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 { @@ -7003,7 +7010,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 15, + 16, "xai.api_key / cached_token must not add rows" ); }