From 1d9b74bece1a88a8a358ba6aabe951e212331f78 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 16:26:53 +0800 Subject: [PATCH 1/4] fix(tui): replay reasoning_content for kimi k3.1 The K3.1 spellings (kimi-k3.1-pro[-highspeed] on the direct platform, k3.1-pro[-highspeed] on Kimi Code) inherit the K3 contract verbatim, including the replay rule: the API requires the assistant reasoning_content on later tool turns and answers HTTP 400 when a replayed tool-call message misses it (Kimi Code error reference, missing-reasoning-content-field). The reasoning-replay gate only learned the bare k3 ids through the exact-K3 matchers, so a K3.1 tool round fell through to the generic heuristics and stripped the required field. The gate carries its own K3.1 arm on the vendor's two official routes; it is deliberately narrower than extending the shared matchers, whose other consumers (effort shaping, route validation, pickers) carry different K3-only contracts. Regression pins both routes with a tool-call round. Signed-off-by: asto18089 --- crates/tui/src/client.rs | 98 ++++++++++++++++++++++++++++++++++- crates/tui/src/client/chat.rs | 35 ++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5051141363..c8c62c99c7 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -4490,7 +4490,8 @@ mod tests { use super::*; use crate::client::chat::{ ReasoningDetailsBuffer, build_chat_messages, build_chat_messages_for_request, - build_chat_messages_for_request_and_provider, count_reasoning_replay_chars, + build_chat_messages_for_request_and_provider, + build_chat_messages_for_request_and_provider_and_route, count_reasoning_replay_chars, parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat, tool_to_chat_for_base_url, }; @@ -9324,6 +9325,101 @@ mod tests { ); } + /// K3.1 replay contract (2026-11-04 pinvou-agent#825 review): the K3.1 + /// spellings inherit the K3 contract verbatim, and the Kimi Code error + /// reference makes a replayed tool-call assistant message WITHOUT its + /// `reasoning_content` an HTTP 400. The exact-K3 matchers only learn the + /// bare k3 ids, so the replay gate carries its own K3.1 arm — pin both + /// vendor routes (direct platform + Kimi Code membership endpoint). + #[test] + fn kimi_k31_tool_rounds_replay_reasoning_content_on_the_vendor_routes() { + let tool_round = || { + vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Need the date".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + signature: None, + state: None, + thinking: "Need to call a tool".to_string(), + redacted_data: None, + reasoning_details: None, + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "get_date".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "2026-04-23".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ] + }; + for (label, base_url, model) in [ + ( + "direct platform", + "https://api.moonshot.cn/v1", + "kimi-k3.1-pro", + ), + ( + "kimi code", + "https://api.kimi.com/coding/v1", + "k3.1-pro-highspeed", + ), + ] { + let request = MessageRequest { + model: model.to_string(), + messages: tool_round(), + max_tokens: 16, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + let out = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Moonshot, + base_url, + ); + let tool_assistant = out + .iter() + .find(|value| { + value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + }) + .unwrap_or_else(|| panic!("{label}: tool-call assistant message missing")); + assert_eq!( + tool_assistant + .get("reasoning_content") + .and_then(Value::as_str), + Some("Need to call a tool"), + "{label}: the K3.1 replay contract must keep reasoning_content on the tool-round assistant message" + ); + } + } + #[test] fn chat_messages_replay_tool_round_reasoning_before_new_user_turn() { let messages = vec![ diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 83d3cbee1c..e7657f2df0 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -18,8 +18,8 @@ use crate::config::{ TOGETHER_INKLING_MODEL, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_k3_route, is_exact_xai_grok_4_6_route, is_exact_zai_chat_route, is_exact_zai_forced_thinking_route, is_exact_zai_tiered_effort_route, is_kimi_code_membership_model, - minimax_m3_route_uses_max_completion_tokens, moonshot_base_url_is_exact_kimi_code, - wire_model_for_provider_route, + minimax_m3_route_uses_max_completion_tokens, moonshot_base_url_is_exact_direct_platform, + moonshot_base_url_is_exact_kimi_code, wire_model_for_provider_route, }; // The bounded response-header wait (`stream_open_timeout`) and its env @@ -3488,6 +3488,19 @@ fn should_replay_reasoning_content_for_provider( /// Moonshot and arbitrary OpenAI-compatible routes can also expose a `k3` /// model name, but only Kimi Code's exact membership-plan endpoint has this /// replay contract. +/// +/// The K3.1 family spellings (`kimi-k3.1-pro[-highspeed]` on the direct +/// platform, `k3.1-pro[-highspeed]` on Kimi Code) ride the same replay +/// contract — see the arm inside. +/// The K3.1 family spellings across the Kimi Code and direct-platform +/// routes, case-insensitive. +fn is_kimi_k31_model_spelling(model: &str) -> bool { + matches!( + model.trim().to_ascii_lowercase().as_str(), + "kimi-k3.1-pro" | "kimi-k3.1-pro-highspeed" | "k3.1-pro" | "k3.1-pro-highspeed" + ) +} + fn should_replay_reasoning_content_for_provider_on_route( provider: ApiProvider, base_url: &str, @@ -3505,6 +3518,24 @@ fn should_replay_reasoning_content_for_provider_on_route( return true; } + // The K3.1 spellings inherit the K3 contract verbatim (models-overview: + // "参数约束也与 kimi-k3 完全一致"), including the replay rule — the API + // requires the assistant reasoning field on later tool turns and answers + // HTTP 400 when it is missing (Kimi Code error reference, + // missing-reasoning-content-field). The exact-K3 matchers only learn the + // bare k3 ids, so the K3.1 spellings carry their own arm here. The gate + // is deliberately narrower than extending the shared matchers, whose + // other consumers (effort shaping, route validation, pickers) carry + // different K3-only contracts. On the vendor's own routes only — custom + // Moonshot-compatible gateways keep their own dialect. + if provider == ApiProvider::Moonshot + && is_kimi_k31_model_spelling(model) + && (moonshot_base_url_is_exact_kimi_code(base_url) + || moonshot_base_url_is_exact_direct_platform(base_url)) + { + return true; + } + // The exact Model Studio route policy evaluates BEFORE any generic // model-name heuristic. Only models Alibaba documents as accepting // `preserve_thinking` replay historical `reasoning_content`, plus the From 5e4545615d3f005cce2955fcadeb26efa98a4b59 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 17:37:15 +0800 Subject: [PATCH 2/4] fix(tui): accept the overseas kimi code endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host catalog registers https://api.kimi.ai/coding/v1 as an official alias of the Kimi Code membership plan and saved settings keep it verbatim, but the exact route gate only learned api.kimi.com — so on the overseas route the K3.1 tool-turn replay contract (and the default model fallback, credential guidance, and billing presentation) still missed. Teach both the exact and the loose Kimi Code route gates the .ai host, keeping neighboring paths, lookalike hosts, and custom gateways outside the membership semantics. The K3.1 regression now pins both bare ids on the overseas route. Review: pinjou-agent#825 5765736266 / 5765853804 Signed-off-by: asto --- crates/config/src/provider.rs | 21 ++++++++++++++++++++- crates/tui/src/client.rs | 13 +++++++++++++ crates/tui/src/config.rs | 4 +++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 26245ca6ad..e1fc0007fe 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -514,7 +514,12 @@ pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool { return false; } + // The membership plan serves both the default host and the official + // overseas alias the host catalog registers (review P2, pinjou-agent#825: + // saved settings keep api.kimi.ai/coding/v1 verbatim, so the route gate + // must learn it or the K3.1 replay contract silently misses there). is_exact_https_route(base_url, "api.kimi.com", "coding/v1") + || is_exact_https_route(base_url, "api.kimi.ai", "coding/v1") } /// Whether a configured Ollama route is exactly the hosted OpenAI-compatible @@ -627,7 +632,7 @@ pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> Credenti acquisition: CredentialAcquisition::ApiKey, credential_url: Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL), docs_url: None, - guidance: "Create a Kimi Code membership-plan API key in the Kimi Code console. This route uses api.kimi.com/coding/v1; Codewhale does not import Kimi CLI credentials.", + guidance: "Create a Kimi Code membership-plan API key in the Kimi Code console. This route uses an official Kimi Code coding endpoint (api.kimi.com/coding/v1 or api.kimi.ai/coding/v1); Codewhale does not import Kimi CLI credentials.", }; } @@ -2218,7 +2223,21 @@ mod tests { ProviderKind::Moonshot, "HTTPS://API.KIMI.COM/coding/v1/" )); + // The official overseas alias carries the same membership semantics. + assert!(is_exact_kimi_code_route( + ProviderKind::Moonshot, + "https://api.kimi.ai/coding/v1" + )); + assert!(is_exact_kimi_code_route( + ProviderKind::Moonshot, + "https://api.kimi.ai/coding/v1/" + )); for neighboring_route in [ + "https://api.kimi.ai/CODING/v1", + "https://api.kimi.ai/coding/V1", + "http://api.kimi.ai/coding/v1", + "https://api.kimi.ai:443/coding/v1", + "https://api.kimi.ai/coding/v1?preview=1", "https://api.kimi.com/CODING/v1", "https://api.kimi.com/coding/V1", "http://api.kimi.com/coding/v1", diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index c8c62c99c7..dc66951a9a 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -9383,6 +9383,19 @@ mod tests { "https://api.kimi.com/coding/v1", "k3.1-pro-highspeed", ), + // The official overseas Kimi Code alias the host catalog + // registers and prefs preserves verbatim (review P2, + // pinjou-agent#825): the replay contract must hold there too. + ( + "kimi code overseas", + "https://api.kimi.ai/coding/v1", + "k3.1-pro", + ), + ( + "kimi code overseas highspeed", + "https://api.kimi.ai/coding/v1", + "k3.1-pro-highspeed", + ), ] { let request = MessageRequest { model: model.to_string(), diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d78aea3fc6..e79a1a9f7f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -7060,7 +7060,7 @@ impl Config { credential_help_for_provider_route(provider, &self.deepseek_base_url()); if moonshot_base_url_is_exact_kimi_code(&self.deepseek_base_url()) { anyhow::bail!( - "Kimi Code membership-plan API key not found. Get a plan key: {}. This route uses api.kimi.com/coding/v1 and does not import Kimi CLI credentials. Run 'codewhale auth set --provider moonshot', set {}, or add [{}] api_key.", + "Kimi Code membership-plan API key not found. Get a plan key: {}. This route uses an official Kimi Code coding endpoint (api.kimi.com/coding/v1 or api.kimi.ai/coding/v1) and does not import Kimi CLI credentials. Run 'codewhale auth set --provider moonshot', set {}, or add [{}] api_key.", credential_help .credential_url .unwrap_or(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL), @@ -9944,6 +9944,8 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { normalized == DEFAULT_KIMI_CODE_BASE_URL || normalized == "https://api.kimi.com/coding" || normalized.starts_with("https://api.kimi.com/coding/") + || normalized == "https://api.kimi.ai/coding" + || normalized.starts_with("https://api.kimi.ai/coding/") } /// The Kimi Code API endpoint, normalized only for insignificant trailing From fd0aef428ee00f556b9b09e9b700998feb56a833 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 19:56:57 +0800 Subject: [PATCH 3/4] fix(config): recognize the overseas kimi host Commit 2 taught the TUI-side loose fallback about api.kimi.ai/coding/v1 but left the config-crate twin and provider_base_url_is_official behind, so the two layers disagreed about the official endpoint family: a model-less Moonshot route on the overseas host resolved the default direct-platform id and then hard-failed membership validation on exactly the route the commit exists to support. Widen the loose membership recognition to both hosts, document the exact-gate subset doctrine it must keep, and pin the relation (and the lookalike/neighbor negatives) with tests. Also fixes the route-gate comment premise (the overseas alias is registered by the app-side model catalog, pinvoy-agent#825) and its pinjou typo. Signed-off-by: asto --- crates/config/src/lib.rs | 7 ++++ crates/config/src/provider.rs | 7 ++-- crates/config/src/tests.rs | 74 +++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 6ae7edfb35..99fba70617 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -4616,11 +4616,18 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { } } +/// Loose Kimi Code membership-endpoint recognition for default-model +/// fallback and official-host scoping. The exact gate +/// ([`provider::is_exact_kimi_code_route`]) must stay a subset of this: the +/// membership plan serves both the default host and the overseas alias, so +/// route canonicalization and credential scoping cannot disagree with it. fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { let normalized = base_url.trim_end_matches('/').to_ascii_lowercase(); normalized == DEFAULT_KIMI_CODE_BASE_URL || normalized == "https://api.kimi.com/coding" || normalized.starts_with("https://api.kimi.com/coding/") + || normalized == "https://api.kimi.ai/coding" + || normalized.starts_with("https://api.kimi.ai/coding/") } /// Dual-wire vendors: dialect is config (`wire`), not a separate ProviderKind. diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index e1fc0007fe..cbc239bfbf 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -515,9 +515,10 @@ pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool { } // The membership plan serves both the default host and the official - // overseas alias the host catalog registers (review P2, pinjou-agent#825: - // saved settings keep api.kimi.ai/coding/v1 verbatim, so the route gate - // must learn it or the K3.1 replay contract silently misses there). + // overseas alias the app's model catalog registers (pinvoy-agent#825 + // review P2: saved settings keep api.kimi.ai/coding/v1 verbatim, so the + // route gate must learn it or the K3.1 replay contract silently misses + // there). is_exact_https_route(base_url, "api.kimi.com", "coding/v1") || is_exact_https_route(base_url, "api.kimi.ai", "coding/v1") } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 5ce8e1848e..7bdd43fbbc 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -2521,6 +2521,80 @@ fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> { Ok(()) } +/// The loose membership recognition (default-model fallback, official-host +/// scoping) must stay a superset of the exact route gate: both membership +/// hosts, case- and trailing-slash-insensitively, while lookalike hosts, +/// neighboring paths, and scheme downgrades stay outside so they cannot +/// inherit membership semantics. +#[test] +fn kimi_code_loose_route_recognition_covers_both_membership_hosts() { + // The exact membership endpoint on both hosts, recognized by the loose + // gate, official-host scoping, and the exact gate alike. + for base_url in [ + DEFAULT_KIMI_CODE_BASE_URL, + "https://api.kimi.com/coding/v1/", + "https://api.kimi.ai/coding/v1", + "https://api.kimi.ai/coding/v1/", + ] { + assert!( + crate::moonshot_base_url_uses_kimi_code(base_url), + "{base_url} must be recognized as a Kimi Code membership route" + ); + assert!( + crate::provider_base_url_is_official(ProviderKind::Moonshot, base_url), + "{base_url} must be an official Moonshot route" + ); + assert!( + crate::provider::is_exact_kimi_code_route(ProviderKind::Moonshot, base_url), + "{base_url} must stay an exact Kimi Code membership route" + ); + } + // Loose recognition and official-host scoping are case- and + // trailing-slash-insensitive over neighboring Kimi-hosted /coding + // paths, while the exact gate does not widen with it (an uppercase path + // is not the route identifier). + for base_url in [ + "https://api.kimi.com/coding", + "https://api.kimi.com/coding/", + "https://api.kimi.ai/coding", + "HTTPS://API.KIMI.AI/CODING/V1", + ] { + assert!( + crate::moonshot_base_url_uses_kimi_code(base_url), + "{base_url} must stay inside the loose membership recognition" + ); + assert!( + crate::provider_base_url_is_official(ProviderKind::Moonshot, base_url), + "{base_url} must stay an official Moonshot route" + ); + assert!( + !crate::provider::is_exact_kimi_code_route(ProviderKind::Moonshot, base_url), + "{base_url} must not become an exact membership route" + ); + } + for lookalike in [ + "https://api.kimi.com/v1", + "https://api.kimi.ai/v1", + "https://kimi.com/coding/v1", + "http://api.kimi.ai/coding/v1", + "https://api.example.com/coding/v1", + ] { + assert!( + !crate::moonshot_base_url_uses_kimi_code(lookalike), + "{lookalike} must not be recognized as a Kimi Code membership route" + ); + } + // The direct platform stays official while remaining outside the + // membership family. + assert!(crate::provider_base_url_is_official( + ProviderKind::Moonshot, + "https://api.moonshot.ai/v1" + )); + assert!(!crate::moonshot_base_url_uses_kimi_code( + "https://api.moonshot.ai/v1" + )); +} + #[test] fn moonshot_provider_config_values_round_trip() -> Result<()> { let mut config = ConfigToml::default(); From e11b90ec524c7d2dbb6c65bc507f5c884aa5741c Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 19:57:06 +0800 Subject: [PATCH 4/4] fix(tui): repair replay-gate docs and test pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The K3.1 helper was inserted between the replay gate and its doc block, so the whole route-aware replay policy comment rustdoc- attaches to the 4-line spelling matcher while the gate itself is left undocumented, with a duplicated sentence pair and a backwards "see the arm inside" pointer. Give the helper its own doc line and restore the gate's policy doc with an accurate K3.1 paragraph. The regression test now pins what the arm actually promises: all four spellings across both vendor hosts, the stale-effort-off always-replay case (which is what the direct-platform matcher is for — the generic kimi- prefix masks it at default effort), the case/padding normalization on the bare Kimi Code ids, and the custom-gateway / non-Moonshot dialect boundary as negatives. Also aligns the membership-rejection and plan-key guidance strings with both membership hosts and fixes the remaining pinjou typo in the overseas test comment. Signed-off-by: asto --- crates/tui/src/client.rs | 139 +++++++++++++++++++++++++--------- crates/tui/src/client/chat.rs | 23 +++--- crates/tui/src/config.rs | 6 +- 3 files changed, 119 insertions(+), 49 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index dc66951a9a..04f4694eea 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -9329,8 +9329,11 @@ mod tests { /// spellings inherit the K3 contract verbatim, and the Kimi Code error /// reference makes a replayed tool-call assistant message WITHOUT its /// `reasoning_content` an HTTP 400. The exact-K3 matchers only learn the - /// bare k3 ids, so the replay gate carries its own K3.1 arm — pin both - /// vendor routes (direct platform + Kimi Code membership endpoint). + /// bare k3 ids, so the replay gate carries its own K3.1 arm — pin all + /// four spellings across both vendor routes (direct platform + Kimi Code + /// membership endpoint), the stale-effort-off always-replay case, the + /// case/padding normalization, and the custom-gateway / non-Moonshot + /// dialect boundary. #[test] fn kimi_k31_tool_rounds_replay_reasoning_content_on_the_vendor_routes() { let tool_round = || { @@ -9372,31 +9375,12 @@ mod tests { }, ] }; - for (label, base_url, model) in [ - ( - "direct platform", - "https://api.moonshot.cn/v1", - "kimi-k3.1-pro", - ), - ( - "kimi code", - "https://api.kimi.com/coding/v1", - "k3.1-pro-highspeed", - ), - // The official overseas Kimi Code alias the host catalog - // registers and prefs preserves verbatim (review P2, - // pinjou-agent#825): the replay contract must hold there too. - ( - "kimi code overseas", - "https://api.kimi.ai/coding/v1", - "k3.1-pro", - ), - ( - "kimi code overseas highspeed", - "https://api.kimi.ai/coding/v1", - "k3.1-pro-highspeed", - ), - ] { + let tool_assistant_reasoning = |label: &str, + provider: ApiProvider, + base_url: &str, + model: &str, + effort: Option<&str>| + -> Option { let request = MessageRequest { model: model.to_string(), messages: tool_round(), @@ -9406,15 +9390,13 @@ mod tests { tool_choice: None, metadata: None, thinking: None, - reasoning_effort: None, + reasoning_effort: effort.map(str::to_string), stream: None, temperature: None, top_p: None, }; let out = build_chat_messages_for_request_and_provider_and_route( - &request, - ApiProvider::Moonshot, - base_url, + &request, provider, base_url, ); let tool_assistant = out .iter() @@ -9423,14 +9405,101 @@ mod tests { && value.get("tool_calls").is_some() }) .unwrap_or_else(|| panic!("{label}: tool-call assistant message missing")); + tool_assistant + .get("reasoning_content") + .and_then(Value::as_str) + .map(str::to_string) + }; + for (label, base_url, model, effort) in [ + ( + "direct platform", + "https://api.moonshot.cn/v1", + "kimi-k3.1-pro", + None, + ), + ( + "direct platform highspeed", + "https://api.moonshot.ai/v1", + "kimi-k3.1-pro-highspeed", + None, + ), + // The arm must precede the effort check like bare K3: a stored + // effort off would otherwise strip the field via the generic + // path on the direct platform, where no catalog entry or + // `kimi-`-prefix-independent pin exists. + ( + "direct platform ignores a stale effort off", + "https://api.moonshot.cn/v1", + "kimi-k3.1-pro", + Some("off"), + ), + ( + "kimi code", + "https://api.kimi.com/coding/v1", + "k3.1-pro-highspeed", + None, + ), + // The official overseas Kimi Code alias the app's model catalog + // registers and prefs preserve verbatim (review P2, + // pinjou-agent#825): the replay contract must hold there too. + ( + "kimi code overseas", + "https://api.kimi.ai/coding/v1", + "k3.1-pro", + None, + ), + ( + "kimi code overseas highspeed", + "https://api.kimi.ai/coding/v1", + "k3.1-pro-highspeed", + None, + ), + // The spelling matcher normalizes case and padding; pin it on + // the bare Kimi Code ids, where no generic heuristic would + // replay in the matcher's place. + ( + "kimi code is case-insensitive", + "https://api.kimi.com/coding/v1", + "K3.1-PRO", + None, + ), + ( + "kimi code trims padding", + "https://api.kimi.com/coding/v1", + " k3.1-pro ", + None, + ), + ] { assert_eq!( - tool_assistant - .get("reasoning_content") - .and_then(Value::as_str), - Some("Need to call a tool"), + tool_assistant_reasoning(label, ApiProvider::Moonshot, base_url, model, effort), + Some("Need to call a tool".to_string()), "{label}: the K3.1 replay contract must keep reasoning_content on the tool-round assistant message" ); } + + // The arm is pinned to the vendor's own routes: a custom + // Moonshot-compatible gateway keeps its own dialect, and so does a + // different provider that happens to point at the membership host. + for (label, provider, base_url, model) in [ + ( + "custom gateway keeps its own dialect", + ApiProvider::Moonshot, + "https://gateway.example/v1", + "k3.1-pro", + ), + ( + "non-moonshot provider keeps its own dialect", + ApiProvider::Openai, + "https://api.kimi.com/coding/v1", + "k3.1-pro", + ), + ] { + assert_eq!( + tool_assistant_reasoning(label, provider, base_url, model, None), + None, + "{label}: reasoning_content must stay stripped outside the vendor routes" + ); + } } #[test] diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index e7657f2df0..629afd5851 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -3482,6 +3482,15 @@ fn should_replay_reasoning_content_for_provider( should_replay_reasoning_content_for_provider_on_route(provider, "", model, effort) } +/// Whether `model` is one of the K3.1 family spellings across the Kimi Code +/// and direct-platform routes, case-insensitive with insignificant padding. +fn is_kimi_k31_model_spelling(model: &str) -> bool { + matches!( + model.trim().to_ascii_lowercase().as_str(), + "kimi-k3.1-pro" | "kimi-k3.1-pro-highspeed" | "k3.1-pro" | "k3.1-pro-highspeed" + ) +} + /// Route-aware reasoning replay policy. /// /// Keep the bare K3 identifier out of the global model catalog: direct @@ -3490,17 +3499,9 @@ fn should_replay_reasoning_content_for_provider( /// replay contract. /// /// The K3.1 family spellings (`kimi-k3.1-pro[-highspeed]` on the direct -/// platform, `k3.1-pro[-highspeed]` on Kimi Code) ride the same replay -/// contract — see the arm inside. -/// The K3.1 family spellings across the Kimi Code and direct-platform -/// routes, case-insensitive. -fn is_kimi_k31_model_spelling(model: &str) -> bool { - matches!( - model.trim().to_ascii_lowercase().as_str(), - "kimi-k3.1-pro" | "kimi-k3.1-pro-highspeed" | "k3.1-pro" | "k3.1-pro-highspeed" - ) -} - +/// platform, `k3.1-pro[-highspeed]` on Kimi Code) inherit the K3 replay +/// contract verbatim through their own arm below — always-reasoning, so +/// like bare K3 the arm precedes the effort check. fn should_replay_reasoning_content_for_provider_on_route( provider: ApiProvider, base_url: &str, diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index e79a1a9f7f..5efcc01cfc 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -7072,7 +7072,7 @@ impl Config { "Moonshot/Kimi API key not found. Get a key: {}. Run 'codewhale auth set --provider moonshot', \ set {}, or add [{}] api_key. \ For a Kimi Code plan key, set [providers.moonshot] base_url = \ - \"https://api.kimi.com/coding/v1\" and model = \"kimi-for-coding\".", + \"https://api.kimi.com/coding/v1\" (or api.kimi.ai/coding/v1) and model = \"kimi-for-coding\".", credential_help .credential_url .unwrap_or("https://platform.kimi.ai/console/api-keys"), @@ -10224,7 +10224,7 @@ pub(crate) fn validate_kimi_code_api_model_id( for direct_id in MOONSHOT_DIRECT_PLATFORM_MODELS { if model.eq_ignore_ascii_case(direct_id) { return Err(format!( - "Kimi Code membership route (api.kimi.com/coding/v1) does not accept model = \"{model}\": it is a direct Moonshot platform id. Use a Kimi Code membership model (\"k3\", \"k3-256k\", \"kimi-for-coding\", or \"kimi-for-coding-highspeed\") for this base_url. Direct Moonshot pay-as-you-go uses base_url = \"https://api.moonshot.ai/v1\" with model = \"{direct_id}\"." + "Kimi Code membership route (api.kimi.com/coding/v1 or api.kimi.ai/coding/v1) does not accept model = \"{model}\": it is a direct Moonshot platform id. Use a Kimi Code membership model (\"k3\", \"k3-256k\", \"kimi-for-coding\", or \"kimi-for-coding-highspeed\") for this base_url. Direct Moonshot pay-as-you-go uses base_url = \"https://api.moonshot.ai/v1\" with model = \"{direct_id}\"." )); } } @@ -10235,7 +10235,7 @@ pub(crate) fn validate_kimi_code_api_model_id( for membership_id in KIMI_CODE_MEMBERSHIP_MODELS { if model.eq_ignore_ascii_case(membership_id) { return Err(format!( - "Moonshot direct route (api.moonshot.ai/v1) does not accept model = \"{model}\": it is a Kimi Code membership model id, not a direct-platform catalog model. Kimi Code membership uses base_url = \"https://api.kimi.com/coding/v1\" with model = \"{membership_id}\"; direct Moonshot pay-as-you-go K3 uses model = \"kimi-k3\"." + "Moonshot direct route (api.moonshot.ai/v1) does not accept model = \"{model}\": it is a Kimi Code membership model id, not a direct-platform catalog model. Kimi Code membership uses base_url = \"https://api.kimi.com/coding/v1\" (or api.kimi.ai/coding/v1) with model = \"{membership_id}\"; direct Moonshot pay-as-you-go K3 uses model = \"kimi-k3\"." )); } }