diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 92e262d..634b29f 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -70,9 +70,12 @@ pub enum PlatformChatCompat { Kimi, DeepSeek, Passthrough, - /// Strict OpenAI-compatible validator (Mistral, Cerebras) — strips + /// Strict OpenAI-compatible validator (Cerebras, NVIDIA) — strips /// `stream_options` and private fields. StrictOpenAi, + /// Mistral: StrictOpenAi plus its exactly-9-alphanumeric tool-call id + /// contract (foreign/OpenAI-style ids are deterministically remapped). + Mistral, } /// How a platform's API key rides requests (listing, validation, inference). @@ -599,10 +602,12 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec { wire_serves_metadata: false, wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, - // Mistral's strict validator 422s on `stream_options`, and its reasoning - // models return array content — the StrictOpenAi dialect strips - // stream_options; the response deserializer handles arrays universally. - chat_compat: PlatformChatCompat::StrictOpenAi, + // Mistral's strict validator 422s on `stream_options`, its reasoning + // models return array content, and tool-call ids must be EXACTLY nine + // `[a-zA-Z0-9]` chars — the Mistral dialect strips stream_options and + // deterministically remaps non-conforming (foreign/OpenAI-style) ids; + // the response deserializer handles arrays universally. + chat_compat: PlatformChatCompat::Mistral, key_header: PlatformKeyHeader::Bearer, // The listing carries embed/moderation/OCR entries; keep tool-calling // chat models only. diff --git a/crates/codegen/kigi-sampler/src/kimi_compat.rs b/crates/codegen/kigi-sampler/src/kimi_compat.rs index d7a71ed..584018d 100644 --- a/crates/codegen/kigi-sampler/src/kimi_compat.rs +++ b/crates/codegen/kigi-sampler/src/kimi_compat.rs @@ -51,6 +51,91 @@ pub(crate) fn adapt_chat_completions_body_for( strip_kigi_private_message_fields(body); strip_stream_options(body); } + kigi_sampling_types::ChatCompat::Mistral => { + strip_kigi_private_message_fields(body); + strip_stream_options(body); + normalize_mistral_tool_call_ids(body); + } + } +} + +/// Mistral's validator requires tool-call ids of EXACTLY nine +/// `[a-zA-Z0-9]` characters. Foreign backends mint arbitrary ids +/// (OpenAI `call_…`, UUIDs, Anthropic `toolu_…`), so non-conforming ids +/// are remapped deterministically — ported from Pi's +/// `mistral-conversations.ts` normalizer: strip non-alphanumerics, keep +/// the id when the result is already exactly nine chars, otherwise hash +/// (FNV-1a → base36) down to nine, retrying with an attempt suffix on +/// collision. ONE map serves `tool_calls[].id` and `tool_call_id` alike, +/// so call/result pairing survives. +fn normalize_mistral_tool_call_ids(body: &mut Value) { + const LEN: usize = 9; + + fn derive(id: &str, attempt: u32) -> String { + let normalized: String = id.chars().filter(char::is_ascii_alphanumeric).collect(); + if attempt == 0 && normalized.len() == LEN { + return normalized; + } + let seed_base = if normalized.is_empty() { + id + } else { + &normalized + }; + let seed = if attempt == 0 { + seed_base.to_string() + } else { + format!("{seed_base}:{attempt}") + }; + // FNV-1a (stable across builds, unlike std's DefaultHasher) → base36. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in seed.bytes() { + hash ^= u64::from(b); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + let mut out = String::with_capacity(LEN); + let digits = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut h = hash; + while out.len() < LEN { + out.push(digits[(h % 36) as usize] as char); + h = h / 36 + 1; // +1 keeps the stream from collapsing to zeros + } + out + } + + let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else { + return; + }; + let mut forward: std::collections::HashMap = std::collections::HashMap::new(); + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut normalize = |id: &str| -> String { + if let Some(mapped) = forward.get(id) { + return mapped.clone(); + } + let mut attempt = 0; + loop { + let candidate = derive(id, attempt); + if taken.insert(candidate.clone()) { + forward.insert(id.to_string(), candidate.clone()); + return candidate; + } + attempt += 1; + } + }; + for message in messages.iter_mut() { + if let Some(tool_calls) = message.get_mut("tool_calls").and_then(|t| t.as_array_mut()) { + for tc in tool_calls { + if let Some(id) = tc.get("id").and_then(|v| v.as_str()).map(str::to_owned) { + tc["id"] = Value::String(normalize(&id)); + } + } + } + if let Some(id) = message + .get("tool_call_id") + .and_then(|v| v.as_str()) + .map(str::to_owned) + { + message["tool_call_id"] = Value::String(normalize(&id)); + } } } @@ -666,4 +751,56 @@ mod tests { assert_eq!(props["num"]["type"], json!("number")); assert_eq!(props["free"]["type"], json!("string")); } + + /// Mistral dialect: exactly-nine `[a-zA-Z0-9]` tool-call ids. A + /// conforming id survives; foreign ids (OpenAI `call_…`, UUIDs) remap + /// deterministically; the SAME map serves `tool_calls[].id` and + /// `tool_call_id`, so pairing survives; distinct inputs never collide. + #[test] + fn mistral_dialect_normalizes_tool_call_ids_symmetrically() { + let mut body = serde_json::json!({ + "messages": [ + {"role": "assistant", "tool_calls": [ + {"id": "abc123XYZ", "type": "function", "function": {"name": "a", "arguments": "{}"}}, + {"id": "call_0123456789abcdef", "type": "function", "function": {"name": "b", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "abc123XYZ", "content": "r1"}, + {"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r2"}, + ], + "stream_options": {"include_usage": true} + }); + adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body); + + let msgs = body["messages"].as_array().unwrap(); + let ids: Vec = msgs[0]["tool_calls"] + .as_array() + .unwrap() + .iter() + .map(|tc| tc["id"].as_str().unwrap().to_string()) + .collect(); + // Conforming id kept verbatim. + assert_eq!(ids[0], "abc123XYZ"); + // Foreign id remapped to exactly nine alphanumerics. + assert_eq!(ids[1].len(), 9, "{ids:?}"); + assert!(ids[1].chars().all(|ch| ch.is_ascii_alphanumeric())); + assert_ne!(ids[0], ids[1], "distinct inputs must not collide"); + // Results carry the SAME mapped ids. + assert_eq!(msgs[1]["tool_call_id"].as_str().unwrap(), ids[0]); + assert_eq!(msgs[2]["tool_call_id"].as_str().unwrap(), ids[1]); + // StrictOpenAi base behavior rides along. + assert!(body.get("stream_options").is_none()); + + // Determinism: the same foreign id maps identically in a fresh body. + let mut body2 = serde_json::json!({ + "messages": [ + {"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r"} + ] + }); + adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body2); + assert_eq!( + body2["messages"][0]["tool_call_id"].as_str().unwrap(), + ids[1], + "remap must be deterministic across requests (prefix-cache stability)" + ); + } } diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 6c6bf8c..dfd3187 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -1260,15 +1260,21 @@ pub enum ChatCompat { DeepSeek, /// Leave the body as-is (OpenAI-style `reasoning_effort` passes through). Passthrough, - /// Strict OpenAI-compatible validators (Mistral, Cerebras) reject any + /// Strict OpenAI-compatible validators (Cerebras, NVIDIA) reject any /// out-of-schema request field with a 4xx (`additionalProperties:false`). /// kigi injects `stream_options.include_usage` on every streaming /// request, which such validators reject, so it is stripped (streaming /// usage falls back to token estimation). `reasoning_effort` passes /// through; private message fields are stripped like Passthrough. - /// (Serde alias `mistral` keeps sessions persisted before the rename.) - #[serde(alias = "mistral")] StrictOpenAi, + /// Mistral: [`Self::StrictOpenAi`] behavior plus its exactly-nine + /// `[a-zA-Z0-9]` tool-call id contract — foreign/OpenAI-style ids are + /// deterministically remapped on call+result in one shared map (the + /// Pi `mistral-conversations` normalizer). Serializes as `mistral`, so + /// sessions persisted before the StrictOpenAi rename (which carried + /// the `mistral` alias) resolve here — correct, they were Mistral + /// sessions. + Mistral, } pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; @@ -1731,9 +1737,15 @@ mod tests { /// persisted before the rename still deserialize. #[test] fn chat_compat_mistral_alias_deserializes_to_strict_openai() { + // `mistral` resolves to the dedicated Mistral dialect — including + // sessions persisted before the StrictOpenAi rename (they were + // Mistral sessions and now get the 9-char id contract too). let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap(); - assert_eq!(v, ChatCompat::StrictOpenAi); - // New value round-trips as strict_open_ai. + assert_eq!(v, ChatCompat::Mistral); + assert_eq!( + serde_json::to_string(&ChatCompat::Mistral).unwrap(), + "\"mistral\"" + ); let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap(); assert_eq!(v, ChatCompat::StrictOpenAi); assert_eq!( diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 5d6a499..1a521fd 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4099,8 +4099,12 @@ pub fn sampling_config_for_model( &credentials.base_url, ); let api_backend = info.api_backend.clone(); - // Managed platform entries speak their registry dialect; BYOK/custom - // entries keep the historical Kimi body adaptation. + // Managed platform entries speak their registry dialect. BYOK/custom + // entries default to Passthrough (vanilla OpenAI semantics — the + // Kimi-specific body mutations `thinking:{…}` + replayed + // `reasoning_content` 400 on third-party OpenAI-compatible servers), + // EXCEPT entries pointed at the house/Kimi coding endpoint, which keep + // the historical Kimi dialect (mirrors Pi's base-url quirk sniffing). let chat_compat = info .id .as_deref() @@ -4114,8 +4118,15 @@ pub fn sampling_config_for_model( kigi_models::PlatformChatCompat::StrictOpenAi => { kigi_sampling_types::ChatCompat::StrictOpenAi } + kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral, }) - .unwrap_or_default(); + .unwrap_or_else(|| { + if crate::util::is_effective_coding_endpoint_url(&credentials.base_url) { + kigi_sampling_types::ChatCompat::Kimi + } else { + kigi_sampling_types::ChatCompat::Passthrough + } + }); // Claude Pro/Max OAuth Messages adaptation: a managed key whose platform is // a generic-OAuth Messages provider (claude-pro-max) drives the OAuth // identity headers + "You are Claude Code" system prefix in the sampler. @@ -5963,6 +5974,72 @@ reasoning_effort = "low" "agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE" ); } + /// BYOK/custom entries (no managed platform key) default to the + /// Passthrough dialect — the historical Kimi default leaked + /// Kimi-specific body mutations (`thinking:{…}`, replayed + /// `reasoning_content`) to third-party OpenAI-compatible servers. + /// The one exception: entries pointed at the house/Kimi coding + /// endpoint keep the Kimi dialect (base-url detection, mirroring + /// Pi's quirk sniffing). + #[test] + fn byok_custom_entries_default_to_passthrough_except_house_endpoint() { + let make_cfg = |base_url: &str| { + let entry_cfg = ModelEntryConfig { + id: None, // BYOK: no managed platform key + model: "my-custom-model".to_string(), + base_url: base_url.to_string(), + name: None, + description: None, + max_completion_tokens: None, + temperature: None, + top_p: None, + api_key: None, + env_key: None, + api_backend: ApiBackend::default(), + auth_scheme: None, + extra_headers: IndexMap::new(), + context_window: NonZeroU64::new(200_000).unwrap(), + auto_compact_threshold_percent: None, + system_prompt_label: None, + api_base_url: None, + use_concise: true, + agent_type: default_agent_type(), + inference_idle_timeout_secs: None, + max_retries: None, + hidden: false, + supported_in_api: true, + reasoning_effort: None, + supports_reasoning_effort: false, + reasoning_efforts: Vec::new(), + capabilities: Vec::new(), + supports_backend_search: false, + compactions_remaining: None, + compaction_at_tokens: None, + show_model_fingerprint: false, + stream_tool_calls: None, + laziness_detector: LazinessDetectorPerModelConfig::default(), + }; + let entry = ModelEntry::from_config_entry(&entry_cfg); + let creds = ResolvedCredentials { + api_key: Some("sk-byok".into()), + base_url: base_url.to_string(), + auth_type: kigi_chat_state::AuthType::ApiKey, + auth_scheme: Default::default(), + }; + sampling_config_for_model(&entry, creds, None) + }; + assert_eq!( + make_cfg("https://api.third-party.example/v1").chat_compat, + kigi_sampling_types::ChatCompat::Passthrough, + "third-party BYOK must get vanilla OpenAI semantics" + ); + assert_eq!( + make_cfg("https://api.kimi.com/coding/v1").chat_compat, + kigi_sampling_types::ChatCompat::Kimi, + "the house coding endpoint keeps the Kimi dialect" + ); + } + /// Managed `{platform}/{model}` entries stamp `meta.provider` with the /// platform's display name so the client's model picker can say which /// connected provider each model belongs to. User-defined `[model.*]` diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 7926112..0fa4ccf 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1841,8 +1841,9 @@ mod tests { let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None); assert_eq!( cfg.chat_compat, - kigi_sampling_types::ChatCompat::StrictOpenAi, - "mistral entries use the StrictOpenAi dialect (stream_options strip)" + kigi_sampling_types::ChatCompat::Mistral, + "mistral entries use the Mistral dialect (StrictOpenAi behavior \ + plus the exactly-nine-alphanumeric tool-call id contract)" ); }