diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 26245ca6ad..16cba24d3b 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -487,11 +487,15 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp { fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: &str) -> bool { // URL schemes and host names are ASCII case-insensitive; paths are not. // Do not lowercase the whole URL here: a differently-cased path is a - // neighboring route, not the official endpoint. Keep this intentionally - // dependency-free because provider metadata is used by low-level config - // callers that should not need URL parsing machinery just for this guard. + // neighboring route, not the official endpoint. Trailing slashes are + // insignificant before the path comparison because the transport trims + // them all (`trim_end_matches('/')`) before dialing, so a route spelled + // with repeated trailing slashes still reaches this exact endpoint. + // Keep this intentionally dependency-free because provider metadata is + // used by low-level config callers that should not need URL parsing + // machinery just for this guard. let trimmed = base_url.trim(); - let normalized = trimmed.strip_suffix('/').unwrap_or(trimmed); + let normalized = trimmed.trim_end_matches('/'); let Some((scheme, authority_and_path)) = normalized.split_once("://") else { return false; }; @@ -504,9 +508,27 @@ fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: && path == expected_path } +/// Whether a configured route is a bare official HTTPS host with no path +/// (`https://`, trailing slashes insignificant). +/// +/// Complements [`is_exact_https_route`] for the one shape the transport +/// normalizes onto the exact route: a version-less base URL gets `/v1` +/// appended by the client before the request is dialed, and the transport +/// trims every trailing slash first, so a bare official host — however many +/// trailing slashes it carries — is the same first-party endpoint as its +/// documented `/v1` spelling. +fn is_bare_https_authority(base_url: &str, expected_authority: &str) -> bool { + let trimmed = base_url.trim(); + let normalized = trimmed.trim_end_matches('/'); + let Some((scheme, authority)) = normalized.split_once("://") else { + return false; + }; + scheme.eq_ignore_ascii_case("https") && authority.eq_ignore_ascii_case(expected_authority) +} + /// Whether a configured route is exactly the official Kimi Code endpoint. /// -/// A trailing slash is insignificant, but neighboring Kimi-hosted paths must +/// Trailing slashes are insignificant, but neighboring Kimi-hosted paths must /// not inherit membership-plan credentials merely because they share a host. #[must_use] pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool { @@ -586,11 +608,21 @@ pub fn is_exact_zai_chat_route(kind: ProviderKind, base_url: &str) -> bool { /// /// This deliberately excludes the `/anthropic` routes: those use the native /// Messages adapter and do not share Chat Completions token-limit fields. +/// The bare official hosts (`https://api.minimax.io`, `https://api.minimaxi.com`, +/// no path) count as first-party too: the client normalizes a version-less +/// base URL by appending `/v1`, so a bare-host configuration dials the same +/// documented endpoint as its `/v1` spelling and must not be classified as +/// a gateway. Repeated trailing slashes count too: the transport trims them +/// all before appending `/v1`, so their classification must match what is +/// actually dialed. Neighboring spellings — explicit ports, other paths, +/// HTTP downgrades — stay fail-closed like the rest of the exact-route family. #[must_use] pub fn is_exact_minimax_chat_route(kind: ProviderKind, base_url: &str) -> bool { kind == ProviderKind::Minimax && (is_exact_https_route(base_url, "api.minimax.io", "v1") - || is_exact_https_route(base_url, "api.minimaxi.com", "v1")) + || is_exact_https_route(base_url, "api.minimaxi.com", "v1") + || is_bare_https_authority(base_url, "api.minimax.io") + || is_bare_https_authority(base_url, "api.minimaxi.com")) } /// Whether a configured route is one of MiniMax's exact first-party @@ -2213,11 +2245,17 @@ mod tests { )); // Scheme and hostname casing are insignificant, but the endpoint - // path is a route identifier and must remain exact. + // path is a route identifier and must remain exact. Repeated + // trailing slashes stay insignificant: the transport trims them all + // before dialing, so this spelling reaches the same endpoint. assert!(is_exact_kimi_code_route( ProviderKind::Moonshot, "HTTPS://API.KIMI.COM/coding/v1/" )); + assert!(is_exact_kimi_code_route( + ProviderKind::Moonshot, + "https://api.kimi.com/coding/v1//" + )); for neighboring_route in [ "https://api.kimi.com/CODING/v1", "https://api.kimi.com/coding/V1", @@ -2225,7 +2263,6 @@ mod tests { "https://api.kimi.com:443/coding/v1", "https://api.kimi.com/coding/v1?preview=1", "https://api.kimi.com/coding/v1#fragment", - "https://api.kimi.com/coding/v1//", ] { assert!( !is_exact_kimi_code_route(ProviderKind::Moonshot, neighboring_route), @@ -2283,7 +2320,13 @@ mod tests { #[test] fn direct_moonshot_route_matching_is_exact() { - for route in ["HTTPS://API.MOONSHOT.AI/v1/", "HTTPS://API.MOONSHOT.CN/v1/"] { + for route in [ + "HTTPS://API.MOONSHOT.AI/v1/", + "HTTPS://API.MOONSHOT.CN/v1/", + // Repeated trailing slashes: the transport trims them all before + // dialing, so this spelling reaches the same endpoint. + "https://api.moonshot.ai/v1//", + ] { assert!(is_exact_moonshot_platform_route( ProviderKind::Moonshot, route @@ -2295,7 +2338,6 @@ mod tests { "https://api.moonshot.ai:443/v1", "https://api.moonshot.ai/v1?preview=1", "https://api.moonshot.ai/v1#fragment", - "https://api.moonshot.ai/v1//", "https://api.moonshot.ai/v1/chat/completions", "https://api.moonshot.cn/v1/chat/completions", "https://api.kimi.com/coding/v1", @@ -2313,6 +2355,10 @@ mod tests { #[test] fn direct_xai_route_matching_is_exact() { + assert!(is_exact_xai_platform_route( + ProviderKind::Xai, + "https://api.x.ai/v1//" + )); assert!(is_exact_xai_platform_route( ProviderKind::Xai, "HTTPS://API.X.AI/v1/" @@ -2323,7 +2369,6 @@ mod tests { "https://api.x.ai:443/v1", "https://api.x.ai/v1?preview=1", "https://api.x.ai/v1#fragment", - "https://api.x.ai/v1//", "https://api.x.ai/v1/chat/completions", "https://gateway.example/v1", ] { @@ -2349,6 +2394,9 @@ mod tests { "https://open.bigmodel.cn/api/paas/v4", "https://open.bigmodel.cn/api/paas/v4/", "HTTPS://OPEN.BIGMODEL.CN/api/paas/v4", + // Repeated trailing slashes: the transport trims them all before + // dialing, so this spelling reaches the same endpoint. + "https://api.z.ai/api/paas/v4//", ] { assert!(is_exact_zai_chat_route(ProviderKind::Zai, route), "{route}"); } @@ -2358,7 +2406,6 @@ mod tests { "https://api.z.ai/API/paas/v4", "https://api.z.ai/api/paas/v4?preview=1", "https://api.z.ai/api/paas/v4#fragment", - "https://api.z.ai/api/paas/v4//", "https://api.z.ai/api/paas/v4/chat/completions", // BigModel neighbors: the undocumented coding path and the // preview product stay fail-closed, like the official-endpoint @@ -2390,6 +2437,17 @@ mod tests { "https://api.minimax.io/v1", "https://api.minimaxi.com/v1/", "HTTPS://API.MINIMAX.IO/v1", + // Bare official hosts: the client appends `/v1` to a version-less + // base URL, so these dial the same first-party endpoint. Repeated + // trailing slashes count too — the transport trims them all before + // appending `/v1`, so their classification must match what is + // actually dialed (review 4539989149 / 4541485725). + "https://api.minimax.io", + "https://api.minimaxi.com/", + "HTTPS://API.MINIMAXI.COM", + "https://api.minimax.io//", + "https://api.minimaxi.com///", + "https://api.minimax.io/v1//", ] { assert!( is_exact_minimax_chat_route(ProviderKind::Minimax, route), @@ -2402,11 +2460,17 @@ mod tests { "https://api.minimax.io/V1", "https://api.minimax.io/v1?preview=1", "https://api.minimax.io/v1#fragment", - "https://api.minimax.io/v1//", "https://api.minimax.io/v1/chat/completions", "https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic", "https://gateway.example/v1", + // Bare-host neighbors: a differently-cased scheme is a downgrade, + // an explicit port and lookalike or neighboring hosts stay + // fail-closed even without a path. + "http://api.minimax.io", + "https://api.minimax.io:443", + "https://api.minimax.io.evil.example", + "https://gateway.example", ] { assert!( !is_exact_minimax_chat_route(ProviderKind::Minimax, neighboring_route), @@ -2425,6 +2489,11 @@ mod tests { "https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic/", "HTTPS://API.MINIMAX.IO/anthropic", + // Repeated trailing slashes: the Messages transport trims them + // all (`anthropic_messages_url`) before appending the versioned + // path, so the classification must match what is dialed. + "https://api.minimaxi.com/anthropic/", + "https://api.minimax.io/anthropic//", ] { assert!( is_exact_minimax_anthropic_route(ProviderKind::MinimaxAnthropic, route), @@ -2437,7 +2506,6 @@ mod tests { "https://api.minimax.io/Anthropic", "https://api.minimax.io/anthropic?preview=1", "https://api.minimax.io/anthropic#fragment", - "https://api.minimax.io/anthropic//", "https://api.minimax.io/anthropic/v1/messages", "https://api.minimax.io/v1", "https://gateway.example/anthropic", diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 83d3cbee1c..9a769cdf38 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1034,8 +1034,35 @@ fn mirror_minimax_reasoning_details_for_messages(messages: &mut [Value]) { } } -fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProvider) { - if provider != ApiProvider::Minimax { +/// Whether this MiniMax route still speaks the `reasoning_details` +/// continuity dialect. +/// +/// MiniMax's own API reference (checked 2026-11-04) carries no +/// `reasoning_details` in the Chat Completions request schema — the +/// documented multi-turn contract is echoing `reasoning_content` (or +/// preserving `` content) alongside role:tool pairing — so mirroring +/// on a first-party endpoint would only add an undocumented field. +/// The route stays user-configurable through the `[providers.minimax]` +/// `base_url` table override (or the global `CODEWHALE_BASE_URL` / +/// `--base-url` flags; `MINIMAX_BASE_URL` never reaches this chat route), +/// so gateway dialects behind a custom `base_url` (e.g. an +/// OpenRouter-compatible proxy for MiniMax M2, which still documents the +/// field) keep the mirror. Bare official hosts are classified first-party +/// because the transport appends `/v1` to a version-less base URL. +fn minimax_route_mirrors_reasoning_details(provider: ApiProvider, base_url: &str) -> bool { + provider == ApiProvider::Minimax + && !codewhale_config::provider::is_exact_minimax_chat_route( + codewhale_config::ProviderKind::Minimax, + base_url, + ) +} + +fn mirror_minimax_reasoning_details_for_body( + body: &mut Value, + provider: ApiProvider, + base_url: &str, +) { + if !minimax_route_mirrors_reasoning_details(provider, base_url) { return; } let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { @@ -1265,7 +1292,7 @@ pub(crate) fn build_chat_wire_body( } else { None }; - mirror_minimax_reasoning_details_for_body(&mut body, provider); + mirror_minimax_reasoning_details_for_body(&mut body, provider, base_url); Ok(ChatWireBody { body, @@ -1838,7 +1865,7 @@ impl<'a> PromptBuilder<'a> { if provider == ApiProvider::Arcee { apply_arcee_waf_safe_message_encoding(&mut messages); } - if provider == ApiProvider::Minimax { + if minimax_route_mirrors_reasoning_details(provider, base_url) { mirror_minimax_reasoning_details_for_messages(&mut messages); } if is_exact_mistral_chat_route(provider, base_url) { @@ -5061,12 +5088,9 @@ mod arcee_waf_message_encoding_tests { #[cfg(test)] mod minimax_reasoning_replay_tests { - use super::{ - build_chat_messages_for_request_and_provider, - build_chat_messages_for_request_and_provider_and_route, - }; + use super::{build_chat_messages_for_request_and_provider_and_route, build_chat_wire_body}; use crate::config::{ - ApiProvider, DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MINIMAX_MODEL, + ApiProvider, DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MOONSHOT_BASE_URL, KIMI_CODE_K3_MODEL, }; use crate::models::Role; @@ -5105,10 +5129,126 @@ mod minimax_reasoning_replay_tests { } #[test] - fn minimax_history_replays_thinking_as_reasoning_details() { + fn minimax_official_route_replays_thinking_as_reasoning_content_only() { + // MiniMax's own API reference (checked 2026-11-04) no longer + // documents `reasoning_details` anywhere in the Chat Completions + // request schema; their multi-turn contract is to echo + // `reasoning_content` (or preserve `` content) alongside + // role:tool pairing. First-party endpoints must not receive the + // synthesized field. let request = request_with_assistant_thinking(); - let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Minimax); + let messages = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Minimax, + DEFAULT_MINIMAX_BASE_URL, + ); + let assistant = &messages[0]; + + assert_eq!( + assistant + .get("reasoning_content") + .and_then(|value| value.as_str()), + Some("Inspect tool state") + ); + assert!( + assistant.get("reasoning_details").is_none(), + "the official endpoint dropped reasoning_details from its documented schema: {assistant}" + ); + } + + #[test] + fn minimax_bare_official_host_replays_thinking_as_reasoning_content_only() { + // A bare official host has no `/v1` in the configured `base_url`, but + // the transport appends `/v1` before dialing, so it reaches the same + // first-party endpoint and must not receive the synthesized field + // through either the prompt builder or the wire-body pass. + let request = request_with_assistant_thinking(); + + let messages = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Minimax, + "https://api.minimax.io", + ); + let assistant = &messages[0]; + + assert_eq!( + assistant + .get("reasoning_content") + .and_then(|value| value.as_str()), + Some("Inspect tool state") + ); + assert!( + assistant.get("reasoning_details").is_none(), + "the bare official host dials the official /v1 endpoint: {assistant}" + ); + + let body = build_chat_wire_body( + &request, + ApiProvider::Minimax, + "https://api.minimaxi.com/", + false, + ) + .expect("MiniMax wire body"); + for message in body.body["messages"].as_array().expect("messages array") { + assert!( + message.get("reasoning_details").is_none(), + "wire body for the bare official host must not gain reasoning_details: {message}" + ); + } + } + + #[test] + fn minimax_repeated_trailing_slash_host_dials_first_party_without_reasoning_details() { + // The transport trims every trailing slash (`trim_end_matches('/')`) + // before appending `/v1`, so `https://api.minimax.io//` reaches the + // official endpoint. The route guards must therefore strip all + // trailing slashes too (review 4539989149 / 4541485725): stripping + // only one left this spelling classified as a gateway and mirrored + // reasoning_details onto the first-party route. + let request = request_with_assistant_thinking(); + + let messages = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Minimax, + "https://api.minimax.io//", + ); + let assistant = &messages[0]; + assert!( + assistant.get("reasoning_details").is_none(), + "the repeated-slash official host dials the official /v1 endpoint: {assistant}" + ); + + let body = build_chat_wire_body( + &request, + ApiProvider::Minimax, + "https://api.minimaxi.com///", + false, + ) + .expect("MiniMax wire body"); + for message in body.body["messages"].as_array().expect("messages array") { + assert!( + message.get("reasoning_details").is_none(), + "wire body for the repeated-slash official host must not gain reasoning_details: {message}" + ); + } + } + + #[test] + fn minimax_gateway_route_still_mirrors_thinking_as_reasoning_details() { + // The route stays user-configurable through the `[providers.minimax]` + // `base_url` table override (or the global `CODEWHALE_BASE_URL` / + // `--base-url` flags), so gateway dialects stay reachable through the + // minimax provider slot; a gateway serving OpenRouter's MiniMax M2 + // page still documents the `reasoning_details` round-trip, so + // non-official endpoints keep the mirror. + let request = request_with_assistant_thinking(); + + let messages = build_chat_messages_for_request_and_provider_and_route( + &request, + ApiProvider::Minimax, + "https://gateway.example/v1", + ); let assistant = &messages[0]; assert_eq!( @@ -5131,6 +5271,29 @@ mod minimax_reasoning_replay_tests { ); } + #[test] + fn minimax_wire_body_on_official_route_carries_no_reasoning_details() { + // The wire-body pass mirrors after the prompt builder; it must + // respect the same first-party-vs-gateway split so a cached or + // rebuilt body cannot reintroduce the field. + let request = request_with_assistant_thinking(); + + let body = build_chat_wire_body( + &request, + ApiProvider::Minimax, + DEFAULT_MINIMAX_BASE_URL, + false, + ) + .expect("MiniMax wire body"); + + for message in body.body["messages"].as_array().expect("messages array") { + assert!( + message.get("reasoning_details").is_none(), + "official endpoint must not receive synthesized reasoning_details: {message}" + ); + } + } + #[test] fn kimi_code_k3_replays_thinking_only_on_the_exact_membership_route() { let mut request = request_with_assistant_thinking();