diff --git a/README.md b/README.md index c8e90ac5..2c4a6c7c 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,7 @@ codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" codewhale --provider openrouter --model deepseek/deepseek-v4-pro codewhale --provider openrouter --model arcee-ai/trinity-large-thinking codewhale --provider openrouter --model qwen/qwen3.7-max +codewhale --provider openrouter --model minimax/minimax-m3 # Xiaomi MiMo codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index e866e3d6..4d9adaba 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -254,6 +254,17 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "minimax/minimax-m3".to_string(), + provider: ProviderKind::Openrouter, + aliases: vec![ + "minimax-m3".to_string(), + "minimax-m-3".to_string(), + "openrouter-minimax-m3".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "z-ai/glm-5.1".to_string(), provider: ProviderKind::Openrouter, @@ -739,6 +750,7 @@ mod tests { ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"), ("gemma-4-31b-it", "google/gemma-4-31b-it"), ("glm-5.1", "z-ai/glm-5.1"), + ("minimax-m3", "minimax/minimax-m3"), ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"), ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"), ] { diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 8ab01a81..87db2f81 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::sync::Mutex as AsyncMutex; -use crate::config::{ApiProvider, Config, RetryPolicy}; +use crate::config::{ApiProvider, Config, RetryPolicy, wire_model_for_provider}; use crate::llm_client::{ LlmClient, LlmError, RetryConfig as LlmRetryConfig, extract_retry_after, with_retry, }; @@ -586,6 +586,7 @@ impl DeepSeekClient { target_language: &str, ) -> Result { let url = api_url(&self.base_url, "chat/completions"); + let model = wire_model_for_provider(self.api_provider, model); let mut body = serde_json::json!({ "model": model, "messages": [ @@ -1096,6 +1097,7 @@ impl DeepSeekClient { max_tokens: u32, ) -> anyhow::Result { let url = api_url(&self.base_url, "beta/completions"); + let model = wire_model_for_provider(self.api_provider, model); let body = json!({ "model": model, "prompt": prompt, diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 2656ec29..d6e39b6f 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -14,6 +14,8 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tokio::time::timeout as tokio_timeout; +use crate::config::wire_model_for_provider; + /// Default idle timeout for SSE stream reads (300 seconds = 5 minutes). /// After this period with no data, the stream is considered stalled and /// yields a recoverable error so the caller can retry. @@ -62,7 +64,7 @@ use crate::llm_client::StreamEventBox; use crate::logging; use crate::models::{ ContentBlock, ContentBlockStart, Delta, Message, MessageDelta, MessageRequest, MessageResponse, - StreamEvent, SystemPrompt, Tool, ToolCaller, Usage, + StreamEvent, SystemPrompt, Tool, ToolCaller, Usage, model_supports_reasoning, }; use super::{ @@ -89,8 +91,9 @@ impl DeepSeekClient { request: &MessageRequest, ) -> Result { let messages = build_chat_messages_for_request_and_provider(request, self.api_provider); + let model = wire_model_for_provider(self.api_provider, &request.model); let mut body = json!({ - "model": request.model, + "model": model, "messages": messages, "max_tokens": request.max_tokens, }); @@ -160,8 +163,9 @@ impl DeepSeekClient { ) -> Result { // Try true SSE streaming via chat completions (widely supported) let messages = build_chat_messages_for_request_and_provider(&request, self.api_provider); + let model = wire_model_for_provider(self.api_provider, &request.model); let mut body = json!({ - "model": request.model, + "model": model.clone(), "messages": messages, "max_tokens": request.max_tokens, "stream": true, @@ -206,7 +210,7 @@ impl DeepSeekClient { // still produces a valid request. let replay_input_tokens = sanitize_thinking_mode_messages( &mut body, - &request.model, + &model, request.reasoning_effort.as_deref(), self.api_provider, ); @@ -228,7 +232,6 @@ impl DeepSeekClient { anyhow::bail!("SSE stream request failed: HTTP {status}: {error_text}"); } - let model = request.model.clone(); let api_provider = self.api_provider; // Capture transport-shape headers before we consume `response` into @@ -280,7 +283,7 @@ impl DeepSeekClient { let mut last_event_at = std::time::Instant::now(); let mut bytes_received: usize = 0; - loop { + 'stream: loop { let chunk_result = match tokio_timeout(idle, byte_stream.next()).await { Ok(Some(result)) => result, Ok(None) => break, // Stream ended normally @@ -348,32 +351,32 @@ impl DeepSeekClient { // Empty line = event boundary, process accumulated data if !line_buf.is_empty() { let data = std::mem::take(&mut line_buf); - if data.trim() == "[DONE]" { - // Stream complete - } else if let Ok(chunk_json) = serde_json::from_str::(&data) { - // Parse the SSE chunk into stream events - for mut event in parse_sse_chunk( - &chunk_json, - &mut content_index, - &mut text_started, - &mut thinking_started, - &mut tool_indices, - is_reasoning_model, - ) { - // Stamp the client-side replay-token estimate - // onto the final usage so the UI can surface - // it (#30). We compute it pre-request and - // overlay it on the server-reported usage at - // stream completion. - if let Some(tokens) = replay_input_tokens - && let StreamEvent::MessageDelta { - usage: Some(usage), - .. - } = &mut event - { - usage.reasoning_replay_tokens = Some(tokens); + match parse_sse_data_frame( + &data, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + is_reasoning_model, + ) { + SseDataFrame::Done => break 'stream, + SseDataFrame::Events(events) => { + for mut event in events { + // Stamp the client-side replay-token estimate + // onto the final usage so the UI can surface + // it (#30). We compute it pre-request and + // overlay it on the server-reported usage at + // stream completion. + if let Some(tokens) = replay_input_tokens + && let StreamEvent::MessageDelta { + usage: Some(usage), + .. + } = &mut event + { + usage.reasoning_replay_tokens = Some(tokens); + } + yield Ok(event); } - yield Ok(event); } } } @@ -1782,22 +1785,16 @@ fn should_replay_reasoning_content_for_provider( /// Should the SSE parser treat incoming `reasoning_content` deltas as thinking /// (vs. inlining them as answer text)? /// -/// This is the streaming-path twin of `should_replay_reasoning_content_for_provider`: -/// both must agree on whether a model is a DeepSeek-family reasoning model, or -/// stream parsing stores reasoning tokens in `content` while the replay path -/// expects them in `reasoning_content` (DeepSeek thinking-mode API 400s — -/// #1739 / #1694). Like that predicate's model-aware gate, a known reasoning -/// model is classified as such on ANY provider (including the generic `openai` -/// provider used for DeepSeek-compatible endpoints); a genuine non-DeepSeek -/// model is never reclassified, so #1542 is not regressed. -/// -/// `provider_accepts_reasoning_content(provider) || requires_reasoning_content(model)` -/// short-circuits to `requires_reasoning_content(model)` once the model gate -/// already holds, so the effective rule is purely model-driven — kept explicit -/// here to mirror the predicate above. +/// DeepSeek-family models are classified on any provider because their API +/// requires `reasoning_content` replay on later turns (#1739 / #1694). Other +/// known reasoning-capable large models are classified only on providers whose +/// streaming shape exposes reasoning fields, so `reasoning`/`reasoning_content` +/// deltas become Thinking cells instead of leaking as normal answer text. fn is_reasoning_model_for_stream(provider: ApiProvider, model: &str) -> bool { - requires_reasoning_content(model) - && (provider_accepts_reasoning_content(provider) || requires_reasoning_content(model)) + if requires_reasoning_content(model) { + return true; + } + provider_accepts_reasoning_content(provider) && model_supports_reasoning(model) } fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool { @@ -2021,6 +2018,38 @@ fn build_stream_events(response: &MessageResponse) -> Vec { // === SSE Chunk Parser === +enum SseDataFrame { + Done, + Events(Vec), +} + +fn parse_sse_data_frame( + data: &str, + content_index: &mut u32, + text_started: &mut bool, + thinking_started: &mut bool, + tool_indices: &mut std::collections::HashMap, + is_reasoning_model: bool, +) -> SseDataFrame { + if data.trim() == "[DONE]" { + return SseDataFrame::Done; + } + let events = serde_json::from_str::(data).map_or_else( + |_| Vec::new(), + |chunk_json| { + parse_sse_chunk( + &chunk_json, + content_index, + text_started, + thinking_started, + tool_indices, + is_reasoning_model, + ) + }, + ); + SseDataFrame::Events(events) +} + /// Parse a single SSE chunk from the Chat Completions streaming API into /// our internal `StreamEvent` representation. pub(super) fn parse_sse_chunk( @@ -2469,6 +2498,45 @@ mod stream_decoder_tests { ); } + #[test] + fn decoder_does_not_render_reasoning_as_text_for_known_provider_models() { + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let is_reasoning_model = + is_reasoning_model_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro"); + let events = parse_sse_chunk( + &serde_json::json!({ + "choices": [{ + "delta": { + "reasoning_content": "private plan" + } + }] + }), + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + is_reasoning_model, + ); + + assert!(events.iter().any(|event| matches!( + event, + StreamEvent::ContentBlockDelta { + delta: Delta::ThinkingDelta { thinking }, + .. + } if thinking == "private plan" + ))); + assert!(!events.iter().any(|event| matches!( + event, + StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text }, + .. + } if text == "private plan" + ))); + } + #[test] fn decoder_treats_reasoning_content_as_text_when_provider_does_not_support_reasoning() { let events = decode_chunk_with_reasoning( @@ -2521,6 +2589,32 @@ mod stream_decoder_tests { ); } + #[test] + fn decoder_treats_done_frame_as_terminal() { + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + + let outcome = parse_sse_data_frame( + " [DONE] ", + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + true, + ); + + assert!( + matches!(outcome, SseDataFrame::Done), + "`data: [DONE]` must terminate the stream instead of waiting for the HTTP connection to close" + ); + assert_eq!(content_index, 0); + assert!(!text_started); + assert!(!thinking_started); + assert!(tool_indices.is_empty()); + } + #[test] fn decoder_emits_tool_use_block_for_tool_call_delta() { // Tool-call deltas are content too — once one arrives, transparent @@ -3339,6 +3433,29 @@ mod alias_thinking_detection_tests { )); } + #[test] + fn stream_classifies_known_large_reasoning_models_as_reasoning() { + // Xiaomi MiMo and OpenRouter/Qwen/Trinity can stream private reasoning through a + // `reasoning` delta without using a DeepSeek-looking model name. The + // renderer must still route that field into Thinking cells instead + // of plain assistant prose. + assert!( + is_reasoning_model_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro"), + "mimo-v2.5-pro should stream reasoning as thinking on Xiaomi MiMo" + ); + for model in [ + "qwen/qwen3.7-max", + "arcee-ai/trinity-large-thinking", + "minimax/minimax-m3", + "xiaomi/mimo-v2.5-pro", + ] { + assert!( + is_reasoning_model_for_stream(ApiProvider::Openrouter, model), + "{model} should stream reasoning as thinking on OpenRouter" + ); + } + } + #[test] fn stream_does_not_classify_generic_model_as_reasoning() { // #1542 no-regression guard: a genuine non-DeepSeek model on the diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/change.rs index 220cdb2e..0f9c3dbd 100644 --- a/crates/tui/src/commands/change.rs +++ b/crates/tui/src/commands/change.rs @@ -344,6 +344,9 @@ mod tests { &config, ); app.ui_locale = locale; + app.api_provider = crate::config::ApiProvider::Deepseek; + app.model_ids_passthrough = false; + app.onboarding_needs_api_key = !has_api_key; app } diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 5f753edf..fad755e1 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -1480,7 +1480,15 @@ mod tests { resume_session_id: None, initial_input: None, }; - App::new(options, &Config::default()) + let mut app = App::new(options, &Config::default()); + // App::new folds in saved TUI settings from the developer machine. + // Pin command tests back to DeepSeek semantics so model aliases are + // not normalized through a provider selected in an interactive run. + app.model = "test-model".to_string(); + app.auto_model = false; + app.api_provider = crate::config::ApiProvider::Deepseek; + app.model_ids_passthrough = false; + app } #[test] diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index aff43d65..349002c7 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -433,6 +433,7 @@ mod tests { let mut app = App::new(options, &Config::default()); app.ui_locale = crate::localization::Locale::En; app.api_provider = crate::config::ApiProvider::Deepseek; + app.model_ids_passthrough = false; app } diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 43fd10b1..e6490449 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -4,7 +4,10 @@ //! `/provider` with no args opens the picker modal (#52). `/provider ` //! keeps the v0.6.6 CLI form for muscle-memory + scripted use. -use crate::config::{ApiProvider, normalize_model_name, provider_passes_model_through}; +use crate::config::{ + ApiProvider, normalize_model_name, normalize_model_name_for_provider, + provider_passes_model_through, +}; use crate::tui::app::{App, AppAction}; use super::CommandResult; @@ -34,14 +37,22 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let model = match model_arg { None => None, Some(raw) if provider_passes_model_through(target) => Some(raw.trim().to_string()), - Some(raw) => match normalize_model_name(&expand_model_alias(raw)) { - Some(normalized) => Some(normalized), - None => { - return CommandResult::error(format!( - "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro." - )); + Some(raw) => { + let expanded = expand_model_alias(raw); + let normalized = if matches!(target, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { + normalize_model_name_for_provider(target, &expanded) + } else { + normalize_model_name(&expanded) + }; + match normalized { + Some(normalized) => Some(normalized), + None => { + return CommandResult::error(format!( + "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro." + )); + } } - }, + } }; if target == app.api_provider && model.is_none() { @@ -309,6 +320,22 @@ mod tests { } } + #[test] + fn switch_to_deepseek_canonicalizes_provider_prefixed_model_override() { + let mut app = create_test_app(); + app.api_provider = ApiProvider::Openrouter; + + let result = provider(&mut app, Some("deepseek deepseek/deepseek-v4-pro")); + + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Deepseek); + assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); + } + other => panic!("expected SwitchProvider action, got {other:?}"), + } + } + #[test] fn invalid_model_returns_error() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 505dbd4a..da4f68c0 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -53,6 +53,7 @@ pub const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it"; pub const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it"; pub const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1"; pub const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6"; +pub const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3"; pub const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"; pub const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max"; @@ -64,6 +65,7 @@ pub const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5"; pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[ OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL, OPENROUTER_QWEN_3_7_MAX_MODEL, + OPENROUTER_MINIMAX_M3_MODEL, OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL, OPENROUTER_XIAOMI_MIMO_V2_5_MODEL, OPENROUTER_QWEN_3_6_35B_A3B_MODEL, @@ -508,6 +510,9 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => { Some(OPENROUTER_KIMI_K2_6_MODEL) } + OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => { + Some(OPENROUTER_MINIMAX_M3_MODEL) + } OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL | "nemotron-3-nano-omni" | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL), @@ -586,6 +591,15 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Some(normalized) } +#[must_use] +pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String { + let trimmed = model.trim(); + if trimmed.is_empty() || provider_passes_model_through(provider) { + return trimmed.to_string(); + } + normalize_model_name_for_provider(provider, trimmed).unwrap_or_else(|| trimmed.to_string()) +} + #[must_use] pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> { match provider { @@ -2071,9 +2085,9 @@ impl Config { return "auto".to_string(); } if let Some(model) = self.default_text_model.as_deref() - && let Some(normalized) = normalize_model_name(model) + && let Some(normalized) = normalize_model_name_for_provider(provider, model) { - return model_for_provider(provider, normalized); + return normalized; } match provider { @@ -3535,15 +3549,10 @@ fn normalize_model_config(config: &mut Config) { } fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option { - if matches!(provider, ApiProvider::Openrouter) - && let Some(canonical) = canonical_openrouter_recent_model_id(model) - { - return Some(canonical.to_string()); - } if provider_passes_model_through(provider) { return None; } - normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized)) + normalize_model_name_for_provider(provider, model) } pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { @@ -6403,6 +6412,53 @@ api_key = "old-openrouter-key" ); } + #[test] + fn deepseek_default_model_canonicalizes_provider_prefixed_ids() { + let config = Config { + provider: Some("deepseek".to_string()), + default_text_model: Some(DEFAULT_OPENROUTER_MODEL.to_string()), + ..Default::default() + }; + assert_eq!(config.default_model(), DEFAULT_TEXT_MODEL); + + let config = Config { + provider: Some("deepseek".to_string()), + providers: Some(ProvidersConfig { + deepseek: ProviderConfig { + model: Some(DEFAULT_OPENROUTER_MODEL.to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(config.default_model(), DEFAULT_TEXT_MODEL); + } + + #[test] + fn wire_model_for_provider_matches_active_provider_shape() { + assert_eq!( + wire_model_for_provider(ApiProvider::Deepseek, DEFAULT_OPENROUTER_MODEL), + DEFAULT_TEXT_MODEL + ); + assert_eq!( + wire_model_for_provider(ApiProvider::Openrouter, DEFAULT_TEXT_MODEL), + DEFAULT_OPENROUTER_MODEL + ); + assert_eq!( + wire_model_for_provider(ApiProvider::NvidiaNim, DEFAULT_TEXT_MODEL), + DEFAULT_NVIDIA_NIM_MODEL + ); + assert_eq!( + wire_model_for_provider(ApiProvider::Openai, DEFAULT_OPENROUTER_MODEL), + DEFAULT_OPENROUTER_MODEL + ); + assert_eq!( + wire_model_for_provider(ApiProvider::Openrouter, OPENROUTER_MINIMAX_M3_MODEL), + OPENROUTER_MINIMAX_M3_MODEL + ); + } + #[test] fn normalize_model_name_for_provider_keeps_provider_specific_ids() { assert_eq!( @@ -6454,6 +6510,7 @@ api_key = "old-openrouter-key" ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL), ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL), ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL), + ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL), ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL), ("glm-5.1", OPENROUTER_GLM_5_1_MODEL), ] { @@ -6482,6 +6539,7 @@ api_key = "old-openrouter-key" OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL, OPENROUTER_QWEN_3_7_MAX_MODEL, OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL, + OPENROUTER_MINIMAX_M3_MODEL, OPENROUTER_QWEN_3_6_35B_A3B_MODEL, OPENROUTER_GEMMA_4_31B_MODEL, ] { @@ -8573,6 +8631,7 @@ model = "deepseek-ai/deepseek-v4-pro" ), (OPENROUTER_QWEN_3_7_MAX_MODEL, 1_000_000, 65_536), (OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL, 1_000_000, 131_072), + (OPENROUTER_MINIMAX_M3_MODEL, 1_000_000, 524_288), ] { let cap = provider_capability(ApiProvider::Openrouter, model); diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index f938fb08..a7cf27f6 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -1023,7 +1023,7 @@ fn bool_str(value: bool) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; + use crate::config::{ApiProvider, Config}; use crate::test_support::lock_test_env; use crate::tui::app::{App, TuiOptions}; use std::fs; @@ -1054,7 +1054,16 @@ mod tests { resume_session_id: None, initial_input: None, }; - App::new(options, &Config::default()) + let mut app = App::new(options, &Config::default()); + // App::new merges developer-local settings, which can include a saved + // provider/model from the interactive TUI. Keep these config UI tests + // pinned to DeepSeek defaults so they only exercise document apply + // semantics. + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.api_provider = ApiProvider::Deepseek; + app.model_ids_passthrough = false; + app } #[test] diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 71bf8214..884a16ed 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -433,6 +433,14 @@ fn join_prompt_parts(parts: &[String]) -> String { parts.join(" ") } +fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String { + explicit_model + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| config.default_model()) +} + fn top_level_prompt_initial_input(parts: &[String]) -> Option { (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts))) } @@ -890,11 +898,7 @@ async fn main() -> Result<()> { } Commands::Exec(args) => { let config = load_config_from_cli(&cli)?; - let model = args - .model - .clone() - .or_else(|| config.default_text_model.clone()) - .unwrap_or_else(|| config.default_model()); + let model = resolve_exec_model(&config, args.model.as_deref()); let prompt = join_prompt_parts(&args.prompt); let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) @@ -6065,6 +6069,28 @@ mod terminal_mode_tests { assert_eq!(Cli::command().get_name(), "codewhale-tui"); } + #[test] + fn exec_model_resolution_uses_provider_scoped_default() { + let config = Config { + provider: Some("openrouter".to_string()), + default_text_model: Some("deepseek/deepseek-v4-pro".to_string()), + providers: Some(crate::config::ProvidersConfig { + openrouter: crate::config::ProviderConfig { + model: Some("qwen/qwen3.7-max".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!(resolve_exec_model(&config, None), "qwen/qwen3.7-max"); + assert_eq!( + resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")), + "arcee-ai/trinity-large-thinking" + ); + } + #[test] fn exec_accepts_split_prompt_words_for_windows_cmd_shims() { let cli = parse_cli(&["codewhale", "exec", "hello", "world"]); diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index a94cd762..bee4fa12 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -252,9 +252,13 @@ fn known_context_window_for_model(model_lower: &str) -> Option { | "moonshotai/kimi-k2.6" | "moonshotai/kimi-k2.6:free" => Some(262_144), "z-ai/glm-5.1" | "z-ai/glm-5v-turbo" => Some(202_752), - "qwen/qwen3.7-max" | "xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" | "qwen/qwen3.6-plus" => { - Some(1_000_000) - } + "minimax/minimax-m3" => Some(1_000_000), + "qwen/qwen3.7-max" + | "xiaomi/mimo-v2.5-pro" + | "xiaomi/mimo-v2.5" + | "mimo-v2.5-pro" + | "mimo-v2.5" + | "qwen/qwen3.6-plus" => Some(1_000_000), _ => None, } } @@ -267,8 +271,11 @@ pub fn max_output_tokens_for_model(model: &str) -> Option { } match lower.as_str() { "arcee-ai/trinity-large-thinking" | "moonshotai/kimi-k2.6" => Some(262_144), + "minimax/minimax-m3" => Some(524_288), "qwen/qwen3.6-35b-a3b" | "qwen/qwen3.6-27b" => Some(262_140), - "xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" => Some(131_072), + "xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5" => { + Some(131_072) + } "qwen/qwen3.7-max" | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" => Some(65_536), "google/gemma-4-31b-it" => Some(16_384), "google/gemma-4-31b-it:free" | "google/gemma-4-26b-a4b-it:free" => Some(32_768), @@ -291,6 +298,7 @@ pub fn model_supports_reasoning(model: &str) -> bool { | "google/gemma-4-26b-a4b-it:free" | "moonshotai/kimi-k2.6" | "moonshotai/kimi-k2.6:free" + | "minimax/minimax-m3" | "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" | "qwen/qwen3.7-max" | "qwen/qwen3.6-35b-a3b" @@ -298,6 +306,8 @@ pub fn model_supports_reasoning(model: &str) -> bool { | "tencent/hy3-preview" | "xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" + | "mimo-v2.5-pro" + | "mimo-v2.5" | "z-ai/glm-5.1" ) } @@ -493,6 +503,8 @@ mod tests { (concat!("qwen/", "qwen3.7-max"), 1_000_000), (concat!("qwen/", "qwen3.6-35b-a3b"), 262_144), (concat!("xiaomi/", "mimo-v2.5-pro"), 1_000_000), + ("mimo-v2.5-pro", 1_000_000), + ("minimax/minimax-m3", 1_000_000), ("moonshotai/kimi-k2.6", 262_144), ("google/gemma-4-31b-it", 262_144), ("z-ai/glm-5.1", 202_752), @@ -516,6 +528,11 @@ mod tests { max_output_tokens_for_model(concat!("xiaomi/", "mimo-v2.5-pro")), Some(131_072) ); + assert_eq!(max_output_tokens_for_model("mimo-v2.5-pro"), Some(131_072)); + assert_eq!( + max_output_tokens_for_model("minimax/minimax-m3"), + Some(524_288) + ); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index c3f6ed33..c2549fac 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -5448,6 +5448,13 @@ mod tests { #[test] fn app_new_with_explicit_api_key_does_not_trigger_onboarding() { + let _lock = lock_test_env(); + let tmp = tempfile::TempDir::new().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); + let _provider_env = EnvVarGuard::remove("CODEWHALE_PROVIDER"); + let _legacy_provider_env = EnvVarGuard::remove("DEEPSEEK_PROVIDER"); + let config = Config { api_key: Some("sk-test-onboarding-key".to_string()), ..Config::default() diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index afd9411a..6413c3f9 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -8,7 +8,7 @@ //! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved //! model id and effort tier. -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, @@ -17,18 +17,11 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; +use crate::config::{ApiProvider, model_completion_names_for_provider}; use crate::palette; use crate::tui::app::{App, ReasoningEffort}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -/// Models the picker exposes by default. Kept short on purpose — power -/// users can still type `/model ` for anything else. -const PICKER_MODELS: &[(&str, &str)] = &[ - ("auto", "choose per turn"), - ("deepseek-v4-pro", "larger model"), - ("deepseek-v4-flash", "faster model"), -]; - /// Thinking-effort rows shown in the picker, in the order DeepSeek /// behaviorally distinguishes them. const PICKER_EFFORTS: &[ReasoningEffort] = &[ @@ -55,30 +48,22 @@ pub struct ModelPickerView { /// True when the active model is one we don't list — we still show it /// so the picker doesn't quietly forget the user's chosen IDs. show_custom_model_row: bool, - /// When true, hide DeepSeek-specific model rows (pass-through providers - /// like openai don't support them). - hide_deepseek_models: bool, + model_ids: Vec<&'static str>, } impl ModelPickerView { #[must_use] pub fn new(app: &App) -> Self { - let hide_deepseek_models = app.accepts_custom_model_ids(); let initial_model = if app.auto_model { "auto".to_string() } else { app.model.clone() }; - // On pass-through providers, only show "auto" and the custom row. - let visible_models: Vec<&str> = if hide_deepseek_models { - vec!["auto"] - } else { - PICKER_MODELS.iter().map(|(id, _)| *id).collect() - }; - let mut selected_model_idx = visible_models.iter().position(|id| *id == initial_model); + let model_ids = picker_model_ids_for_provider(app.api_provider); + let mut selected_model_idx = model_ids.iter().position(|id| *id == initial_model); let show_custom_model_row = selected_model_idx.is_none(); if show_custom_model_row { - selected_model_idx = Some(visible_models.len()); + selected_model_idx = Some(model_ids.len()); } let selected_model_idx = selected_model_idx.unwrap_or(0); @@ -100,16 +85,12 @@ impl ModelPickerView { selected_effort_idx, focus: Pane::Model, show_custom_model_row, - hide_deepseek_models, + model_ids, } } fn visible_model_ids(&self) -> Vec<&'static str> { - if self.hide_deepseek_models { - vec!["auto"] - } else { - PICKER_MODELS.iter().map(|(id, _)| *id).collect() - } + self.model_ids.clone() } fn model_row_count(&self) -> usize { @@ -203,9 +184,16 @@ impl ModelPickerView { } else { Style::default().fg(palette::BORDER_COLOR) }; + let visible_height = usize::from(area.height.saturating_sub(2)); + let (start, end) = visible_row_window(selected, rows.len(), visible_height); + let title = if rows.len() > visible_height && visible_height > 0 { + format!(" {title} {}-{}/{} ", start + 1, end, rows.len()) + } else { + format!(" {title} ") + }; let block = Block::default() .title(Line::from(Span::styled( - format!(" {title} "), + title, Style::default().fg(palette::TEXT_PRIMARY).bold(), ))) .borders(Borders::ALL) @@ -214,8 +202,8 @@ impl ModelPickerView { let inner = block.inner(area); block.render(area, buf); - let mut lines = Vec::with_capacity(rows.len()); - for (idx, (label, hint)) in rows.iter().enumerate() { + let mut lines = Vec::with_capacity(end.saturating_sub(start)); + for (idx, (label, hint)) in rows.iter().enumerate().skip(start).take(end - start) { let is_selected = idx == selected; let marker = if is_selected { "▸" } else { " " }; let label_style = if is_selected { @@ -233,22 +221,123 @@ impl ModelPickerView { } else { Style::default().fg(palette::TEXT_MUTED) }; - let mut spans = vec![ - Span::raw(" "), - Span::styled(marker, label_style), - Span::raw(" "), - Span::styled(label.clone(), label_style), - ]; - if !hint.is_empty() { - spans.push(Span::raw(" ")); - spans.push(Span::styled(format!("({hint})"), hint_style)); - } + let spans = picker_row_spans( + label, + hint, + marker, + usize::from(inner.width), + label_style, + hint_style, + ); lines.push(Line::from(spans)); } Paragraph::new(lines).render(inner, buf); } } +fn visible_row_window(selected: usize, total: usize, viewport_height: usize) -> (usize, usize) { + if total == 0 || viewport_height == 0 { + return (0, 0); + } + + let visible = viewport_height.min(total); + let mut start = selected.saturating_sub(visible / 2); + if start + visible > total { + start = total.saturating_sub(visible); + } + (start, start + visible) +} + +fn picker_row_spans<'a>( + label: &'a str, + hint: &'a str, + marker: &'static str, + width: usize, + label_style: Style, + hint_style: Style, +) -> Vec> { + let prefix_width = 3; + let label_width = width.saturating_sub(prefix_width); + let label = fit_text(label, label_width); + let mut spans = vec![ + Span::raw(" "), + Span::styled(marker, label_style), + Span::raw(" "), + Span::styled(label, label_style), + ]; + + if !hint.is_empty() { + let hint_text = format!(" ({hint})"); + let used = prefix_width + + unicode_width::UnicodeWidthStr::width( + spans + .last() + .map(|span| span.content.as_ref()) + .unwrap_or_default(), + ); + if used + unicode_width::UnicodeWidthStr::width(hint_text.as_str()) <= width { + spans.push(Span::styled(hint_text, hint_style)); + } + } + + spans +} + +fn fit_text(text: &str, width: usize) -> String { + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + + if UnicodeWidthStr::width(text) <= width { + return text.to_string(); + } + if width == 0 { + return String::new(); + } + if width <= 3 { + return ".".repeat(width); + } + + let mut out = String::new(); + let target = width - 3; + let mut used = 0usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > target { + break; + } + used += ch_width; + out.push(ch); + } + out.push_str("..."); + out +} + +fn picker_model_ids_for_provider(provider: ApiProvider) -> Vec<&'static str> { + let mut models = vec!["auto"]; + for id in model_completion_names_for_provider(provider) { + if id != "auto" && !models.contains(&id) { + models.push(id); + } + } + models +} + +fn picker_model_hint(id: &str) -> &'static str { + match id { + "auto" => "select per turn", + "deepseek-v4-pro" | "deepseek/deepseek-v4-pro" | "deepseek-ai/deepseek-v4-pro" => { + "larger model" + } + "deepseek-v4-flash" | "deepseek/deepseek-v4-flash" | "deepseek-ai/deepseek-v4-flash" => { + "faster model" + } + "arcee-ai/trinity-large-thinking" => "large thinking", + "qwen/qwen3.7-max" => "large Qwen", + "xiaomi/mimo-v2.5-pro" | "mimo-v2.5-pro" => "long context", + "minimax/minimax-m3" => "1M multimodal", + _ => "provider model", + } +} + impl ModalView for ModelPickerView { fn kind(&self) -> ModalKind { ModalKind::ModelPicker @@ -270,6 +359,36 @@ impl ModalView for ModelPickerView { self.move_down(); ViewAction::None } + KeyCode::PageUp => { + for _ in 0..5 { + self.move_up(); + } + ViewAction::None + } + KeyCode::PageDown => { + for _ in 0..5 { + self.move_down(); + } + ViewAction::None + } + KeyCode::Home => { + match self.focus { + Pane::Model => self.selected_model_idx = 0, + Pane::Effort => self.selected_effort_idx = 0, + } + ViewAction::None + } + KeyCode::End => { + match self.focus { + Pane::Model => { + self.selected_model_idx = self.model_row_count().saturating_sub(1); + } + Pane::Effort => { + self.selected_effort_idx = PICKER_EFFORTS.len().saturating_sub(1); + } + } + ViewAction::None + } KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => { self.toggle_focus(); ViewAction::None @@ -278,6 +397,20 @@ impl ModalView for ModelPickerView { } } + fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { + match mouse.kind { + MouseEventKind::ScrollUp => { + self.move_up(); + ViewAction::None + } + MouseEventKind::ScrollDown => { + self.move_down(); + ViewAction::None + } + _ => ViewAction::None, + } + } + fn render(&self, area: Rect, buf: &mut Buffer) { self.render_classic(area, buf); } @@ -285,8 +418,21 @@ impl ModalView for ModelPickerView { impl ModelPickerView { fn render_classic(&self, area: Rect, buf: &mut Buffer) { - let popup_width = 64.min(area.width.saturating_sub(4)).max(40); - let popup_height = 14.min(area.height.saturating_sub(4)).max(10); + let available_width = area.width.saturating_sub(4); + let popup_width = if available_width >= 60 { + available_width.min(96) + } else { + area.width.saturating_sub(2).max(1) + }; + let desired_height = (self.model_row_count().max(PICKER_EFFORTS.len()) as u16) + .saturating_add(4) + .clamp(10, 22); + let available_height = area.height.saturating_sub(4); + let popup_height = if available_height >= 10 { + desired_height.min(available_height) + } else { + area.height.saturating_sub(2).max(1) + }; let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, y: area.y + (area.height.saturating_sub(popup_height)) / 2, @@ -322,17 +468,14 @@ impl ModelPickerView { let columns = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .constraints([Constraint::Percentage(68), Constraint::Percentage(32)]) .split(inner); - let mut model_rows: Vec<(String, String)> = if self.hide_deepseek_models { - vec![("auto".to_string(), "select per turn".to_string())] - } else { - PICKER_MODELS - .iter() - .map(|(id, hint)| ((*id).to_string(), (*hint).to_string())) - .collect() - }; + let mut model_rows: Vec<(String, String)> = self + .visible_model_ids() + .into_iter() + .map(|id| (id.to_string(), picker_model_hint(id).to_string())) + .collect(); if self.show_custom_model_row { model_rows.push((self.initial_model.clone(), "current (custom)".to_string())); } @@ -469,7 +612,7 @@ mod tests { #[test] fn picker_exposes_auto_and_distinct_thinking_tiers() { - let model_labels: Vec<_> = PICKER_MODELS.iter().map(|(id, _)| *id).collect(); + let model_labels = picker_model_ids_for_provider(crate::config::ApiProvider::Deepseek); assert_eq!( model_labels, vec!["auto", "deepseek-v4-pro", "deepseek-v4-flash"] @@ -493,15 +636,70 @@ mod tests { } #[test] - fn picker_uses_pass_through_layout_for_custom_base_url_model_ids() { + fn picker_lists_openrouter_large_models() { let (mut app, _lock) = create_test_app(); + app.api_provider = crate::config::ApiProvider::Openrouter; + app.model_ids_passthrough = true; + app.model = "qwen/qwen3.7-max".to_string(); + app.auto_model = false; + + let view = ModelPickerView::new(&app); + let model_ids = view.visible_model_ids(); + + assert!(model_ids.contains(&"arcee-ai/trinity-large-thinking")); + assert!(model_ids.contains(&"qwen/qwen3.7-max")); + assert!(model_ids.contains(&"xiaomi/mimo-v2.5-pro")); + assert!(model_ids.contains(&"minimax/minimax-m3")); + assert!( + model_ids + .iter() + .take(6) + .any(|id| *id == "minimax/minimax-m3"), + "MiniMax M3 should be visible in the first picker window on normal terminals" + ); + assert!(!view.show_custom_model_row); + assert_eq!(view.resolved_model(), "qwen/qwen3.7-max"); + } + + #[test] + fn visible_row_window_tracks_selection_in_short_panes() { + assert_eq!(visible_row_window(0, 16, 8), (0, 8)); + assert_eq!(visible_row_window(7, 16, 8), (3, 11)); + assert_eq!(visible_row_window(15, 16, 8), (8, 16)); + assert_eq!(visible_row_window(3, 4, 8), (0, 4)); + assert_eq!(visible_row_window(3, 4, 0), (0, 0)); + } + + #[test] + fn narrow_picker_rows_hide_hint_before_clipping_model_id() { + let spans = picker_row_spans( + "minimax/minimax-m3", + "1M multimodal", + "▸", + 24, + Style::default(), + Style::default(), + ); + let rendered = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(rendered.contains("minimax/minimax-m3")); + assert!(!rendered.contains("1M multimodal")); + assert!(unicode_width::UnicodeWidthStr::width(rendered.as_str()) <= 24); + } + + #[test] + fn picker_preserves_custom_passthrough_model_ids() { + let (mut app, _lock) = create_test_app(); + app.api_provider = crate::config::ApiProvider::Openrouter; app.model_ids_passthrough = true; app.model = "opencode-go/glm-5.1".to_string(); app.auto_model = false; let view = ModelPickerView::new(&app); - assert!(view.hide_deepseek_models); assert!(view.show_custom_model_row); assert_eq!(view.resolved_model(), "opencode-go/glm-5.1"); } @@ -537,6 +735,30 @@ mod tests { assert_eq!(view.selected_effort_idx, 3); } + #[test] + fn mouse_wheel_moves_focused_picker_pane() { + let (mut app, _lock) = create_test_app(); + app.model = "deepseek-v4-pro".to_string(); + let mut view = ModelPickerView::new(&app); + assert_eq!(view.selected_model_idx, 1); + + view.handle_mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: crossterm::event::KeyModifiers::NONE, + }); + assert_eq!(view.selected_model_idx, 2); + + view.handle_mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: crossterm::event::KeyModifiers::NONE, + }); + assert_eq!(view.selected_model_idx, 1); + } + #[test] fn tab_switches_between_model_and_thinking() { let (app, _lock) = create_test_app(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 82e50854..489cc8f2 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1,5 +1,8 @@ use super::*; -use crate::config::{ApiProvider, Config, DEFAULT_TEXT_MODEL}; +use crate::config::{ + ApiProvider, Config, DEFAULT_OPENROUTER_MODEL, DEFAULT_TEXT_MODEL, ProviderConfig, + ProvidersConfig, +}; use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::mock_engine_handle; use crate::tui::active_cell::ActiveCell; @@ -2148,6 +2151,67 @@ async fn provider_switch_clears_turn_cache_history() { assert!(app.session.turn_cache_history.is_empty()); } +#[tokio::test] +async fn provider_switch_to_deepseek_canonicalizes_openrouter_default_model() { + let _home = SettingsHomeGuard::new(); + let mut app = create_test_app(); + app.api_provider = ApiProvider::Openrouter; + app.model = DEFAULT_OPENROUTER_MODEL.to_string(); + let mut engine = mock_engine_handle(); + let mut config = Config { + provider: Some("openrouter".to_string()), + api_key: Some("test-key".to_string()), + default_text_model: Some(DEFAULT_OPENROUTER_MODEL.to_string()), + ..Default::default() + }; + + switch_provider( + &mut app, + &mut engine.handle, + &mut config, + ApiProvider::Deepseek, + None, + ) + .await; + + assert_eq!(app.api_provider, ApiProvider::Deepseek); + assert!(!app.model_ids_passthrough); + assert_eq!(app.model, DEFAULT_TEXT_MODEL); +} + +#[tokio::test] +async fn provider_switch_to_openrouter_canonicalizes_deepseek_default_model() { + let _home = SettingsHomeGuard::new(); + let mut app = create_test_app(); + app.api_provider = ApiProvider::Deepseek; + app.model = DEFAULT_TEXT_MODEL.to_string(); + let mut engine = mock_engine_handle(); + let mut config = Config { + provider: Some("deepseek".to_string()), + default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()), + providers: Some(ProvidersConfig { + openrouter: ProviderConfig { + api_key: Some("test-key".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + switch_provider( + &mut app, + &mut engine.handle, + &mut config, + ApiProvider::Openrouter, + None, + ) + .await; + + assert_eq!(app.api_provider, ApiProvider::Openrouter); + assert_eq!(app.model, DEFAULT_OPENROUTER_MODEL); +} + #[tokio::test] async fn dispatch_user_message_failed_send_clears_loading_state() { let mut app = create_test_app(); diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 5f91ca25..1be800e6 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -221,34 +221,39 @@ pub fn flush_and_sync(writer: &mut std::io::BufWriter) -> std::io /// the codebase should use this instead of hardcoding `Command::new("open")`, /// `Command::new("xdg-open")`, or `Command::new("cmd")`. pub fn open_url(url: &str) -> Result<()> { + let mut command = browser_open_command(url)?; + command + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("failed to launch browser command: {e}")) +} + +fn browser_open_command(url: &str) -> Result { + if url.trim().is_empty() { + return Err(anyhow::anyhow!("browser URL cannot be empty")); + } + #[cfg(target_os = "macos")] - let mut command = Command::new("open"); + { + let mut command = Command::new("open"); + command.arg(url); + return Ok(command); + } + #[cfg(target_os = "linux")] - let mut command = Command::new("xdg-open"); + { + let mut command = Command::new("xdg-open"); + command.arg(url); + return Ok(command); + } + #[cfg(target_os = "windows")] - let _command = { + { let mut cmd = Command::new("cmd"); cmd.args(["/C", "start", "", url]); - return match cmd - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - Ok(_) => Ok(()), - Err(e) => Err(anyhow::anyhow!("failed to launch browser command: {e}")), - }; - }; - - // macOS / Linux path - #[cfg(any(target_os = "macos", target_os = "linux"))] - { - command.arg(url); - command - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map(|_| ()) - .map_err(|e| anyhow::anyhow!("failed to launch browser command: {e}")) + return Ok(cmd); } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] @@ -828,36 +833,56 @@ mod project_mapping_tests { // =================================================================== #[test] - fn open_url_does_not_panic_on_valid_url() { - // We can't open a browser in CI, but we can verify the function - // doesn't panic and returns a Result (either Ok or Err with a - // meaningful message). - let result = super::open_url("https://example.com"); - match result { - Ok(()) => {} // browser opened — fine - Err(e) => { - let msg = e.to_string(); - // The error must contain something about "browser" or - // "unsupported" — not a random panic message. - assert!( - msg.contains("browser") - || msg.contains("unsupported") - || msg.contains("failed"), - "unexpected error message: {msg}" - ); - } + fn open_url_builds_platform_command_without_spawning() { + let command = super::browser_open_command("https://example.com").expect("command"); + + #[cfg(target_os = "macos")] + { + assert_eq!(command.get_program(), "open"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["https://example.com"] + ); + } + + #[cfg(target_os = "linux")] + { + assert_eq!(command.get_program(), "xdg-open"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["https://example.com"] + ); + } + + #[cfg(target_os = "windows")] + { + assert_eq!(command.get_program(), "cmd"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["/C", "start", "", "https://example.com"] + ); } } #[test] fn open_url_rejects_empty_url_gracefully() { // An empty URL should fail with a clear error, not panic. - let result = super::open_url(""); + let result = super::browser_open_command(""); match result { - Ok(()) => {} // some openers might accept empty string + Ok(_) => panic!("empty URL should not build an opener command"), Err(e) => { let msg = e.to_string(); assert!(!msg.is_empty(), "error message must not be empty"); + assert!(msg.contains("empty"), "unexpected error message: {msg}"); } } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7e90b046..ab8e670f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -590,7 +590,7 @@ If you are upgrading from older releases: - `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `qwen/qwen3.7-max`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `qwen/qwen3.7-max`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index fb0e75b6..75fc9cf3 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -117,7 +117,7 @@ endpoint. | `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, and the static `ModelRegistry` includes AtlasCloud fallback rows for CLI model resolution. | | `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | -| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `qwen/qwen3.7-max`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | +| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `qwen/qwen3.7-max`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | | `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | `mimo-v2.5-pro`, `mimo-v2.5` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | @@ -139,14 +139,16 @@ separate `[vision_model]` / `image_analyze` path; set that model to ### Recent OpenRouter Large Models OpenRouter completions and static registry rows include the April 2026 onward -large open-weight or open-labeled models verified through OpenRouter's model -metadata: `arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-35b-a3b`, -`qwen/qwen3.6-27b`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, -`moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `tencent/hy3-preview`, +large models verified through OpenRouter's model metadata: +`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-35b-a3b`, +`qwen/qwen3.6-27b`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, +`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`. `qwen/qwen3.7-max` is also included because it is a current user-requested large OpenRouter model, but it is treated as a hosted Qwen model rather than documented as open-weight. +`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M +context multimodal model for coding, tool use, and long-horizon agentic work. ## Static Model Registry @@ -163,7 +165,7 @@ endpoint when the endpoint supports model listing. | `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes | | `wanjie-ark` | `deepseek-reasoner` | yes | yes | | `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes | -| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `qwen/qwen3.7-max`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-27b`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | yes | yes | +| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `qwen/qwen3.7-max`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-27b`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | yes | yes | | `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5` | yes | yes | | `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | | `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |