返回 CodeWhale
provider.rs
根目录 / crates / tui / src / commands / groups / core / provider.rs
1 //! Provider switching: flip between DeepSeek, hosted providers, and self-hosted
2 //! OpenAI-compatible DeepSeek V4 servers at runtime.
3 //!
4 //! `/provider` with no args opens the picker modal (#52). `/provider <name>`
5 //! keeps the v0.6.6 CLI form for muscle-memory + scripted use.
6
7 use crate::commands::traits::{CommandInfo, RegisterCommand};
8 use crate::config::{ApiProvider, canonical_model_id_for_provider, provider_passes_model_through};
9 use crate::tui::app::{App, AppAction};
10 use codewhale_localization::MessageId;
11
12 use super::CommandResult;
13
14 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
15 name: "provider",
16 aliases: &[],
17 usage: "/provider [setup [name]|name [model]]",
18 description_id: MessageId::CmdProviderDescription,
19 };
20
21 pub(in crate::commands) struct ProviderCmd;
22
23 impl RegisterCommand for ProviderCmd {
24 fn info() -> &'static CommandInfo {
25 &COMMAND_INFO
26 }
27
28 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
29 provider(app, arg)
30 }
31 }
32
33 /// Switch or view the current LLM backend.
34 ///
35 /// With no args, opens the picker modal. With `<provider> [model]`, performs
36 /// the switch directly (e.g. `/provider nim flash` lands on
37 /// `deepseek-ai/deepseek-v4-flash`). The optional model accepts shorthand
38 /// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal provider model ID.
39 pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
40 let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
41 let Some(args) = trimmed else {
42 return CommandResult::action(AppAction::OpenProviderPicker);
43 };
44
45 let mut parts = args.split_whitespace();
46 let name = parts.next().unwrap_or("");
47 let model_arg = parts.next();
48
49 if name.eq_ignore_ascii_case("fallback") {
50 return provider_fallback(app, model_arg);
51 }
52 if name.eq_ignore_ascii_case("setup") {
53 return match model_arg {
54 None => CommandResult::action(AppAction::OpenProviderSetup { provider: None }),
55 Some(raw) => match provider_setup_action_for_name(raw) {
56 Ok(action) => CommandResult::action(action),
57 Err(message) => CommandResult::error(message),
58 },
59 };
60 }
61
62 if crate::config::is_legacy_antigravity_identity(name) {
63 return CommandResult::error(
64 codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string(),
65 );
66 }
67
68 let Some(target) = ApiProvider::parse(name) else {
69 return CommandResult::error(format!(
70 "Unknown provider '{name}'. Expected: {}.",
71 ApiProvider::names_hint()
72 ));
73 };
74
75 let model = match model_arg {
76 None => None,
77 Some(raw) => {
78 // Expand provider shorthands (flash/pro, Xiaomi MiMo tts/omni, …)
79 // uniformly, then either keep the id verbatim for providers that take
80 // opaque/custom model tags, or resolve it to the canonical family id.
81 // Families are treated equally: each resolves through its own
82 // canonical map (DeepSeek, GLM via Z.ai/Zhipu, Kimi, MiniMax, …) and
83 // an id matching none passes through unchanged — the upstream API is
84 // the authority. Wire-id translation is deferred to the route
85 // resolver at request time. DeepSeek's two retiring aliases are
86 // also deferred because this command does not own the target base
87 // URL: a custom endpoint may still use either id natively.
88 let expanded = expand_model_alias_for_provider(target, raw);
89 if provider_passes_model_through(target)
90 || is_route_ambiguous_deepseek_alias(target, &expanded)
91 {
92 Some(expanded)
93 } else {
94 match canonical_model_id_for_provider(target, &expanded) {
95 Some(canonical) => Some(canonical),
96 None => {
97 return CommandResult::error(format!(
98 "Invalid model '{raw}'. Provide a non-empty model id."
99 ));
100 }
101 }
102 }
103 }
104 };
105
106 if target == app.api_provider && model.is_none() {
107 return CommandResult::message(format!("Already on provider: {}", target.as_str()));
108 }
109
110 CommandResult::action(AppAction::SwitchProvider {
111 provider: target,
112 model,
113 })
114 }
115
116 pub(in crate::commands) fn provider_setup_action_for_name(raw: &str) -> Result<AppAction, String> {
117 if crate::config::is_legacy_antigravity_identity(raw) {
118 return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
119 }
120 if raw.eq_ignore_ascii_case("ds4") || raw.eq_ignore_ascii_case("dwarfstar") {
121 return Ok(AppAction::OpenDs4Setup);
122 }
123 // First-class aliases (zen, opencode-zen, …) resolve through the provider
124 // registry. There are no setup templates anymore: named custom hosts are
125 // configured with `/provider setup` and the blank custom form (#6289).
126 match ApiProvider::parse(raw) {
127 Some(provider) => Ok(AppAction::OpenProviderSetup {
128 provider: Some(provider),
129 }),
130 None => Err(format!(
131 "Unknown provider '{raw}'. Expected: {}.",
132 ApiProvider::names_hint()
133 )),
134 }
135 }
136
137 fn is_route_ambiguous_deepseek_alias(provider: ApiProvider, model: &str) -> bool {
138 matches!(
139 provider,
140 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
141 ) && (model.eq_ignore_ascii_case("deepseek-chat")
142 || model.eq_ignore_ascii_case("deepseek-reasoner"))
143 }
144
145 fn provider_fallback(app: &mut App, subcommand: Option<&str>) -> CommandResult {
146 match subcommand {
147 Some("reset") => {
148 let Some((_, primary, _)) = app.fallback_chain_entries().first().copied() else {
149 return CommandResult::message(
150 "No fallback providers configured. Add `fallback_providers` to your config.",
151 );
152 };
153 CommandResult::with_message_and_action(
154 format!(
155 "Fallback chain reset to primary provider: {}.",
156 primary.as_str()
157 ),
158 AppAction::SwitchProvider {
159 provider: primary,
160 model: None,
161 },
162 )
163 }
164 Some(other) => CommandResult::error(format!(
165 "Unknown fallback command '{other}'. Usage: /provider fallback [reset]"
166 )),
167 None => {
168 let entries = app.fallback_chain_entries();
169 if entries.is_empty() {
170 return CommandResult::message(
171 "No fallback providers configured. Add `fallback_providers` to your config.",
172 );
173 }
174
175 let mut lines = vec![
176 format!(
177 "Current provider: {}",
178 app.provider_identity_for_persistence()
179 ),
180 "Fallback chain:".to_string(),
181 ];
182 for (index, provider, is_current) in entries {
183 let role = if index == 0 { "primary" } else { "fallback" };
184 let marker = if is_current { " <- current" } else { "" };
185 lines.push(format!(
186 " [{index}] {} ({role}){marker}",
187 provider.as_str()
188 ));
189 }
190 if let Some(reason) = app.last_fallback_reason.as_deref() {
191 lines.push(format!("Last fallback: {reason}"));
192 }
193 lines.push("Use `/provider fallback reset` to return to the primary provider.".into());
194 CommandResult::message(lines.join("\n"))
195 }
196 }
197 }
198
199 fn expand_model_alias_for_provider(provider: ApiProvider, name: &str) -> String {
200 let trimmed = name.trim();
201 let lower = trimmed.to_ascii_lowercase();
202 if matches!(provider, ApiProvider::XiaomiMimo) {
203 return match lower.as_str() {
204 "pro" | "mimo" => "mimo-v2.5-pro".to_string(),
205 "ultraspeed" | "pro-ultraspeed" => "mimo-v2.5-pro-ultraspeed".to_string(),
206 "text" | "omni" | "v2.5-omni" => "mimo-v2.5".to_string(),
207 "tts" | "speech" | "mimo-tts" => "mimo-v2.5-tts".to_string(),
208 "voicedesign" | "voice-design" | "mimo-voice-design" => {
209 "mimo-v2.5-tts-voicedesign".to_string()
210 }
211 "voiceclone" | "voice-clone" | "mimo-voice-clone" => {
212 "mimo-v2.5-tts-voiceclone".to_string()
213 }
214 // Not a shorthand: keep the id as typed (case preserved for custom
215 // token-plan model ids).
216 _ => trimmed.to_string(),
217 };
218 }
219
220 match lower.as_str() {
221 "pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
222 "flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
223 // Not a shorthand: keep the id as typed (case preserved for opaque
224 // model tags on passthrough providers like Ollama/HuggingFace).
225 _ => trimmed.to_string(),
226 }
227 }
228
229 #[cfg(test)]
230 mod tests {
231 use super::*;
232 use crate::config::Config;
233 use crate::test_support::lock_test_env;
234 use crate::tui::app::TuiOptions;
235 use std::path::PathBuf;
236
237 fn create_test_app() -> App {
238 let options = TuiOptions {
239 ..crate::test_support::test_tui_options(PathBuf::from("."))
240 };
241 let mut app = App::new(options, &Config::default());
242 app.ui_locale = codewhale_localization::Locale::En;
243 app.api_provider = crate::config::ApiProvider::Deepseek;
244 app
245 }
246
247 #[test]
248 fn no_args_opens_picker_modal() {
249 let mut app = create_test_app();
250 let result = provider(&mut app, None);
251 assert!(result.message.is_none());
252 assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
253 }
254
255 #[test]
256 fn retired_antigravity_selectors_return_the_tombstone_without_an_action() {
257 let _guard = lock_test_env();
258 for identity in ["antigravity", "agy", "AGY"] {
259 let mut app = create_test_app();
260 let result = provider(&mut app, Some(identity));
261 assert!(result.is_error, "{identity}");
262 assert_eq!(result.action, None, "{identity}");
263 let message = result.message.expect("tombstone message");
264 assert!(message.contains("non-runnable"), "{identity}: {message}");
265 assert!(message.contains("GEMINI_API_KEY"), "{identity}: {message}");
266 assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek);
267
268 let setup = provider_setup_action_for_name(identity)
269 .expect_err("setup must not open for the tombstone");
270 assert!(setup.contains("provider `google`"), "{identity}: {setup}");
271 }
272 }
273
274 #[test]
275 fn setup_subcommand_opens_provider_setup_catalog() {
276 let mut app = create_test_app();
277 let result = provider(&mut app, Some("setup"));
278 assert!(result.message.is_none());
279 assert_eq!(
280 result.action,
281 Some(AppAction::OpenProviderSetup { provider: None })
282 );
283 }
284
285 #[test]
286 fn setup_subcommand_can_focus_provider() {
287 let mut app = create_test_app();
288 let result = provider(&mut app, Some("setup anthropic"));
289 assert_eq!(
290 result.action,
291 Some(AppAction::OpenProviderSetup {
292 provider: Some(ApiProvider::Anthropic),
293 })
294 );
295 }
296
297 #[test]
298 fn setup_subcommand_opens_ds4_preset() {
299 let mut app = create_test_app();
300 let result = provider(&mut app, Some("setup ds4"));
301 assert_eq!(result.action, Some(AppAction::OpenDs4Setup));
302 assert!(result.message.is_none());
303 }
304
305 #[test]
306 fn setup_subcommand_rejects_retired_template_name() {
307 let mut app = create_test_app();
308 let result = provider(&mut app, Some("setup agnes"));
309 assert!(result.action.is_none());
310 let msg = result.message.expect("expected error message");
311 assert!(msg.contains("Unknown provider 'agnes'"));
312 assert!(result.is_error);
313 }
314
315 #[test]
316 fn setup_subcommand_opens_first_class_zen_provider() {
317 let mut app = create_test_app();
318 for name in ["setup opencode-zen", "setup zen"] {
319 let result = provider(&mut app, Some(name));
320 assert_eq!(
321 result.action,
322 Some(AppAction::OpenProviderSetup {
323 provider: Some(ApiProvider::OpencodeZen),
324 }),
325 "{name} must resolve through the provider registry"
326 );
327 }
328 }
329
330 #[test]
331 fn setup_subcommand_rejects_unknown_provider() {
332 let mut app = create_test_app();
333 let result = provider(&mut app, Some("setup not-a-provider"));
334 let msg = result.message.expect("expected error message");
335 assert!(msg.contains("Unknown provider"));
336 assert!(msg.contains("openrouter"));
337 assert!(result.is_error);
338 }
339
340 #[test]
341 fn unknown_provider_returns_error() {
342 let mut app = create_test_app();
343 // "anthropic" became a real provider in #3014; probe with an id that
344 // stays unknown.
345 let result = provider(&mut app, Some("not-a-provider"));
346 let msg = result.message.expect("expected error message");
347 assert!(msg.contains("Unknown provider"));
348 assert!(msg.contains("openrouter"));
349 assert!(msg.contains("xiaomi-mimo"));
350 assert!(msg.contains("novita"));
351 assert!(result.action.is_none());
352 }
353
354 #[test]
355 fn switch_to_openrouter_emits_action() {
356 let mut app = create_test_app();
357 let result = provider(&mut app, Some("openrouter"));
358 match result.action {
359 Some(AppAction::SwitchProvider { provider, model }) => {
360 assert_eq!(provider, ApiProvider::Openrouter);
361 assert_eq!(model, None);
362 }
363 other => panic!("expected SwitchProvider, got {other:?}"),
364 }
365 }
366
367 #[test]
368 fn switch_to_xiaomi_mimo_emits_action() {
369 let mut app = create_test_app();
370 let result = provider(&mut app, Some("xiaomi-mimo"));
371 match result.action {
372 Some(AppAction::SwitchProvider { provider, model }) => {
373 assert_eq!(provider, ApiProvider::XiaomiMimo);
374 assert_eq!(model, None);
375 }
376 other => panic!("expected SwitchProvider, got {other:?}"),
377 }
378 }
379
380 #[test]
381 fn switch_to_xiaomi_mimo_accepts_tts_shorthands() {
382 let mut app = create_test_app();
383 let result = provider(&mut app, Some("xiaomi-mimo tts"));
384 match result.action {
385 Some(AppAction::SwitchProvider { provider, model }) => {
386 assert_eq!(provider, ApiProvider::XiaomiMimo);
387 assert_eq!(model.as_deref(), Some("mimo-v2.5-tts"));
388 }
389 other => panic!("expected SwitchProvider, got {other:?}"),
390 }
391
392 let result = provider(&mut app, Some("xiaomi-mimo voiceclone"));
393 match result.action {
394 Some(AppAction::SwitchProvider { provider, model }) => {
395 assert_eq!(provider, ApiProvider::XiaomiMimo);
396 assert_eq!(model.as_deref(), Some("mimo-v2.5-tts-voiceclone"));
397 }
398 other => panic!("expected SwitchProvider, got {other:?}"),
399 }
400 }
401
402 #[test]
403 fn switch_to_xiaomi_mimo_accepts_chat_shorthands() {
404 let mut app = create_test_app();
405 for (input, expected) in [
406 ("xiaomi-mimo pro-ultraspeed", "mimo-v2.5-pro-ultraspeed"),
407 ("xiaomi-mimo ultraspeed", "mimo-v2.5-pro-ultraspeed"),
408 ("xiaomi-mimo omni", "mimo-v2.5"),
409 ("xiaomi-mimo v2.5-omni", "mimo-v2.5"),
410 ] {
411 let result = provider(&mut app, Some(input));
412 match result.action {
413 Some(AppAction::SwitchProvider { provider, model }) => {
414 assert_eq!(provider, ApiProvider::XiaomiMimo);
415 assert_eq!(model.as_deref(), Some(expected));
416 }
417 other => panic!("expected SwitchProvider for {input}, got {other:?}"),
418 }
419 }
420 }
421
422 #[test]
423 fn switch_to_atlascloud_emits_action() {
424 let mut app = create_test_app();
425 let result = provider(&mut app, Some("atlascloud"));
426 match result.action {
427 Some(AppAction::SwitchProvider { provider, model }) => {
428 assert_eq!(provider, ApiProvider::Atlascloud);
429 assert_eq!(model, None);
430 }
431 other => panic!("expected SwitchProvider, got {other:?}"),
432 }
433 }
434
435 #[test]
436 fn switch_to_wanjie_ark_preserves_model_id() {
437 let mut app = create_test_app();
438 let result = provider(&mut app, Some("ark-wanjie account-model-id"));
439 match result.action {
440 Some(AppAction::SwitchProvider { provider, model }) => {
441 assert_eq!(provider, ApiProvider::WanjieArk);
442 assert_eq!(model.as_deref(), Some("account-model-id"));
443 }
444 other => panic!("expected SwitchProvider, got {other:?}"),
445 }
446 }
447
448 #[test]
449 fn switch_to_openai_preserves_dashscope_model_id() {
450 let mut app = create_test_app();
451 let result = provider(&mut app, Some("openai qwen-plus"));
452 match result.action {
453 Some(AppAction::SwitchProvider { provider, model }) => {
454 assert_eq!(provider, ApiProvider::Openai);
455 assert_eq!(model.as_deref(), Some("qwen-plus"));
456 }
457 other => panic!("expected SwitchProvider, got {other:?}"),
458 }
459 }
460
461 #[test]
462 fn switch_to_qianfan_preserves_model_id() {
463 let mut app = create_test_app();
464 let result = provider(&mut app, Some("qianfan custom-qianfan-service-id"));
465 match result.action {
466 Some(AppAction::SwitchProvider { provider, model }) => {
467 assert_eq!(provider, ApiProvider::Qianfan);
468 assert_eq!(model.as_deref(), Some("custom-qianfan-service-id"));
469 }
470 other => panic!("expected SwitchProvider, got {other:?}"),
471 }
472 }
473
474 #[test]
475 fn zhipu_aliases_fold_into_zai_and_canonicalize_glm() {
476 // Zhipu AI and Z.ai are the same vendor: `zhipu`/`zhipuai` select the
477 // single Zai provider and store the canonical GLM family id in Z.ai's own
478 // casing (`glm-5.2` → `GLM-5.2`).
479 let mut app = create_test_app();
480 let result = provider(&mut app, Some("zhipu glm-5.2"));
481 match result.action {
482 Some(AppAction::SwitchProvider { provider, model }) => {
483 assert_eq!(provider, ApiProvider::Zai);
484 assert_eq!(model.as_deref(), Some("GLM-5.2"));
485 }
486 other => panic!("expected SwitchProvider, got {other:?}"),
487 }
488
489 let result = provider(&mut app, Some("zhipuai glm-5-1"));
490 match result.action {
491 Some(AppAction::SwitchProvider { provider, model }) => {
492 assert_eq!(provider, ApiProvider::Zai);
493 assert_eq!(model.as_deref(), Some("GLM-5.1"));
494 }
495 other => panic!("expected SwitchProvider, got {other:?}"),
496 }
497 }
498
499 #[test]
500 fn switch_to_novita_emits_action() {
501 let mut app = create_test_app();
502 let result = provider(&mut app, Some("novita"));
503 match result.action {
504 Some(AppAction::SwitchProvider { provider, model }) => {
505 assert_eq!(provider, ApiProvider::Novita);
506 assert_eq!(model, None);
507 }
508 other => panic!("expected SwitchProvider, got {other:?}"),
509 }
510 }
511
512 #[test]
513 fn switch_to_fireworks_emits_action() {
514 let mut app = create_test_app();
515 let result = provider(&mut app, Some("fireworks pro"));
516 match result.action {
517 Some(AppAction::SwitchProvider { provider, model }) => {
518 assert_eq!(provider, ApiProvider::Fireworks);
519 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
520 }
521 other => panic!("expected SwitchProvider, got {other:?}"),
522 }
523 }
524
525 #[test]
526 fn switch_to_siliconflow_emits_action() {
527 let mut app = create_test_app();
528 let result = provider(&mut app, Some("siliconflow flash"));
529 match result.action {
530 Some(AppAction::SwitchProvider { provider, model }) => {
531 assert_eq!(provider, ApiProvider::Siliconflow);
532 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
533 }
534 other => panic!("expected SwitchProvider, got {other:?}"),
535 }
536 }
537
538 #[test]
539 fn switch_to_siliconflow_cn_emits_action() {
540 let mut app = create_test_app();
541 let result = provider(&mut app, Some("siliconflow-CN flash"));
542 match result.action {
543 Some(AppAction::SwitchProvider { provider, model }) => {
544 assert_eq!(provider, ApiProvider::SiliconflowCn);
545 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
546 }
547 other => panic!("expected SwitchProvider, got {other:?}"),
548 }
549 }
550
551 #[test]
552 fn switch_to_together_canonicalizes_deepseek_aliases() {
553 // Together is symmetric with the other DeepSeek-hosting routes: the
554 // canonical family id is stored and the route resolver performs the
555 // wire-id translation (deepseek-v4-pro → Together's catalog slug) at
556 // request time, rather than the command storing a wire slug.
557 let mut app = create_test_app();
558 let result = provider(&mut app, Some("together deepseek-v4-pro"));
559 match result.action {
560 Some(AppAction::SwitchProvider { provider, model }) => {
561 assert_eq!(provider, ApiProvider::Together);
562 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
563 }
564 other => panic!("expected SwitchProvider, got {other:?}"),
565 }
566
567 let result = provider(&mut app, Some("together flash"));
568 match result.action {
569 Some(AppAction::SwitchProvider { provider, model }) => {
570 assert_eq!(provider, ApiProvider::Together);
571 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
572 }
573 other => panic!("expected SwitchProvider, got {other:?}"),
574 }
575 }
576
577 #[test]
578 fn switch_to_sglang_flash_emits_action() {
579 let mut app = create_test_app();
580 let result = provider(&mut app, Some("sglang flash"));
581 match result.action {
582 Some(AppAction::SwitchProvider { provider, model }) => {
583 assert_eq!(provider, ApiProvider::Sglang);
584 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
585 }
586 other => panic!("expected SwitchProvider, got {other:?}"),
587 }
588 }
589
590 #[test]
591 fn switch_to_vllm_flash_emits_action() {
592 let mut app = create_test_app();
593 let result = provider(&mut app, Some("vllm flash"));
594 match result.action {
595 Some(AppAction::SwitchProvider { provider, model }) => {
596 assert_eq!(provider, ApiProvider::Vllm);
597 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
598 }
599 other => panic!("expected SwitchProvider, got {other:?}"),
600 }
601 }
602
603 #[test]
604 fn switch_to_ollama_preserves_model_tag() {
605 let mut app = create_test_app();
606 let result = provider(&mut app, Some("ollama qwen2.5-coder:7b"));
607 match result.action {
608 Some(AppAction::SwitchProvider { provider, model }) => {
609 assert_eq!(provider, ApiProvider::Ollama);
610 assert_eq!(model.as_deref(), Some("qwen2.5-coder:7b"));
611 }
612 other => panic!("expected SwitchProvider, got {other:?}"),
613 }
614 }
615
616 #[test]
617 fn switching_to_active_provider_without_model_is_a_noop() {
618 let mut app = create_test_app();
619 let result = provider(&mut app, Some("deepseek"));
620 let msg = result.message.expect("expected message");
621 assert!(msg.contains("Already on provider"));
622 assert!(result.action.is_none());
623 }
624
625 #[test]
626 fn switch_to_nim_emits_action_without_model_override() {
627 let mut app = create_test_app();
628 let result = provider(&mut app, Some("nvidia-nim"));
629 assert!(result.message.is_none());
630 match result.action {
631 Some(AppAction::SwitchProvider { provider, model }) => {
632 assert_eq!(provider, ApiProvider::NvidiaNim);
633 assert_eq!(model, None);
634 }
635 other => panic!("expected SwitchProvider action, got {other:?}"),
636 }
637 }
638
639 #[test]
640 fn nim_flash_shorthand_emits_action_with_model_override() {
641 let mut app = create_test_app();
642 let result = provider(&mut app, Some("nim flash"));
643 match result.action {
644 Some(AppAction::SwitchProvider { provider, model }) => {
645 assert_eq!(provider, ApiProvider::NvidiaNim);
646 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
647 }
648 other => panic!("expected SwitchProvider action, got {other:?}"),
649 }
650 }
651
652 #[test]
653 fn nim_pro_shorthand_emits_action_with_model_override() {
654 let mut app = create_test_app();
655 let result = provider(&mut app, Some("nim pro"));
656 match result.action {
657 Some(AppAction::SwitchProvider { provider, model }) => {
658 assert_eq!(provider, ApiProvider::NvidiaNim);
659 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
660 }
661 other => panic!("expected SwitchProvider action, got {other:?}"),
662 }
663 }
664
665 #[test]
666 fn switch_to_active_provider_with_new_model_still_emits_action() {
667 let mut app = create_test_app();
668 let result = provider(&mut app, Some("deepseek flash"));
669 match result.action {
670 Some(AppAction::SwitchProvider { provider, model }) => {
671 assert_eq!(provider, ApiProvider::Deepseek);
672 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
673 }
674 other => panic!("expected SwitchProvider action, got {other:?}"),
675 }
676 }
677
678 #[test]
679 fn switch_to_deepseek_canonicalizes_provider_prefixed_model_override() {
680 let mut app = create_test_app();
681 app.api_provider = ApiProvider::Openrouter;
682
683 let result = provider(&mut app, Some("deepseek deepseek/deepseek-v4-pro"));
684
685 match result.action {
686 Some(AppAction::SwitchProvider { provider, model }) => {
687 assert_eq!(provider, ApiProvider::Deepseek);
688 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
689 }
690 other => panic!("expected SwitchProvider action, got {other:?}"),
691 }
692 }
693
694 #[test]
695 fn direct_deepseek_provider_commands_retire_aliases_at_official_wire_boundary() {
696 let mut app = create_test_app();
697 app.api_provider = ApiProvider::Openrouter;
698
699 for provider_name in ["deepseek", "deepseek-cn", "deepseek-anthropic"] {
700 for alias in ["deepseek-chat", "deepseek-reasoner"] {
701 let result = provider(&mut app, Some(&format!("{provider_name} {alias}")));
702 match result.action {
703 Some(AppAction::SwitchProvider { provider, model }) => {
704 assert!(matches!(
705 provider,
706 ApiProvider::Deepseek
707 | ApiProvider::DeepseekCN
708 | ApiProvider::DeepseekAnthropic
709 ));
710 assert_eq!(model.as_deref(), Some(alias));
711 let official_base_url = match provider {
712 ApiProvider::Deepseek => crate::config::DEFAULT_DEEPSEEK_BASE_URL,
713 ApiProvider::DeepseekCN => crate::config::DEFAULT_DEEPSEEKCN_BASE_URL,
714 ApiProvider::DeepseekAnthropic => {
715 crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
716 }
717 _ => unreachable!("asserted direct DeepSeek provider"),
718 };
719 assert_eq!(
720 crate::config::wire_model_for_provider_route(
721 provider,
722 official_base_url,
723 model.as_deref().expect("command model"),
724 ),
725 crate::config::DEEPSEEK_ALIAS_REPLACEMENT
726 );
727 app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::Max;
728 app.reasoning_effort_preference = None;
729 app.apply_provider_switch_reasoning_effort(
730 provider,
731 official_base_url,
732 model.as_deref(),
733 );
734 assert_eq!(
735 app.reasoning_effort,
736 if alias == "deepseek-chat" {
737 crate::reasoning_preference::ReasoningEffort::Off
738 } else {
739 crate::reasoning_preference::ReasoningEffort::High
740 },
741 "{provider:?} {alias}"
742 );
743 }
744 other => panic!("expected SwitchProvider action, got {other:?}"),
745 }
746 }
747 }
748
749 let wanjie = provider(&mut app, Some("wanjie-ark deepseek-reasoner"));
750 assert!(matches!(
751 wanjie.action,
752 Some(AppAction::SwitchProvider {
753 provider: ApiProvider::WanjieArk,
754 model: Some(ref model),
755 }) if model == "deepseek-reasoner"
756 ));
757 }
758
759 #[test]
760 fn provider_command_preserves_alias_owned_by_custom_deepseek_endpoint() {
761 let mut app = create_test_app();
762 app.model_ids_passthrough = true;
763
764 let result = provider(&mut app, Some("deepseek deepseek-reasoner"));
765 let Some(AppAction::SwitchProvider { provider, model }) = result.action else {
766 panic!("expected SwitchProvider action");
767 };
768 let model = model.expect("command model");
769
770 assert_eq!(provider, ApiProvider::Deepseek);
771 assert_eq!(model, "deepseek-reasoner");
772 assert_eq!(
773 crate::config::wire_model_for_provider_route(
774 provider,
775 "https://models.example/v1",
776 &model,
777 ),
778 "deepseek-reasoner"
779 );
780 app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::Max;
781 app.reasoning_effort_preference = None;
782 app.apply_provider_switch_reasoning_effort(
783 provider,
784 "https://models.example/v1",
785 Some(&model),
786 );
787 assert_eq!(
788 app.reasoning_effort,
789 crate::reasoning_preference::ReasoningEffort::Max,
790 "custom endpoint owns alias semantics"
791 );
792
793 app.reasoning_effort_preference = Some(crate::reasoning_preference::ReasoningEffort::Max);
794 app.apply_provider_switch_reasoning_effort(
795 provider,
796 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
797 Some(&model),
798 );
799 assert_eq!(
800 app.reasoning_effort,
801 crate::reasoning_preference::ReasoningEffort::Max,
802 "explicit effort must beat compatibility inference"
803 );
804 }
805
806 #[test]
807 fn provider_fallback_status_and_reset_use_configured_chain() {
808 let mut app = create_test_app();
809 app.provider_chain = Some(codewhale_config::ProviderChain::new(
810 codewhale_config::ProviderKind::Deepseek,
811 &[codewhale_config::ProviderKind::Openrouter],
812 ));
813
814 let status = provider(&mut app, Some("fallback"));
815 let message = status.message.expect("fallback status");
816 assert!(message.contains("Current provider: deepseek"));
817 assert!(message.contains("[0] deepseek (primary) <- current"));
818 assert!(message.contains("[1] openrouter (fallback)"));
819
820 let reset = provider(&mut app, Some("fallback reset"));
821 assert!(reset.message.as_deref().unwrap_or("").contains("deepseek"));
822 assert!(matches!(
823 reset.action,
824 Some(AppAction::SwitchProvider {
825 provider: ApiProvider::Deepseek,
826 model: None
827 })
828 ));
829 }
830
831 /// #2574: `/provider fallback reset` returns to the *primary* (chain entry
832 /// 0), not to whatever fallback is currently active. The resolved
833 /// `SwitchProvider` action is the canonical restore path — it re-seats
834 /// `api_provider` and rebuilds the chain at position 0 (see
835 /// `switch_provider`), so a bare `ProviderChain::reset()` is not needed here.
836 #[test]
837 fn provider_fallback_reset_targets_primary_even_when_on_fallback() {
838 let _lock = lock_test_env();
839 let mut app = create_test_app();
840 app.api_provider = ApiProvider::Deepseek;
841 app.provider_chain = Some(codewhale_config::ProviderChain::new(
842 codewhale_config::ProviderKind::Deepseek,
843 &[codewhale_config::ProviderKind::Openrouter],
844 ));
845 // Simulate having already fallen back to the secondary provider.
846 // (Openrouter is treated as ready by default — no readiness snapshot.)
847 let advanced = app.advance_fallback("recoverable error");
848 assert_eq!(advanced, Some(ApiProvider::Openrouter));
849 assert_eq!(app.api_provider, ApiProvider::Openrouter);
850
851 let reset = provider(&mut app, Some("fallback reset"));
852 assert!(
853 reset
854 .message
855 .as_deref()
856 .unwrap_or("")
857 .contains("primary provider: deepseek")
858 );
859 assert!(matches!(
860 reset.action,
861 Some(AppAction::SwitchProvider {
862 provider: ApiProvider::Deepseek,
863 model: None
864 })
865 ));
866 }
867
868 #[test]
869 fn aggregator_passes_unrecognized_model_through() {
870 // Equal treatment: a non-DeepSeek id on a DeepSeek-hosting aggregator is
871 // not rejected — it passes through so the upstream API stays the
872 // authority on what it can serve.
873 let mut app = create_test_app();
874 let result = provider(&mut app, Some("nim gpt-4"));
875 assert!(result.message.is_none());
876 match result.action {
877 Some(AppAction::SwitchProvider { provider, model }) => {
878 assert_eq!(provider, ApiProvider::NvidiaNim);
879 assert_eq!(model.as_deref(), Some("gpt-4"));
880 }
881 other => panic!("expected SwitchProvider action, got {other:?}"),
882 }
883 }
884 }
885
885 lines RUST