diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index ba679c8..d32f22d 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -70,6 +70,7 @@ pub enum PlatformChatCompat { Kimi, DeepSeek, Passthrough, + Mistral, } /// How a platform's API key rides requests (listing, validation, inference). @@ -316,6 +317,35 @@ const GROQ_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +/// Base-URL override for Mistral (dev/test escape hatch). +pub const MISTRAL_BASE_URL_ENV: &str = "KIGI_MISTRAL_BASE_URL"; + +const MISTRAL_SPEC: PlatformSpec = PlatformSpec { + id: "mistral", + display_name: "Mistral", + base_url: BaseUrlSource::EnvOr { + env: MISTRAL_BASE_URL_ENV, + default: "https://api.mistral.ai/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["MISTRAL_API_KEY"], + vendor: "Mistral", + console_host: Some("console.mistral.ai"), + login_label: Some("Mistral (API key)"), + models_dev_id: Some("mistral"), + 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 Mistral dialect handles both. + chat_compat: PlatformChatCompat::Mistral, + key_header: PlatformKeyHeader::Bearer, + // The listing carries embed/moderation/OCR entries; keep tool-calling + // chat models only. + restrict_to_enriched: true, +}; + /// The platform registry. Platforms are compiled-in spec rows; there is no /// dynamic provider registration (PRD F2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -334,12 +364,14 @@ pub enum PlatformId { DeepSeek, /// Groq platform API (API key, OpenAI-compatible ChatCompletions). Groq, + /// Mistral platform API (API key, OpenAI-compatible ChatCompletions). + Mistral, } 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; 7] = [ + pub const ALL: [PlatformId; 8] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -347,6 +379,7 @@ impl PlatformId { Self::Anthropic, Self::DeepSeek, Self::Groq, + Self::Mistral, ]; /// The registry row backing this platform (single source of per-platform @@ -360,6 +393,7 @@ impl PlatformId { Self::Anthropic => &ANTHROPIC_SPEC, Self::DeepSeek => &DEEPSEEK_SPEC, Self::Groq => &GROQ_SPEC, + Self::Mistral => &MISTRAL_SPEC, } } @@ -1017,9 +1051,10 @@ mod tests { PlatformId::Anthropic => 4, PlatformId::DeepSeek => 5, PlatformId::Groq => 6, + PlatformId::Mistral => 7, } } - const VARIANT_COUNT: usize = 7; // update together with `ordinal` + const VARIANT_COUNT: usize = 8; // 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/kimi_compat.rs b/crates/codegen/kigi-sampler/src/kimi_compat.rs index 653aeda..4ad39dd 100644 --- a/crates/codegen/kigi-sampler/src/kimi_compat.rs +++ b/crates/codegen/kigi-sampler/src/kimi_compat.rs @@ -47,6 +47,22 @@ pub(crate) fn adapt_chat_completions_body_for( kigi_sampling_types::ChatCompat::Passthrough => { strip_kigi_private_message_fields(body); } + kigi_sampling_types::ChatCompat::Mistral => { + strip_kigi_private_message_fields(body); + strip_stream_options(body); + } + } +} + +/// Mistral's strict Pydantic validator 422-rejects `stream_options` +/// (`extra_forbidden` on `stream_options.include_usage`; its request model +/// has no such field). kigi injects `stream_options.include_usage` on every +/// streaming request for the other providers, so strip the whole object for +/// Mistral. Streaming usage falls back to token estimation (as for any +/// provider that omits streaming usage). +fn strip_stream_options(body: &mut Value) { + if let Some(obj) = body.as_object_mut() { + obj.remove("stream_options"); } } @@ -393,6 +409,38 @@ mod tests { assert_eq!(body["messages"][0].get("model_id"), None); } + #[test] + fn mistral_dialect_strips_stream_options_and_private_fields() { + use kigi_sampling_types::ChatCompat; + // Mistral 422s on stream_options (extra_forbidden) and doesn't know + // kigi's private message fields; OpenAI-style reasoning_effort stays. + let mut body = json!({ + "model": "mistral-medium-latest", + "reasoning_effort": "high", + "stream": true, + "stream_options": { "include_usage": true }, + "messages": [ + { "role": "assistant", "content": "hi", + "reasoning_content": "internal", "model_id": "kigi/x" } + ] + }); + adapt_chat_completions_body_for(ChatCompat::Mistral, &mut body); + assert_eq!( + body.get("stream_options"), + None, + "stream_options must be stripped" + ); + assert_eq!(body["stream"], json!(true), "stream flag stays"); + assert_eq!( + body["reasoning_effort"], + json!("high"), + "OpenAI-style effort passes through (Mistral accepts it natively)" + ); + assert_eq!(body["messages"][0].get("reasoning_content"), None); + assert_eq!(body["messages"][0].get("model_id"), None); + assert_eq!(body["messages"][0]["content"], json!("hi")); + } + #[test] fn passthrough_dialect_leaves_openai_body_verbatim() { use kigi_sampling_types::ChatCompat; diff --git a/crates/codegen/kigi-sampler/src/stream/chat_completions.rs b/crates/codegen/kigi-sampler/src/stream/chat_completions.rs index bc7ff67..4d47325 100644 --- a/crates/codegen/kigi-sampler/src/stream/chat_completions.rs +++ b/crates/codegen/kigi-sampler/src/stream/chat_completions.rs @@ -517,6 +517,85 @@ mod tests { } } + /// Deserialize a full chunk from a JSON `delta` so it flows through the + /// `#[serde(from = "RawChatChunkDelta")]` content-split path (Mistral + /// sends array `content`). + fn chunk_from_delta(delta: serde_json::Value) -> ChatCompletionChunk { + serde_json::from_value(serde_json::json!({ + "id": "c", + "object": "chat.completion.chunk", + "created": 0, + "model": "mistral-medium-latest", + "choices": [{ "index": 0, "delta": delta, "finish_reason": null }], + })) + .expect("mistral chunk deserializes") + } + + /// End-to-end: a Mistral reasoning stream (array `content` thinking + /// deltas → a transition delta carrying both a thinking and a text chunk + /// → plain-string answer deltas) is split by deserialization and consumed + /// into the SAME reasoning-sibling + assistant-answer result the + /// `reasoning_content` string path produces. Closes the array-path + /// integration gap. + #[tokio::test] + async fn mistral_reasoning_array_stream_splits_into_reasoning_and_answer() { + let chunks: Vec> = vec![ + Ok(chunk_from_delta(serde_json::json!({ "content": [ + { "type": "thinking", + "thinking": [{ "type": "text", "text": "Let me think. " }] } + ]}))), + Ok(chunk_from_delta(serde_json::json!({ "content": [ + { "type": "thinking", + "thinking": [{ "type": "text", "text": "It's 22." }] } + ]}))), + // Transition: one array with a closing thinking chunk AND the + // first answer text chunk. + Ok(chunk_from_delta(serde_json::json!({ "content": [ + { "type": "thinking", "thinking": [{ "type": "text", "text": " Done." }] }, + { "type": "text", "text": "Answer: " } + ]}))), + // Answer phase: plain-string deltas (no longer arrays). + Ok(chunk_from_delta(serde_json::json!({ "content": "22." }))), + Ok(final_chunk(FinishReason::Stop)), + ]; + let raw = stream::iter(chunks).boxed(); + let events = collect(stream_chat_completions( + raw, + None, + rid(), + Duration::from_secs(60), + )) + .await; + + // Channel tokens: thinking rode the Reasoning channel, answer the Text + // channel — never crossed. + let mut reasoning = String::new(); + let mut answer = String::new(); + for e in &events { + if let SamplingEvent::ChannelToken { channel, text, .. } = e { + match channel { + SamplingChannel::Reasoning => reasoning.push_str(text), + SamplingChannel::Text => answer.push_str(text), + } + } + } + assert_eq!(reasoning, "Let me think. It's 22. Done."); + assert_eq!(answer, "Answer: 22."); + + // The accumulated final response carries the same split. + match events.last().unwrap() { + SamplingEvent::Completed { response, .. } => { + let r = response + .reasoning_items() + .next() + .expect("array thinking became a reasoning sibling"); + let rs::SummaryPart::SummaryText(t) = &r.summary[0]; + assert_eq!(t.text, "Let me think. It's 22. Done."); + } + other => panic!("expected Completed, got {other:?}"), + } + } + #[tokio::test] async fn tool_call_stream_emits_deltas_and_assembles_final_call() { // First chunk has id + name + part of arguments. diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index a2c4877..814fbde 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -487,7 +487,69 @@ pub enum FinishReason { FunctionCall, } +/// Wire `content` field on a chat response/delta. OpenAI-compatible providers +/// send a plain string, but reasoning providers (Mistral) send an ARRAY of +/// typed chunks: `{"type":"text","text":".."}` for the answer and +/// `{"type":"thinking","thinking":[{"type":"text","text":".."}],...}` for the +/// chain of thought (verified against the mistralai/client-python SDK models). +/// The chunk union is OPEN — unknown chunk types are ignored, never fatal. +#[derive(Deserialize)] +#[serde(untagged)] +enum WireContent { + Text(String), + Chunks(Vec), +} + +/// Read a chunk's `text` string field into `dst` when present (defensive: a +/// reference/tool-reference/unknown chunk simply has no `text`). +fn push_chunk_text(dst: &mut String, chunk: &Value) { + if let Some(t) = chunk.get("text").and_then(Value::as_str) { + dst.push_str(t); + } +} + +fn non_empty(s: String) -> Option { + if s.is_empty() { None } else { Some(s) } +} + +impl WireContent { + /// Split into `(visible answer text, thinking text)`. A plain string is + /// the answer with no thinking; an array routes `text` chunks to the + /// answer and the nested `text` of `thinking` chunks to the reasoning + /// channel. Byte-identical for string content (the only shape non-Mistral + /// providers ever send). + fn split(self) -> (Option, Option) { + match self { + // Preserve string content verbatim (incl. empty) — byte-identical + // to the pre-change `content: Option` for every provider + // that sends a string. + WireContent::Text(s) => (Some(s), None), + WireContent::Chunks(chunks) => { + let mut answer = String::new(); + let mut thinking = String::new(); + for c in &chunks { + match c.get("type").and_then(Value::as_str) { + Some("text") => push_chunk_text(&mut answer, c), + Some("thinking") => { + // `thinking` is a nested list of chunks. + if let Some(inner) = c.get("thinking").and_then(Value::as_array) { + for ic in inner { + push_chunk_text(&mut thinking, ic); + } + } + } + // reference / tool_reference / unknown → not text. + _ => {} + } + } + (non_empty(answer), non_empty(thinking)) + } + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(from = "RawChatResponseMessage")] pub struct ChatResponseMessage { pub role: Role, #[serde(skip_serializing_if = "Option::is_none")] @@ -502,6 +564,38 @@ pub struct ChatResponseMessage { pub citations: Option>, } +/// Deserialization mirror of [`ChatResponseMessage`] that accepts array +/// `content` and folds thinking chunks into `reasoning_content`. +#[derive(Deserialize)] +struct RawChatResponseMessage { + role: Role, + #[serde(default)] + content: Option, + #[serde(default)] + reasoning_content: Option, + #[serde(default)] + tool_calls: Vec, + #[serde(default)] + tool_call_id: Option, + #[serde(default)] + citations: Option>, +} + +impl From for ChatResponseMessage { + fn from(r: RawChatResponseMessage) -> Self { + let (answer, thinking) = r.content.map(WireContent::split).unwrap_or((None, None)); + ChatResponseMessage { + role: r.role, + content: answer, + // The wire's own `reasoning_content` wins; else thinking chunks. + reasoning_content: r.reasoning_content.or(thinking), + tool_calls: r.tool_calls, + tool_call_id: r.tool_call_id, + citations: r.citations, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ToolCallResponse { pub id: String, @@ -649,6 +743,7 @@ pub struct ToolCallFunctionDelta { } #[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(from = "RawChatChunkDelta")] pub struct ChatChunkDelta { #[serde(skip_serializing_if = "Option::is_none")] pub role: Option, @@ -656,16 +751,43 @@ pub struct ChatChunkDelta { pub content: Option, pub reasoning_content: Option, /// Tool call deltas. Handles `null` in JSON as empty vec. - #[serde( - default, - skip_serializing_if = "Vec::is_empty", - deserialize_with = "deserialize_null_default" - )] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tool_calls: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, } +/// Deserialization mirror of [`ChatChunkDelta`] that accepts array `content` +/// (Mistral reasoning streams a thinking-chunk array during the thinking +/// phase, then plain-string answer deltas) and folds thinking into +/// `reasoning_content`. +#[derive(Deserialize)] +struct RawChatChunkDelta { + #[serde(default)] + role: Option, + #[serde(default)] + content: Option, + #[serde(default)] + reasoning_content: Option, + #[serde(default, deserialize_with = "deserialize_null_default")] + tool_calls: Vec, + #[serde(default)] + tool_call_id: Option, +} + +impl From for ChatChunkDelta { + fn from(r: RawChatChunkDelta) -> Self { + let (answer, thinking) = r.content.map(WireContent::split).unwrap_or((None, None)); + ChatChunkDelta { + role: r.role, + content: answer, + reasoning_content: r.reasoning_content.or(thinking), + tool_calls: r.tool_calls, + tool_call_id: r.tool_call_id, + } + } +} + /// Parameters to control realtime data. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct SearchParameters { @@ -956,6 +1078,11 @@ pub enum ChatCompat { DeepSeek, /// Leave the body as-is (OpenAI-style `reasoning_effort` passes through). Passthrough, + /// Mistral wire: OpenAI-style `reasoning_effort` passes through, but the + /// strict Pydantic validator 422-rejects `stream_options` (the SDK's + /// request model has no such field), so it must be stripped. Also strips + /// the kigi-private message fields like Passthrough. + Mistral, } pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; @@ -1319,6 +1446,98 @@ mod tests { use super::*; use serde_json::json; + /// String content (the only shape non-Mistral providers send) stays the + /// answer verbatim with no thinking — byte-identical to the pre-change + /// deserialization. + #[test] + fn chunk_delta_string_content_unchanged() { + let delta: ChatChunkDelta = + serde_json::from_value(json!({ "content": "hello world" })).unwrap(); + assert_eq!(delta.content.as_deref(), Some("hello world")); + assert_eq!(delta.reasoning_content, None); + // A DeepSeek-style wire reasoning_content still rides its own field. + let delta: ChatChunkDelta = + serde_json::from_value(json!({ "content": "hi", "reasoning_content": "because" })) + .unwrap(); + assert_eq!(delta.content.as_deref(), Some("hi")); + assert_eq!(delta.reasoning_content.as_deref(), Some("because")); + // Null / absent content → None. + let delta: ChatChunkDelta = serde_json::from_value(json!({})).unwrap(); + assert_eq!(delta.content, None); + } + + /// Mistral reasoning: array content routes `text` chunks to the answer + /// and the nested `text` of `thinking` chunks to reasoning_content + /// (verified shapes from the mistralai/client-python SDK). + #[test] + fn chunk_delta_array_content_splits_thinking_and_answer() { + // Thinking phase: array with only a thinking chunk (nested list). + let delta: ChatChunkDelta = serde_json::from_value(json!({ + "content": [ + { "type": "thinking", "thinking": [{ "type": "text", "text": "step 1" }], + "signature": null, "closed": false } + ] + })) + .unwrap(); + assert_eq!(delta.content, None, "thinking phase has no visible answer"); + assert_eq!(delta.reasoning_content.as_deref(), Some("step 1")); + + // Answer phase: plain string delta. + let delta: ChatChunkDelta = + serde_json::from_value(json!({ "content": "the answer" })).unwrap(); + assert_eq!(delta.content.as_deref(), Some("the answer")); + assert_eq!(delta.reasoning_content, None); + + // Transition delta: one array carrying both a closing thinking chunk + // and the first text chunk → both channels populated. + let delta: ChatChunkDelta = serde_json::from_value(json!({ + "content": [ + { "type": "thinking", "thinking": [{ "type": "text", "text": "done" }] }, + { "type": "text", "text": "Answer: 22" } + ] + })) + .unwrap(); + assert_eq!(delta.content.as_deref(), Some("Answer: 22")); + assert_eq!(delta.reasoning_content.as_deref(), Some("done")); + } + + /// The chunk union is OPEN: unknown chunk types and reference chunks are + /// ignored, never fatal (SDK's UnknownContentChunk fallback). + #[test] + fn content_array_tolerates_unknown_chunk_types() { + let delta: ChatChunkDelta = serde_json::from_value(json!({ + "content": [ + { "type": "reference", "reference_ids": [1, 2] }, + { "type": "some_future_chunk", "payload": { "x": 1 } }, + { "type": "text", "text": "visible" } + ] + })) + .unwrap(); + assert_eq!(delta.content.as_deref(), Some("visible")); + assert_eq!(delta.reasoning_content, None); + } + + /// Non-streaming ChatResponseMessage gets the same array handling. + #[test] + fn response_message_array_content_splits() { + let msg: ChatResponseMessage = serde_json::from_value(json!({ + "role": "assistant", + "content": [ + { "type": "thinking", "thinking": [{ "type": "text", "text": "reasoning" }] }, + { "type": "text", "text": "final" } + ] + })) + .unwrap(); + assert_eq!(msg.content.as_deref(), Some("final")); + assert_eq!(msg.reasoning_content.as_deref(), Some("reasoning")); + // String content still works. + let msg: ChatResponseMessage = serde_json::from_value(json!({ + "role": "assistant", "content": "plain" + })) + .unwrap(); + assert_eq!(msg.content.as_deref(), Some("plain")); + } + #[test] fn reasoning_effort_serde_lowercase_round_trip() { for v in [ diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 3f5e6a8..9a7bdb7 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -595,7 +595,8 @@ mod tests { "openai", "anthropic", "deepseek", - "groq" + "groq", + "mistral" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -625,7 +626,8 @@ mod tests { "openai", "anthropic", "deepseek", - "groq" + "groq", + "mistral" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -648,7 +650,8 @@ mod tests { "openai", "anthropic", "deepseek", - "groq" + "groq", + "mistral" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -674,7 +677,8 @@ mod tests { "openai", "anthropic", "deepseek", - "groq" + "groq", + "mistral" ] ); assert_eq!(default_id(&built), None); diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 354ede0..b432775 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4074,6 +4074,7 @@ pub fn sampling_config_for_model( kigi_models::PlatformChatCompat::Passthrough => { kigi_sampling_types::ChatCompat::Passthrough } + kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral, }) .unwrap_or_default(); SamplerConfig { diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 51ed063..035597a 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1206,6 +1206,90 @@ mod tests { ); } + /// 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, &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 must map to the Mistral dialect (stream_options strip)" + ); + } + #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index eb46d61..0936cac 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(), 8, "7 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 9, "8 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 {:?}", @@ -6943,7 +6943,14 @@ pub(crate) mod tests { label: "Groq (API key)".into(), } ); - assert_eq!(items[7], PendingMenuItem::Quit); + assert_eq!( + items[7], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Mistral), + label: "Mistral (API key)".into(), + } + ); + assert_eq!(items[8], 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 { @@ -6954,7 +6961,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 8, + 9, "xai.api_key / cached_token must not add rows" ); }