From ba6f0d5b95d13cc23bbc7cb0b8967e2e13d80c24 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:26:58 +0800 Subject: [PATCH 1/3] fix(tui): mirror MiniMax reasoning_details only on gateway routes The mirror (introduced in e9f0c1c13 with the first-party MiniMax route) rewrites replayed assistant reasoning_content into reasoning_details on every MiniMax request. MiniMax's own API reference, re-checked on 2026-11-04 (https://platform.minimax.io/docs/api-reference/text-openai-api and https://platform.minimax.io/docs/api-reference/text-chat-openai), no longer lists reasoning_details anywhere in the Chat Completions request schema: their documented multi-turn contract is to append the full assistant message and preserve reasoning_content (M3.1-Flash) or the content (M2.x) alongside role:tool pairing. The field now only survives in gateway/third-party dialects (APISIX, LiteLLM, OpenRouter's MiniMax M2 page). Two options were weighed. Wholesale deletion is not provably safe: MINIMAX_BASE_URL and [providers.minimax] base_url stay user-configurable, so gateway dialects remain reachable through the minimax provider slot (PROVIDERS.md already classifies custom/gateway endpoints on this slot), and OpenRouter's MiniMax M2 page still documents the reasoning_details round-trip the mirror feeds. So the mirror is narrowed instead: it now fires only when the route is provider=minimax on a non-first-party base_url, judged with the existing exact-route predicate (is_exact_minimax_chat_route: https://api.minimax.io/v1 and https://api.minimaxi.com/v1). OpenRouter's own traffic keeps its independent capture/replay path on the openrouter provider branch and never needed this mirror. reasoning_split and max_completion_tokens are documented request parameters and are untouched, as is the OpenRouter- owned reasoning_details capture. Red-green evidence: minimax_official_route_replays_thinking_as_ reasoning_content_only and minimax_wire_body_on_official_route_carries_ no_reasoning_details fail against the old implementation (synthesized reasoning_details present on the official endpoint) and pass after the narrowing; minimax_gateway_route_still_mirrors_thinking_as_reasoning_ details pins the kept gateway behavior. All 35 minimax-matching tui lib tests green. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 102 ++++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 83d3cbee1c..6f2d6a3b68 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1034,8 +1034,31 @@ 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. +/// `MINIMAX_BASE_URL` stays user-configurable, 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. +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 +1288,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 +1861,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 +5084,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 +5125,47 @@ 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_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_gateway_route_still_mirrors_thinking_as_reasoning_details() { + // MINIMAX_BASE_URL is user-configurable, 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(&request, ApiProvider::Minimax); + 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 +5188,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(); From 2c0cfcf862220ba1a21c8e37d969f94fc3112776 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 11 Nov 2026 23:08:20 +0800 Subject: [PATCH 2/3] fix(config): classify bare MiniMax official hosts as first-party A configured base_url of https://api.minimax.io (or api.minimaxi.com) without a path previously fell through the exact-route guard and was classified as a gateway, so reasoning_details were mirrored onto a route that the transport normalizes to the official /v1 endpoint. Classify the bare official hosts as first-party alongside their /v1 spellings; neighboring spellings (ports, lookalike hosts, HTTP downgrades) stay fail-closed. Name the operative configuration knobs in the mirror-guard doc comment and the gateway test instead of MINIMAX_BASE_URL, which never reaches the TUI chat route. Requested by review on #145 (P2: bare-host form bypassed the guard); also fixes the review's doc-comment accuracy note. --- crates/config/src/provider.rs | 38 +++++++++++++++++++++- crates/tui/src/client/chat.rs | 61 +++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 26245ca6ad..88b77bd944 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -504,6 +504,22 @@ 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://`, optional trailing slash). +/// +/// 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, so a bare official +/// host 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.strip_suffix('/').unwrap_or(trimmed); + 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 @@ -586,11 +602,19 @@ 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. 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 @@ -2390,6 +2414,11 @@ 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. + "https://api.minimax.io", + "https://api.minimaxi.com/", + "HTTPS://API.MINIMAXI.COM", ] { assert!( is_exact_minimax_chat_route(ProviderKind::Minimax, route), @@ -2407,6 +2436,13 @@ mod tests { "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), diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 6f2d6a3b68..0c4781dc56 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1042,9 +1042,13 @@ fn mirror_minimax_reasoning_details_for_messages(messages: &mut [Value]) { /// 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. -/// `MINIMAX_BASE_URL` stays user-configurable, 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. +/// 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( @@ -5153,12 +5157,55 @@ mod minimax_reasoning_replay_tests { ); } + #[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_gateway_route_still_mirrors_thinking_as_reasoning_details() { - // MINIMAX_BASE_URL is user-configurable, 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. + // 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( From 876cb1d1085a89161bbfb2e68d54ca17f534b569 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:30:46 +0800 Subject: [PATCH 3/3] fix(config): trim all trailing slashes before classifying official routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_exact_https_route and is_bare_https_authority stripped a single trailing slash, but the transport's versioned_base_url trims them all (trim_end_matches('/')) before appending /v1 — so https://api.minimax.io// dialed the official endpoint while the guard classified it as a gateway and mirrored reasoning_details onto the first-party route. Normalize every trailing slash in both helpers so classification always matches what is dialed, without broadening path matching (a path with more than trailing slashes stays a neighboring route). The same mismatch existed for the exact-route spellings of every provider using the shared helper (kimi, moonshot, xai, zai, minimax anthropic), and their pinned expectations are updated to the dialed-endpoint semantics. Requested by review on #145 (P2: repeated trailing slashes bypassed the bare-host normalization). Signed-off-by: asto18089 --- crates/config/src/provider.rs | 72 +++++++++++++++++++++++++---------- crates/tui/src/client/chat.rs | 36 ++++++++++++++++++ 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 88b77bd944..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; }; @@ -505,15 +509,17 @@ fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: } /// Whether a configured route is a bare official HTTPS host with no path -/// (`https://`, optional trailing slash). +/// (`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, so a bare official -/// host is the same first-party endpoint as its documented `/v1` spelling. +/// 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.strip_suffix('/').unwrap_or(trimmed); + let normalized = trimmed.trim_end_matches('/'); let Some((scheme, authority)) = normalized.split_once("://") else { return false; }; @@ -522,7 +528,7 @@ fn is_bare_https_authority(base_url: &str, expected_authority: &str) -> bool { /// 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 { @@ -606,8 +612,10 @@ pub fn is_exact_zai_chat_route(kind: ProviderKind, base_url: &str) -> bool { /// 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. Neighboring spellings — explicit ports, other paths, HTTP -/// downgrades — stay fail-closed like the rest of the exact-route family. +/// 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 @@ -2237,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", @@ -2249,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), @@ -2307,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 @@ -2319,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", @@ -2337,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/" @@ -2347,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", ] { @@ -2373,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}"); } @@ -2382,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 @@ -2415,10 +2438,16 @@ mod tests { "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. + // 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), @@ -2431,7 +2460,6 @@ 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", @@ -2461,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), @@ -2473,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 0c4781dc56..9a769cdf38 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -5198,6 +5198,42 @@ mod minimax_reasoning_replay_tests { } } + #[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]`