//! Model catalog fetch (PRD F4). //! //! Fetches the model catalog with `GET {base}/models` per enabled platform //! (the subscription platform via the OAuth session, the open platforms via //! their API keys), plus the custom-endpoint OpenAI-compatible listing path. //! //! It talks only to the configured platform model endpoints (plus the //! models.dev metadata refresh when an enabled platform needs enrichment — //! see `enrichment_fetch`), never to a proxy backend. use crate::auth::KimiAuth; use indexmap::IndexMap; use serde::Deserialize; #[derive(Debug, thiserror::Error)] pub(crate) enum BackendError { #[error("Network error: {0}")] Network(#[from] reqwest::Error), #[error("Request failed: {status} - {body}")] RequestFailed { status: u16, body: String }, #[error("Auth error: {0}")] Auth(String), } pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 256_000; #[derive(Debug, Deserialize)] struct ModelsResponse { data: Vec, } /// Session bearer tokens for the GENERIC device-code OAuth platforms /// (`platform.oauth().is_some()` — xai-grok today), resolved per provider from /// its own scope-keyed `AuthManager` (refreshed on expiry) before the blocking /// fetch. kimi-code is NOT here — it still rides the single `auth: &KimiAuth`. /// /// SECURITY: values are access tokens — never logged, never persisted here. pub(crate) type OAuthSessionTokens = std::collections::BTreeMap; /// Resolve session bearers for every generic device-code OAuth platform whose /// own `AuthManager` holds a usable (refreshed-on-expiry) session. Each /// platform resolves from ITS OWN scope (`oauth/xai`, …) — independent of the /// Kimi session. Providers without a stored session are simply absent. Only a /// non-secret count is logged. pub(crate) async fn resolve_generic_oauth_tokens( kigi_home: &std::path::Path, ) -> OAuthSessionTokens { let mut tokens = OAuthSessionTokens::new(); for platform in kigi_models::PlatformId::ALL { let Some(oauth) = platform.oauth() else { continue; }; let manager = std::sync::Arc::new(crate::auth::AuthManager::new_oauth_provider( kigi_home, oauth, )); manager.configure_refresher(); if let Ok(auth) = manager.auth().await { tokens.insert(platform, auth.key); } } if !tokens.is_empty() { tracing::info!( count = tokens.len(), "resolved generic oauth platform session tokens" ); } tokens } /// The generic device-code OAuth platforms with a STORED session scope in /// auth.json — the cheap sync companion to [`resolve_generic_oauth_tokens`] /// (no `AuthManager`, no refresh, no secrets read beyond scope presence). /// /// Every place that reasons about the FETCH PLAN without resolving bearers /// (the prefetch arming gate, `on_auth_changed`'s wipe guard, cache-origin /// computation) must consult this, or a subscription-OAuth-only user (e.g. /// claude-pro-max with no Kimi session and no API key) is treated as /// credential-less: catalog wiped/never fetched, session stuck on the /// bundled Kimi table with an empty picker. pub(crate) fn stored_oauth_platforms(kigi_home: &std::path::Path) -> Vec { let Ok(store) = crate::auth::read_auth_json(&kigi_home.join("auth.json")) else { return Vec::new(); }; kigi_models::PlatformId::ALL .into_iter() .filter(|p| p.oauth().is_some_and(|o| store.contains_key(o.scope_key))) .collect() } /// Presence-only stand-in for [`OAuthSessionTokens`] in fetch-plan/origin /// computations. Values are EMPTY strings: cache origins encode enabled /// platform NAMES and URLs only ([`models_fetch_origin`]), and this map must /// never reach a request builder — real bearers come from /// [`resolve_generic_oauth_tokens`] on the fetch path itself. pub(crate) fn stored_oauth_token_stubs(kigi_home: &std::path::Path) -> OAuthSessionTokens { stored_oauth_platforms(kigi_home) .into_iter() .map(|p| (p, String::new())) .collect() } /// The models-fetch origin key for this endpoints/auth shape. Used as the /// models disk-cache origin: cached entries embed absolute `base_url`s from /// the backend(s) that served them, so a catalog fetched against one fetch /// plan (env override, different set of platform credentials, a test's mock /// server) must be a cache miss for any other. Encodes URLs and enabled /// platform NAMES only — never credential values. pub(crate) fn models_fetch_origin( endpoints: &crate::agent::config::EndpointsConfig, fetch_auth: crate::agent::models::ModelFetchAuth, has_oauth: bool, oauth_tokens: &OAuthSessionTokens, platform_keys: &crate::agent::models::PlatformApiKeys, ) -> String { match fetch_auth { crate::agent::models::ModelFetchAuth::CustomEndpoint => endpoints.resolve_models_list_url(), crate::agent::models::ModelFetchAuth::Platforms => { let parts: Vec = enabled_platforms(has_oauth, oauth_tokens, platform_keys) .into_iter() .map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints))) .collect(); format!("platforms[{}]", parts.join(";")) } } } /// The platforms with usable credentials, in registry order (kimi-code first /// so "default model = first list item" favors the subscription). /// /// - kimi-code (`uses_oauth`, no `OAuthConfig`) is gated on the single Kimi /// session (`has_oauth`); /// - a generic device-code OAuth platform (`oauth().is_some()`, e.g. xai-grok) /// is gated on ITS OWN resolved session token (`oauth_tokens`); /// - an API-key platform is gated on a stored key. fn enabled_platforms( has_oauth: bool, oauth_tokens: &OAuthSessionTokens, platform_keys: &crate::agent::models::PlatformApiKeys, ) -> Vec { kigi_models::PlatformId::ALL .into_iter() .filter(|p| { if p.oauth().is_some() { oauth_tokens.contains_key(p) } else if p.uses_oauth() { has_oauth } else { platform_keys.key_for(*p).is_some() } }) .collect() } /// The inference/listing base for one platform's `/models` fetch: /// - kimi-code (`uses_oauth`, no `OAuthConfig`) → the Kimi subscription base /// via the endpoints config (`proxy_url`); /// - every other platform — API-key AND generic OAuth (xai-grok) — → its own /// registry `base_url()` (e.g. api.x.ai/v1). fn platform_fetch_base( platform: kigi_models::PlatformId, endpoints: &crate::agent::config::EndpointsConfig, ) -> String { if platform.uses_oauth() && platform.oauth().is_none() { endpoints.proxy_url() } else { platform.base_url() } } /// `{base}/models` for one platform (see [`platform_fetch_base`]). fn platform_models_url( platform: kigi_models::PlatformId, endpoints: &crate::agent::config::EndpointsConfig, ) -> String { format!( "{}/models", platform_fetch_base(platform, endpoints).trim_end_matches('/') ) } /// Fetch result: model entries + optional etag from the subscription platform. pub struct FetchModelsResult { pub models: Vec, pub etag: Option, /// The OAuth platform answered 401. The async layer forces a token /// refresh and retries once (port of kimi-cli `refresh_managed_models`). pub oauth_unauthorized: bool, } /// Fetch the model catalog (PRD F4). /// /// - Custom endpoint mode (`KIGI_MODELS_BASE_URL` / `models_list_url`): a /// single OpenAI-compatible listing fetched with the BYOK key or session /// bearer, parsed leniently ([`parse_remote_model_value`]). /// - Otherwise, the fixed platform registry: `GET {base}/models` with /// `Authorization: Bearer ` per enabled platform, /// parsed per the F4 wire contract with capability derivation and the /// `kimi-k` prefix filter for the open platforms. /// /// Succeeds when at least one platform delivers; per-platform failures are /// logged (status codes only, never credentials). pub(crate) fn fetch_models_blocking( endpoints: &crate::agent::config::EndpointsConfig, auth: Option<&KimiAuth>, oauth_tokens: &OAuthSessionTokens, fetch_auth: crate::agent::models::ModelFetchAuth, platform_keys: &crate::agent::models::PlatformApiKeys, ) -> Result { match fetch_auth { crate::agent::models::ModelFetchAuth::CustomEndpoint => { fetch_custom_endpoint_models_blocking(endpoints, auth) } crate::agent::models::ModelFetchAuth::Platforms => { fetch_platform_models_blocking(endpoints, auth, oauth_tokens, platform_keys) } } } fn fetch_custom_endpoint_models_blocking( endpoints: &crate::agent::config::EndpointsConfig, auth: Option<&KimiAuth>, ) -> Result { let client = crate::http::shared_blocking_client(); let url = endpoints.resolve_models_list_url(); let inference_base_url = endpoints.resolve_inference_base_url(); tracing::info!("Fetching models from custom endpoint {}", url); let api_key = crate::agent::auth_method::read_xai_api_key_env() .or_else(|_| { auth.map(|a| a.key.clone()) .ok_or(std::env::VarError::NotPresent) }) .map_err(|_| { BackendError::Auth("No API key for custom models endpoint. Set KIGI_API_KEY.".into()) })?; let request = client .get(&url) .header("Authorization", format!("Bearer {}", api_key)); let response = request.send()?; if !response.status().is_success() { let status = response.status().as_u16(); let body = response.text().unwrap_or_default(); tracing::warn!("Failed to fetch models: {} - {}", status, body); return Err(BackendError::RequestFailed { status, body }); } let etag = response .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let models_response: ModelsResponse = response.json()?; tracing::info!("Fetched {} models from {}", models_response.data.len(), url); let mut models = Vec::with_capacity(models_response.data.len()); for (idx, value) in models_response.data.into_iter().enumerate() { match parse_remote_model_value(&value, &inference_base_url) { Some(model) => models.push(model), None => { tracing::warn!( "Skipping model at index {}: missing required field ('model' or 'context_window') or invalid types", idx ) } } } Ok(FetchModelsResult { models, etag, oauth_unauthorized: false, }) } /// Registry fetch across all platforms with usable credentials. fn fetch_platform_models_blocking( endpoints: &crate::agent::config::EndpointsConfig, auth: Option<&KimiAuth>, oauth_tokens: &OAuthSessionTokens, platform_keys: &crate::agent::models::PlatformApiKeys, ) -> Result { let enabled = enabled_platforms(auth.is_some(), oauth_tokens, platform_keys); if enabled.is_empty() { return Err(BackendError::Auth( "No platform credentials: log in with `kigi login`, paste a platform API key in \ the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \ as KIGI_MOONSHOT_API_KEY." .into(), )); } let mut models = Vec::new(); let mut etag = None; let mut oauth_unauthorized = false; let mut successes = 0usize; let mut last_error: Option = None; // Loaded once per fetch pass; zero IO while every enabled platform // serves its own metadata (kimi/moonshot today). let enrichment = crate::agent::enrichment_fetch::load_enrichment_catalog(&enabled); for platform in &enabled { let bearer = if platform.oauth().is_some() { // Generic device-code OAuth platform (xai-grok): its OWN resolved // session token — never the Kimi session. oauth_tokens .get(platform) .expect("enabled_platforms gated on generic-oauth token presence") .clone() } else if platform.uses_oauth() { auth.map(|a| a.key.clone()) .expect("enabled_platforms gated on auth presence") } else { platform_keys .key_for(*platform) .expect("enabled_platforms gated on key presence") .to_owned() }; match fetch_one_platform_models(*platform, endpoints, &bearer, &enrichment) { Ok((platform_models, platform_etag)) => { tracing::info!( platform = platform.as_str(), count = platform_models.len(), "platform models fetch succeeded" ); successes += 1; // The catalog etag tracks the Kimi subscription listing only // (kimi-code: uses_oauth with no generic OAuthConfig). if platform.uses_oauth() && platform.oauth().is_none() { etag = platform_etag; } models.extend(platform_models); } Err(e) => { if platform.uses_oauth() && matches!(&e, BackendError::RequestFailed { status: 401, .. }) { oauth_unauthorized = true; } tracing::warn!( platform = platform.as_str(), error = %e, "platform models fetch failed" ); last_error = Some(e); } } } if successes == 0 { // All enabled platforms failed. When the failure includes an OAuth // 401, return `Ok` with the flag set (and no models) so the async // layer can force a token refresh and retry — an `Err` would drop // the signal. Non-401 failures propagate as the last error. if oauth_unauthorized { return Ok(FetchModelsResult { models: Vec::new(), etag: None, oauth_unauthorized: true, }); } return Err(last_error.unwrap_or_else(|| { BackendError::Auth("no platform models fetch was attempted".into()) })); } Ok(FetchModelsResult { models, etag, oauth_unauthorized, }) } /// `GET {base}/models` for one platform (PRD F4 wire contract): /// `Authorization: Bearer ` → `{data:[{id, context_length, /// supports_reasoning, supports_image_in, supports_video_in, display_name?}]}`. /// Applies the platform's `kimi-k` prefix filter and capability derivation, /// and keys each entry `{platform_id}/{model_id}`. fn fetch_one_platform_models( platform: kigi_models::PlatformId, endpoints: &crate::agent::config::EndpointsConfig, bearer: &str, enrichment: &kigi_models::enrichment::EnrichmentCatalog, ) -> Result<(Vec, Option), BackendError> { // A platform that serves NO live `/models` listing (openai-codex) delivers a // HARDCODED catalog: short-circuit BEFORE any HTTP, mapping the compiled-in // `WireModel`s through the SAME `platform_wire_model_to_entry` output a live // listing produces (context window + per-model reasoning efforts). The // bearer is unused here (login gates availability; no request is made). if let Some(wire_models) = platform.hardcoded_catalog() { let base_url = platform_fetch_base(platform, endpoints); let models = wire_models .into_iter() .map(|wire| platform_wire_model_to_entry(platform, wire, &base_url)) .collect(); return Ok((models, None)); } let client = crate::http::shared_blocking_client(); let url = match platform.listing() { kigi_models::ListingDialect::OpenAi => platform_models_url(platform, endpoints), // Anthropic paginates (default 20); limit=1000 is the documented max // and far above the catalog size (the adapter warns on has_more). kigi_models::ListingDialect::Anthropic => { format!("{}?limit=1000", platform_models_url(platform, endpoints)) } }; tracing::info!(platform = platform.as_str(), url = %url, "fetching platform models"); let request = match platform.key_header() { kigi_models::PlatformKeyHeader::Bearer => { let mut req = client .get(&url) .header("Authorization", format!("Bearer {}", bearer)); // An Anthropic listing reached with a Bearer key is the OAuth // channel (claude-pro-max): the /v1/models endpoint requires // anthropic-version, and the OAuth bearer needs the oauth beta. // The OpenAI-listing Bearer platforms (xai-grok, api-key OpenAI // rows) add neither, so their requests stay byte-identical. if platform.listing() == kigi_models::ListingDialect::Anthropic { req = req .header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION) .header("anthropic-beta", kigi_sampling_types::ANTHROPIC_OAUTH_BETA); } // GitHub Copilot /models needs the VS Code editor identity + the // Copilot API version. github-copilot-GATED, so every other Bearer // OpenAI-listing platform (xai-grok, api-key OpenAI rows) is // byte-identical. if platform.sends_copilot_editor_headers() { req = req .header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT) .header( "Editor-Version", kigi_sampling_types::COPILOT_EDITOR_VERSION, ) .header( "Editor-Plugin-Version", kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION, ) .header( "Copilot-Integration-Id", kigi_sampling_types::COPILOT_INTEGRATION_ID, ) .header( "X-GitHub-Api-Version", kigi_sampling_types::COPILOT_API_VERSION, ); } req } kigi_models::PlatformKeyHeader::XApiKey => client .get(&url) .header("x-api-key", bearer) .header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION), }; let response = request.send()?; if !response.status().is_success() { let status = response.status().as_u16(); let body = response.text().unwrap_or_default(); return Err(BackendError::RequestFailed { status, body }); } let etag = response .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let data = match platform.listing() { // GitHub Copilot serves an OpenAI-shape listing with extra availability // fields (model_picker_enabled/policy/tool_calls). Parse + filter it // with the Copilot-specific adapter (keep only selectable, tool-calling, // openai-completions-served ids). Platform-gated; every other OpenAi // listing takes the plain path. kigi_models::ListingDialect::OpenAi if platform.sends_copilot_editor_headers() => { let body = response.text()?; kigi_models::parse_github_copilot_listing(&body).map_err(|e| { BackendError::RequestFailed { status: 200, body: format!("copilot listing parse failed: {e}"), } })? } kigi_models::ListingDialect::OpenAi => { // Tolerant of both the {data:[...]} envelope and a bare array // (Together AI serves the bare form). let body = response.text()?; kigi_models::parse_openai_listing(&body).map_err(|e| BackendError::RequestFailed { status: 200, body: format!("openai listing parse failed: {e}"), })? } kigi_models::ListingDialect::Anthropic => { let body = response.text()?; kigi_models::parse_anthropic_listing(&body).map_err(|e| { BackendError::RequestFailed { status: 200, body: format!("anthropic listing parse failed: {e}"), } })? } }; // 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 { tracing::info!( platform = platform.as_str(), total, kept = filtered.len(), "applied platform model-prefix filter" ); } // Polluted listings (tts/embeddings/image entries) are restricted to // models the enrichment catalog knows. FAIL-SAFE: if enrichment has no // data for this provider at all (refresh broken AND snapshot gap), keep // the full listing with a warning — a noisy picker beats an empty one. if platform.restrict_to_enriched() && let Some(dev_id) = platform.models_dev_id() { let provider_known = enrichment.get(dev_id).is_some_and(|m| !m.is_empty()); if provider_known { let before = filtered.len(); let mut dropped: Vec = Vec::new(); // Keep only tool-calling chat models: membership alone would // admit models.dev-known embeddings/moderation entries, which // would 400 on every agentic request (EnrichmentModel.tool_call // exists exactly for this cut). filtered.retain(|wire| { let keep = kigi_models::enrichment::lookup(enrichment, dev_id, &wire.id) .is_some_and(|meta| meta.tool_call); if !keep { dropped.push(wire.id.clone()); } keep }); if filtered.len() != before { tracing::info!( platform = platform.as_str(), before, kept = filtered.len(), "restricted listing to tool-calling enrichment-known models" ); // A launch-day model missing from enrichment lands here for // up to models.dev lag + cache TTL — keep the ids traceable. tracing::debug!( platform = platform.as_str(), dropped = ?dropped, "listing ids dropped by the enrichment restriction" ); } } else { tracing::warn!( platform = platform.as_str(), "no enrichment data for provider; keeping full listing" ); } } let base_url = platform_fetch_base(platform, endpoints); let models = filtered .into_iter() .map(|mut wire| { // Metadata-poor listings (bare ids) get context window / thinking // levels from the models.dev catalog; wire-served platforms skip // this entirely and wire values always win (enrich_wire_model). if !platform.wire_serves_metadata() && let Some(dev_id) = platform.models_dev_id() { match kigi_models::enrichment::lookup(enrichment, dev_id, &wire.id) { Some(meta) => kigi_models::enrichment::enrich_wire_model(&mut wire, meta), None => tracing::debug!( platform = platform.as_str(), model = %wire.id, "no enrichment entry; defaults will apply" ), } } platform_wire_model_to_entry(platform, wire, &base_url) }) .collect(); Ok((models, etag)) } /// Map a live `think_efforts` block to catalog effort options. The wire /// token stays the option id/label (`"max"` → label `"Max"`) so the UI /// mirrors the server's vocabulary, while the canonical value maps through /// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Max` /// since the Xhigh/Max split). Unknown tokens are dropped with a warning /// rather than inventing a level. fn think_efforts_to_options( think: &kigi_models::WireThinkEfforts, ) -> Vec { think .valid_efforts .iter() .filter_map(|token| { let value = match token.parse::() { Ok(v) => v, Err(error) => { tracing::warn!(%token, %error, "unknown think_efforts token; dropping"); return None; } }; let mut label: String = token.clone(); if let Some(first) = label.get_mut(0..1) { first.make_ascii_uppercase(); } Some(kigi_sampling_types::ReasoningEffortOption { id: token.clone(), value, label, description: None, default: think.default_effort.as_deref() == Some(token.as_str()), }) }) .collect() } /// Map one F4 wire model to a catalog entry config. /// /// SECURITY: the entry carries only env-var NAMES (`env_key`) for the open /// platforms — never key values — because raw fetched entries are persisted /// to the models disk cache. Config-file keys are stamped in-memory later by /// `resolve_model_list`'s platform-credentials layer. pub(crate) fn platform_wire_model_to_entry( platform: kigi_models::PlatformId, wire: kigi_models::WireModel, base_url: &str, ) -> crate::agent::config::ModelEntryConfig { let capabilities = wire.capabilities(); // Selectable thinking levels (live wire `think_efforts`, e.g. K3's // low/high/max). `support: false` or absence both mean "no levels". let think_efforts = wire.think_efforts.as_ref().filter(|t| t.support); let context_window = std::num::NonZeroU64::new(wire.context_length).unwrap_or_else(|| { tracing::debug!( model = %wire.id, default = DEFAULT_CONTEXT_WINDOW, "platform model missing context_length; using default" ); std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero") }); let env_key = (!platform.uses_oauth()) .then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied())); let api_backend = match platform.wire_api() { kigi_models::PlatformWireApi::ChatCompletions => { crate::sampling::ApiBackend::ChatCompletions } kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses, kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages, }; let auth_scheme = match platform.key_header() { kigi_models::PlatformKeyHeader::Bearer => None, kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey), }; crate::agent::config::ModelEntryConfig { id: Some(platform.managed_model_key(&wire.id)), name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())), model: wire.id, base_url: base_url.to_owned(), description: None, // The wire/enrichment output cap; the sampler otherwise defaults to // 128K, which Anthropic rejects on smaller-cap models (400 on every // request for e.g. a 64K haiku). max_completion_tokens: (wire.max_output_tokens > 0) .then(|| u32::try_from(wire.max_output_tokens).unwrap_or(u32::MAX)), temperature: None, top_p: None, api_key: None, env_key, api_backend, auth_scheme, reasoning_effort: think_efforts .and_then(|t| t.default_effort.as_deref()) .and_then(|s| s.parse().ok()), supports_reasoning_effort: think_efforts.is_some(), reasoning_efforts: think_efforts .map(think_efforts_to_options) .unwrap_or_default(), capabilities, extra_headers: IndexMap::new(), context_window, auto_compact_threshold_percent: None, system_prompt_label: None, api_base_url: None, use_concise: false, agent_type: crate::agent::config::default_agent_type(), inference_idle_timeout_secs: None, max_retries: None, hidden: false, // `supported_in_api: false` hides a model unless the PRIMARY session is // an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code` // rides that primary session, so only it may be gated on it. Every // other OAuth platform (claude-pro-max, openai-codex, github-copilot, // xai-grok) carries its OWN pooled credential, and its models only // enter the catalog once THAT provider is signed in — gating them on // the Kimi session would hide every model from a user who signed in // with only a Claude/ChatGPT/Copilot/Grok subscription. supported_in_api: platform != kigi_models::PlatformId::KimiCode, supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, show_model_fingerprint: false, stream_tool_calls: None, laziness_detector: Default::default(), } } /// Parse a single model entry from the /models response. /// Used by both initial model fetch and session-resume metadata refresh. pub fn parse_remote_model_value( value: &serde_json::Value, default_base_url: &str, ) -> Option { let obj = value.as_object()?; let meta = obj.get("_meta").and_then(|v| v.as_object()); let id = get_string(obj, "id"); let model = get_string(obj, "model") .or_else(|| get_string(obj, "modelId")) .or_else(|| id.clone()) .or_else(|| meta.and_then(|m| get_string(m, "model"))) .or_else(|| meta.and_then(|m| get_string(m, "modelId")))?; let base_url = get_string(obj, "baseUrl") .or_else(|| get_string(obj, "base_url")) .unwrap_or_else(|| default_base_url.to_owned()); let name = get_string(obj, "name").or_else(|| Some(model.clone())); let context_window = get_u64(obj, "contextWindow") .or_else(|| get_u64(obj, "context_window")) .or_else(|| meta.and_then(|m| get_u64(m, "contextWindow"))) .or_else(|| meta.and_then(|m| get_u64(m, "totalContextTokens"))) .unwrap_or(DEFAULT_CONTEXT_WINDOW); let context_window = std::num::NonZeroU64::new(context_window)?; let agent_type = get_string(obj, "systemPromptType") .or_else(|| get_string(obj, "system_prompt_type")) .or_else(|| get_string(obj, "agent_type")) .or_else(|| get_string(obj, "agentType")) .or_else(|| meta.and_then(|m| get_string(m, "agentType"))) .or_else(|| meta.and_then(|m| get_string(m, "agent_type"))) .unwrap_or_else(crate::agent::config::default_agent_type); let api_backend = get_string(obj, "apiBackend") .or_else(|| get_string(obj, "api_backend")) .and_then(|s| match s.as_str() { "responses" => Some(crate::sampling::ApiBackend::Responses), "chat_completions" => Some(crate::sampling::ApiBackend::ChatCompletions), "messages" => Some(crate::sampling::ApiBackend::Messages), _ => None, }) .unwrap_or_default(); Some(crate::agent::config::ModelEntryConfig { id, model, base_url, name, description: get_string(obj, "description"), max_completion_tokens: get_u64(obj, "maxCompletionTokens") .or_else(|| get_u64(obj, "max_completion_tokens")) .and_then(|v| u32::try_from(v).ok()), temperature: get_f64(obj, "temperature").map(|v| v as f32), top_p: get_f64(obj, "topP").or_else(|| get_f64(obj, "top_p")).map(|v| v as f32), api_key: get_string(obj, "apiKey").or_else(|| get_string(obj, "api_key")), env_key: get_env_keys(obj, "envKey").or_else(|| get_env_keys(obj, "env_key")), api_backend, context_window, auto_compact_threshold_percent: get_u64(obj, "autoCompactThresholdPercent") .or_else(|| get_u64(obj, "auto_compact_threshold_percent")) .and_then(|v| u8::try_from(v).ok()), system_prompt_label: get_string(obj, "systemPromptLabel") .or_else(|| get_string(obj, "system_prompt_label")) .filter(|s| !s.trim().is_empty()), extra_headers: get_string_map(obj, "extraHeaders"), api_base_url: get_string(obj, "apiBaseUrl") .or_else(|| get_string(obj, "api_base_url")), use_concise: obj .get("useConcise") .or_else(|| obj.get("use_concise")) .and_then(|v| v.as_bool()) .unwrap_or(false), agent_type, inference_idle_timeout_secs: get_u64(obj, "inferenceIdleTimeoutSecs") .or_else(|| get_u64(obj, "inference_idle_timeout_secs")), max_retries: get_u64(obj, "maxRetries") .or_else(|| get_u64(obj, "max_retries")) .and_then(|v| u32::try_from(v).ok()), hidden: obj .get("hidden") .or_else(|| meta.and_then(|m| m.get("hidden"))) .and_then(|v| v.as_bool()) .unwrap_or(false), supported_in_api: obj .get("supportedInApi") .or_else(|| obj.get("supported_in_api")) .or_else(|| meta.and_then(|m| m.get("supportedInApi"))) .and_then(|v| v.as_bool()) .unwrap_or(true), auth_scheme: None, reasoning_effort: get_string(obj, "reasoningEffort") .or_else(|| get_string(obj, "reasoning_effort")) .or_else(|| meta.and_then(|m| get_string(m, "reasoningEffort"))) .and_then(|s| s.parse().ok()), supports_reasoning_effort: obj .get("supportsReasoningEffort") .or_else(|| obj.get("supports_reasoning_effort")) .or_else(|| meta.and_then(|m| m.get("supportsReasoningEffort"))) .and_then(|v| v.as_bool()) .unwrap_or(false), reasoning_efforts: obj .get("reasoningEfforts") .or_else(|| obj.get("reasoning_efforts")) .or_else(|| meta.and_then(|m| m.get("reasoningEfforts"))) .and_then(|v| v.as_array()) .map(|arr| kigi_sampling_types::parse_reasoning_effort_options(arr)) .unwrap_or_default(), capabilities: obj .get("capabilities") .and_then(|v| { serde_json::from_value::>(v.clone()).ok() }) .unwrap_or_default(), supports_backend_search: obj .get("supportsBackendSearch") .or_else(|| obj.get("supports_backend_search")) .or_else(|| meta.and_then(|m| m.get("supportsBackendSearch"))) .and_then(|v| v.as_bool()) .unwrap_or(false), compactions_remaining: obj .get("compactionsRemaining") .or_else(|| obj.get("compactions_remaining")) .or_else(|| meta.and_then(|m| m.get("compactionsRemaining"))) .and_then(parse_compactions_remaining) .or_else(|| { obj .get("sendCompactionsRemaining") .or_else(|| obj.get("send_compactions_remaining")) .or_else(|| meta.and_then(|m| m.get("sendCompactionsRemaining"))) .and_then(|v| v.as_bool()) .map(kigi_sampling_types::CompactionsRemaining::Dynamic) }), compaction_at_tokens: obj .get("compactionAtTokens") .or_else(|| obj.get("compaction_at_tokens")) .or_else(|| meta.and_then(|m| m.get("compactionAtTokens"))) .and_then(parse_compaction_at_tokens), show_model_fingerprint: obj .get("showModelFingerprint") .or_else(|| obj.get("show_model_fingerprint")) .or_else(|| meta.and_then(|m| m.get("showModelFingerprint"))) .and_then(|v| v.as_bool()) .unwrap_or(false), stream_tool_calls: obj .get("streamToolCalls") .or_else(|| obj.get("stream_tool_calls")) .and_then(|v| v.as_bool()), laziness_detector: get_object(obj, "lazinessDetector") .or_else(|| get_object(obj, "laziness_detector")) .or_else(|| meta.and_then(|m| get_object(m, "lazinessDetector"))) .and_then(|v| match serde_json::from_value::< crate::agent::config::LazinessDetectorPerModelConfig, >(v.clone()) { Ok(cfg) => Some(cfg), Err(e) => { tracing::warn!( error = % e, "Failed to deserialize laziness_detector block from remote model; falling back to default" ); None } }) .unwrap_or_default(), }) } fn get_string(obj: &serde_json::Map, key: &str) -> Option { obj.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) } /// Parse `env_key` / `envKey` as a single string or a string array. fn get_env_keys( obj: &serde_json::Map, key: &str, ) -> Option { let v = obj.get(key)?; if let Some(s) = v.as_str() { return Some(crate::agent::config::EnvKeys::single(s)); } if let Some(arr) = v.as_array() { let mut names = Vec::with_capacity(arr.len()); for item in arr { let Some(s) = item.as_str() else { tracing::warn!( key, "env_key array has a non-string element; ignoring env_key" ); return None; }; if !s.is_empty() { names.push(s.to_owned()); } } if names.is_empty() { return None; } return Some(crate::agent::config::EnvKeys::new(names)); } None } fn parse_compaction_at_tokens( v: &serde_json::Value, ) -> Option { use kigi_sampling_types::CompactionAtTokens; v.as_bool() .map(CompactionAtTokens::Enabled) .or_else(|| v.as_u64().map(CompactionAtTokens::Fixed)) } fn parse_compactions_remaining( v: &serde_json::Value, ) -> Option { use kigi_sampling_types::CompactionsRemaining; v.as_bool().map(CompactionsRemaining::Dynamic).or_else(|| { v.as_u64() .and_then(|n| u8::try_from(n).ok()) .map(CompactionsRemaining::Fixed) }) } fn get_u64(obj: &serde_json::Map, key: &str) -> Option { obj.get(key).and_then(|v| v.as_u64()) } fn get_f64(obj: &serde_json::Map, key: &str) -> Option { obj.get(key).and_then(|v| v.as_f64()) } fn get_object<'a>( obj: &'a serde_json::Map, key: &str, ) -> Option<&'a serde_json::Value> { obj.get(key).filter(|v| v.is_object()) } fn get_string_map( obj: &serde_json::Map, key: &str, ) -> IndexMap { obj.get(key) .and_then(|v| v.as_object()) .map(|map| { map.iter() .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) .collect() }) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; /// Whether a freshly-fetched platform entry shows up in the picker for a /// user whose PRIMARY session is NOT an OAuth session (`is_session_auth == /// false`): an API-key user, or — the case that made this a ship-blocker — /// someone who signed in with ONLY a Claude Pro/Max, ChatGPT, Copilot, or /// Grok subscription. `ModelInfo::visible_for_auth` is the picker's real /// predicate (`agent/models.rs` → `available()`). fn visible_to_non_primary_session_user(entry: &crate::agent::config::ModelEntryConfig) -> bool { crate::agent::config::ModelEntry::from_config_entry(entry).visible_for_auth(false) } /// OpenAI-cycle e2e (mock wire): a polluted bare-id `/models` listing + /// a models.dev refresh produce a catalog with ONLY chat models, enriched /// context windows / efforts, and the Responses backend — the full /// "live list + documented metadata" contract. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn openai_listing_is_enriched_filtered_and_responses_backed() { 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 sk-oai")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "gpt-5-test", "object": "model", "owned_by": "openai" }, { "id": "whisper-1", "object": "model", "owned_by": "openai" }, { "id": "text-embedding-tiny", "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!({ "openai": { "models": { "gpt-5-test": { "name": "GPT-5 Test", "reasoning": true, "reasoning_options": [ {"type": "effort", "values": ["low", "medium", "high"]} ], "limit": {"context": 400000, "output": 128000}, "modalities": {"input": ["text", "image"]}, "tool_call": true }, // models.dev KNOWS embeddings models — membership alone // must not admit them; the tool_call cut does. "text-embedding-tiny": { "limit": {"context": 8191} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::OPENAI_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::OpenAi, "sk-oai", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["openai/gpt-5-test"], "pollution must be filtered: whisper (enrichment-unknown) AND \ text-embedding-tiny (enrichment-known but not tool-calling)" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 400_000, "context window must come from enrichment (wire had none)" ); assert_eq!( entry.api_backend, crate::sampling::ApiBackend::Responses, "OpenAI entries must use the Responses backend" ); assert_eq!(entry.name.as_deref(), Some("GPT-5 Test")); assert!(entry.supports_reasoning_effort, "efforts must be filled"); assert_eq!( entry .reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["low", "medium", "high"] ); assert!( entry .capabilities .contains(&kigi_models::ModelCapability::Thinking), "enrichment reasoning flag must derive the thinking capability" ); assert_eq!( entry.env_key, Some(crate::agent::config::EnvKeys::single("OPENAI_API_KEY")), "entries carry the env NAME (never key values)" ); assert!( cache_dir.path().join("models_dev_cache.json").exists(), "the refresh must be cached in the overridden dir" ); } /// Anthropic-cycle e2e (mock wire): the Anthropic listing dialect — /// x-api-key + anthropic-version headers, ?limit=1000 — maps /// wire-served metadata (max_input_tokens, per-level effort /// capabilities) onto Messages-backed XApiKey entries, and enrichment /// fills a zero max_input_tokens without touching wire-served values. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn anthropic_listing_maps_wire_metadata_and_enrichment_fills_gaps() { let platform_server = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/models")) .and(wiremock::matchers::query_param("limit", "1000")) .and(wiremock::matchers::header("x-api-key", "sk-ant")) .and(wiremock::matchers::header( "anthropic-version", "2023-06-01", )) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "claude-opus-4-8", "display_name": "Claude Opus 4.8", "type": "model", "max_input_tokens": 1_000_000, "capabilities": { "effort": { "supported": true, "low": {"supported": true}, "medium": {"supported": true}, "high": {"supported": true}, "xhigh": {"supported": true}, "max": {"supported": true} }, "thinking": {"supported": true}, "image_input": {"supported": true} } }, { "id": "claude-gap-test", "type": "model", "max_input_tokens": 0, "capabilities": { "effort": {"supported": false}, "thinking": {"supported": true}, "image_input": {"supported": false} } } ], "has_more": false }), )) .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!({ "anthropic": { "models": { "claude-gap-test": { "limit": {"context": 200000, "output": 64000}, "tool_call": true, "reasoning": true, "reasoning_options": [ {"type": "effort", "values": ["low", "high"]} ] }, "claude-opus-4-8": { "limit": {"context": 555}, "tool_call": true } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::ANTHROPIC_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::Anthropic, "sk-ant", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!(result.models.len(), 2); let opus = &result.models[0]; assert_eq!(opus.id.as_deref(), Some("anthropic/claude-opus-4-8")); assert_eq!( opus.context_window.get(), 1_000_000, "wire max_input_tokens must WIN over enrichment (555)" ); assert_eq!(opus.api_backend, crate::sampling::ApiBackend::Messages); assert_eq!( opus.auth_scheme, Some(kigi_sampler::AuthScheme::XApiKey), "anthropic entries must ride x-api-key at inference" ); assert_eq!( opus.reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["low", "medium", "high", "xhigh", "max"], "wire effort capabilities become the menu" ); let gap = &result.models[1]; assert_eq!( gap.context_window.get(), 200_000, "a zero wire context must be filled by enrichment" ); assert_eq!( gap.max_completion_tokens, Some(64_000), "the enrichment output cap must reach max_completion_tokens" ); assert!( gap.reasoning_efforts.is_empty() && !gap.supports_reasoning_effort, "the wire's explicit effort decline must block enrichment's menu \ (pre-4.6 models 400 on adaptive thinking); efforts={:?} supports={}", gap.reasoning_efforts, gap.supports_reasoning_effort, ); let opus = &result.models[0]; assert_eq!( opus.max_completion_tokens, None, "no wire/enrichment cap on this fixture entry — sampler default applies" ); assert!( gap.capabilities .contains(&kigi_models::ModelCapability::Thinking), "wire thinking capability must survive" ); } /// Claude Pro/Max OAuth fetch e2e (mock wire): `GET /v1/models?limit=1000` /// gated on the OAuth `Authorization: Bearer` + the oauth `anthropic-beta` /// (the OAuth listing contract) → anthropic listing → enriched from /// models.dev "anthropic" → keyed `claude-pro-max/` on the Messages /// backend. The token is drawn from the claude-pro-max OAuth-session map, /// NOT an x-api-key. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn claude_pro_max_oauth_listing_is_bearer_gated_and_keyed() { let platform_server = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/models")) .and(wiremock::matchers::query_param("limit", "1000")) // OAuth listing: Bearer token + the oauth beta + anthropic-version. // NO x-api-key header (that is the API-key `anthropic` path). .and(wiremock::matchers::header( "Authorization", "Bearer sk-ant-oat-session", )) // The oauth beta is comma-joined; wiremock's exact `header` matcher // splits on commas, so assert both tokens via the multi-valued form. .and(wiremock::matchers::headers( "anthropic-beta", vec!["claude-code-20250219", "oauth-2025-04-20"], )) .and(wiremock::matchers::header( "anthropic-version", "2023-06-01", )) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "claude-opus-4-8", "display_name": "Claude Opus 4.8", "type": "model" } ], "has_more": false }), )) .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!({ "anthropic": { "models": { "claude-opus-4-8": { "limit": {"context": 1000000, "output": 128000}, "tool_call": true } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::CLAUDE_OAUTH_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(); // The listing bearer comes from the claude-pro-max OAuth-session map, // never a Kimi session (auth=None) or an API key (keys empty). let mut oauth_tokens = OAuthSessionTokens::new(); oauth_tokens.insert( kigi_models::PlatformId::ClaudeProMax, "sk-ant-oat-session".to_string(), ); let keys = crate::agent::models::PlatformApiKeys::default(); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys) }) .await .unwrap() .expect("claude-pro-max oauth fetch must succeed"); assert_eq!(result.models.len(), 1); let opus = &result.models[0]; assert_eq!( opus.id.as_deref(), Some("claude-pro-max/claude-opus-4-8"), "the entry must key under the claude-pro-max platform" ); assert_eq!( opus.api_backend, crate::sampling::ApiBackend::Messages, "claude-pro-max speaks the Messages wire" ); assert_eq!( opus.auth_scheme, None, "OAuth Bearer entries carry no XApiKey auth scheme" ); assert_eq!( opus.context_window.get(), 1_000_000, "enrichment fills the context window from models.dev anthropic" ); assert!( opus.supported_in_api, "claude-pro-max carries its OWN pooled credential — it must NOT be \ gated on the primary (Kimi) session" ); assert!( visible_to_non_primary_session_user(opus), "a Claude-Pro/Max-only user has no primary OAuth session; their \ models must still appear in the picker" ); } /// GitHub Copilot OAuth fetch e2e (mock wire): `GET /models` gated on the /// Bearer COPILOT token + the VS Code editor headers + X-GitHub-Api-Version /// returns a mix (a good completions model, a claude-4.x messages model, a /// gpt-5 responses-only model, and a disabled model). The catalog keeps ONLY /// the completions-served enabled tool-calling model, keyed `github-copilot/ /// ` on the ChatCompletions backend, enriched from models.dev /// "github-copilot". The bearer is drawn from the copilot OAuth-session map, /// never a Kimi session or an API key. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn github_copilot_oauth_listing_filters_and_keys_completions_models() { let platform_server = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/models")) // Bearer COPILOT token + the editor identity + the Copilot API // version — the request MUST carry all of them or the mock 404s. .and(wiremock::matchers::header( "Authorization", "Bearer copilot-session-tok", )) .and(wiremock::matchers::header( "User-Agent", "GitHubCopilotChat/0.35.0", )) .and(wiremock::matchers::header( "Editor-Version", "vscode/1.107.0", )) .and(wiremock::matchers::header( "Copilot-Integration-Id", "vscode-chat", )) .and(wiremock::matchers::header( "X-GitHub-Api-Version", "2026-06-01", )) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ // KEPT: completions-served, selectable, tool-calling. { "id": "gpt-4.1", "name": "GPT-4.1", "model_picker_enabled": true, "policy": {"state": "enabled"}, "capabilities": {"supports": {"tool_calls": true}} }, // DROPPED: claude-4.x → anthropic-messages wire (excluded). { "id": "claude-opus-4-8", "model_picker_enabled": true, "capabilities": {"supports": {"tool_calls": true}} }, // DROPPED: gpt-5* → responses-only (excluded). { "id": "gpt-5.2", "model_picker_enabled": true, "capabilities": {"supports": {"tool_calls": true}} }, // DROPPED: policy disabled. { "id": "gemini-3-flash-preview", "model_picker_enabled": true, "policy": {"state": "disabled"} } ]}), )) .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!({ "github-copilot": { "models": { "gpt-4.1": { "limit": {"context": 128000, "output": 16384}, "tool_call": true } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::COPILOT_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(); // The listing bearer comes from the github-copilot OAuth-session map, // never a Kimi session (auth=None) or an API key (keys empty). let mut oauth_tokens = OAuthSessionTokens::new(); oauth_tokens.insert( kigi_models::PlatformId::GithubCopilot, "copilot-session-tok".to_string(), ); let keys = crate::agent::models::PlatformApiKeys::default(); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys) }) .await .unwrap() .expect("github-copilot oauth fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["github-copilot/gpt-4.1"], "only the completions-served, enabled, tool-calling model survives \ (claude-4.x, gpt-5, and the disabled model are dropped)" ); let entry = &result.models[0]; assert_eq!( entry.api_backend, crate::sampling::ApiBackend::ChatCompletions, "github-copilot speaks the ChatCompletions wire" ); assert_eq!( entry.auth_scheme, None, "OAuth Bearer entries carry no XApiKey auth scheme" ); assert_eq!(entry.name.as_deref(), Some("GPT-4.1")); assert_eq!( entry.context_window.get(), 128_000, "context window comes from models.dev github-copilot enrichment" ); assert!( entry.supported_in_api, "github-copilot carries its OWN pooled credential — it must NOT be \ gated on the primary (Kimi) session" ); assert!( visible_to_non_primary_session_user(entry), "a Copilot-only user has no primary OAuth session; their models \ must still appear in the picker" ); } /// openai-codex fetch: the catalog is HARDCODED, so the fetch path /// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet /// the fetch returns exactly the 4 compiled-in models keyed /// `openai-codex/` on the Responses backend, ctx 272000, each exposing /// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`). /// A BOGUS base URL confirms no live `/models` request is attempted (it would /// otherwise fail against an unroutable host). #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn openai_codex_catalog_is_hardcoded_with_no_http_fetch() { // Unroutable base: if the fetch path tried a live `/models` request it // would error here; the hardcoded short-circuit ignores it for fetching. let _base = kigi_test_support::EnvGuard::set( kigi_models::CODEX_BASE_URL_ENV, "http://127.0.0.1:1/codex", ); let endpoints = crate::agent::config::EndpointsConfig::default(); // The session token merely marks openai-codex "enabled"; it is unused by // the hardcoded path (no request rides it). let mut oauth_tokens = OAuthSessionTokens::new(); oauth_tokens.insert( kigi_models::PlatformId::OpenaiCodex, "codex-session-tok".to_string(), ); let keys = crate::agent::models::PlatformApiKeys::default(); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys) }) .await .unwrap() .expect("openai-codex hardcoded catalog fetch must succeed with no HTTP"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec![ "openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.6-terra", "openai-codex/gpt-5.6-luna", "openai-codex/gpt-5.5", ], "exactly the 4 hardcoded models, keyed openai-codex/" ); // Excluded models never appear. for absent in [ "openai-codex/gpt-5.3-codex-spark", "openai-codex/gpt-5.4", "openai-codex/gpt-5.4-mini", "openai-codex/codex-auto-review", ] { assert!( !result .models .iter() .any(|m| m.id.as_deref() == Some(absent)), "{absent} must be absent from the hardcoded catalog" ); } let sol = &result.models[0]; assert_eq!( sol.api_backend, crate::sampling::ApiBackend::Responses, "openai-codex speaks the Responses wire" ); assert_eq!(sol.context_window.get(), 272_000); assert_eq!(sol.name.as_deref(), Some("GPT-5.6-Sol")); assert!( sol.supported_in_api, "openai-codex carries its OWN pooled credential — it must NOT be \ gated on the primary (Kimi) session" ); assert!( visible_to_non_primary_session_user(sol), "a ChatGPT/Codex-only user has no primary OAuth session; their \ models must still appear in the picker" ); assert!(sol.supports_reasoning_effort); assert_eq!( sol.reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["low", "medium", "high", "xhigh", "max"], "sol exposes the full codex effort menu" ); // gpt-5.5 tops out at xhigh (no max). let five_five = result .models .iter() .find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.5")) .unwrap(); assert_eq!( five_five .reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["low", "medium", "high", "xhigh"] ); } /// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts /// (high/max) produce ChatCompletions entries whose sampler config /// speaks the DeepSeek thinking dialect. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn deepseek_listing_enriches_and_maps_dialect() { 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 sk-ds")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek" } ]}), )) .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!({ "deepseek": { "models": { "deepseek-v4-pro": { "reasoning": true, "reasoning_options": [ {"type": "toggle"}, {"type": "effort", "values": ["high", "max"]} ], "limit": {"context": 1000000, "output": 384000}, "tool_call": true }}}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::DEEPSEEK_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::DeepSeek, "sk-ds", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!(result.models.len(), 1); let entry = &result.models[0]; assert_eq!(entry.id.as_deref(), Some("deepseek/deepseek-v4-pro")); assert_eq!(entry.context_window.get(), 1_000_000); assert_eq!(entry.max_completion_tokens, Some(384_000)); assert_eq!( entry.api_backend, crate::sampling::ApiBackend::ChatCompletions ); assert_eq!( entry .reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["high", "max"] ); // The managed id maps to the DeepSeek chat dialect; a BYOK entry // (no managed key) keeps the historical Kimi adaptation. let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("sk-ds".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::DeepSeek); let mut byok = entry.clone(); byok.id = Some("my-custom".into()); let byok_entry = crate::agent::config::ModelEntry::from_config_entry(&byok); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("sk-x".into()), base_url: byok.base_url.clone(), auth_type: kigi_chat_state::AuthType::ApiKey, auth_scheme: Default::default(), }; let cfg = crate::agent::config::sampling_config_for_model(&byok_entry, creds, None); assert_eq!(cfg.chat_compat, kigi_sampling_types::ChatCompat::Kimi); } /// Groq-cycle e2e: pure pattern — polluted listing restricted to /// tool-calling enrichment models, Passthrough dialect (OpenAI-style /// reasoning_effort untouched on this wire). #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn groq_listing_restricts_and_maps_passthrough_dialect() { 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 gsk-1")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "llama-3.3-70b-versatile", "object": "model" }, { "id": "whisper-large-v3", "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!({ "groq": { "models": { "llama-3.3-70b-versatile": { "limit": {"context": 131072, "output": 32768}, "tool_call": true }, "whisper-large-v3": { "limit": {"context": 448} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set(kigi_models::GROQ_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::Groq, "gsk-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["groq/llama-3.3-70b-versatile"], "whisper (enrichment-known, not tool-calling) must be dropped" ); let entry = &result.models[0]; assert_eq!(entry.context_window.get(), 131_072); assert_eq!(entry.max_completion_tokens, Some(32_768)); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("gsk-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, "groq entries must leave OpenAI-style bodies untouched" ); } /// Mistral-cycle e2e: embed pollution restricted away, and the Mistral /// dialect (strips stream_options, handles reasoning arrays) is mapped. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn mistral_listing_restricts_and_maps_mistral_dialect() { 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 msk-1")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "devstral-latest", "object": "model" }, { "id": "mistral-embed", "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!({ "mistral": { "models": { "devstral-latest": { "limit": {"context": 262144, "output": 65536}, "tool_call": true }, "mistral-embed": { "limit": {"context": 8000} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::MISTRAL_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::Mistral, "msk-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["mistral/devstral-latest"], "embed (enrichment-known, not tool-calling) must be dropped" ); let entry = &result.models[0]; assert_eq!(entry.context_window.get(), 262_144); assert_eq!(entry.max_completion_tokens, Some(65_536)); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("msk-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::Mistral, "mistral entries use the Mistral dialect (StrictOpenAi behavior \ plus the exactly-nine-alphanumeric tool-call id contract)" ); } /// Fireworks-cycle e2e (Groq pattern): embedding pollution restricted /// away, Passthrough dialect, and Fireworks' deeply-slashed native ids /// (`accounts/fireworks/models/…`) round-trip through the managed key /// (`fireworks/accounts/fireworks/models/…`, first-slash split). #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn fireworks_listing_restricts_maps_dialect_and_keeps_slashed_ids() { 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 fw-1")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "accounts/fireworks/models/glm-5p2", "object": "model" }, { "id": "nomic-ai/nomic-embed-text-v1.5", "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!({ "fireworks-ai": { "models": { "accounts/fireworks/models/glm-5p2": { "limit": {"context": 1048575, "output": 65536}, "tool_call": true }, "nomic-ai/nomic-embed-text-v1.5": { "limit": {"context": 8192} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::FIREWORKS_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::Fireworks, "fw-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["fireworks/accounts/fireworks/models/glm-5p2"], "embedding model (enrichment-known, not tool-calling) must be dropped; \ the slashed native id survives in the managed key" ); let entry = &result.models[0]; assert_eq!(entry.context_window.get(), 1_048_575); assert_eq!(entry.max_completion_tokens, Some(65_536)); // The NATIVE slashed id rides the inference wire (`model` field); // the `fireworks/` managed-key prefix is internal routing only. A // regression here would 404 every Fireworks request. assert_eq!( entry.model, "accounts/fireworks/models/glm-5p2", "wire model must be the native id, not the managed key" ); // The managed key parses back to (Fireworks, native-slashed-id). assert_eq!( kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()), Some(( kigi_models::PlatformId::Fireworks, "accounts/fireworks/models/glm-5p2" )) ); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("fw-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 ); assert_eq!( cfg.model, "accounts/fireworks/models/glm-5p2", "the sampler wire model is the native slashed id end-to-end" ); } /// 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, &Default::default(), &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 ); } /// 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, &Default::default(), &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 ); } /// Together-cycle e2e: the listing is a BARE JSON ARRAY (no {data:[]} /// envelope) — parse_openai_listing tolerates it. models.dev enrichment /// (matching org/Model keys) supplies context + the tool-calling /// restriction drops non-chat models; Passthrough dialect; slashed id. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn together_bare_array_listing_enriches_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 tg-1")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( // BARE ARRAY, not {object:list,data:[]}. serde_json::json!([ { "id": "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", "object": "model", "type": "chat", "context_length": 262144 }, { "id": "togethercomputer/m2-bert-80M-8k-retrieval", "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!({ "togetherai": { "models": { "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { "limit": {"context": 262144, "output": 32768}, "tool_call": true }, "togethercomputer/m2-bert-80M-8k-retrieval": { "limit": {"context": 8192} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::TOGETHER_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::Together, "tg-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["together/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"], "bare array parsed; embedding (not tool-calling) dropped; slashed id in key" ); let entry = &result.models[0]; assert_eq!(entry.context_window.get(), 262_144); assert_eq!(entry.max_completion_tokens, Some(32_768)); assert_eq!( entry.model, "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", "the native slashed id rides the wire" ); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("tg-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 ); } /// Cerebras-cycle e2e: enrich WITHOUT restrict (the unpolluted-catalog /// path). A minimal listing (bare ids, no context) → every live model is /// kept; the enrichment-known one gains context + an effort menu, the /// unknown one keeps the default context. Passthrough dialect. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn cerebras_enriches_without_restrict_keeping_all_models() { 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 cb-1")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( // Minimal Cerebras listing: ids only, no context. serde_json::json!({ "data": [ { "id": "gpt-oss-120b", "object": "model" }, { "id": "brand-new-cerebras-model", "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!({ "cerebras": { "models": { "gpt-oss-120b": { "limit": {"context": 131072, "output": 40960}, "reasoning": true, "reasoning_options": [ {"type": "effort", "values": ["low", "medium", "high"]} ], "tool_call": true } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::CEREBRAS_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::Cerebras, "cb-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); // restrict=false → BOTH the known and the unknown model are kept. assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["cerebras/gpt-oss-120b", "cerebras/brand-new-cerebras-model"], "enrich-without-restrict keeps every live model" ); let known = &result.models[0]; assert_eq!(known.context_window.get(), 131_072, "known model enriched"); assert_eq!(known.max_completion_tokens, Some(40_960)); assert!( known.supports_reasoning_effort, "enrichment effort menu → selectable levels" ); assert_eq!( known .reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["low", "medium", "high"], "effort menu comes from enrichment" ); let unknown = &result.models[1]; assert_eq!( unknown.context_window.get(), DEFAULT_CONTEXT_WINDOW, "an enrichment-unknown model keeps the default context (not dropped)" ); let model_entry = crate::agent::config::ModelEntry::from_config_entry(known); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("cb-1".into()), base_url: known.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::StrictOpenAi, "Cerebras uses the StrictOpenAi dialect (strict validator strips stream_options)" ); } /// NVIDIA-cycle e2e: polluted listing (embedding/image models) restricted /// to tool-calling enrichment-known chat models; slashed org/model ids; /// StrictOpenAi dialect (strips stream_options — some NIM models reject it). #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn nvidia_restricts_slashed_ids_and_maps_strict_dialect() { 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 nvapi-1", )) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "meta/llama-3.3-70b-instruct", "object": "model" }, { "id": "baai/bge-m3", "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!({ "nvidia": { "models": { "meta/llama-3.3-70b-instruct": { "limit": {"context": 128000, "output": 32768}, "tool_call": true }, "baai/bge-m3": { "limit": {"context": 8192} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::NVIDIA_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::Nvidia, "nvapi-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["nvidia/meta/llama-3.3-70b-instruct"], "bge-m3 embedding (not tool-calling) dropped; slashed org/model id kept" ); let entry = &result.models[0]; assert_eq!(entry.context_window.get(), 128_000); assert_eq!(entry.max_completion_tokens, Some(32_768)); assert_eq!( entry.model, "meta/llama-3.3-70b-instruct", "the native slashed id rides the wire" ); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("nvapi-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::StrictOpenAi, "NVIDIA uses StrictOpenAi (strips stream_options for NIM models that reject it)" ); } /// 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, &Default::default(), &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 ); } /// xAI-cycle e2e: /v1/models is minimal (bare grok ids, no context), so /// enrichment supplies context/limits; the restriction drops the /// non-tool-calling grok-imagine-* generators; bare id round-trips under /// the `xai/` key; Passthrough dialect. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn xai_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( // xAI /v1/models is minimal: id/object only, NO context field — // so a non-zero context below can only come from enrichment. serde_json::json!({ "data": [ { "id": "grok-4.5", "object": "model" }, { "id": "grok-imagine-image", "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!({ "xai": { "models": { "grok-4.5": { "limit": {"context": 500000, "output": 128000}, "tool_call": true }, // present in enrichment too, but not tool-calling → dropped. "grok-imagine-image": { "limit": {"context": 8000} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set(kigi_models::XAI_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::Xai, "xai-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["xai/grok-4.5"], "grok-imagine-image (not tool-calling) dropped; bare id kept under the xai key" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 500_000, "context comes from enrichment (the wire listing carries none)" ); assert_eq!(entry.max_completion_tokens, Some(128_000)); assert_eq!(entry.model, "grok-4.5", "the bare grok id rides the wire"); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("xai-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 ); } /// Qwen-Token-Plan e2e: DashScope compatible-mode /models is auth-gated and /// minimal (ids only), so enrichment supplies context; the restriction drops /// the non-tool-calling qwen-image / wan generators the token plan lists; /// bare id round-trips under the platform key; Passthrough dialect. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn qwen_token_plan_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( // DashScope /models is minimal: id/object only, NO context. serde_json::json!({ "data": [ { "id": "qwen3.7-max", "object": "model" }, { "id": "qwen-image-2.0", "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!({ "alibaba-token-plan": { "models": { "qwen3.7-max": { "limit": {"context": 1000000, "output": 32768}, "tool_call": true }, // present in enrichment too, but not tool-calling → dropped. "qwen-image-2.0": { "limit": {"context": 8192} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::QWEN_TOKEN_PLAN_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::QwenTokenPlan, "qtp-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["qwen-token-plan/qwen3.7-max"], "qwen-image (not tool-calling) dropped; bare id kept under the platform key" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 1_000_000, "context comes from enrichment (the wire listing carries none)" ); assert_eq!(entry.max_completion_tokens, Some(32_768)); assert_eq!(entry.model, "qwen3.7-max", "the bare id rides the wire"); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("qtp-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 ); } /// Kimi-For-Coding static-key e2e: same endpoint + Kimi dialect as the OAuth /// kimi-code platform, keyed by KIMI_API_KEY. Kimi's /models serves its own /// metadata (wire_serves_metadata), so context comes from the WIRE and the /// models.dev fetch is skipped entirely (all enabled platforms self-serve); /// no restriction; Kimi dialect. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn kimi_coding_static_key_uses_wire_metadata_and_kimi_dialect() { 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( serde_json::json!({ "data": [ { "id": "k3", "object": "model", "context_length": 1_048_576, "supports_reasoning": true } ]}), )) .expect(1) .mount(&platform_server) .await; // Point models.dev at an ALWAYS-500 server: enrichment must NOT be // fetched for an all-wire-metadata provider, so this is never hit. 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(500)) .expect(0) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set(kigi_env::CODE_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::KimiCoding, "kc-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["kimi-coding/k3"], "wire model kept under the platform key (no restriction)" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 1_048_576, "context comes from the WIRE (wire_serves_metadata); enrichment was skipped" ); assert_eq!(entry.model, "k3"); // The managed key must parse back to KimiCoding — so the Kimi dialect // below is a real attribution, not the default-dialect fallback that a // failed parse would also yield. assert_eq!( kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()), Some((kigi_models::PlatformId::KimiCoding, "k3")), ); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("kc-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::Kimi); } /// Z.AI-cycle e2e: OpenAI-compatible GLM coding plan (per Pi, plain /// completions — no thinking dialect). /models is auth-gated + minimal, so /// enrichment supplies context; restrict drops any wire model absent from /// the models.dev "zai-coding-plan" snapshot; bare id round-trips under the /// zai key; Passthrough dialect. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn zai_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( serde_json::json!({ "data": [ { "id": "glm-5.2", "object": "model" }, // not in the enrichment snapshot → dropped by restrict. { "id": "glm-experimental-unlisted", "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!({ "zai-coding-plan": { "models": { "glm-5.2": { "limit": {"context": 200000, "output": 128000}, "tool_call": true } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set(kigi_models::ZAI_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::Zai, "zai-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["zai/glm-5.2"], "the non-enriched wire model is dropped by restrict_to_enriched" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 200_000, "context comes from enrichment (the wire listing carries none)" ); assert_eq!(entry.max_completion_tokens, Some(128_000)); assert_eq!(entry.model, "glm-5.2"); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("zai-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 ); } /// Xiaomi-cycle e2e: OpenAI-compatible MiMo (per Pi, plain completions). /// /models is auth-gated + minimal; enrichment supplies context; restrict's /// tool_call cut drops the mimo TTS models (tool_call=false) the token plan /// lists; bare id round-trips under the xiaomi key; Passthrough. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn xiaomi_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( serde_json::json!({ "data": [ { "id": "mimo-v2.5-pro", "object": "model" }, // a TTS model (tool_call=false in enrichment) → dropped. { "id": "mimo-v2-tts", "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!({ "xiaomi": { "models": { "mimo-v2.5-pro": { "limit": {"context": 256000, "output": 32768}, "tool_call": true }, "mimo-v2-tts": { "limit": {"context": 8192}, "tool_call": false } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::XIAOMI_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::Xiaomi, "xm-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["xiaomi/mimo-v2.5-pro"], "the TTS model (tool_call=false) is dropped by restrict_to_enriched" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 256_000, "context comes from enrichment (the wire listing carries none)" ); assert_eq!(entry.max_completion_tokens, Some(32_768)); assert_eq!(entry.model, "mimo-v2.5-pro"); let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("xm-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 ); } /// MiniMax e2e: Pi drives MiniMax through its Anthropic-compatible surface, /// so Kigi uses the Anthropic listing (x-api-key + anthropic-version, /// ?limit=1000) + Messages wire. /anthropic/v1/models is minimal, so /// enrichment supplies context; restrict=false keeps the clean MiniMax-M* /// catalog; id round-trips under the minimax key. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn minimax_anthropic_listing_enriches_and_keys_under_platform() { let platform_server = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/models")) // x-api-key auth (Anthropic key header) must be present. .and(wiremock::matchers::header("x-api-key", "mm-1")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( // Anthropic listing envelope; minimal (id only) → enrichment fills. serde_json::json!({ "data": [ { "id": "MiniMax-M2.5", "type": "model" } ], "has_more": false }), )) .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!({ "minimax": { "models": { "MiniMax-M2.5": { "limit": {"context": 204800, "output": 131072}, "tool_call": true } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::MINIMAX_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::Minimax, "mm-1", ); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["minimax/MiniMax-M2.5"], "the Anthropic-listed model is keyed under the minimax platform" ); let entry = &result.models[0]; assert_eq!( entry.context_window.get(), 204_800, "context comes from enrichment (the wire listing carries none)" ); assert_eq!(entry.max_completion_tokens, Some(131_072)); assert_eq!(entry.model, "MiniMax-M2.5"); assert_eq!( kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()), Some((kigi_models::PlatformId::Minimax, "MiniMax-M2.5")), ); } #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; let parse = |v: serde_json::Value| { let obj = serde_json::json!({ "env_key" : v }); get_env_keys(obj.as_object().unwrap(), "env_key") }; assert_eq!(parse(serde_json::json!("A")), Some(EnvKeys::single("A"))); assert_eq!( parse(serde_json::json!(["A", "B"])), Some(EnvKeys::new(["A", "B"])) ); assert_eq!(parse(serde_json::json!(["A", 123])), None); assert_eq!(parse(serde_json::json!([])), None); } #[test] fn parse_openai_format_uses_id_field() { let value = serde_json::json!( { "id" : "kigi-3", "object" : "model", "owned_by" : "xai", "context_window" : 131072 } ); let result = parse_remote_model_value(&value, "https://byok.example/v1").unwrap(); assert_eq!(result.model, "kigi-3"); assert_eq!(result.base_url, "https://byok.example/v1"); assert_eq!(result.name.as_deref(), Some("kigi-3")); } #[test] fn parse_model_field_takes_priority_over_id() { let value = serde_json::json!( { "id" : "display-key", "model" : "actual-model-id", "name" : "Display Name", "context_window" : 131072 } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!(result.model, "actual-model-id"); assert_eq!(result.name.as_deref(), Some("Display Name")); } /// Live-wire regression: the K3 `/models` entry (api.kimi.com, 2026-07) /// must land in the catalog with selectable low/high/max efforts and a /// max default — this is what feeds `/model [effort]` and `/effort`. #[test] fn platform_entry_maps_live_k3_think_efforts() { use kigi_sampling_types::ReasoningEffort; let wire: kigi_models::WireModel = serde_json::from_value(serde_json::json!({ "id": "k3", "display_name": "K3", "context_length": 1_048_576, "supports_reasoning": true, "supports_image_in": true, "supports_video_in": true, "supports_thinking_type": "only", "think_efforts": { "support": true, "valid_efforts": ["low", "high", "max"], "default_effort": "max" } })) .unwrap(); let entry = platform_wire_model_to_entry( kigi_models::PlatformId::KimiCode, wire, "https://api.kimi.com/coding/v1", ); assert!(entry.supports_reasoning_effort); // The wire token "max" is canonical Max since the Xhigh/Max split; // kimi_compat still spells it "max" on the inference wire. assert_eq!(entry.reasoning_effort, Some(ReasoningEffort::Max)); let ids: Vec<&str> = entry .reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect(); assert_eq!( ids, ["low", "high", "max"], "wire tokens stay the option ids" ); assert_eq!( entry .reasoning_efforts .iter() .map(|o| o.value) .collect::>(), [ ReasoningEffort::Low, ReasoningEffort::High, ReasoningEffort::Max ], ); let max = entry .reasoning_efforts .iter() .find(|o| o.id == "max") .unwrap(); assert!(max.default, "max is the server default for K3"); assert_eq!(max.label, "Max"); // K2.7-style entries (no think_efforts) stay effort-less. let plain: kigi_models::WireModel = serde_json::from_value(serde_json::json!({ "id": "kimi-for-coding", "context_length": 262_144, "supports_reasoning": true, "supports_thinking_type": "only" })) .unwrap(); let entry = platform_wire_model_to_entry( kigi_models::PlatformId::KimiCode, plain, "https://api.kimi.com/coding/v1", ); assert!(!entry.supports_reasoning_effort); assert!(entry.reasoning_efforts.is_empty()); assert!(entry.reasoning_effort.is_none()); } #[test] fn parse_reads_reasoning_effort_fields() { use kigi_sampling_types::ReasoningEffort; let value = serde_json::json!( { "model" : "kigi-4.5", "context_window" : 1_000_000, "supports_reasoning_effort" : true, "reasoning_effort" : "high" } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(result.supports_reasoning_effort); assert_eq!(result.reasoning_effort, Some(ReasoningEffort::High)); let value = serde_json::json!( { "model" : "kigi-4.5", "contextWindow" : 1_000_000, "supportsReasoningEffort" : true, "reasoningEffort" : "xhigh" } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(result.supports_reasoning_effort); assert_eq!(result.reasoning_effort, Some(ReasoningEffort::Xhigh)); let value = serde_json::json!({ "model" : "x", "context_window" : 256_000 }); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(!result.supports_reasoning_effort); assert!(result.reasoning_effort.is_none()); } #[test] fn parse_reads_reasoning_efforts_list() { use kigi_sampling_types::ReasoningEffort; let value = serde_json::json!( { "model" : "kigi-4.5", "context_window" : 1_000_000, "reasoning_efforts" : [{ "id" : "deep", "value" : "xhigh", "label" : "Deep" }, { "value" : "quantum" }, "low",] } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!(result.reasoning_efforts.len(), 2); assert_eq!(result.reasoning_efforts[0].id, "deep"); assert_eq!(result.reasoning_efforts[0].value, ReasoningEffort::Xhigh); assert_eq!(result.reasoning_efforts[1].value, ReasoningEffort::Low); for value in [ serde_json::json!( { "model" : "m", "context_window" : 256_000, "reasoningEfforts" : [{ "value" : "high" }] } ), serde_json::json!( { "model" : "m", "context_window" : 256_000, "_meta" : { "reasoningEfforts" : [{ "value" : "high" }] } } ), ] { let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!(result.reasoning_efforts.len(), 1); assert_eq!(result.reasoning_efforts[0].value, ReasoningEffort::High); } let value = serde_json::json!({ "model" : "x", "context_window" : 256_000 }); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(result.reasoning_efforts.is_empty()); } #[test] fn parse_reads_meta_fallback_fields() { let value = serde_json::json!( { "_meta" : { "model" : "meta-model-id", "contextWindow" : 131072, "agentType" : "concise" } } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!(result.model, "meta-model-id"); assert_eq!( result.context_window, std::num::NonZeroU64::new(131072).unwrap() ); assert_eq!(result.agent_type, "concise"); } #[test] fn parse_remote_model_value_no_laziness_detector_block_yields_default() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!( result.laziness_detector, crate::agent::config::LazinessDetectorPerModelConfig::default() ); } #[test] fn parse_remote_model_value_parses_camelcase_key() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : 2, "idle_threshold_ms" : 12_000, "min_confidence" : 0.75, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); let expected = crate::agent::config::LazinessDetectorPerModelConfig { enabled: true, max_nudges_per_session: 2, idle_threshold_ms: Some(12_000), min_confidence: Some(0.75), include_reasoning: None, }; assert_eq!(result.laziness_detector, expected); } #[test] fn parse_remote_model_value_parses_snake_case_laziness_detector() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "laziness_detector" : { "enabled" : true, "max_nudges_per_session" : 3, "idle_threshold_ms" : 8_000, "min_confidence" : 0.6, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); let expected = crate::agent::config::LazinessDetectorPerModelConfig { enabled: true, max_nudges_per_session: 3, idle_threshold_ms: Some(8_000), min_confidence: Some(0.6), include_reasoning: None, }; assert_eq!(result.laziness_detector, expected); } #[test] fn parse_remote_model_value_parses_meta_laziness_detector() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "_meta" : { "lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : 1, "idle_threshold_ms" : 15_000, "min_confidence" : 0.9, }, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); let expected = crate::agent::config::LazinessDetectorPerModelConfig { enabled: true, max_nudges_per_session: 1, idle_threshold_ms: Some(15_000), min_confidence: Some(0.9), include_reasoning: None, }; assert_eq!(result.laziness_detector, expected); } #[test] fn parse_remote_model_value_partial_block_uses_field_defaults() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); let expected = crate::agent::config::LazinessDetectorPerModelConfig { enabled: true, max_nudges_per_session: 0, idle_threshold_ms: None, min_confidence: None, include_reasoning: None, }; assert_eq!(result.laziness_detector, expected); } #[test] fn parse_remote_model_value_malformed_block_falls_back_to_default() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : "abc", }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!( result.laziness_detector, crate::agent::config::LazinessDetectorPerModelConfig::default() ); } #[test] fn parse_remote_model_value_non_object_value_falls_back_to_default() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : "not-an-object", } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!( result.laziness_detector, crate::agent::config::LazinessDetectorPerModelConfig::default() ); } #[test] fn parse_remote_model_value_top_level_camelcase_wins_over_snake_case() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : 7, }, "laziness_detector" : { "enabled" : false, "max_nudges_per_session" : 99, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); let expected = crate::agent::config::LazinessDetectorPerModelConfig { enabled: true, max_nudges_per_session: 7, idle_threshold_ms: None, min_confidence: None, include_reasoning: None, }; assert_eq!(result.laziness_detector, expected); } /// `include_reasoning: false` parses cleanly under the per-model /// `lazinessDetector` block (camelCase wrapper, snake_case inner — /// matching the existing field-naming convention used for the /// sibling `min_confidence`, `idle_threshold_ms`, etc.). #[test] fn parse_remote_model_value_parses_include_reasoning_under_camelcase_wrapper() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, "include_reasoning" : false, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!(result.laziness_detector.include_reasoning, Some(false)); } #[test] fn parse_remote_model_value_parses_include_reasoning_under_snake_case_wrapper() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "laziness_detector" : { "enabled" : true, "include_reasoning" : true, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!(result.laziness_detector.include_reasoning, Some(true)); } #[test] fn parse_remote_model_value_omitted_include_reasoning_defaults_to_none() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : 2, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert_eq!( result.laziness_detector.include_reasoning, None, "absent include_reasoning defers to harness default via None", ); } #[test] fn parse_remote_model_value_top_level_wins_over_meta() { let value = serde_json::json!( { "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : 5, }, "_meta" : { "lazinessDetector" : { "enabled" : false, "max_nudges_per_session" : 99, }, }, } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); let expected = crate::agent::config::LazinessDetectorPerModelConfig { enabled: true, max_nudges_per_session: 5, idle_threshold_ms: None, min_confidence: None, include_reasoning: None, }; assert_eq!(result.laziness_detector, expected); } #[test] fn parse_reads_show_model_fingerprint_field() { let value = serde_json::json!( { "model" : "kigi", "context_window" : 256_000, "show_model_fingerprint" : true } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(result.show_model_fingerprint); let value = serde_json::json!( { "model" : "kigi", "contextWindow" : 256_000, "showModelFingerprint" : true } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(result.show_model_fingerprint); let value = serde_json::json!( { "model" : "kigi", "context_window" : 256_000, "_meta" : { "showModelFingerprint" : true } } ); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(result.show_model_fingerprint); let value = serde_json::json!({ "model" : "x", "context_window" : 256_000 }); let result = parse_remote_model_value(&value, "https://default.url").unwrap(); assert!(!result.show_model_fingerprint); } #[test] fn get_object_returns_none_for_non_object_values() { let value = serde_json::json!( { "string" : "hello", "number" : 42, "bool" : true, "array" : [1, 2, 3], "null" : null, } ); let obj = value.as_object().unwrap(); assert!(get_object(obj, "string").is_none()); assert!(get_object(obj, "number").is_none()); assert!(get_object(obj, "bool").is_none()); assert!(get_object(obj, "array").is_none()); assert!(get_object(obj, "null").is_none()); assert!(get_object(obj, "missing").is_none()); } #[test] fn get_object_returns_some_for_actual_object() { let value = serde_json::json!({ "nested" : { "a" : 1, "b" : "two" }, }); let obj = value.as_object().unwrap(); let nested = get_object(obj, "nested").expect("nested key should resolve to object"); assert!(nested.is_object()); assert_eq!(nested["a"], serde_json::json!(1)); assert_eq!(nested["b"], serde_json::json!("two")); } fn endpoints( proxy: &str, models_base_url: Option<&str>, models_list_url: Option<&str>, ) -> crate::agent::config::EndpointsConfig { crate::agent::config::EndpointsConfig { coding_api_base_url: Some(proxy.to_owned()), models_base_url: models_base_url.map(|s| s.to_owned()), models_list_url: models_list_url.map(|s| s.to_owned()), ..Default::default() } } #[test] fn inference_url_defaults_to_proxy() { let ep = endpoints("https://proxy.kigi.com/v1", None, None); assert_eq!(ep.resolve_inference_base_url(), "https://proxy.kigi.com/v1"); } #[test] fn inference_url_uses_models_base_url() { let ep = endpoints( "https://proxy.kigi.com/v1", Some("https://enterprise.acme.com/v1"), None, ); assert_eq!( ep.resolve_inference_base_url(), "https://enterprise.acme.com/v1" ); } #[test] fn inference_url_base_url_wins_over_proxy() { let ep = endpoints( "https://proxy.kigi.com/v1", Some("https://inference.acme.com/v1"), Some("https://registry.acme.com/api/models"), ); assert_eq!( ep.resolve_inference_base_url(), "https://inference.acme.com/v1" ); } #[test] fn list_url_defaults_to_proxy_models() { let ep = endpoints("https://proxy.kigi.com/v1", None, None); assert_eq!( ep.resolve_models_list_url(), "https://proxy.kigi.com/v1/models" ); } #[test] fn list_url_derived_from_base_url() { let ep = endpoints( "https://proxy.kigi.com/v1", Some("https://byok.example/v1"), None, ); assert_eq!( ep.resolve_models_list_url(), "https://byok.example/v1/models" ); } #[test] fn list_url_explicit_overrides_derivation() { let ep = endpoints( "https://proxy.kigi.com/v1", Some("https://inference.acme.com/v1"), Some("https://registry.acme.com/api/list-models"), ); assert_eq!( ep.resolve_models_list_url(), "https://registry.acme.com/api/list-models" ); } /// xai-grok OAuth-cycle e2e (mock wire): the generic device-code OAuth /// bearer (`grok-oauth-tok`, resolved from a stored `oauth/xai` session — /// mocked here as the token map) fetches `GET {base}/models` against the /// platform's OWN base (api.x.ai/v1 via its env), enriches from models.dev /// "xai", restricts to tool-calling chat models, and keys each entry under /// `xai-grok/` with the Passthrough dialect — NOT the API-key `xai/`. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn xai_grok_oauth_listing_is_enriched_restricted_and_keyed() { 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 grok-oauth-tok", )) .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( serde_json::json!({ "data": [ { "id": "grok-4.5", "object": "model" }, // enrichment-known but NOT tool-calling → dropped by restrict. { "id": "grok-2-image", "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!({ "xai": { "models": { "grok-4.5": { "name": "Grok 4.5", "reasoning": true, "limit": {"context": 256000, "output": 64000}, "tool_call": true }, "grok-2-image": { "limit": {"context": 8192} } }}}), )) .expect(1) .mount(&modelsdev_server) .await; let cache_dir = tempfile::tempdir().unwrap(); let _base = kigi_test_support::EnvGuard::set( kigi_models::XAI_GROK_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::default(); let mut tokens = OAuthSessionTokens::new(); tokens.insert(kigi_models::PlatformId::XaiGrok, "grok-oauth-tok".into()); let result = tokio::task::spawn_blocking(move || { fetch_platform_models_blocking(&endpoints, None, &tokens, &keys) }) .await .unwrap() .expect("fetch must succeed"); assert_eq!( result .models .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), vec!["xai-grok/grok-4.5"], "grok-2-image (enrichment-known, not tool-calling) must be dropped; \ the surviving model is keyed under xai-grok/ (not xai/)" ); let entry = &result.models[0]; assert_eq!(entry.model, "grok-4.5"); assert_eq!( entry.base_url, platform_server.uri(), "xai-grok fetches against its OWN base (its base env), not proxy_url" ); assert_eq!( entry.context_window.get(), 256_000, "context window must come from models.dev \"xai\" enrichment" ); assert_eq!(entry.max_completion_tokens, Some(64_000)); assert_eq!( entry.api_backend, crate::sampling::ApiBackend::ChatCompletions ); assert_eq!(entry.name.as_deref(), Some("Grok 4.5")); assert!( entry.env_key.is_none(), "an OAuth channel carries no api-key env" ); assert!( entry.supported_in_api, "xai-grok carries its OWN pooled credential — it must NOT be gated \ on the primary (Kimi) session" ); assert!( visible_to_non_primary_session_user(entry), "a Grok-only user has no primary OAuth session; their models must \ still appear in the picker" ); // Passthrough dialect (identical to the API-key xai wire). let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry); let creds = crate::agent::config::ResolvedCredentials { api_key: Some("grok-oauth-tok".into()), base_url: entry.base_url.clone(), auth_type: kigi_chat_state::AuthType::SessionToken, 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 ); } /// INVARIANT: each platform's `/models` URL matches its registry base — /// kimi-code → the subscription proxy (config override respected, else the /// kigi-env default), moonshot platforms → their fixed bases — and the /// cache-origin key encodes the enabled fetch plan without any secrets. #[test] #[serial_test::serial] fn platform_models_urls_and_fetch_origin() { use crate::agent::config::EndpointsConfig; use crate::agent::models::{ModelFetchAuth, PlatformApiKeys}; for k in [ "KIGI_CODE_BASE_URL", "KIGI_CODE_BASE_URL", "KIGI_MODELS_LIST_URL", ] { unsafe { std::env::remove_var(k) }; } let cfg = EndpointsConfig::from_config_value(&toml::Value::Table(Default::default())); assert_eq!( platform_models_url(kigi_models::PlatformId::KimiCode, &cfg), "https://api.kimi.com/coding/v1/models" ); assert_eq!( platform_models_url(kigi_models::PlatformId::MoonshotCn, &cfg), "https://api.moonshot.cn/v1/models" ); assert_eq!( platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg), "https://api.moonshot.ai/v1/models" ); assert_eq!( platform_models_url(kigi_models::PlatformId::OpenAi, &cfg), "https://api.openai.com/v1/models" ); // xai-grok is uses_oauth but carries an OAuthConfig → its OWN base // (api.x.ai/v1), NOT the Kimi subscription proxy. assert_eq!( platform_models_url(kigi_models::PlatformId::XaiGrok, &cfg), "https://api.x.ai/v1/models" ); // Proxy override re-points the subscription platform only. let proxied = EndpointsConfig::from_config_value( &toml::from_str( r#"[endpoints] coding_api_base_url = "https://proxy.acme.example/v1""#, ) .unwrap(), ); assert_eq!( platform_models_url(kigi_models::PlatformId::KimiCode, &proxied), "https://proxy.acme.example/v1/models" ); assert_eq!( platform_models_url(kigi_models::PlatformId::MoonshotCn, &proxied), "https://api.moonshot.cn/v1/models" ); // Origin key: OAuth-only plan lists kimi-code only; adding a moonshot // key changes the plan (→ cache miss); the key VALUE never appears. let oauth_only = models_fetch_origin( &cfg, ModelFetchAuth::Platforms, true, &Default::default(), &PlatformApiKeys::default(), ); assert_eq!( oauth_only, "platforms[kimi-code=https://api.kimi.com/coding/v1/models]" ); let with_cn = models_fetch_origin( &cfg, ModelFetchAuth::Platforms, true, &Default::default(), &crate::agent::models::PlatformApiKeys::test_keys(Some("sk-secret-cn"), None), ); assert_ne!( oauth_only, with_cn, "enabling a platform must change the origin" ); assert!(with_cn.contains("moonshot-cn=https://api.moonshot.cn/v1/models")); assert!( !with_cn.contains("sk-secret-cn"), "origin key must never embed credential values" ); // Custom endpoint mode → the explicit list URL verbatim. let custom = EndpointsConfig::from_config_value( &toml::from_str( r#"[endpoints] models_base_url = "https://models.acme.com/v1""#, ) .unwrap(), ); assert_eq!( models_fetch_origin( &custom, ModelFetchAuth::CustomEndpoint, false, &Default::default(), &PlatformApiKeys::default(), ), "https://models.acme.com/v1/models" ); } /// `stored_oauth_platforms` maps auth.json `oauth/` scopes to /// their platforms — presence only, no AuthManager, no refresh. Missing /// or empty auth.json → empty (self-cleaning TempDir). #[test] fn stored_oauth_platforms_reads_scopes_from_auth_json() { let home = tempfile::tempdir().expect("tempdir"); assert!( stored_oauth_platforms(home.path()).is_empty(), "no auth.json → no stored oauth platforms" ); let mut store = std::collections::BTreeMap::new(); store.insert( "oauth/claude-pro-max".to_string(), crate::auth::KimiAuth::test_default(), ); // A platform API-key scope must NOT count as a stored OAuth session. store.insert( "deepseek".to_string(), crate::auth::KimiAuth::test_default(), ); std::fs::write( home.path().join("auth.json"), serde_json::to_string(&store).expect("serialize store"), ) .expect("write auth.json"); assert_eq!( stored_oauth_platforms(home.path()), vec![kigi_models::PlatformId::ClaudeProMax], ); // The stub map carries the same platform set with EMPTY values. let stubs = stored_oauth_token_stubs(home.path()); assert_eq!(stubs.len(), 1); assert_eq!(stubs[&kigi_models::PlatformId::ClaudeProMax], ""); } /// The origin computed from presence-only stubs equals the origin the /// fetch path computes from REAL resolved tokens — the whole point of /// the stubs (a claude-inclusive cached catalog must load at startup). #[test] fn stub_origin_matches_real_token_origin() { use crate::agent::config::EndpointsConfig; use crate::agent::models::{ModelFetchAuth, PlatformApiKeys}; let cfg = EndpointsConfig::default(); let mut real = OAuthSessionTokens::new(); real.insert( kigi_models::PlatformId::ClaudeProMax, "live-bearer".to_string(), ); let mut stubs = OAuthSessionTokens::new(); stubs.insert(kigi_models::PlatformId::ClaudeProMax, String::new()); let with_real = models_fetch_origin( &cfg, ModelFetchAuth::Platforms, false, &real, &PlatformApiKeys::default(), ); let with_stubs = models_fetch_origin( &cfg, ModelFetchAuth::Platforms, false, &stubs, &PlatformApiKeys::default(), ); assert_eq!(with_real, with_stubs); assert!(with_real.contains("claude-pro-max=")); assert!( !with_real.contains("live-bearer"), "origin never embeds tokens" ); } }