Fix provider model selection and stream completion

This commit is contained in:
Hunter B
2026-05-31 20:11:56 -07:00
parent 33abdf1269
commit 2bd77edc15
18 changed files with 782 additions and 180 deletions
+1
View File
@@ -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"
+12
View File
@@ -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"),
] {
+3 -1
View File
@@ -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<String> {
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<String> {
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,
+163 -46
View File
@@ -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<MessageResponse> {
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<StreamEventBox> {
// 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::<Value>(&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<StreamEvent> {
// === SSE Chunk Parser ===
enum SseDataFrame {
Done,
Events(Vec<StreamEvent>),
}
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<u32, u32>,
is_reasoning_model: bool,
) -> SseDataFrame {
if data.trim() == "[DONE]" {
return SseDataFrame::Done;
}
let events = serde_json::from_str::<Value>(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
+3
View File
@@ -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
}
+9 -1
View File
@@ -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]
+1
View File
@@ -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
}
+35 -8
View File
@@ -4,7 +4,10 @@
//! `/provider` with no args opens the picker modal (#52). `/provider <name>`
//! 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();
+67 -8
View File
@@ -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<String> {
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);
+11 -2
View File
@@ -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]
+31 -5
View File
@@ -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<tui::InitialInput> {
(!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"]);
+21 -4
View File
@@ -252,9 +252,13 @@ fn known_context_window_for_model(model_lower: &str) -> Option<u32> {
| "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<u32> {
}
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]
+7
View File
@@ -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()
+276 -54
View File
@@ -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 <id>` 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<Span<'a>> {
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::<String>();
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();
+65 -1
View File
@@ -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();
+68 -43
View File
@@ -221,34 +221,39 @@ pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> 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<Command> {
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<_>>(),
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<_>>(),
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<_>>(),
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}");
}
}
}
+1 -1
View File
@@ -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.
+8 -6
View File
@@ -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 |