From 1795048eb883daf6a59d2cfafea3ef2b9c198b81 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:11:14 +0800 Subject: [PATCH] feat(minimax): prefer M3.1 Flash and preserve reasoning history --- .../providers/anthropic_native/messages.rs | 31 ++- .../providers/anthropic_native/request.rs | 18 +- .../anthropic_native/stream_parser.rs | 10 +- .../providers/anthropic_native/streaming.rs | 10 +- .../src/core/providers/auxiliary_model.rs | 2 +- .../src/core/providers/model_capabilities.rs | 15 + .../providers/tests/auxiliary_model_tests.rs | 6 + .../providers/tests/minimax_requests_tests.rs | 259 ++++++++++++++++++ .../src/core/providers/tests/mod.rs | 1 + .../src/core/providers/thinking_mode.rs | 31 ++- src-tauri/crates/git-api/src/commands/ai.rs | 13 +- .../src/commands/crud/projections.rs | 14 +- .../src/commands/validate/dispatch.rs | 11 +- .../crates/key-vault/src/model_catalog.rs | 139 ++++++++++ 14 files changed, 521 insertions(+), 39 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/providers/tests/minimax_requests_tests.rs diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/messages.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/messages.rs index 441a3b0863..0a33e6fad3 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/messages.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/messages.rs @@ -69,7 +69,11 @@ pub(super) fn extract_system( } } "assistant" => { - let mut content_blocks: Vec = Vec::new(); + let mut content_blocks: Vec = msg + .get("content") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); let text = msg.get("content").and_then(|c| c.as_str()).unwrap_or(""); if !text.is_empty() { @@ -434,17 +438,24 @@ fn finalize_wire_hygiene(messages: &mut Vec) { fn anthropic_thinking_block(tool_call: &Value) -> Option { let anthropic = tool_call.get("extra_content")?.get("anthropic")?; let thinking = anthropic.get("thinking")?.as_str()?; - let signature = anthropic.get("signature")?.as_str()?; - // Fable 5.1 can sign an empty visible summary. The opaque signature, - // not the amount of display text, determines whether to replay it. - if signature.is_empty() { + let mut block = thinking_history(thinking, anthropic.get("signature").and_then(Value::as_str))? + ["anthropic"] + .clone(); + block["type"] = Value::String("thinking".into()); + Some(block) +} + +/// MiniMax returns unsigned reasoning; Claude may sign even an empty summary. +/// Preserve whichever fields the provider supplied without fabricating a signature. +pub(super) fn thinking_history(thinking: &str, signature: Option<&str>) -> Option { + if thinking.is_empty() && signature.is_none_or(str::is_empty) { return None; } - Some(serde_json::json!({ - "type": "thinking", - "thinking": thinking, - "signature": signature, - })) + let mut block = serde_json::json!({ "thinking": thinking }); + if let Some(signature) = signature { + block["signature"] = Value::String(signature.into()); + } + Some(serde_json::json!({ "anthropic": block })) } /// Convert a single content block from OpenAI format to Anthropic format. diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs index b181172982..e8c3ee8820 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs @@ -91,8 +91,22 @@ pub(super) fn prepare_request( max_tokens, temperature, ); - let thinking = outcome.thinking; - let effort = outcome.effort; + // Flash always thinks; it accepts adaptive thinking and five effort levels. + // Old `-none` selections retain the model's default instead of sending an + // unsupported disabled/none request. M3 keeps its protocol-native default. + let minimax_flash = client.provider_spec.name + == crate::providers::registry::provider_id::MINIMAX + && resolved_model.eq_ignore_ascii_case("MiniMax-M3.1-Flash"); + let thinking = if minimax_flash { + Some(json!({ "type": "adaptive" })) + } else { + outcome.thinking + }; + let effort = if minimax_flash { + Some(crate::providers::thinking_mode::minimax_flash_effort(parsed.level).to_string()) + } else { + outcome.effort + }; let mut effective_temp = outcome.temperature; let effective_max_tokens = outcome.max_tokens; diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/stream_parser.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/stream_parser.rs index ffe5fcca9b..bac5c44c4a 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/stream_parser.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/stream_parser.rs @@ -447,14 +447,8 @@ pub(super) fn finalize_blocks( } } BlockAcc::Thinking { text, signature } => { - if let Some(sig) = signature { - pending_anthropic_thinking = Some(serde_json::json!({ - "anthropic": { - "thinking": text, - "signature": sig, - } - })); - } + pending_anthropic_thinking = + super::messages::thinking_history(&text, signature.as_deref()); if !text.is_empty() { blocks.push(AssistantBlock::Reasoning { text }); } diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/streaming.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/streaming.rs index a00a88c077..2c50ce50a3 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/streaming.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/streaming.rs @@ -564,14 +564,8 @@ fn build_non_streaming_response(parsed: MessagesResponse) -> LLMResponse { signature, } => { if let Some(ref thought) = thinking { - if let Some(sig) = signature { - pending_anthropic_thinking = Some(serde_json::json!({ - "anthropic": { - "thinking": thought, - "signature": sig, - } - })); - } + pending_anthropic_thinking = + super::messages::thinking_history(thought, signature.as_deref()); // Empty summaries still carry signed history (Fable 5.1). if thought.is_empty() { continue; diff --git a/src-tauri/crates/agent-core/src/core/providers/auxiliary_model.rs b/src-tauri/crates/agent-core/src/core/providers/auxiliary_model.rs index dccc60b165..568d798d71 100644 --- a/src-tauri/crates/agent-core/src/core/providers/auxiliary_model.rs +++ b/src-tauri/crates/agent-core/src/core/providers/auxiliary_model.rs @@ -139,7 +139,7 @@ fn preferences(spec: &ProviderSpec, codex_oauth: bool) -> &'static [&'static str "glm-4.7-flashx", ], provider_id::DASHSCOPE => &["qwen-flash", "qwen-turbo", "qwen3.5-flash"], - provider_id::MINIMAX => &["minimax-m2.5", "minimax-m2.1", "minimax-m2"], + provider_id::MINIMAX => &["minimax-m3.1-flash", "minimax-m3"], provider_id::LONGCAT => &["longcat-flash-chat", "longcat-flash"], provider_id::MOONSHOT => &["kimi-k2-0905-preview", "kimi-k2.5", "kimi-k2"], provider_id::OPENROUTER diff --git a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs index c6e8986dfc..ef58ce7101 100644 --- a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs @@ -728,6 +728,21 @@ fn resolve_with_keyvault_variants(model: &str, variants: &[ModelVariant]) -> Mod fn resolve_from_family_table(model: &str) -> ModelCapabilities { let base_model = parse_model_variant_id(model).base_model; + // Direct IDs only: gateway routes (including :free / -free) must use + // their own reported limits instead of inheriting direct API metadata. + if base_model.eq_ignore_ascii_case("MiniMax-M3.1-Flash") + || base_model.eq_ignore_ascii_case("MiniMax-M3") + { + return ModelCapabilities { + context_window: 1_000_000, + thinking: if base_model.eq_ignore_ascii_case("MiniMax-M3.1-Flash") { + ThinkingSupport::AlwaysOn + } else { + ThinkingSupport::Optional + }, + omit_temperature_with_thinking: true, + }; + } let normalized = super::model_hints::normalize_claude_shorthand(&base_model); let model_lower = normalize_claude_release_separators(&normalized.to_lowercase()); for rule in FAMILY_RULES { diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/auxiliary_model_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/auxiliary_model_tests.rs index 2ee34e454b..1063a047c8 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/auxiliary_model_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/auxiliary_model_tests.rs @@ -13,6 +13,12 @@ fn account(model_type: ModelType, model: &str) -> ModelKey { #[test] fn auxiliary_model_respects_account_access_and_enabled_models() { for (family, model_type, parent, fast) in [ + ( + provider_id::MINIMAX, + ModelType::MinimaxApi, + "MiniMax-M3", + "MiniMax-M3.1-Flash", + ), ( provider_id::OPENAI, ModelType::OpenaiApi, diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/minimax_requests_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/minimax_requests_tests.rs new file mode 100644 index 0000000000..a945b32ae1 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/providers/tests/minimax_requests_tests.rs @@ -0,0 +1,259 @@ +//! Exercise the real HTTP clients with synthetic credentials and a local server. +use crate::providers::anthropic_native::AnthropicClient; +use crate::providers::openai_compat::OpenAICompatClient; +use crate::providers::registry::{find_by_name, provider_id}; +use crate::providers::traits::{LLMProvider, ProviderConfig}; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +fn fixture_response(anthropic: bool, stream: bool, with_tools: bool) -> String { + if !with_tools { + return match (anthropic, stream) { + (false, false) => json!({"id":"plain","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}).to_string(), + (false, true) => "data: {\"id\":\"plain\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"plain\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n".into(), + (true, false) => json!({"id":"plain","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}).to_string(), + (true, true) => "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"plain\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n".into(), + }; + } + if !stream { + return if anthropic { + json!({"id":"fixture","type":"message","role":"assistant","model":"MiniMax-M3.1-Flash","content":[{"type":"thinking","thinking":"Keep this reasoning."},{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"hello"}}],"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":2}}).to_string() + } else { + json!({"id":"fixture","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":"Keep this reasoning.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"hello\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2}}).to_string() + }; + } + if !anthropic { + return [ + json!({"id":"fixture","choices":[{"index":0,"delta":{"reasoning_content":"Keep this reasoning."},"finish_reason":null}]}), + json!({"id":"fixture","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"hello\"}"}}]},"finish_reason":null}]}), + json!({"id":"fixture","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), + ].into_iter().map(|event| format!("data: {event}\n\n")).collect::() + "data: [DONE]\n\n"; + } + [ + ("message_start", json!({"type":"message_start","message":{"id":"fixture","type":"message","role":"assistant","model":"MiniMax-M3.1-Flash","content":[],"usage":{"input_tokens":1,"output_tokens":0}}})), + ("content_block_start", json!({"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}})), + ("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Keep this reasoning."}})), + ("content_block_stop", json!({"type":"content_block_stop","index":0})), + ("content_block_start", json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_1","name":"lookup","input":{}}})), + ("content_block_delta", json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"q\":\"hello\"}"}})), + ("content_block_stop", json!({"type":"content_block_stop","index":1})), + ("message_delta", json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":2}})), + ("message_stop", json!({"type":"message_stop"})), + ].into_iter().map(|(event, data)| format!("event: {event}\ndata: {data}\n\n")).collect() +} + +async fn check_transport( + anthropic: bool, + stream: bool, + model: &str, + effort: &str, + with_tools: bool, +) { + crate::test_support::install_crypto_provider_for_tests(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let prefix = if anthropic { "/anthropic/v1" } else { "/v1" }; + let endpoint = format!("http://{}{prefix}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let mut captured = Vec::new(); + for _ in 0..2 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let (header_end, length) = loop { + let mut buffer = [0; 4096]; + let n = socket.read(&mut buffer).await.unwrap(); + assert!(n > 0 && bytes.len() + n < 131_072); + bytes.extend_from_slice(&buffer[..n]); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .unwrap() + .trim() + .parse() + .unwrap(); + break (end + 4, length); + } + }; + while bytes.len() < header_end + length { + let mut buffer = [0; 4096]; + let n = socket.read(&mut buffer).await.unwrap(); + assert!(n > 0 && bytes.len() + n < 131_072); + bytes.extend_from_slice(&buffer[..n]); + } + let headers = String::from_utf8_lossy(&bytes[..header_end]).to_lowercase(); + let body: Value = + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap(); + let response = fixture_response(anthropic, stream, with_tools); + let content_type = if stream { + "text/event-stream" + } else { + "application/json" + }; + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).as_bytes()).await.unwrap(); + captured.push((headers, body)); + } + captured + }); + let config = ProviderConfig { + api_key: "fixture-key".into(), + api_base: Some(endpoint), + extra_headers: Default::default(), + is_azure: false, + }; + let spec = find_by_name(provider_id::MINIMAX).unwrap(); + let client: Box = if anthropic { + Box::new(AnthropicClient::new(config, spec, model.into())) + } else { + Box::new(OpenAICompatClient::new(config, spec, model.into())) + }; + let image = "data:image/png;base64,aGVsbG8="; + let remote_image = "https://example.invalid/fixture.png"; + let mut messages = vec![ + json!({"role":"user","content":[{"type":"text","text":"Inspect these images"},{"type":"image_url","image_url":{"url":image}},{"type":"image_url","image_url":{"url":remote_image}}]}), + ]; + let tools = [ + json!({"type":"function","function":{"name":"lookup","description":"Lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}}), + ]; + let request_tools = if with_tools { + Some(tools.as_slice()) + } else { + None + }; + for turn in 0..2 { + let response = if stream { + client + .chat_streaming(&messages, request_tools, model, 4096, 0.1, &|_| {}, None) + .await + } else { + client + .chat(&messages, request_tools, model, 4096, 0.1) + .await + } + .unwrap(); + if !with_tools { + assert_eq!(response.content.as_deref(), Some("ok")); + assert!(response.tool_calls.is_empty()); + continue; + } + assert_eq!( + response.reasoning_content.as_deref(), + Some("Keep this reasoning.") + ); + assert_eq!(response.tool_calls.len(), 1); + if turn == 0 { + let call = &response.tool_calls[0]; + messages.push(json!({"role":"assistant","content":response.content,"reasoning_content":response.reasoning_content,"tool_calls":[{"id":call.id,"type":"function","function":{"name":call.name,"arguments":call.arguments.to_string()},"extra_content":call.thought_signature}]})); + messages.push(json!({"role":"tool","tool_call_id":call.id,"content":"found"})); + } + } + let captured = server.await.unwrap(); + for (headers, body) in &captured { + let base = crate::providers::thinking_mode::parse_model_variant(model).base_model; + assert_eq!(body["model"], base); + if effort.is_empty() { + assert!(body.get("thinking").is_none()); + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("output_config").is_none()); + } else { + assert_eq!(body["thinking"]["type"], "adaptive"); + } + assert!(body.get("service_tier").is_none()); + assert!(body.get("reasoning_split").is_none()); + assert_eq!(body["stream"].as_bool().unwrap_or(false), stream); + if anthropic { + assert!(headers.starts_with("post /anthropic/v1/messages ")); + assert!(headers.contains("x-api-key: fixture-key")); + if !effort.is_empty() { + assert_eq!(body["output_config"]["effort"], effort); + } + assert!(body.get("reasoning_effort").is_none()); + assert_eq!( + body["messages"][0]["content"][1]["source"]["data"], + "aGVsbG8=" + ); + assert_eq!( + body["messages"][0]["content"][2]["source"]["url"], + remote_image + ); + if with_tools { + assert_eq!(body["tools"][0]["name"], "lookup"); + } else { + assert!(body.get("tools").is_none()); + } + } else { + assert!(headers.starts_with("post /v1/chat/completions ")); + assert!(headers.contains("authorization: bearer fixture-key")); + if !effort.is_empty() { + assert_eq!(body["reasoning_effort"], effort); + } + assert!(body.get("output_config").is_none()); + assert_eq!(body["messages"][0]["content"][1]["image_url"]["url"], image); + assert_eq!( + body["messages"][0]["content"][2]["image_url"]["url"], + remote_image + ); + if with_tools { + assert_eq!(body["tools"][0]["function"]["name"], "lookup"); + } else { + assert!(body.get("tools").is_none()); + } + } + } + if !with_tools { + return; + } + let history = &captured[1].1["messages"]; + if anthropic { + assert_eq!(history[1]["content"][0]["type"], "thinking"); + assert_eq!(history[1]["content"][0]["thinking"], "Keep this reasoning."); + assert!(history[1]["content"][0].get("signature").is_none()); + assert_eq!(history[1]["content"][1]["id"], "call_1"); + assert_eq!(history[2]["content"][0]["tool_use_id"], "call_1"); + } else { + assert_eq!(history[1]["reasoning_content"], "Keep this reasoning."); + assert_eq!(history[1]["tool_calls"][0]["id"], "call_1"); + assert_eq!(history[2]["tool_call_id"], "call_1"); + } +} + +#[test] +fn minimax_direct_context_does_not_override_gateway_contracts() { + use crate::providers::model_capabilities::{resolve, ThinkingSupport}; + let flash = resolve("MiniMax-M3.1-Flash", None); + assert_eq!(flash.context_window, 1_000_000); + assert_eq!(flash.thinking, ThinkingSupport::AlwaysOn); + assert_eq!(resolve("MiniMax-M3", None).context_window, 1_000_000); + for gateway in ["minimax/minimax-m3.1-flash:free", "minimax-m3.1-flash-free"] { + assert_eq!(resolve(gateway, None).context_window, 200_000); + } +} + +#[tokio::test] +async fn minimax_flash_http_default_effort_tools_history_and_images() { + tokio::time::timeout(Duration::from_secs(30), async { + for anthropic in [false, true] { + for stream in [false, true] { + for (model, effort) in [ + ("MiniMax-M3.1-Flash", "max"), + ("MiniMax-M3.1-Flash-low", "low"), + ("MiniMax-M3.1-Flash-medium", "medium"), + ("MiniMax-M3.1-Flash-high", "high"), + ("MiniMax-M3.1-Flash-xhigh", "xhigh"), + ("MiniMax-M3.1-Flash-none", "max"), + ("MiniMax-M3", ""), + ("MiniMax-M2.7", ""), + ] { + check_transport(anthropic, stream, model, effort, true).await; + if model == "MiniMax-M3.1-Flash" { + check_transport(anthropic, stream, model, effort, false).await; + } + } + } + } + }) + .await + .expect("MiniMax HTTP fixture timed out"); +} diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/mod.rs b/src-tauri/crates/agent-core/src/core/providers/tests/mod.rs index 6894c2837e..56c43c9487 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/mod.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/mod.rs @@ -10,3 +10,4 @@ pub mod registry_tests; mod custom_model_requests_tests; mod auxiliary_model_tests; +mod minimax_requests_tests; diff --git a/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs b/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs index cf5f46fb4d..58184d46b5 100644 --- a/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs +++ b/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs @@ -314,7 +314,11 @@ mod reasoning_trigger_tests { #[test] fn escalate_leaves_custom_provider_ids_literal() { use crate::providers::registry::provider_id; - for model in ["my-deployment", "deployment-high", "new-provider/model-2026-09-01"] { + for model in [ + "my-deployment", + "deployment-high", + "new-provider/model-2026-09-01", + ] { assert_eq!( escalate_model_reasoning(model, ReasoningLevel::Max, provider_id::CUSTOM), model @@ -552,8 +556,8 @@ pub fn zhipu_thinking(level: Option) -> Option { /// paths (`openai_compat::streaming::chat` / `sse_stream`) so they cannot /// drift on the strip + dispatch logic. /// -/// At most one of `reasoning_effort` (OpenAI) and `thinking` (Zhipu GLM) is -/// set — never both, never for a non-reasoning model. +/// OpenAI uses `reasoning_effort`; Zhipu uses `thinking`. MiniMax Flash +/// requires adaptive `thinking` together with its supported effort value. #[derive(Debug, Clone, PartialEq)] pub struct OpenAiCompatThinking { /// Real model id providers accept (suffix stripped). @@ -562,6 +566,17 @@ pub struct OpenAiCompatThinking { pub thinking: Option, } +/// MiniMax Flash rejects `none`; unsupported saved rungs use its default max. +pub fn minimax_flash_effort(level: Option) -> &'static str { + match level { + Some(ReasoningLevel::Low) => "low", + Some(ReasoningLevel::Medium) => "medium", + Some(ReasoningLevel::High) => "high", + Some(ReasoningLevel::ExtraHigh) => "xhigh", + _ => "max", + } +} + pub fn resolve_openai_compat_thinking( resolved_model: &str, provider_name: &str, @@ -571,13 +586,19 @@ pub fn resolve_openai_compat_thinking( } else { parse_model_variant(resolved_model) }; + let minimax_flash = provider_name == crate::providers::registry::provider_id::MINIMAX + && parsed.base_model.eq_ignore_ascii_case("MiniMax-M3.1-Flash"); let mode = resolve_thinking_mode(&parsed.base_model, provider_name); - let reasoning_effort = if mode == ThinkingMode::OpenAiEffort { + let reasoning_effort = if minimax_flash { + Some(minimax_flash_effort(parsed.level).to_string()) + } else if mode == ThinkingMode::OpenAiEffort { openai_effort(parsed.level).map(str::to_string) } else { None }; - let thinking = if mode == ThinkingMode::ZhipuToggle { + let thinking = if minimax_flash { + Some(json!({ "type": "adaptive" })) + } else if mode == ThinkingMode::ZhipuToggle { zhipu_thinking(parsed.level) } else { None diff --git a/src-tauri/crates/git-api/src/commands/ai.rs b/src-tauri/crates/git-api/src/commands/ai.rs index 1b15a51fd3..9a2f75cf81 100644 --- a/src-tauri/crates/git-api/src/commands/ai.rs +++ b/src-tauri/crates/git-api/src/commands/ai.rs @@ -103,7 +103,7 @@ fn default_model_for(agent_type: &ModelType) -> &'static str { ModelType::ZenmuxApi => "deepseek/deepseek-chat", ModelType::DashscopeApi => "qwen-turbo", ModelType::MoonshotApi => "moonshot-v1-8k", - ModelType::MinimaxApi => "MiniMax-M3", + ModelType::MinimaxApi => "MiniMax-M3.1-Flash", ModelType::ZhipuApi => "glm-4-flash", ModelType::VllmApi => "default", _ => "gpt-4o-mini", @@ -291,8 +291,15 @@ pub async fn generate_commit_message( json!({ "role": "user", "content": diff_summary }), ]; + // Flash always thinks, and reasoning counts toward the output budget. + // Leave enough room for both reasoning and the commit message. + let max_tokens = if model == "MiniMax-M3.1-Flash" { + 8192 + } else { + 256 + }; let response = client - .chat(&messages, None, model, 256, 0.3) + .chat(&messages, None, model, max_tokens, 0.3) .await .map_err(|err| format!("LLM request failed: {}", err))?; @@ -374,7 +381,7 @@ mod tests { (ModelType::ZenmuxApi, "zenmux", "deepseek/deepseek-chat"), (ModelType::DashscopeApi, "dashscope", "qwen-turbo"), (ModelType::MoonshotApi, "moonshot", "moonshot-v1-8k"), - (ModelType::MinimaxApi, "minimax", "MiniMax-M3"), + (ModelType::MinimaxApi, "minimax", "MiniMax-M3.1-Flash"), (ModelType::ZhipuApi, "zhipu", "glm-4-flash"), (ModelType::VllmApi, "vllm", "default"), ]; diff --git a/src-tauri/crates/key-vault/src/commands/crud/projections.rs b/src-tauri/crates/key-vault/src/commands/crud/projections.rs index 31c4d63c23..39cc524950 100644 --- a/src-tauri/crates/key-vault/src/commands/crud/projections.rs +++ b/src-tauri/crates/key-vault/src/commands/crud/projections.rs @@ -155,6 +155,18 @@ impl From for KeyInfo { let can_launch_cli = can_launch_cli(&entry); let can_refresh_quota = key_can_refresh_quota(&entry); + let explicit_models: Vec = entry + .enabled_models + .iter() + .cloned() + .chain(entry.model_aliases.iter().map(|alias| alias.alias.clone())) + .collect(); + let available_models = crate::model_catalog::current_minimax_catalog( + entry.model_type.as_str(), + &entry.available_models, + &explicit_models, + ); + KeyInfo { id: entry.id.clone(), name: entry.name.clone(), @@ -170,7 +182,7 @@ impl From for KeyInfo { env_vars: entry.env_vars.keys().cloned().collect(), env_vars_masked, account_metadata: entry.account_metadata.clone(), - available_models: entry.available_models.clone(), + available_models, enabled_models: entry.enabled_models.clone(), model_aliases: entry .model_aliases diff --git a/src-tauri/crates/key-vault/src/commands/validate/dispatch.rs b/src-tauri/crates/key-vault/src/commands/validate/dispatch.rs index 9996be1468..553a7b57d0 100644 --- a/src-tauri/crates/key-vault/src/commands/validate/dispatch.rs +++ b/src-tauri/crates/key-vault/src/commands/validate/dispatch.rs @@ -57,7 +57,7 @@ pub async fn run_validate_key( let agent_type_lower = agent_type.to_lowercase(); let protocol_lower = protocol.as_deref().map(str::to_lowercase); - match agent_type_lower.as_str() { + let mut result = match agent_type_lower.as_str() { // GitHub Copilot "copilot" | "github_copilot" => { let validator = CopilotValidator::new(); @@ -172,7 +172,16 @@ pub async fn run_validate_key( "Unknown agent type: {}. Supported: copilot, cursor_cli, openai, anthropic, google, codex, claude_code, kiro, opencode, openai_api, atlascloud_api, anthropic_api, gemini_api, deepseek_api, groq_api, xai_api, zhipu_api, dashscope_api, moonshot_api, minimax_api, longcat_api, openrouter_api, zenmux_api, siliconflow_api, modelscope_api, aihubmix_api, cherryin_api, bedrock_api, custom_api, vllm_api, azure_openai_api, azure_anthropic_api", agent_type )), + }?; + if result.valid { + let explicit: Vec = test_model.into_iter().collect(); + result.models_available = crate::model_catalog::current_minimax_catalog( + &agent_type_lower, + &result.models_available, + &explicit, + ); } + Ok(result) } /// Validate a key for a given agent type diff --git a/src-tauri/crates/key-vault/src/model_catalog.rs b/src-tauri/crates/key-vault/src/model_catalog.rs index 6a0fa84a91..6043d50db2 100644 --- a/src-tauri/crates/key-vault/src/model_catalog.rs +++ b/src-tauri/crates/key-vault/src/model_catalog.rs @@ -1,5 +1,144 @@ //! Canonical product-supported model catalog and completion helpers. +/// Current direct MiniMax text models, in default-selection order. +pub const MINIMAX_TEXT_MODELS: &[&str] = &["MiniMax-M3.1-Flash", "MiniMax-M3"]; + +/// Project discovery into the current text catalog without rewriting saved IDs. +/// Gateways retain their own spelling and only advertise models they discovered. +/// Enabled models and explicit aliases remain available, including legacy IDs. +pub(crate) fn current_minimax_catalog( + provider: &str, + available: &[String], + explicit: &[String], +) -> Vec { + let direct = provider == "minimax_api"; + let gateway = matches!(provider, "openrouter_api" | "opencode" | "opencode_cli"); + if !direct && !gateway { + return available.to_vec(); + } + let mut models: Vec = if direct { + MINIMAX_TEXT_MODELS + .iter() + .map(|id| id.to_string()) + .collect() + } else { + Vec::new() + }; + for id in available.iter().chain(explicit) { + let lower = id.to_ascii_lowercase(); + let bare = lower.strip_prefix("minimax/").unwrap_or(&lower); + let current_text = bare == "minimax-m3" + || match provider { + "minimax_api" => bare == "minimax-m3.1-flash", + "openrouter_api" => bare == "minimax-m3.1-flash:free", + _ => bare == "minimax-m3.1-flash-free", + }; + let legacy_text = + (bare.starts_with("minimax-m") || bare.starts_with("abab")) && !current_text; + if (!legacy_text || explicit.contains(id)) && !models.contains(id) { + models.push(id.clone()); + } + } + if gateway { + let flash = models.iter().position(|id| { + matches!( + id.to_ascii_lowercase().as_str(), + "minimax/minimax-m3.1-flash:free" | "minimax-m3.1-flash-free" + ) + }); + let m3 = models.iter().position(|id| { + matches!( + id.to_ascii_lowercase().as_str(), + "minimax/minimax-m3" | "minimax-m3" + ) + }); + if let (Some(flash), Some(m3)) = (flash, m3) { + if flash > m3 { + let model = models.remove(flash); + models.insert(m3, model); + } + } + } + models +} + +#[cfg(test)] +mod minimax_tests { + use super::*; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn minimax_catalog_keeps_saved_and_custom_models() { + assert_eq!( + current_minimax_catalog( + "minimax_api", + &ids(&["MiniMax-M2.7", "MiniMax-M3", "speech-02-hd"]), + &ids(&["MiniMax-M2.5", "private-model"]), + ), + ids(&[ + "MiniMax-M3.1-Flash", + "MiniMax-M3", + "speech-02-hd", + "MiniMax-M2.5", + "private-model" + ]) + ); + } + + #[test] + fn minimax_gateway_flash_is_first_without_changing_nontext_or_saved_ids() { + for (provider, flash, m3, old) in [ + ( + "openrouter_api", + "minimax/minimax-m3.1-flash:free", + "minimax/minimax-m3", + "minimax/minimax-m2.7", + ), + ( + "opencode", + "minimax-m3.1-flash-free", + "minimax-m3", + "minimax-m2.7", + ), + ] { + let discovered = ids(&["other/model", m3, old, flash, "speech-02-hd"]); + let explicit = ids(&[old, "private-deployment"]); + assert_eq!( + current_minimax_catalog(provider, &discovered, &explicit), + ids(&[ + "other/model", + flash, + m3, + old, + "speech-02-hd", + "private-deployment" + ]) + ); + assert_eq!(explicit, ids(&[old, "private-deployment"])); + assert_eq!( + discovered, + ids(&["other/model", m3, old, flash, "speech-02-hd"]) + ); + } + } + + #[test] + fn minimax_gateway_keeps_wire_ids_and_does_not_invent_models() { + let discovered = ids(&["other/model", "minimax/minimax-m2.7", "minimax/minimax-m3"]); + assert_eq!( + current_minimax_catalog("openrouter_api", &discovered, &[]), + ids(&["other/model", "minimax/minimax-m3"]) + ); + assert_eq!( + current_minimax_catalog("custom", &discovered, &[]), + discovered + ); + } +} + /// Product-supported Codex OAuth bases. /// /// Live Codex discovery can be version-gated by the installed CLI. These