返回 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::{ProviderKind, 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(row) = codewhale_config::descriptors::compatibility_for_selector(name) else {
69 return CommandResult::error(format!(
70 "Unknown provider '{name}'. Expected: {}.",
71 ProviderKind::names_hint()
72 ));
73 };
74
75 let target = row.kind;
76 let selected_key: codewhale_config::ProviderId = row.id.into();
77
78 let model = match model_arg {
79 None => None,
80 Some(raw) => {
81 // Expand provider shorthands (flash/pro, Xiaomi MiMo tts/omni, …)
82 // uniformly, then either keep the id verbatim for providers that take
83 // opaque/custom model tags, or resolve it to the canonical family id.
84 // Families are treated equally: each resolves through its own
85 // canonical map (DeepSeek, GLM via Z.ai/Zhipu, Kimi, MiniMax, …) and
86 // an id matching none passes through unchanged — the upstream API is
87 // the authority. Wire-id translation is deferred to the route
88 // resolver at request time. DeepSeek's two retiring aliases are
89 // also deferred because this command does not own the target base
90 // URL: a custom endpoint may still use either id natively.
91 let expanded = expand_model_alias_for_provider(target, raw);
92 if provider_passes_model_through(target)
93 || is_route_ambiguous_deepseek_alias(target, &expanded)
94 {
95 Some(expanded)
96 } else {
97 match canonical_model_id_for_provider(target, &expanded) {
98 Some(canonical) => Some(canonical),
99 None => {
100 return CommandResult::error(format!(
101 "Invalid model '{raw}'. Provide a non-empty model id."
102 ));
103 }
104 }
105 }
106 }
107 };
108
109 if app
110 .provider_identity
111 .as_ref()
112 .is_some_and(|identity| identity.key == selected_key)
113 && model.is_none()
114 {
115 return CommandResult::message(format!("Already on provider: {}", selected_key));
116 }
117
118 CommandResult::action(AppAction::SwitchProvider {
119 provider: selected_key,
120 model,
121 })
122 }
123
124 pub(in crate::commands) fn provider_setup_action_for_name(raw: &str) -> Result<AppAction, String> {
125 if crate::config::is_legacy_antigravity_identity(raw) {
126 return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
127 }
128 if raw.eq_ignore_ascii_case("ds4") || raw.eq_ignore_ascii_case("dwarfstar") {
129 return Ok(AppAction::OpenDs4Setup);
130 }
131 // First-class aliases (zen, opencode-zen, …) resolve through the provider
132 // registry. There are no setup templates anymore: named custom hosts are
133 // configured with `/provider setup` and the blank custom form (#6289).
134 match codewhale_config::descriptors::compatibility_for_selector(raw) {
135 Some(provider) => Ok(AppAction::OpenProviderSetup {
136 provider: Some(provider.id.into()),
137 }),
138 None => Err(format!(
139 "Unknown provider '{raw}'. Expected: {}.",
140 ProviderKind::names_hint()
141 )),
142 }
143 }
144
145 fn is_route_ambiguous_deepseek_alias(provider: ProviderKind, model: &str) -> bool {
146 matches!(
147 provider,
148 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
149 ) && (model.eq_ignore_ascii_case("deepseek-chat")
150 || model.eq_ignore_ascii_case("deepseek-reasoner"))
151 }
152
153 fn provider_fallback(app: &mut App, subcommand: Option<&str>) -> CommandResult {
154 match subcommand {
155 Some("reset") => {
156 let Some((_, primary, _)) = app.fallback_chain_entries().first().copied() else {
157 return CommandResult::message(
158 "No fallback providers configured. Add `fallback_providers` to your config.",
159 );
160 };
161 CommandResult::with_message_and_action(
162 format!(
163 "Fallback chain reset to primary provider: {}.",
164 primary.as_str()
165 ),
166 AppAction::SwitchProvider {
167 provider: primary.as_str().into(),
168 model: None,
169 },
170 )
171 }
172 Some(other) => CommandResult::error(format!(
173 "Unknown fallback command '{other}'. Usage: /provider fallback [reset]"
174 )),
175 None => {
176 let entries = app.fallback_chain_entries();
177 if entries.is_empty() {
178 return CommandResult::message(
179 "No fallback providers configured. Add `fallback_providers` to your config.",
180 );
181 }
182
183 let mut lines = vec![
184 format!(
185 "Current provider: {}",
186 app.provider_identity_for_persistence()
187 ),
188 "Fallback chain:".to_string(),
189 ];
190 for (index, provider, is_current) in entries {
191 let role = if index == 0 { "primary" } else { "fallback" };
192 let marker = if is_current { " <- current" } else { "" };
193 lines.push(format!(
194 " [{index}] {} ({role}){marker}",
195 provider.as_str()
196 ));
197 }
198 if let Some(reason) = app.last_fallback_reason.as_deref() {
199 lines.push(format!("Last fallback: {reason}"));
200 }
201 lines.push("Use `/provider fallback reset` to return to the primary provider.".into());
202 CommandResult::message(lines.join("\n"))
203 }
204 }
205 }
206
207 fn expand_model_alias_for_provider(provider: ProviderKind, name: &str) -> String {
208 let trimmed = name.trim();
209 let lower = trimmed.to_ascii_lowercase();
210 if matches!(provider, ProviderKind::XiaomiMimo) {
211 return match lower.as_str() {
212 "pro" | "mimo" => "mimo-v2.5-pro".to_string(),
213 "ultraspeed" | "pro-ultraspeed" => "mimo-v2.5-pro-ultraspeed".to_string(),
214 "text" | "omni" | "v2.5-omni" => "mimo-v2.5".to_string(),
215 "tts" | "speech" | "mimo-tts" => "mimo-v2.5-tts".to_string(),
216 "voicedesign" | "voice-design" | "mimo-voice-design" => {
217 "mimo-v2.5-tts-voicedesign".to_string()
218 }
219 "voiceclone" | "voice-clone" | "mimo-voice-clone" => {
220 "mimo-v2.5-tts-voiceclone".to_string()
221 }
222 // Not a shorthand: keep the id as typed (case preserved for custom
223 // token-plan model ids).
224 _ => trimmed.to_string(),
225 };
226 }
227
228 match lower.as_str() {
229 "pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
230 "flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
231 // Not a shorthand: keep the id as typed (case preserved for opaque
232 // model tags on passthrough providers like Ollama/HuggingFace).
233 _ => trimmed.to_string(),
234 }
235 }
236
237 #[cfg(test)]
238 mod tests {
239 use super::*;
240 use crate::config::Config;
241 use crate::test_support::lock_test_env;
242 use crate::tui::app::TuiOptions;
243 use std::path::PathBuf;
244
245 fn create_test_app() -> App {
246 let options = TuiOptions {
247 ..crate::test_support::test_tui_options(PathBuf::from("."))
248 };
249 let mut app = App::new(options, &Config::default());
250 app.ui_locale = codewhale_localization::Locale::En;
251 app.api_provider = crate::config::ProviderKind::Deepseek;
252 app
253 }
254
255 #[test]
256 fn no_args_opens_picker_modal() {
257 let mut app = create_test_app();
258 let result = provider(&mut app, None);
259 assert!(result.message.is_none());
260 assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
261 }
262
263 #[test]
264 fn retired_antigravity_selectors_return_the_tombstone_without_an_action() {
265 let _guard = lock_test_env();
266 for identity in ["antigravity", "agy", "AGY"] {
267 let mut app = create_test_app();
268 let result = provider(&mut app, Some(identity));
269 assert!(result.is_error, "{identity}");
270 assert_eq!(result.action, None, "{identity}");
271 let message = result.message.expect("tombstone message");
272 assert!(message.contains("non-runnable"), "{identity}: {message}");
273 assert!(message.contains("GEMINI_API_KEY"), "{identity}: {message}");
274 assert_eq!(app.api_provider, crate::config::ProviderKind::Deepseek);
275
276 let setup = provider_setup_action_for_name(identity)
277 .expect_err("setup must not open for the tombstone");
278 assert!(setup.contains("provider `google`"), "{identity}: {setup}");
279 }
280 }
281
282 #[test]
283 fn setup_subcommand_opens_provider_setup_catalog() {
284 let mut app = create_test_app();
285 let result = provider(&mut app, Some("setup"));
286 assert!(result.message.is_none());
287 assert_eq!(
288 result.action,
289 Some(AppAction::OpenProviderSetup { provider: None })
290 );
291 }
292
293 #[test]
294 fn setup_subcommand_can_focus_provider() {
295 let mut app = create_test_app();
296 let result = provider(&mut app, Some("setup anthropic"));
297 assert_eq!(
298 result.action,
299 Some(AppAction::OpenProviderSetup {
300 provider: Some(ProviderKind::Anthropic.as_str().into()),
301 })
302 );
303 }
304
305 #[test]
306 fn setup_subcommand_opens_ds4_preset() {
307 let mut app = create_test_app();
308 let result = provider(&mut app, Some("setup ds4"));
309 assert_eq!(result.action, Some(AppAction::OpenDs4Setup));
310 assert!(result.message.is_none());
311 }
312
313 #[test]
314 fn setup_subcommand_rejects_retired_template_name() {
315 let mut app = create_test_app();
316 let result = provider(&mut app, Some("setup agnes"));
317 assert!(result.action.is_none());
318 let msg = result.message.expect("expected error message");
319 assert!(msg.contains("Unknown provider 'agnes'"));
320 assert!(result.is_error);
321 }
322
323 #[test]
324 fn setup_subcommand_opens_first_class_zen_provider() {
325 let mut app = create_test_app();
326 for name in ["setup opencode-zen", "setup zen"] {
327 let result = provider(&mut app, Some(name));
328 assert_eq!(
329 result.action,
330 Some(AppAction::OpenProviderSetup {
331 provider: Some(ProviderKind::OpencodeZen.as_str().into()),
332 }),
333 "{name} must resolve through the provider registry"
334 );
335 }
336 }
337
338 #[test]
339 fn setup_subcommand_rejects_unknown_provider() {
340 let mut app = create_test_app();
341 let result = provider(&mut app, Some("setup not-a-provider"));
342 let msg = result.message.expect("expected error message");
343 assert!(msg.contains("Unknown provider"));
344 assert!(msg.contains("openrouter"));
345 assert!(result.is_error);
346 }
347
348 #[test]
349 fn unknown_provider_returns_error() {
350 let mut app = create_test_app();
351 // "anthropic" became a real provider in #3014; probe with an id that
352 // stays unknown.
353 let result = provider(&mut app, Some("not-a-provider"));
354 let msg = result.message.expect("expected error message");
355 assert!(msg.contains("Unknown provider"));
356 assert!(msg.contains("openrouter"));
357 assert!(msg.contains("xiaomi-mimo"));
358 assert!(msg.contains("novita"));
359 assert!(result.action.is_none());
360 }
361
362 #[test]
363 fn switch_to_openrouter_emits_action() {
364 let mut app = create_test_app();
365 let result = provider(&mut app, Some("openrouter"));
366 match result.action {
367 Some(AppAction::SwitchProvider { provider, model }) => {
368 assert_eq!(provider.as_str(), ProviderKind::Openrouter.as_str());
369 assert_eq!(model, None);
370 }
371 other => panic!("expected SwitchProvider, got {other:?}"),
372 }
373 }
374
375 #[test]
376 fn switch_to_xiaomi_mimo_emits_action() {
377 let mut app = create_test_app();
378 let result = provider(&mut app, Some("xiaomi-mimo"));
379 match result.action {
380 Some(AppAction::SwitchProvider { provider, model }) => {
381 assert_eq!(provider.as_str(), ProviderKind::XiaomiMimo.as_str());
382 assert_eq!(model, None);
383 }
384 other => panic!("expected SwitchProvider, got {other:?}"),
385 }
386 }
387
388 #[test]
389 fn switch_to_xiaomi_mimo_accepts_tts_shorthands() {
390 let mut app = create_test_app();
391 let result = provider(&mut app, Some("xiaomi-mimo tts"));
392 match result.action {
393 Some(AppAction::SwitchProvider { provider, model }) => {
394 assert_eq!(provider.as_str(), ProviderKind::XiaomiMimo.as_str());
395 assert_eq!(model.as_deref(), Some("mimo-v2.5-tts"));
396 }
397 other => panic!("expected SwitchProvider, got {other:?}"),
398 }
399
400 let result = provider(&mut app, Some("xiaomi-mimo voiceclone"));
401 match result.action {
402 Some(AppAction::SwitchProvider { provider, model }) => {
403 assert_eq!(provider.as_str(), ProviderKind::XiaomiMimo.as_str());
404 assert_eq!(model.as_deref(), Some("mimo-v2.5-tts-voiceclone"));
405 }
406 other => panic!("expected SwitchProvider, got {other:?}"),
407 }
408 }
409
410 #[test]
411 fn switch_to_xiaomi_mimo_accepts_chat_shorthands() {
412 let mut app = create_test_app();
413 for (input, expected) in [
414 ("xiaomi-mimo pro-ultraspeed", "mimo-v2.5-pro-ultraspeed"),
415 ("xiaomi-mimo ultraspeed", "mimo-v2.5-pro-ultraspeed"),
416 ("xiaomi-mimo omni", "mimo-v2.5"),
417 ("xiaomi-mimo v2.5-omni", "mimo-v2.5"),
418 ] {
419 let result = provider(&mut app, Some(input));
420 match result.action {
421 Some(AppAction::SwitchProvider { provider, model }) => {
422 assert_eq!(provider.as_str(), ProviderKind::XiaomiMimo.as_str());
423 assert_eq!(model.as_deref(), Some(expected));
424 }
425 other => panic!("expected SwitchProvider for {input}, got {other:?}"),
426 }
427 }
428 }
429
430 #[test]
431 fn switch_to_atlascloud_emits_action() {
432 let mut app = create_test_app();
433 let result = provider(&mut app, Some("atlascloud"));
434 match result.action {
435 Some(AppAction::SwitchProvider { provider, model }) => {
436 assert_eq!(provider.as_str(), ProviderKind::Atlascloud.as_str());
437 assert_eq!(model, None);
438 }
439 other => panic!("expected SwitchProvider, got {other:?}"),
440 }
441 }
442
443 #[test]
444 fn switch_to_wanjie_ark_preserves_model_id() {
445 let mut app = create_test_app();
446 let result = provider(&mut app, Some("ark-wanjie account-model-id"));
447 match result.action {
448 Some(AppAction::SwitchProvider { provider, model }) => {
449 assert_eq!(provider.as_str(), ProviderKind::WanjieArk.as_str());
450 assert_eq!(model.as_deref(), Some("account-model-id"));
451 }
452 other => panic!("expected SwitchProvider, got {other:?}"),
453 }
454 }
455
456 #[test]
457 fn switch_to_openai_preserves_dashscope_model_id() {
458 let mut app = create_test_app();
459 let result = provider(&mut app, Some("openai qwen-plus"));
460 match result.action {
461 Some(AppAction::SwitchProvider { provider, model }) => {
462 assert_eq!(provider.as_str(), ProviderKind::Openai.as_str());
463 assert_eq!(model.as_deref(), Some("qwen-plus"));
464 }
465 other => panic!("expected SwitchProvider, got {other:?}"),
466 }
467 }
468
469 #[test]
470 fn switch_to_qianfan_preserves_model_id() {
471 let mut app = create_test_app();
472 let result = provider(&mut app, Some("qianfan custom-qianfan-service-id"));
473 match result.action {
474 Some(AppAction::SwitchProvider { provider, model }) => {
475 assert_eq!(provider.as_str(), ProviderKind::Qianfan.as_str());
476 assert_eq!(model.as_deref(), Some("custom-qianfan-service-id"));
477 }
478 other => panic!("expected SwitchProvider, got {other:?}"),
479 }
480 }
481
482 #[test]
483 fn zhipu_aliases_fold_into_zai_and_canonicalize_glm() {
484 // Zhipu AI and Z.ai are the same vendor: `zhipu`/`zhipuai` select the
485 // single Zai provider and store the canonical GLM family id in Z.ai's own
486 // casing (`glm-5.2` → `GLM-5.2`).
487 let mut app = create_test_app();
488 let result = provider(&mut app, Some("zhipu glm-5.2"));
489 match result.action {
490 Some(AppAction::SwitchProvider { provider, model }) => {
491 assert_eq!(provider.as_str(), ProviderKind::Zai.as_str());
492 assert_eq!(model.as_deref(), Some("GLM-5.2"));
493 }
494 other => panic!("expected SwitchProvider, got {other:?}"),
495 }
496
497 let result = provider(&mut app, Some("zhipuai glm-5-1"));
498 match result.action {
499 Some(AppAction::SwitchProvider { provider, model }) => {
500 assert_eq!(provider.as_str(), ProviderKind::Zai.as_str());
501 assert_eq!(model.as_deref(), Some("GLM-5.1"));
502 }
503 other => panic!("expected SwitchProvider, got {other:?}"),
504 }
505 }
506
507 #[test]
508 fn switch_to_novita_emits_action() {
509 let mut app = create_test_app();
510 let result = provider(&mut app, Some("novita"));
511 match result.action {
512 Some(AppAction::SwitchProvider { provider, model }) => {
513 assert_eq!(provider.as_str(), ProviderKind::Novita.as_str());
514 assert_eq!(model, None);
515 }
516 other => panic!("expected SwitchProvider, got {other:?}"),
517 }
518 }
519
520 #[test]
521 fn switch_to_fireworks_emits_action() {
522 let mut app = create_test_app();
523 let result = provider(&mut app, Some("fireworks pro"));
524 match result.action {
525 Some(AppAction::SwitchProvider { provider, model }) => {
526 assert_eq!(provider.as_str(), ProviderKind::Fireworks.as_str());
527 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
528 }
529 other => panic!("expected SwitchProvider, got {other:?}"),
530 }
531 }
532
533 #[test]
534 fn switch_to_siliconflow_emits_action() {
535 let mut app = create_test_app();
536 let result = provider(&mut app, Some("siliconflow flash"));
537 match result.action {
538 Some(AppAction::SwitchProvider { provider, model }) => {
539 assert_eq!(provider.as_str(), ProviderKind::Siliconflow.as_str());
540 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
541 }
542 other => panic!("expected SwitchProvider, got {other:?}"),
543 }
544 }
545
546 #[test]
547 fn switch_to_siliconflow_cn_emits_action() {
548 let mut app = create_test_app();
549 let result = provider(&mut app, Some("siliconflow-CN flash"));
550 match result.action {
551 Some(AppAction::SwitchProvider { provider, model }) => {
552 assert_eq!(provider.as_str(), ProviderKind::SiliconflowCN.as_str());
553 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
554 }
555 other => panic!("expected SwitchProvider, got {other:?}"),
556 }
557 }
558
559 #[test]
560 fn switch_to_together_canonicalizes_deepseek_aliases() {
561 // Together is symmetric with the other DeepSeek-hosting routes: the
562 // canonical family id is stored and the route resolver performs the
563 // wire-id translation (deepseek-v4-pro → Together's catalog slug) at
564 // request time, rather than the command storing a wire slug.
565 let mut app = create_test_app();
566 let result = provider(&mut app, Some("together deepseek-v4-pro"));
567 match result.action {
568 Some(AppAction::SwitchProvider { provider, model }) => {
569 assert_eq!(provider.as_str(), ProviderKind::Together.as_str());
570 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
571 }
572 other => panic!("expected SwitchProvider, got {other:?}"),
573 }
574
575 let result = provider(&mut app, Some("together flash"));
576 match result.action {
577 Some(AppAction::SwitchProvider { provider, model }) => {
578 assert_eq!(provider.as_str(), ProviderKind::Together.as_str());
579 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
580 }
581 other => panic!("expected SwitchProvider, got {other:?}"),
582 }
583 }
584
585 #[test]
586 fn switch_to_sglang_flash_emits_action() {
587 let mut app = create_test_app();
588 let result = provider(&mut app, Some("sglang flash"));
589 match result.action {
590 Some(AppAction::SwitchProvider { provider, model }) => {
591 assert_eq!(provider.as_str(), ProviderKind::Sglang.as_str());
592 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
593 }
594 other => panic!("expected SwitchProvider, got {other:?}"),
595 }
596 }
597
598 #[test]
599 fn switch_to_vllm_flash_emits_action() {
600 let mut app = create_test_app();
601 let result = provider(&mut app, Some("vllm flash"));
602 match result.action {
603 Some(AppAction::SwitchProvider { provider, model }) => {
604 assert_eq!(provider.as_str(), ProviderKind::Vllm.as_str());
605 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
606 }
607 other => panic!("expected SwitchProvider, got {other:?}"),
608 }
609 }
610
611 #[test]
612 fn switch_to_ollama_preserves_model_tag() {
613 let mut app = create_test_app();
614 let result = provider(&mut app, Some("ollama qwen2.5-coder:7b"));
615 match result.action {
616 Some(AppAction::SwitchProvider { provider, model }) => {
617 assert_eq!(provider.as_str(), ProviderKind::Ollama.as_str());
618 assert_eq!(model.as_deref(), Some("qwen2.5-coder:7b"));
619 }
620 other => panic!("expected SwitchProvider, got {other:?}"),
621 }
622 }
623
624 #[test]
625 fn switching_to_active_provider_without_model_is_a_noop() {
626 let mut app = create_test_app();
627 let result = provider(&mut app, Some("deepseek"));
628 let msg = result.message.expect("expected message");
629 assert!(msg.contains("Already on provider"));
630 assert!(result.action.is_none());
631 }
632
633 #[test]
634 fn switch_to_nim_emits_action_without_model_override() {
635 let mut app = create_test_app();
636 let result = provider(&mut app, Some("nvidia-nim"));
637 assert!(result.message.is_none());
638 match result.action {
639 Some(AppAction::SwitchProvider { provider, model }) => {
640 assert_eq!(provider.as_str(), ProviderKind::NvidiaNim.as_str());
641 assert_eq!(model, None);
642 }
643 other => panic!("expected SwitchProvider action, got {other:?}"),
644 }
645 }
646
647 #[test]
648 fn nim_flash_shorthand_emits_action_with_model_override() {
649 let mut app = create_test_app();
650 let result = provider(&mut app, Some("nim flash"));
651 match result.action {
652 Some(AppAction::SwitchProvider { provider, model }) => {
653 assert_eq!(provider.as_str(), ProviderKind::NvidiaNim.as_str());
654 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
655 }
656 other => panic!("expected SwitchProvider action, got {other:?}"),
657 }
658 }
659
660 #[test]
661 fn nim_pro_shorthand_emits_action_with_model_override() {
662 let mut app = create_test_app();
663 let result = provider(&mut app, Some("nim pro"));
664 match result.action {
665 Some(AppAction::SwitchProvider { provider, model }) => {
666 assert_eq!(provider.as_str(), ProviderKind::NvidiaNim.as_str());
667 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
668 }
669 other => panic!("expected SwitchProvider action, got {other:?}"),
670 }
671 }
672
673 #[test]
674 fn switch_to_active_provider_with_new_model_still_emits_action() {
675 let mut app = create_test_app();
676 let result = provider(&mut app, Some("deepseek flash"));
677 match result.action {
678 Some(AppAction::SwitchProvider { provider, model }) => {
679 assert_eq!(provider.as_str(), ProviderKind::Deepseek.as_str());
680 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
681 }
682 other => panic!("expected SwitchProvider action, got {other:?}"),
683 }
684 }
685
686 #[test]
687 fn switch_to_deepseek_canonicalizes_provider_prefixed_model_override() {
688 let mut app = create_test_app();
689 app.api_provider = ProviderKind::Openrouter;
690
691 let result = provider(&mut app, Some("deepseek deepseek/deepseek-v4-pro"));
692
693 match result.action {
694 Some(AppAction::SwitchProvider { provider, model }) => {
695 assert_eq!(provider.as_str(), ProviderKind::Deepseek.as_str());
696 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
697 }
698 other => panic!("expected SwitchProvider action, got {other:?}"),
699 }
700 }
701
702 #[test]
703 fn direct_deepseek_provider_commands_retire_aliases_at_official_wire_boundary() {
704 let mut app = create_test_app();
705 app.api_provider = ProviderKind::Openrouter;
706
707 for provider_name in ["deepseek", "deepseek-cn", "deepseek-anthropic"] {
708 for alias in ["deepseek-chat", "deepseek-reasoner"] {
709 let result = provider(&mut app, Some(&format!("{provider_name} {alias}")));
710 match result.action {
711 Some(AppAction::SwitchProvider { provider, model }) => {
712 assert_eq!(provider.as_str(), provider_name);
713 assert_eq!(model.as_deref(), Some(alias));
714 let row =
715 codewhale_config::descriptors::compatibility_for_id(provider.as_str())
716 .expect("command descriptor");
717 let official_base_url = row.base_url;
718 let intrinsic = row.kind;
719 assert_eq!(
720 crate::config::wire_model_for_provider_route(
721 intrinsic,
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 intrinsic,
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: ref selected,
754 model: Some(ref model),
755 }) if selected.as_str() == "wanjie-ark" && 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.as_str(), ProviderKind::Deepseek.as_str());
771 assert_eq!(model, "deepseek-reasoner");
772 assert_eq!(
773 crate::config::wire_model_for_provider_route(
774 ProviderKind::Deepseek,
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 ProviderKind::Deepseek,
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 ProviderKind::Deepseek,
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: ref selected,
826 model: None
827 }) if selected.as_str() == "deepseek"
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 providers = crate::config::ProvidersConfig::default();
840 providers.openrouter.api_key = Some("fixture-fallback-key".to_string());
841 let config = crate::config::Config {
842 provider: Some("deepseek".to_string()),
843 fallback_providers: vec![ProviderKind::Openrouter],
844 providers: Some(providers),
845 ..Default::default()
846 };
847 let mut app = App::new(
848 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
849 &config,
850 );
851 app.api_provider = ProviderKind::Deepseek;
852 app.provider_chain = Some(codewhale_config::ProviderChain::new(
853 codewhale_config::ProviderKind::Deepseek,
854 &[codewhale_config::ProviderKind::Openrouter],
855 ));
856 // Simulate having already fallen back to the secondary provider.
857 // The secondary route is admitted from the captured fixture config.
858 let advanced = app.advance_fallback("recoverable error");
859 assert_eq!(advanced, Some(ProviderKind::Openrouter));
860 assert_eq!(app.api_provider, ProviderKind::Openrouter);
861
862 let reset = provider(&mut app, Some("fallback reset"));
863 assert!(
864 reset
865 .message
866 .as_deref()
867 .unwrap_or("")
868 .contains("primary provider: deepseek")
869 );
870 assert!(matches!(
871 reset.action,
872 Some(AppAction::SwitchProvider {
873 provider: ref selected,
874 model: None
875 }) if selected.as_str() == "deepseek"
876 ));
877 }
878
879 #[test]
880 fn aggregator_passes_unrecognized_model_through() {
881 // Equal treatment: a non-DeepSeek id on a DeepSeek-hosting aggregator is
882 // not rejected — it passes through so the upstream API stays the
883 // authority on what it can serve.
884 let mut app = create_test_app();
885 let result = provider(&mut app, Some("nim gpt-4"));
886 assert!(result.message.is_none());
887 match result.action {
888 Some(AppAction::SwitchProvider { provider, model }) => {
889 assert_eq!(provider.as_str(), ProviderKind::NvidiaNim.as_str());
890 assert_eq!(model.as_deref(), Some("gpt-4"));
891 }
892 other => panic!("expected SwitchProvider action, got {other:?}"),
893 }
894 }
895 }
896
896 lines RUST