From ba6f0d5b95d13cc23bbc7cb0b8967e2e13d80c24 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:26:58 +0800 Subject: [PATCH] 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();