From 7efb4b07cc7a2c76faf7b49a1bef4792e6fddf5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 21 Jul 2026 07:21:05 -0400 Subject: [PATCH] Add DeepSeek platform + ChatCompletions dialect system (provider 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 6th registry row: id "deepseek", DEEPSEEK_API_KEY > auth.json "deepseek" scope, base https://api.deepseek.com (chat rides {base}/chat/completions per official docs) with KIGI_DEEPSEEK_BASE_URL override, enrichment-backed metadata (1M context, 384k output cap, high/max effort menu). Structural fix the cycle exposed: kigi's Kimi-specific body adaptation ran UNCONDITIONALLY on every ChatCompletions request. New ChatCompat dialect, declared per platform row and threaded through SamplerConfig, ClientDefaults, and the session-persisted SamplingConfig (serde-default Kimi keeps restored pre-field sessions and BYOK endpoints byte-identical; production persist seams copy it; subagents inherit it): - Kimi: full legacy pipeline (dispatch ≡ legacy pinned) - DeepSeek: thinking:{type, reasoning_effort} per api-docs.deepseek.com (server maps low/medium→high, xhigh→max itself; none disables; absent leaves the server default) - Passthrough: OpenAI-style reasoning_effort scalar untouched (unblocks Groq and the rest of the OpenAI-compatible list) Review-confirmed release blocker fixed: kigi replays Kimi's reasoning_content (and its private model_id) on input assistant messages — Kimi consumes these, but DeepSeek documents input reasoning_content as prefix-mode-only (historically a 400) and other providers don't know either field. The DeepSeek and Passthrough arms now strip both; Kimi's own pipeline is untouched. Pinned on both message shapes. --- .../kigi-chat-state/src/actor/state.rs | 1 + .../kigi-chat-state/src/actor/tests.rs | 6 + .../codegen/kigi-chat-state/src/commands.rs | 1 + crates/codegen/kigi-chat-state/src/types.rs | 2 + crates/codegen/kigi-models/src/lib.rs | 58 ++++++- .../codegen/kigi-sampler/src/actor/state.rs | 1 + crates/codegen/kigi-sampler/src/client.rs | 16 +- crates/codegen/kigi-sampler/src/config.rs | 6 + .../codegen/kigi-sampler/src/kimi_compat.rs | 152 ++++++++++++++++++ .../codegen/kigi-sampler/tests/test_actor.rs | 1 + .../codegen/kigi-sampling-types/src/types.rs | 25 +++ .../kigi-shell/src/agent/auth_method.rs | 12 +- crates/codegen/kigi-shell/src/agent/config.rs | 15 ++ .../kigi-shell/src/agent/models_fetch.rs | 101 ++++++++++++ .../kigi-shell/src/agent/subagent/mod.rs | 1 + .../src/agent/subagent/tests/mod.rs | 1 + .../session/acp_session_impl/model_switch.rs | 1 + .../session/acp_session_impl/sampler_turn.rs | 2 + .../src/session/acp_session_impl/spawn.rs | 1 + .../auth_error_no_retry_tests.rs | 1 + .../cancel_running_task_tests.rs | 7 + .../acp_session_tests/idle_resume_tests.rs | 1 + .../inline_auto_compact_flow_tests.rs | 3 + .../acp_session_tests/memory_config_tests.rs | 1 + .../src/session/acp_session_tests/support.rs | 1 + .../kigi-shell/src/session/compaction.rs | 1 + .../src/session/helpers/session_compact.rs | 1 + .../src/test_support/lsp_runtime.rs | 1 + crates/codegen/kigi-shell/tests/common/mod.rs | 1 + crates/codegen/kigi-tui/src/app/app_view.rs | 13 +- 30 files changed, 423 insertions(+), 11 deletions(-) diff --git a/crates/codegen/kigi-chat-state/src/actor/state.rs b/crates/codegen/kigi-chat-state/src/actor/state.rs index 4d364c0..74c822e 100644 --- a/crates/codegen/kigi-chat-state/src/actor/state.rs +++ b/crates/codegen/kigi-chat-state/src/actor/state.rs @@ -272,6 +272,7 @@ mod tests { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, diff --git a/crates/codegen/kigi-chat-state/src/actor/tests.rs b/crates/codegen/kigi-chat-state/src/actor/tests.rs index c625b13..9277de4 100644 --- a/crates/codegen/kigi-chat-state/src/actor/tests.rs +++ b/crates/codegen/kigi-chat-state/src/actor/tests.rs @@ -23,6 +23,7 @@ fn test_config_with_window(context_window: u64) -> SamplingConfig { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), @@ -913,6 +914,7 @@ async fn update_sampling_config_is_queryable() { temperature: Some(0.5), top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, @@ -1298,6 +1300,7 @@ async fn build_request_uses_sampling_config() { temperature: Some(0.7), top_p: Some(0.9), api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, @@ -3401,6 +3404,7 @@ async fn sampling_config_survives_compaction_replacement() { temperature: Some(0.7), top_p: Some(0.95), api_backend: ApiBackend::Responses, + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, @@ -3481,6 +3485,7 @@ async fn model_metadata_lost_after_compaction_then_recovered_on_next_turn() { temperature: Some(0.7), top_p: Some(0.95), api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, @@ -3569,6 +3574,7 @@ async fn context_window_downgrade_triggers_auto_compact() { temperature: Some(0.7), top_p: Some(0.95), api_backend: ApiBackend::Responses, + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, diff --git a/crates/codegen/kigi-chat-state/src/commands.rs b/crates/codegen/kigi-chat-state/src/commands.rs index c3ea595..db467e0 100644 --- a/crates/codegen/kigi-chat-state/src/commands.rs +++ b/crates/codegen/kigi-chat-state/src/commands.rs @@ -370,6 +370,7 @@ mod tests { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, diff --git a/crates/codegen/kigi-chat-state/src/types.rs b/crates/codegen/kigi-chat-state/src/types.rs index 06e7107..8aed961 100644 --- a/crates/codegen/kigi-chat-state/src/types.rs +++ b/crates/codegen/kigi-chat-state/src/types.rs @@ -178,6 +178,7 @@ mod tests { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, @@ -221,6 +222,7 @@ mod tests { temperature: Some(0.7), top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index eaa17ba..f1b9b8c 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -63,6 +63,15 @@ pub enum ListingDialect { Anthropic, } +/// ChatCompletions body-adaptation dialect (leaf-safe mirror of the +/// sampler's `ChatCompat`; the shell maps it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformChatCompat { + Kimi, + DeepSeek, + Passthrough, +} + /// How a platform's API key rides requests (listing, validation, inference). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlatformKeyHeader { @@ -125,6 +134,8 @@ struct PlatformSpec { wire_api: PlatformWireApi, /// Model-listing endpoint shape + headers. listing: ListingDialect, + /// ChatCompletions body-adaptation dialect (ignored for other backends). + chat_compat: PlatformChatCompat, /// Key header style for listing/validation/inference. key_header: PlatformKeyHeader, /// Restrict the live listing to models the enrichment catalog knows — @@ -148,6 +159,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, }; @@ -169,6 +181,7 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, }; @@ -190,6 +203,7 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Kimi, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, }; @@ -216,6 +230,7 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec { wire_serves_metadata: false, wire_api: PlatformWireApi::Responses, listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: true, }; @@ -242,10 +257,38 @@ const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec { wire_serves_metadata: false, wire_api: PlatformWireApi::Messages, listing: ListingDialect::Anthropic, + chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::XApiKey, restrict_to_enriched: false, }; +/// Base-URL override for DeepSeek (dev/test escape hatch). +pub const DEEPSEEK_BASE_URL_ENV: &str = "KIGI_DEEPSEEK_BASE_URL"; + +const DEEPSEEK_SPEC: PlatformSpec = PlatformSpec { + id: "deepseek", + display_name: "DeepSeek", + base_url: BaseUrlSource::EnvOr { + env: DEEPSEEK_BASE_URL_ENV, + // No /v1: chat rides {base}/chat/completions, listing {base}/models + // (official docs). + default: "https://api.deepseek.com", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["DEEPSEEK_API_KEY"], + vendor: "DeepSeek", + console_host: Some("platform.deepseek.com"), + login_label: Some("DeepSeek (API key)"), + models_dev_id: Some("deepseek"), + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::DeepSeek, + key_header: PlatformKeyHeader::Bearer, + restrict_to_enriched: false, +}; + /// 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)] @@ -260,17 +303,20 @@ pub enum PlatformId { OpenAi, /// Anthropic platform API (API key, Messages dialect). Anthropic, + /// DeepSeek platform API (API key, ChatCompletions dialect). + DeepSeek, } 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; 5] = [ + pub const ALL: [PlatformId; 6] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, Self::OpenAi, Self::Anthropic, + Self::DeepSeek, ]; /// The registry row backing this platform (single source of per-platform @@ -282,6 +328,7 @@ impl PlatformId { Self::MoonshotAi => &MOONSHOT_AI_SPEC, Self::OpenAi => &OPENAI_SPEC, Self::Anthropic => &ANTHROPIC_SPEC, + Self::DeepSeek => &DEEPSEEK_SPEC, } } @@ -381,6 +428,12 @@ impl PlatformId { pub fn key_header(self) -> PlatformKeyHeader { self.spec().key_header } + + /// ChatCompletions body-adaptation dialect (shell maps to the sampler's + /// `ChatCompat`; meaningless for other backends). + pub fn chat_compat(self) -> PlatformChatCompat { + self.spec().chat_compat + } } /// Split a managed catalog key `{platform_id}/{model_id}` back into its @@ -931,9 +984,10 @@ mod tests { PlatformId::MoonshotAi => 2, PlatformId::OpenAi => 3, PlatformId::Anthropic => 4, + PlatformId::DeepSeek => 5, } } - const VARIANT_COUNT: usize = 5; // update together with `ordinal` + const VARIANT_COUNT: usize = 6; // 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/actor/state.rs b/crates/codegen/kigi-sampler/src/actor/state.rs index cfa6685..aacba13 100644 --- a/crates/codegen/kigi-sampler/src/actor/state.rs +++ b/crates/codegen/kigi-sampler/src/actor/state.rs @@ -89,6 +89,7 @@ mod tests { top_p: None, api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), + chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 8192, force_http1: false, diff --git a/crates/codegen/kigi-sampler/src/client.rs b/crates/codegen/kigi-sampler/src/client.rs index 78de0f0..e477b16 100644 --- a/crates/codegen/kigi-sampler/src/client.rs +++ b/crates/codegen/kigi-sampler/src/client.rs @@ -270,6 +270,7 @@ struct ClientDefaults { top_p: Option, api_backend: ApiBackend, auth_scheme: AuthScheme, + chat_compat: kigi_sampling_types::ChatCompat, stream_tool_calls: bool, doom_loop_recovery: Option, } @@ -456,6 +457,7 @@ impl SamplingClient { top_p: config.top_p, api_backend: config.api_backend, auth_scheme: config.auth_scheme, + chat_compat: config.chat_compat, stream_tool_calls: config.stream_tool_calls, doom_loop_recovery: config.doom_loop_recovery, }; @@ -775,7 +777,10 @@ impl SamplingClient { tracing::error!("Failed to serialize chat/completions request: {}", e); SamplingError::Serialization(e) })?; - crate::kimi_compat::adapt_chat_completions_body(&mut request_body); + crate::kimi_compat::adapt_chat_completions_body_for( + self.defaults.chat_compat, + &mut request_body, + ); let http_request = self .post(self.endpoint("chat/completions")) @@ -829,7 +834,10 @@ impl SamplingClient { tracing::error!("Failed to serialize chat/completions request: {}", e); SamplingError::Serialization(e) })?; - crate::kimi_compat::adapt_chat_completions_body(&mut request_body); + crate::kimi_compat::adapt_chat_completions_body_for( + self.defaults.chat_compat, + &mut request_body, + ); let http_request = self .post(self.endpoint("chat/completions")) @@ -1880,6 +1888,7 @@ mod tests { top_p: None, api_backend: ApiBackend::ChatCompletions, auth_scheme: AuthScheme::Bearer, + chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 8192, force_http1: false, @@ -2076,6 +2085,7 @@ mod tests { api_key: Some("bearer-key-abc123".to_string()), api_backend: ApiBackend::Messages, auth_scheme: AuthScheme::Bearer, + chat_compat: Default::default(), ..minimal_config() }; let client = SamplingClient::new(cfg).expect("client should build"); @@ -2242,6 +2252,7 @@ mod tests { api_key: Some("stale-bearer".to_string()), api_backend: ApiBackend::Messages, auth_scheme: AuthScheme::Bearer, + chat_compat: Default::default(), bearer_resolver: Some(std::sync::Arc::new(StaticBearerResolver("fresh-bearer"))), ..minimal_config() }; @@ -2270,6 +2281,7 @@ mod tests { api_key: Some("stale-bearer".to_string()), api_backend: ApiBackend::Responses, auth_scheme: AuthScheme::Bearer, + chat_compat: Default::default(), bearer_resolver: Some(std::sync::Arc::new(StaticBearerResolver("fresh-bearer"))), ..minimal_config() }; diff --git a/crates/codegen/kigi-sampler/src/config.rs b/crates/codegen/kigi-sampler/src/config.rs index e20e21a..b7175ae 100644 --- a/crates/codegen/kigi-sampler/src/config.rs +++ b/crates/codegen/kigi-sampler/src/config.rs @@ -70,6 +70,11 @@ pub struct SamplerConfig { // Reasoning effort pub reasoning_effort: Option, + /// ChatCompletions body-adaptation dialect (per-platform; BYOK/custom + /// endpoints default to the historical Kimi behavior; lenient default on + /// deserialize so persisted configs from before the field parse). + #[serde(default)] + pub chat_compat: kigi_sampling_types::ChatCompat, /// Client identity for the User-Agent header (`kigi/{version}` plus an /// optional origin product). The old xAI proxy's identity headers @@ -135,6 +140,7 @@ impl Default for SamplerConfig { model: String::new(), max_completion_tokens: None, temperature: None, + chat_compat: kigi_sampling_types::ChatCompat::default(), top_p: None, api_backend: ApiBackend::default(), auth_scheme: AuthScheme::default(), diff --git a/crates/codegen/kigi-sampler/src/kimi_compat.rs b/crates/codegen/kigi-sampler/src/kimi_compat.rs index 4be4e44..653aeda 100644 --- a/crates/codegen/kigi-sampler/src/kimi_compat.rs +++ b/crates/codegen/kigi-sampler/src/kimi_compat.rs @@ -29,6 +29,73 @@ pub(crate) fn adapt_chat_completions_body(body: &mut Value) { adapt_tool_schemas(body); } +/// Dialect-dispatched body adaptation. Kimi keeps the full historical +/// pipeline (thinking + message hygiene + schema normalization — all built +/// for the Kimi wire's strictness); DeepSeek differs ONLY in how thinking +/// rides the body; Passthrough providers take OpenAI-style bodies verbatim +/// (their `reasoning_effort` scalar is already the wire form). +pub(crate) fn adapt_chat_completions_body_for( + compat: kigi_sampling_types::ChatCompat, + body: &mut Value, +) { + match compat { + kigi_sampling_types::ChatCompat::Kimi => adapt_chat_completions_body(body), + kigi_sampling_types::ChatCompat::DeepSeek => { + adapt_thinking_deepseek(body); + strip_kigi_private_message_fields(body); + } + kigi_sampling_types::ChatCompat::Passthrough => { + strip_kigi_private_message_fields(body); + } + } +} + +/// Remove kigi-internal history artifacts from input messages before they +/// reach a non-Kimi wire. `reasoning_content` is Kimi's replayed-thinking +/// field (Kimi consumes it; DeepSeek documents it as prefix-mode-only and +/// historically 400s on it; other providers don't know it) and `model_id` +/// is kigi's private per-message provenance. Kimi's own pipeline handles +/// these in `adapt_messages`. +fn strip_kigi_private_message_fields(body: &mut Value) { + let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else { + return; + }; + for message in messages { + if let Some(obj) = message.as_object_mut() { + obj.remove("reasoning_content"); + obj.remove("model_id"); + } + } +} + +/// DeepSeek spells the thinking control `thinking:{type, reasoning_effort}` +/// (api-docs.deepseek.com create-chat-completion; the server maps +/// low/medium→high and xhigh→max itself, so the canonical level passes +/// through verbatim). `none` disables thinking; absent leaves the server +/// default (enabled). +fn adapt_thinking_deepseek(body: &mut Value) { + let Some(obj) = body.as_object_mut() else { + return; + }; + let Some(effort) = obj.remove("reasoning_effort") else { + return; + }; + let Some(level) = effort.as_str().map(str::to_owned) else { + return; + }; + if level == "none" { + obj.insert( + "thinking".to_string(), + serde_json::json!({ "type": "disabled" }), + ); + } else { + obj.insert( + "thinking".to_string(), + serde_json::json!({ "type": "enabled", "reasoning_effort": level }), + ); + } +} + /// Map the OpenAI-style `reasoning_effort` knob onto Kimi's `thinking` /// request field and drop `reasoning_effort` from the wire. /// @@ -280,6 +347,91 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn deepseek_dialect_spells_thinking_reasoning_effort() { + use kigi_sampling_types::ChatCompat; + // Official docs: thinking:{type, reasoning_effort}; server maps + // low/medium→high, xhigh→max itself — levels pass through verbatim. + let mut body = json!({ "model": "deepseek-v4-pro", "reasoning_effort": "high" }); + adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); + assert_eq!(body.get("reasoning_effort"), None); + assert_eq!( + body["thinking"], + json!({ "type": "enabled", "reasoning_effort": "high" }) + ); + + let mut body = json!({ "model": "deepseek-v4-flash", "reasoning_effort": "max" }); + adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); + assert_eq!( + body["thinking"], + json!({ "type": "enabled", "reasoning_effort": "max" }) + ); + + // none disables; absent leaves the server default (no thinking key). + let mut body = json!({ "reasoning_effort": "none" }); + adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); + assert_eq!(body["thinking"], json!({ "type": "disabled" })); + let mut body = json!({ "model": "deepseek-chat" }); + adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); + assert_eq!(body.get("thinking"), None); + + // DeepSeek does NOT get kimi's message/tool-schema rewrites (empty + // assistant tool-call content survives — DeepSeek's documented + // function-calling round-trip uses that shape), but kigi-private + // fields are stripped: replayed reasoning_content is prefix-mode-only + // on the DeepSeek wire (historically a 400 in input messages). + let mut body = json!({ + "reasoning_effort": "high", + "messages": [ + { "role": "assistant", "content": "", "tool_calls": [{}], + "reasoning_content": "replayed thinking", "model_id": "kigi/x" } + ] + }); + adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); + assert_eq!(body["messages"][0]["content"], json!("")); + assert_eq!(body["messages"][0].get("reasoning_content"), None); + assert_eq!(body["messages"][0].get("model_id"), None); + } + + #[test] + fn passthrough_dialect_leaves_openai_body_verbatim() { + use kigi_sampling_types::ChatCompat; + // Verbatim EXCEPT kigi-private history artifacts, which no non-Kimi + // wire understands. + let mut body = json!({ + "model": "gpt-oss", + "reasoning_effort": "high", + "messages": [ + { "role": "user", "content": "hi" }, + { "role": "assistant", "content": "yo", + "reasoning_content": "internal", "model_id": "kigi/x" } + ] + }); + adapt_chat_completions_body_for(ChatCompat::Passthrough, &mut body); + assert_eq!( + body, + json!({ + "model": "gpt-oss", + "reasoning_effort": "high", + "messages": [ + { "role": "user", "content": "hi" }, + { "role": "assistant", "content": "yo" } + ] + }), + "reasoning_effort stays OpenAI-style; private fields are stripped" + ); + } + + #[test] + fn kimi_dialect_dispatch_matches_legacy_pipeline() { + use kigi_sampling_types::ChatCompat; + let mut via_dispatch = json!({ "model": "k3", "reasoning_effort": "max" }); + adapt_chat_completions_body_for(ChatCompat::Kimi, &mut via_dispatch); + let mut via_legacy = json!({ "model": "k3", "reasoning_effort": "max" }); + adapt_chat_completions_body(&mut via_legacy); + assert_eq!(via_dispatch, via_legacy, "Kimi dispatch = legacy pipeline"); + } + #[test] fn reasoning_effort_maps_to_kimi_thinking_field() { // Level rides along as thinking.effort (live wire: 200 with diff --git a/crates/codegen/kigi-sampler/tests/test_actor.rs b/crates/codegen/kigi-sampler/tests/test_actor.rs index 5dd690f..319ba43 100644 --- a/crates/codegen/kigi-sampler/tests/test_actor.rs +++ b/crates/codegen/kigi-sampler/tests/test_actor.rs @@ -78,6 +78,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig { top_p: None, api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), + chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 128_000, force_http1: false, diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 0e002b9..a2c4877 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -938,6 +938,26 @@ pub fn normalize_effort_echo(value: &mut Value) { /// wires (Messages inference and the /v1/models listing). pub const ANTHROPIC_VERSION: &str = "2023-06-01"; +/// ChatCompletions request-body adaptation dialect. Providers disagree on +/// how thinking rides an OpenAI-compatible body: Kimi wants +/// `thinking:{type,effort}`, DeepSeek wants +/// `thinking:{type,reasoning_effort}`, most others take the OpenAI-style +/// `reasoning_effort` scalar untouched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChatCompat { + /// Kimi wire (`thinking:{type,effort}`, canonical xhigh spelled max). + /// The default: BYOK/custom ChatCompletions endpoints keep the + /// historical kigi behavior. + #[default] + Kimi, + /// DeepSeek wire (`thinking:{type,reasoning_effort}`, official docs: + /// low/medium map to high and xhigh to max server-side). + DeepSeek, + /// Leave the body as-is (OpenAI-style `reasoning_effort` passes through). + Passthrough, +} + pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort"; @@ -1126,6 +1146,11 @@ pub struct SamplingConfig { pub max_completion_tokens: Option, pub temperature: Option, pub top_p: Option, + /// ChatCompletions body-adaptation dialect (per-platform; serde-default + /// Kimi keeps pre-field sessions and BYOK endpoints on the historical + /// behavior). + #[serde(default)] + pub chat_compat: ChatCompat, /// Which API backend to use for this model #[serde(default)] pub api_backend: ApiBackend, diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 17c94c9..b01ed9e 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -593,7 +593,8 @@ mod tests { MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", - "anthropic" + "anthropic", + "deepseek" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -621,7 +622,8 @@ mod tests { MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", - "anthropic" + "anthropic", + "deepseek" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -642,7 +644,8 @@ mod tests { MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", - "anthropic" + "anthropic", + "deepseek" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -666,7 +669,8 @@ mod tests { MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", - "anthropic" + "anthropic", + "deepseek" ] ); 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 60b2885..13276d3 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4062,6 +4062,20 @@ 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. + let chat_compat = info + .id + .as_deref() + .and_then(kigi_models::parse_managed_model_key) + .map(|(platform, _)| match platform.chat_compat() { + kigi_models::PlatformChatCompat::Kimi => kigi_sampling_types::ChatCompat::Kimi, + kigi_models::PlatformChatCompat::DeepSeek => kigi_sampling_types::ChatCompat::DeepSeek, + kigi_models::PlatformChatCompat::Passthrough => { + kigi_sampling_types::ChatCompat::Passthrough + } + }) + .unwrap_or_default(); SamplerConfig { api_key: credentials.api_key, model: model_name, @@ -4071,6 +4085,7 @@ pub fn sampling_config_for_model( top_p, api_backend, auth_scheme: credentials.auth_scheme, + chat_compat, extra_headers, context_window: info.context_window.get(), reasoning_effort: info.reasoning_effort, diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 39d3224..d9362d6 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1022,6 +1022,107 @@ mod tests { ); } + /// 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, &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); + } + #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; diff --git a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs index bc54037..a79dcc3 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs @@ -889,6 +889,7 @@ async fn read_parent_sampling_config( top_p: cfg.top_p, api_backend: cfg.api_backend, auth_scheme, + chat_compat: cfg.chat_compat, extra_headers, context_window: cfg.context_window.get(), reasoning_effort: cfg.reasoning_effort, diff --git a/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs index de3b21f..e569945 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs @@ -3272,6 +3272,7 @@ fn test_sampling_config(model_slug: &str) -> kigi_sampling_types::SamplingConfig temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(256_000).expect("non-zero context window"), reasoning_effort: None, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/model_switch.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/model_switch.rs index 5a73001..20d2b80 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/model_switch.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/model_switch.rs @@ -53,6 +53,7 @@ impl SessionActor { temperature: sampling_config.temperature, top_p: sampling_config.top_p, api_backend: sampling_config.api_backend.clone(), + chat_compat: sampling_config.chat_compat, extra_headers: sampling_config.extra_headers.clone(), context_window: new_context_window, reasoning_effort: sampling_config.reasoning_effort, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs index ac8a7ed..b2a4b4c 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs @@ -260,6 +260,7 @@ impl SessionActor { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(256_000).unwrap(), reasoning_effort: None, @@ -312,6 +313,7 @@ impl SessionActor { top_p: cfg.top_p, api_backend: cfg.api_backend, auth_scheme, + chat_compat: cfg.chat_compat, extra_headers, context_window: cfg.context_window.get(), reasoning_effort: cfg.reasoning_effort, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs index d337a93..adb2042 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs @@ -346,6 +346,7 @@ pub(crate) async fn spawn_session_actor( temperature: sampling_config.temperature, top_p: sampling_config.top_p, api_backend: sampling_config.api_backend.clone(), + chat_compat: sampling_config.chat_compat, extra_headers: sampling_config.extra_headers.clone(), context_window: context_window_override.unwrap_or(baseline_context_window), reasoning_effort: sampling_config.reasoning_effort, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs index 10778d3..0ce2311 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs @@ -847,6 +847,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() { temperature: None, top_p: None, api_backend: crate::sampling::ApiBackend::ChatCompletions, + chat_compat: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), context_window: 256_000, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index c511fed..e9155e7 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -44,6 +44,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), context_window: 100_000, @@ -87,6 +88,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(100_000).unwrap(), reasoning_effort: None, @@ -338,6 +340,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), auth_scheme: Default::default(), context_window: 100_000, force_http1: false, @@ -381,6 +384,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(100_000).unwrap(), reasoning_effort: None, @@ -466,6 +470,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), auth_scheme: Default::default(), context_window: 100_000, force_http1: false, @@ -513,6 +518,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(100_000).unwrap(), reasoning_effort: None, @@ -1732,6 +1738,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { temperature: None, top_p: None, api_backend: kigi_sampler::ApiBackend::Responses, + chat_compat: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), context_window: 100_000, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs index 13dfeba..1b4282b 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs @@ -104,6 +104,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index def39ef..1315a11 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -52,6 +52,7 @@ async fn create_test_actor( temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), @@ -486,6 +487,7 @@ async fn create_test_actor_with_memory( temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), @@ -1240,6 +1242,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs index 3495be2..9516c73 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -104,6 +104,7 @@ async fn create_test_actor_with_memory( temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs index a76cc63..d525310 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs @@ -168,6 +168,7 @@ pub(crate) async fn create_test_actor_ex( temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), diff --git a/crates/codegen/kigi-shell/src/session/compaction.rs b/crates/codegen/kigi-shell/src/session/compaction.rs index 49a6192..d26ac8b 100644 --- a/crates/codegen/kigi-shell/src/session/compaction.rs +++ b/crates/codegen/kigi-shell/src/session/compaction.rs @@ -2149,6 +2149,7 @@ mod inline_auto_compact_flow_tests { temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), extra_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), diff --git a/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs b/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs index eddb4f9..0cf7dbf 100644 --- a/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs +++ b/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs @@ -1588,6 +1588,7 @@ mod reasoning_compaction_regression_tests { temperature: Some(0.7), top_p: None, api_backend: ApiBackend::ChatCompletions, + chat_compat: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), context_window: 256_000, diff --git a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs index 98aef21..0786b8b 100644 --- a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs +++ b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs @@ -44,6 +44,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap) -> SubagentSpawnCon temperature: None, top_p: None, api_backend: Default::default(), + chat_compat: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), context_window: 256_000, diff --git a/crates/codegen/kigi-shell/tests/common/mod.rs b/crates/codegen/kigi-shell/tests/common/mod.rs index a705090..c2f7217 100644 --- a/crates/codegen/kigi-shell/tests/common/mod.rs +++ b/crates/codegen/kigi-shell/tests/common/mod.rs @@ -38,6 +38,7 @@ pub fn test_sampler_config( top_p: None, api_backend, auth_scheme: Default::default(), + chat_compat: Default::default(), extra_headers: extra_headers .iter() .map(|(k, v)| (k.to_string(), v.to_string())) diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index a0bf179..9e80e75 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(), 6, "5 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 7, "6 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 {:?}", @@ -6929,7 +6929,14 @@ pub(crate) mod tests { label: "Anthropic (API key)".into(), } ); - assert_eq!(items[5], PendingMenuItem::Quit); + assert_eq!( + items[5], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek), + label: "DeepSeek (API key)".into(), + } + ); + assert_eq!(items[6], 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 { @@ -6940,7 +6947,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 6, + 7, "xai.api_key / cached_token must not add rows" ); }