diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 215b1d9..decfd81 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -42,6 +42,15 @@ fn env_or(var: &str, compiled: &str) -> String { } } +/// Inference dialect a platform speaks. Leaf-safe mirror of the sampler's +/// `ApiBackend` (kigi-models must stay dependency-light); the shell maps it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformWireApi { + ChatCompletions, + Responses, + Messages, +} + /// Where a platform's base URL is resolved from. enum BaseUrlSource { /// The Kimi Code subscription base, owned by `kigi_env` @@ -91,6 +100,13 @@ struct PlatformSpec { /// window / thinking metadata — enrichment (and its network refresh) is /// skipped entirely for such platforms. wire_serves_metadata: bool, + /// Inference dialect (mapped to the sampler backend by the shell). + wire_api: PlatformWireApi, + /// Restrict the live listing to models the enrichment catalog knows — + /// for providers whose `/models` is polluted with non-chat entries + /// (tts/embeddings/image). Availability still requires the LIVE listing; + /// this only drops listing noise, never adds models. + restrict_to_enriched: bool, } const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { @@ -105,6 +121,8 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { login_label: None, models_dev_id: Some("kimi-for-coding"), wire_serves_metadata: true, + wire_api: PlatformWireApi::ChatCompletions, + restrict_to_enriched: false, }; const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { @@ -122,6 +140,8 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.cn)"), models_dev_id: Some("moonshotai-cn"), wire_serves_metadata: true, + wire_api: PlatformWireApi::ChatCompletions, + restrict_to_enriched: false, }; const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { @@ -139,6 +159,32 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { login_label: Some("Moonshot Open Platform (API key \u{b7} moonshot.ai)"), models_dev_id: Some("moonshotai"), wire_serves_metadata: true, + wire_api: PlatformWireApi::ChatCompletions, + restrict_to_enriched: false, +}; + +/// Base-URL override for OpenAI (dev/test escape hatch). +pub const OPENAI_BASE_URL_ENV: &str = "KIGI_OPENAI_BASE_URL"; + +const OPENAI_SPEC: PlatformSpec = PlatformSpec { + id: "openai", + display_name: "OpenAI", + base_url: BaseUrlSource::EnvOr { + env: OPENAI_BASE_URL_ENV, + default: "https://api.openai.com/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["OPENAI_API_KEY"], + vendor: "OpenAI", + console_host: Some("platform.openai.com"), + login_label: Some("OpenAI (API key)"), + models_dev_id: Some("openai"), + // GET /v1/models returns bare ids only (no context/thinking metadata) + // and is polluted with tts/embeddings/image entries. + wire_serves_metadata: false, + wire_api: PlatformWireApi::Responses, + restrict_to_enriched: true, }; /// The platform registry. Platforms are compiled-in spec rows; there is no @@ -151,12 +197,19 @@ pub enum PlatformId { MoonshotCn, /// Moonshot AI open platform, api.moonshot.ai (API key). MoonshotAi, + /// OpenAI platform API (API key, Responses dialect). + OpenAi, } impl PlatformId { /// All platforms, in catalog precedence order: the subscription channel /// first so "default model = first list item" favors it when present. - pub const ALL: [PlatformId; 3] = [Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi]; + pub const ALL: [PlatformId; 4] = [ + Self::KimiCode, + Self::MoonshotCn, + Self::MoonshotAi, + Self::OpenAi, + ]; /// The registry row backing this platform (single source of per-platform /// data; every accessor below reads it). @@ -165,6 +218,7 @@ impl PlatformId { Self::KimiCode => &KIMI_CODE_SPEC, Self::MoonshotCn => &MOONSHOT_CN_SPEC, Self::MoonshotAi => &MOONSHOT_AI_SPEC, + Self::OpenAi => &OPENAI_SPEC, } } @@ -243,6 +297,17 @@ impl PlatformId { pub fn wire_serves_metadata(self) -> bool { self.spec().wire_serves_metadata } + + /// Inference dialect this platform speaks (shell maps to `ApiBackend`). + pub fn wire_api(self) -> PlatformWireApi { + self.spec().wire_api + } + + /// Restrict the live listing to enrichment-known models (drops non-chat + /// listing noise on polluted providers). Never adds models. + pub fn restrict_to_enriched(self) -> bool { + self.spec().restrict_to_enriched + } } /// Split a managed catalog key `{platform_id}/{model_id}` back into its @@ -573,9 +638,10 @@ mod tests { PlatformId::KimiCode => 0, PlatformId::MoonshotCn => 1, PlatformId::MoonshotAi => 2, + PlatformId::OpenAi => 3, } } - const VARIANT_COUNT: usize = 3; // update together with `ordinal` + const VARIANT_COUNT: usize = 4; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); diff --git a/crates/codegen/kigi-sampler/src/client.rs b/crates/codegen/kigi-sampler/src/client.rs index 5b01334..9d59fee 100644 --- a/crates/codegen/kigi-sampler/src/client.rs +++ b/crates/codegen/kigi-sampler/src/client.rs @@ -70,6 +70,9 @@ fn deserialize_response_event(data: &str) -> Result { Err(first_err) => { // Try sanitizing: parse as Value, strip unknown tools, retry. if let Ok(mut value) = serde_json::from_str::(data) { + // A `max` reasoning-effort echo is unrepresentable in the + // typed enum; drop it so the event parses. + kigi_sampling_types::normalize_effort_echo(&mut value); // Strip tools that async_openai's rs::Tool can't deserialize // (e.g., xAI-specific "x_search"). Instead of maintaining a // hardcoded allowlist, try deserializing each tool entry — @@ -1028,6 +1031,7 @@ impl SamplingClient { // it in post-serialize. This is the last surviving piece of the // old raw_output machinery. kigi_sampling_types::patch_reasoning_text_types(&mut request_body); + kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort); let http_request = self.post(self.endpoint("responses")).json(&request_body); let response = http_request.send().await.map_err(|e| { @@ -1074,7 +1078,16 @@ impl SamplingClient { }); } - let response_obj = serde_json::from_slice::(&bytes).map_err(|e| { + let mut response_value = + serde_json::from_slice::(&bytes).map_err(|e| { + let raw_body = String::from_utf8_lossy(&bytes); + tracing::error!(error = %e, raw_body = %raw_body, "Response body is not JSON"); + SamplingError::Serialization(e) + })?; + // A `max` effort echo is unrepresentable in the typed enum — drop it + // rather than failing the whole response. + kigi_sampling_types::normalize_effort_echo(&mut response_value); + let response_obj = serde_json::from_value::(response_value).map_err(|e| { let raw_body = String::from_utf8_lossy(&bytes); tracing::error!( error = %e, @@ -1156,6 +1169,7 @@ impl SamplingClient { } } kigi_sampling_types::patch_reasoning_text_types(&mut request_body); + kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort); // Fresh per attempt so signals never leak across retries; `None` // (check disabled) sends no header and does no peek work per event. let doom_loop = self @@ -1687,6 +1701,7 @@ impl SamplingClient { let responses_request: rs::CreateResponse = (&request).into(); let mut wrapper = CreateResponseWrapper::new(responses_request); + wrapper.reasoning_effort = request.reasoning_effort; wrapper.x_kigi_conv_id = x_kigi_conv_id; wrapper.x_kigi_req_id = x_kigi_req_id; wrapper.x_kigi_session_id = x_kigi_session_id; @@ -1720,6 +1735,7 @@ impl SamplingClient { let responses_request: rs::CreateResponse = (&request).into(); let mut wrapper = CreateResponseWrapper::new(responses_request); + wrapper.reasoning_effort = request.reasoning_effort; wrapper.x_kigi_conv_id = x_kigi_conv_id; wrapper.x_kigi_req_id = x_kigi_req_id; wrapper.x_kigi_session_id = x_kigi_session_id; diff --git a/crates/codegen/kigi-sampling-types/src/conversation.rs b/crates/codegen/kigi-sampling-types/src/conversation.rs index c3a10e5..956e020 100644 --- a/crates/codegen/kigi-sampling-types/src/conversation.rs +++ b/crates/codegen/kigi-sampling-types/src/conversation.rs @@ -2158,7 +2158,10 @@ impl From<&ConversationRequest> for rs::CreateResponse { prompt_cache_key: None, prompt_cache_retention: None, reasoning: Some(rs::Reasoning { - effort: req.reasoning_effort.map(|e| e.to_responses_api()), + // Effort is written onto the serialized body by + // `patch_reasoning_effort` (the typed enum cannot spell + // `max`); the wrapper carries the canonical value. + effort: None, summary: Some(rs::ReasoningSummary::Concise), }), safety_identifier: None, @@ -5191,6 +5194,10 @@ mod tests { #[test] fn test_responses_request_carries_reasoning_effort_nested() { + // Wire contract since the Max split: the typed conversion leaves + // reasoning.effort UNSET (async-openai's enum cannot spell `max`); + // `patch_reasoning_effort` writes the canonical token onto the + // serialized body — every level, `xhigh` and `max` distinct. for (variant, expected) in [ (crate::ReasoningEffort::None, "none"), (crate::ReasoningEffort::Minimal, "minimal"), @@ -5198,6 +5205,7 @@ mod tests { (crate::ReasoningEffort::Medium, "medium"), (crate::ReasoningEffort::High, "high"), (crate::ReasoningEffort::Xhigh, "xhigh"), + (crate::ReasoningEffort::Max, "max"), ] { let req = ConversationRequest { reasoning_effort: Some(variant), @@ -5205,15 +5213,56 @@ mod tests { .with_model("test") }; let resp: crate::rs::CreateResponse = (&req).into(); - let json = serde_json::to_value(&resp).unwrap(); + let mut json = serde_json::to_value(&resp).unwrap(); + assert_eq!( + json.pointer("/reasoning/effort"), + None, + "typed conversion must leave effort unset ({variant:?})" + ); + crate::patch_reasoning_effort(&mut json, req.reasoning_effort); assert_eq!( json.pointer("/reasoning/effort").and_then(|v| v.as_str()), Some(expected), - "{variant:?} should serialize as reasoning.effort={expected:?}; got: {json:#}", + "{variant:?} should be patched as reasoning.effort={expected:?}; got: {json:#}", + ); + assert_eq!( + json.pointer("/reasoning/summary").and_then(|v| v.as_str()), + Some("concise"), + "the patch must not clobber reasoning.summary" ); } } + #[test] + fn normalize_effort_echo_drops_only_unrepresentable_tokens() { + // `max` echo (bare response shape) is dropped so typed parsing + // succeeds; known tokens pass through; stream-event envelopes are + // handled via /response/reasoning. + let mut bare = serde_json::json!({ "reasoning": { "effort": "max", "summary": "c" } }); + crate::normalize_effort_echo(&mut bare); + assert_eq!(bare.pointer("/reasoning/effort"), None); + assert_eq!( + bare.pointer("/reasoning/summary").and_then(|v| v.as_str()), + Some("c") + ); + + let mut known = serde_json::json!({ "reasoning": { "effort": "high" } }); + crate::normalize_effort_echo(&mut known); + assert_eq!( + known.pointer("/reasoning/effort").and_then(|v| v.as_str()), + Some("high"), + "representable echoes must pass through" + ); + + let mut event = serde_json::json!({ "response": { "reasoning": { "effort": "max" } } }); + crate::normalize_effort_echo(&mut event); + assert_eq!(event.pointer("/response/reasoning/effort"), None); + + let mut absent = serde_json::json!({ "output": [] }); + crate::normalize_effort_echo(&mut absent); + assert_eq!(absent, serde_json::json!({ "output": [] })); + } + #[test] fn test_responses_request_omits_effort_when_unset() { let req = diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 25b5cdf..2151802 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -794,34 +794,11 @@ pub enum ReasoningEffort { } impl ReasoningEffort { - pub fn to_responses_api(self) -> crate::rs::ReasoningEffort { - match self { - Self::None => crate::rs::ReasoningEffort::None, - Self::Minimal => crate::rs::ReasoningEffort::Minimal, - Self::Low => crate::rs::ReasoningEffort::Low, - Self::Medium => crate::rs::ReasoningEffort::Medium, - Self::High => crate::rs::ReasoningEffort::High, - Self::Xhigh => crate::rs::ReasoningEffort::Xhigh, - // INTERIM: async-openai (0.33 pinned; upstream through 0.41 - // still lacks it) has no `Max` variant. The Responses backend - // gains true `max` via a post-serialize body patch in the OpenAI - // provider cycle; until then Max downgrades to xhigh — reachable - // via `[model.*] reasoning_effort = "max"` on a responses-backend - // model, hence the loud warn. - Self::Max => { - tracing::warn!( - "reasoning effort `max` downgraded to `xhigh` on the \ - Responses backend (typed wire enum has no max yet)" - ); - crate::rs::ReasoningEffort::Xhigh - } - } - } - - /// Inverse of [`to_responses_api`](Self::to_responses_api): the effort the - /// Responses API echoes back on `response.reasoning.effort`. (`max` - /// echoes cannot reach here — async-openai's enum has no such variant; - /// the OpenAI provider cycle handles them before typed parsing.) + /// The canonical effort behind a typed Responses-API echo + /// (`response.reasoning.effort`). `max` echoes never reach the typed + /// enum — [`normalize_effort_echo`] drops them pre-parse (async-openai + /// has no such variant); the request direction writes the wire string + /// via [`patch_reasoning_effort`]. pub fn from_responses_api(effort: crate::rs::ReasoningEffort) -> Self { match effort { crate::rs::ReasoningEffort::None => Self::None, @@ -911,6 +888,52 @@ where })) } +/// Write the canonical effort onto a serialized Responses request body +/// (`body.reasoning.effort`). This is the ONLY place effort reaches the +/// Responses wire: the typed `rs::ReasoningEffort` tops out at `xhigh`, so +/// `max` must be written post-serialize. A `None` effort leaves the body +/// untouched (the provider default applies). +pub fn patch_reasoning_effort(body: &mut Value, effort: Option) { + let Some(effort) = effort else { return }; + let Some(obj) = body.as_object_mut() else { + return; + }; + let reasoning = obj + .entry("reasoning") + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if let Some(reasoning) = reasoning.as_object_mut() { + reasoning.insert( + "effort".to_string(), + Value::String(effort.as_str().to_string()), + ); + } +} + +/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse +/// (`max`): remove it so response deserialization succeeds. The turn's +/// canonical effort lives in the session sampling config regardless; only +/// the per-item echo metadata is dropped, recorded as not-echoed. +/// (Ceiling: async-openai lacks a Max variant; delete this when it grows +/// one.) Handles both bare response bodies (`/reasoning/effort`) and +/// stream-event envelopes (`/response/reasoning/effort`). +pub fn normalize_effort_echo(value: &mut Value) { + for path in ["/reasoning", "/response/reasoning"] { + if let Some(reasoning) = value.pointer_mut(path).and_then(|v| v.as_object_mut()) + && let Some(effort) = reasoning.get("effort").and_then(|v| v.as_str()) + && crate::rs::ReasoningEffort::deserialize(serde_json::Value::String( + effort.to_string(), + )) + .is_err() + { + tracing::debug!( + effort, + "reasoning.effort echo unrepresentable in the typed enum; dropping" + ); + reasoning.remove("effort"); + } + } +} + pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort"; @@ -1126,6 +1149,12 @@ pub struct CreateResponseWrapper { /// The inner Responses API request. pub inner: crate::rs::CreateResponse, + /// Canonical reasoning effort for this request. The typed + /// `inner.reasoning.effort` stays `None` (async-openai's enum cannot + /// spell `max`); [`patch_reasoning_effort`] writes this value onto the + /// serialized body just before send. + pub reasoning_effort: Option, + /// Custom header: conversation ID for tracking. pub x_kigi_conv_id: Option, @@ -1152,6 +1181,7 @@ impl CreateResponseWrapper { pub fn new(inner: crate::rs::CreateResponse) -> Self { Self { inner, + reasoning_effort: None, x_kigi_conv_id: None, x_kigi_req_id: None, x_kigi_session_id: None, diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 23a7935..821e9d6 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -583,7 +583,8 @@ mod tests { XAI_API_KEY_METHOD_ID, KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, - MOONSHOT_AI_METHOD_ID + MOONSHOT_AI_METHOD_ID, + "openai" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -609,7 +610,8 @@ mod tests { CACHED_TOKEN_AUTH_METHOD_ID, KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, - MOONSHOT_AI_METHOD_ID + MOONSHOT_AI_METHOD_ID, + "openai" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -628,7 +630,8 @@ mod tests { CACHED_TOKEN_AUTH_METHOD_ID, KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, - MOONSHOT_AI_METHOD_ID + MOONSHOT_AI_METHOD_ID, + "openai" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -650,7 +653,8 @@ mod tests { vec![ KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, - MOONSHOT_AI_METHOD_ID + MOONSHOT_AI_METHOD_ID, + "openai" ] ); assert_eq!(default_id(&built), None); diff --git a/crates/codegen/kigi-shell/src/agent/enrichment_fetch.rs b/crates/codegen/kigi-shell/src/agent/enrichment_fetch.rs index a81317e..180afba 100644 --- a/crates/codegen/kigi-shell/src/agent/enrichment_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/enrichment_fetch.rs @@ -57,8 +57,15 @@ fn registry_models_dev_ids() -> BTreeSet<&'static str> { .collect() } +/// Cache dir override (tests re-home the cache away from the real +/// `~/.kigi` — same pattern as `KIGI_MODELS_CACHE_DIR`). +pub(crate) const MODELS_DEV_CACHE_DIR_ENV: &str = "KIGI_MODELS_DEV_CACHE_DIR"; + fn cache_path() -> std::path::PathBuf { - crate::util::kigi_home::kigi_home().join(CACHE_FILE) + match std::env::var(MODELS_DEV_CACHE_DIR_ENV) { + Ok(dir) if !dir.trim().is_empty() => std::path::PathBuf::from(dir).join(CACHE_FILE), + _ => crate::util::kigi_home::kigi_home().join(CACHE_FILE), + } } fn refresh_url() -> Option { @@ -198,13 +205,21 @@ mod tests { /// Wire-served-only platform sets never trigger IO — and never force the /// bundled parse (empty owned catalog; the merge branch is gated off). + /// Kimi/Moonshot users therefore keep a zero-egress, zero-cache fetch + /// path even now that enrichment-needing platforms (OpenAI) exist. #[test] fn wire_served_platforms_get_empty_catalog_without_io() { - assert!(!any_platform_needs_enrichment( - &kigi_models::PlatformId::ALL - )); - let catalog = load_enrichment_catalog(&kigi_models::PlatformId::ALL); + let wire_served = [ + kigi_models::PlatformId::KimiCode, + kigi_models::PlatformId::MoonshotCn, + kigi_models::PlatformId::MoonshotAi, + ]; + assert!(!any_platform_needs_enrichment(&wire_served)); + let catalog = load_enrichment_catalog(&wire_served); assert!(catalog.is_empty()); + // The full registry now DOES need enrichment (OpenAI is + // wire_serves_metadata=false) — the fast path must not hide that. + assert!(any_platform_needs_enrichment(&kigi_models::PlatformId::ALL)); } fn cache_file_in(dir: &tempfile::TempDir) -> std::path::PathBuf { diff --git a/crates/codegen/kigi-shell/src/agent/models.rs b/crates/codegen/kigi-shell/src/agent/models.rs index d728ef5..0dc4f87 100644 --- a/crates/codegen/kigi-shell/src/agent/models.rs +++ b/crates/codegen/kigi-shell/src/agent/models.rs @@ -141,6 +141,14 @@ impl PlatformApiKeys { } Self { keys } } + + /// Test-only constructor for a single API-key platform. + #[cfg(test)] + pub(crate) fn test_single(platform: kigi_models::PlatformId, key: &str) -> Self { + Self { + keys: std::collections::BTreeMap::from([(platform, key.to_owned())]), + } + } } pub(crate) fn task_model_error_for_catalog( diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 979b72e..be53bb7 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -284,7 +284,7 @@ fn fetch_one_platform_models( .map(|s| s.to_string()); let listing: kigi_models::WireModelsResponse = response.json()?; let total = listing.data.len(); - let filtered = kigi_models::filter_allowed_models(platform, listing.data); + let mut filtered = kigi_models::filter_allowed_models(platform, listing.data); if filtered.len() != total { tracing::info!( platform = platform.as_str(), @@ -293,6 +293,51 @@ fn fetch_one_platform_models( "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 = if platform.uses_oauth() { endpoints.proxy_url() } else { @@ -329,8 +374,9 @@ fn fetch_one_platform_models( /// 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"` → `Xhigh`). -/// Unknown tokens are dropped with a warning rather than inventing a level. +/// 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 { @@ -379,6 +425,13 @@ pub(crate) fn platform_wire_model_to_entry( }); 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, + }; crate::agent::config::ModelEntryConfig { id: Some(platform.managed_model_key(&wire.id)), name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())), @@ -390,7 +443,7 @@ pub(crate) fn platform_wire_model_to_entry( top_p: None, api_key: None, env_key, - api_backend: Default::default(), + api_backend, auth_scheme: None, reasoning_effort: think_efforts .and_then(|t| t.default_effort.as_deref()) @@ -662,6 +715,127 @@ fn get_string_map( #[cfg(test)] mod tests { use super::*; + + /// 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, &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" + ); + } + #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; @@ -1161,6 +1335,10 @@ mod tests { 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" + ); // Proxy override re-points the subscription platform only. let proxied = EndpointsConfig::from_config_value( &toml::from_str( diff --git a/crates/codegen/kigi-shell/src/kimi_import.rs b/crates/codegen/kigi-shell/src/kimi_import.rs index 2c1f6ee..3e36343 100644 --- a/crates/codegen/kigi-shell/src/kimi_import.rs +++ b/crates/codegen/kigi-shell/src/kimi_import.rs @@ -160,18 +160,22 @@ struct KimiProviderToml { } /// Built-in kigi platform a kimi provider duplicates, if any: provider type -/// `kimi` is the Kimi Code subscription channel; the two Moonshot open -/// platforms are recognized by their fixed production hosts (the same hosts -/// `kigi_models::PlatformId::base_url` compiles in). +/// `kimi` is the Kimi Code subscription channel; API-key platforms are +/// recognized by their production hosts (the same hosts +/// `kigi_models::PlatformId::base_url` compiles in — moonshot, openai, and +/// every future registry row automatically). fn builtin_platform(provider: &KimiProviderToml) -> Option { if provider.provider_type == "kimi" { return Some(PlatformId::KimiCode); } - match url_host(&provider.base_url) { - Some("api.moonshot.cn") => Some(PlatformId::MoonshotCn), - Some("api.moonshot.ai") => Some(PlatformId::MoonshotAi), - _ => None, - } + let host = url_host(&provider.base_url)?; + PlatformId::ALL.into_iter().find(|platform| { + if platform.uses_oauth() { + return false; + } + let base = platform.base_url(); + url_host(&base) == Some(host) + }) } /// Host component of an http(s) URL. `None` for other schemes. diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index ade199f..c42f85a 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -6894,7 +6894,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 4, "3 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 5, "4 login rows + Quit, got {items:?}"); assert!( matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), "row 0 must be the OAuth login, got {:?}", @@ -6914,7 +6914,15 @@ pub(crate) mod tests { label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(), } ); - assert_eq!(items[3], PendingMenuItem::Quit); + assert_eq!( + items[3], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi), + label: "OpenAI (API key)".into(), + }, + "new registry rows must appear in the picker with zero TUI changes" + ); + assert_eq!(items[4], PendingMenuItem::Quit); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -6925,7 +6933,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 4, + 5, "xai.api_key / cached_token must not add rows" ); }