From 04ace33121abfa0d43627735580ae8c46b3cb73d Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 16:51:04 +0800 Subject: [PATCH] fix(route): replay k3.1 reasoning on tool turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi Code K3.1 flagship ids (k3.1-pro, k3.1-pro-highspeed) are always-reasoning: the vendor requires the assistant reasoning_content on the follow-up request after a tool call. The exact replay predicate only learned the bare k3 ids, so the K3.1 presets from the host catalog dropped the field and the follow-up request was rejected. Add a separate exact predicate for the two K3.1 wire ids and hook it into the replay decision and the stream reasoning classification only — the K3 route shapers (nested thinking.effort, fixed sampling, output ceilings) stay K3-only because K3.1 rejects the thinking parameter. Reported-by: JensenChen28 Reported-by: zhuowp Signed-off-by: asto --- crates/tui/src/client/chat.rs | 170 +++++++++++++++++++++++++++++++- crates/tui/src/config.rs | 20 ++++ crates/tui/src/config/models.rs | 9 ++ crates/tui/src/config/tests.rs | 61 ++++++++++++ 4 files changed, 256 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 83d3cbee1c..582c1b36a7 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -16,10 +16,10 @@ use tokio::time::timeout as tokio_timeout; 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, + is_exact_kimi_code_k31_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, }; // The bounded response-header wait (`stream_open_timeout`) and its env @@ -3499,6 +3499,7 @@ fn should_replay_reasoning_content_for_provider_on_route( // reasoning field on later tool turns for multi-turn continuity. if is_exact_direct_moonshot_k3_route(provider, base_url, model) || is_exact_kimi_code_k3_route(provider, base_url, model) + || is_exact_kimi_code_k31_route(provider, base_url, model) || (is_exact_mistral_chat_route(provider, base_url) && mistral_model_has_native_reasoning(model)) { @@ -3589,6 +3590,7 @@ fn is_reasoning_model_for_stream_on_route( ) -> bool { if is_exact_kimi_code_k3_route(provider, base_url, model) || is_exact_direct_moonshot_k3_route(provider, base_url, model) + || is_exact_kimi_code_k31_route(provider, base_url, model) { return true; } @@ -5159,6 +5161,166 @@ mod minimax_reasoning_replay_tests { ); } + /// Assistant Thinking + Text + ToolUse turn plus its matching ToolResult: + /// the exact "tool call, tool result, inspect the second request" shape + /// the review asked the regression to cover (pinvou-agent#825). + fn request_with_kimi_thinking_and_tool(model: &str, effort: Option<&str>) -> MessageRequest { + MessageRequest { + model: model.to_string(), + messages: vec![ + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "Inspect tool state".to_string(), + signature: None, + state: None, + redacted_data: None, + reasoning_details: None, + }, + ContentBlock::Text { + text: "Calling the tool now.".to_string(), + cache_control: None, + }, + ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "read_file".to_string(), + input: serde_json::json!({"path": "README.md"}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "contents".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 16, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: effort.map(|value| value.to_string()), + stream: None, + temperature: None, + top_p: None, + } + } + + #[test] + fn kimi_code_k31_replays_reasoning_across_the_tool_loop() { + // Review P2 (pinvou-agent#825): the K3.1 flagship ids on the exact + // Kimi Code route are always-reasoning models — the vendor requires + // the assistant `reasoning_content` on the follow-up request after a + // tool call. The bare ids must replay exactly like the older `k3` + // membership ids, regardless of the caller effort, and must not + // inherit the replay on a different Moonshot endpoint. + for (model, effort) in [ + ("k3.1-pro", None), + ("k3.1-pro", Some("high")), + ("k3.1-pro-highspeed", None), + ("K3.1-PRO", Some("high")), + ] { + let request = request_with_kimi_thinking_and_tool(model, effort); + let messages = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + ); + assert_eq!( + messages[0] + .get("reasoning_content") + .and_then(serde_json::Value::as_str), + Some("Inspect tool state"), + "{model} (effort {effort:?}) must replay reasoning on the tool turn" + ); + // The tool-call id and its result must stay intact in the same + // replayed sequence. + assert_eq!( + messages[0] + .pointer("/tool_calls/0/id") + .and_then(serde_json::Value::as_str), + Some("call-1"), + "{model} must keep the tool call binding intact" + ); + } + + // Negative arm: the same bare id on the direct Moonshot platform + // endpoint is a different route and must not inherit Kimi Code + // replay (mirrors the k3 membership-route isolation above). + let request = request_with_kimi_thinking_and_tool("k3.1-pro", None); + let neighbor = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Moonshot, + DEFAULT_MOONSHOT_BASE_URL, + ); + assert!( + neighbor[0].get("reasoning_content").is_none(), + "a bare k3.1 id on the direct platform endpoint must not inherit Kimi Code replay" + ); + } + + #[test] + fn kimi_code_k31_sanitizer_injects_placeholder_when_trace_was_dropped() { + // When an upstream hop dropped the reasoning field, the route-aware + // sanitizer must still satisfy the vendor contract with the replay + // placeholder on assistant tool-call turns. + use super::{REASONING_REPLAY_PLACEHOLDER, sanitize_thinking_mode_messages_for_route}; + let base = serde_json::json!({ + "model": "k3.1-pro", + "messages": [ + { + "role": "assistant", + "content": "Calling the tool now.", + "tool_calls": [ + {"id": "call-1", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}} + ] + }, + {"role": "tool", "tool_call_id": "call-1", "content": "contents"} + ] + }); + let mut body = base.clone(); + // The return value is the replay-telemetry token estimate, not the + // injection count: Some(_) means the route replayed reasoning content. + let replay_tokens = sanitize_thinking_mode_messages_for_route( + &mut body, + "k3.1-pro", + Some("high"), + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + ); + assert!( + replay_tokens.is_some(), + "k3.1-pro must count as a replay route" + ); + assert_eq!( + body.pointer("/messages/0/reasoning_content") + .and_then(serde_json::Value::as_str), + Some(REASONING_REPLAY_PLACEHOLDER), + ); + + // Negative arm: the same id on the direct platform endpoint is not a + // replay route, so the sanitizer must leave the body untouched. + let mut neighbor = base.clone(); + let replay_tokens = sanitize_thinking_mode_messages_for_route( + &mut neighbor, + "k3.1-pro", + Some("high"), + ApiProvider::Moonshot, + DEFAULT_MOONSHOT_BASE_URL, + ); + assert_eq!(replay_tokens, None); + assert!(neighbor.pointer("/messages/0/reasoning_content").is_none()); + } + #[test] fn modelstudio_qwen38_wire_body_replays_no_historical_reasoning_across_tool_loop() { // One user handoff, one assistant Thinking + Text + ToolUse turn, and diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d78aea3fc6..6e358f9125 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -10006,6 +10006,26 @@ pub(crate) fn is_exact_kimi_code_k3_route( .any(|id| model.trim().eq_ignore_ascii_case(id)) } +/// Whether a route is one of the official Kimi Code K3.1 flagship models. +/// +/// K3.1 is always-reasoning and rejects the K2.x `thinking` parameter, so it +/// deliberately stays out of [`is_exact_kimi_code_k3_route`]: the K3 route +/// shapers (nested `thinking.effort`, fixed sampling, output ceilings) are +/// K3-only contracts. Only the reasoning-replay/stream contract is shared +/// (review P2, pinvou-agent#825: tool-turn follow-ups on the bare `k3.1-pro` +/// ids dropped the vendor-required `reasoning_content`). +pub(crate) fn is_exact_kimi_code_k31_route( + provider: ApiProvider, + base_url: &str, + model: &str, +) -> bool { + provider == ApiProvider::Moonshot + && moonshot_base_url_is_exact_kimi_code(base_url) + && [KIMI_CODE_K3_1_MODEL, KIMI_CODE_K3_1_HIGHSPEED_MODEL] + .iter() + .any(|id| model.trim().eq_ignore_ascii_case(id)) +} + /// Whether a route uses plan-tier-dependent bare `k3`. /// /// Keep entitlement handling separate from `k3-256k`, whose window is fixed. diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index 5ac837f168..16a14272c0 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -121,6 +121,15 @@ pub const KIMI_CODE_K3_MODEL: &str = "k3"; /// Official fixed-256K Kimi Code K3 wire model id. This is a distinct /// membership-route model, not a client-side context alias for `k3`. pub const KIMI_CODE_K3_256K_MODEL: &str = "k3-256k"; +/// Kimi Code K3.1 flagship wire id. Always-reasoning: the model rejects the +/// K2.x `thinking` parameter and requires `reasoning_content` to be replayed +/// on later tool turns. Only the reasoning-replay/stream route contract +/// consumes this id — the K3 route shapers (nested `thinking.effort`, fixed +/// sampling, output ceilings) are K3-only and must not pick it up. +pub const KIMI_CODE_K3_1_MODEL: &str = "k3.1-pro"; +/// Kimi Code K3.1 flagship high-speed wire id. Same always-reasoning contract +/// as [`KIMI_CODE_K3_1_MODEL`] on the low-latency membership lane. +pub const KIMI_CODE_K3_1_HIGHSPEED_MODEL: &str = "k3.1-pro-highspeed"; /// Kimi Code membership high-speed wire model id. Membership route only, not /// a direct Moonshot platform catalog model. pub const KIMI_CODE_HIGHSPEED_MODEL: &str = "kimi-for-coding-highspeed"; diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 6f83f477a8..238162854f 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -13307,6 +13307,67 @@ fn k3_and_kimi_k3_never_cross_products_and_fail_visibly() { )); } +/// The K3.1 flagship ids share the Kimi Code endpoint with the K3 ids but are +/// a distinct always-reasoning contract: the new predicate must hit exactly +/// the two bare ids on the membership endpoint and stay disjoint from every +/// K3 predicate (review P2, pinvou-agent#825). +#[test] +fn kimi_code_k31_route_predicate_is_exact_and_disjoint_from_k3() { + for model in [KIMI_CODE_K3_1_MODEL, KIMI_CODE_K3_1_HIGHSPEED_MODEL] { + assert!(is_exact_kimi_code_k31_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + model + )); + // Case-insensitive on the wire id, like every exact predicate here. + assert!(is_exact_kimi_code_k31_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + &model.to_ascii_uppercase() + )); + // Disjoint from every K3 route predicate. + assert!(!is_exact_kimi_code_k3_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + model + )); + assert!(!is_exact_kimi_code_bare_k3_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + model + )); + assert!(!is_exact_direct_moonshot_k3_route( + ApiProvider::Moonshot, + DEFAULT_MOONSHOT_BASE_URL, + model + )); + } + + // Endpoint isolation: the platform spellings (`kimi-k3.1-pro*`) are a + // different product route and must not hit the Kimi Code predicate. + assert!(!is_exact_kimi_code_k31_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + "kimi-k3.1-pro" + )); + assert!(!is_exact_kimi_code_k31_route( + ApiProvider::Moonshot, + DEFAULT_MOONSHOT_BASE_URL, + KIMI_CODE_K3_1_MODEL + )); + // And the K3 ids must not hit the K3.1 predicate (bidirectional). + assert!(!is_exact_kimi_code_k31_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + KIMI_CODE_K3_MODEL + )); + assert!(!is_exact_kimi_code_k31_route( + ApiProvider::Moonshot, + DEFAULT_KIMI_CODE_BASE_URL, + KIMI_CODE_K3_256K_MODEL + )); +} + #[test] fn unknown_models_pass_through_on_canonical_moonshot_endpoints() { for base_url in [DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MOONSHOT_BASE_URL] {