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 26245ca6ad..cbc239bfbf 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -514,7 +514,13 @@ 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 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") } /// Whether a configured Ollama route is exactly the hosted OpenAI-compatible @@ -627,7 +633,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 +2224,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/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(); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5051141363..04f4694eea 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,183 @@ 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 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 = || { + 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, + }], + }, + ] + }; + 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(), + max_tokens: 16, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: 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, provider, 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")); + 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_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] 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..629afd5851 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 @@ -3482,12 +3482,26 @@ 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 /// 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) 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, @@ -3505,6 +3519,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 diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d78aea3fc6..5efcc01cfc 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), @@ -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"), @@ -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 @@ -10222,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}\"." )); } } @@ -10233,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\"." )); } }