返回 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::localization::MessageId;
10 use crate::tui::app::{App, AppAction};
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 let provider = match model_arg {
54 None => None,
55 Some(raw) => match ApiProvider::parse(raw) {
56 Some(provider) => Some(provider),
57 None => {
58 return CommandResult::error(format!(
59 "Unknown provider '{raw}'. Expected: {}.",
60 ApiProvider::names_hint()
61 ));
62 }
63 },
64 };
65 return CommandResult::action(AppAction::OpenProviderSetup { provider });
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 fn is_route_ambiguous_deepseek_alias(provider: ApiProvider, model: &str) -> bool {
117 matches!(
118 provider,
119 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
120 ) && (model.eq_ignore_ascii_case("deepseek-chat")
121 || model.eq_ignore_ascii_case("deepseek-reasoner"))
122 }
123
124 fn provider_fallback(app: &mut App, subcommand: Option<&str>) -> CommandResult {
125 match subcommand {
126 Some("reset") => {
127 let Some((_, primary, _)) = app.fallback_chain_entries().first().copied() else {
128 return CommandResult::message(
129 "No fallback providers configured. Add `fallback_providers` to your config.",
130 );
131 };
132 CommandResult::with_message_and_action(
133 format!(
134 "Fallback chain reset to primary provider: {}.",
135 primary.as_str()
136 ),
137 AppAction::SwitchProvider {
138 provider: primary,
139 model: None,
140 },
141 )
142 }
143 Some(other) => CommandResult::error(format!(
144 "Unknown fallback command '{other}'. Usage: /provider fallback [reset]"
145 )),
146 None => {
147 let entries = app.fallback_chain_entries();
148 if entries.is_empty() {
149 return CommandResult::message(
150 "No fallback providers configured. Add `fallback_providers` to your config.",
151 );
152 }
153
154 let mut lines = vec![
155 format!(
156 "Current provider: {}",
157 app.provider_identity_for_persistence()
158 ),
159 "Fallback chain:".to_string(),
160 ];
161 for (index, provider, is_current) in entries {
162 let role = if index == 0 { "primary" } else { "fallback" };
163 let marker = if is_current { " <- current" } else { "" };
164 lines.push(format!(
165 " [{index}] {} ({role}){marker}",
166 provider.as_str()
167 ));
168 }
169 if let Some(reason) = app.last_fallback_reason.as_deref() {
170 lines.push(format!("Last fallback: {reason}"));
171 }
172 lines.push("Use `/provider fallback reset` to return to the primary provider.".into());
173 CommandResult::message(lines.join("\n"))
174 }
175 }
176 }
177
178 fn expand_model_alias_for_provider(provider: ApiProvider, name: &str) -> String {
179 let trimmed = name.trim();
180 let lower = trimmed.to_ascii_lowercase();
181 if matches!(provider, ApiProvider::XiaomiMimo) {
182 return match lower.as_str() {
183 "pro" | "mimo" => "mimo-v2.5-pro".to_string(),
184 "ultraspeed" | "pro-ultraspeed" => "mimo-v2.5-pro-ultraspeed".to_string(),
185 "text" | "omni" | "v2.5-omni" => "mimo-v2.5".to_string(),
186 "tts" | "speech" | "mimo-tts" => "mimo-v2.5-tts".to_string(),
187 "voicedesign" | "voice-design" | "mimo-voice-design" => {
188 "mimo-v2.5-tts-voicedesign".to_string()
189 }
190 "voiceclone" | "voice-clone" | "mimo-voice-clone" => {
191 "mimo-v2.5-tts-voiceclone".to_string()
192 }
193 // Not a shorthand: keep the id as typed (case preserved for custom
194 // token-plan model ids).
195 _ => trimmed.to_string(),
196 };
197 }
198
199 match lower.as_str() {
200 "pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
201 "flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
202 // Not a shorthand: keep the id as typed (case preserved for opaque
203 // model tags on passthrough providers like Ollama/HuggingFace).
204 _ => trimmed.to_string(),
205 }
206 }
207
208 #[cfg(test)]
209 mod tests {
210 use super::*;
211 use crate::config::Config;
212 use crate::test_support::lock_test_env;
213 use crate::tui::app::TuiOptions;
214 use std::path::PathBuf;
215
216 fn create_test_app() -> App {
217 let options = TuiOptions {
218 ..crate::test_support::test_tui_options(PathBuf::from("."))
219 };
220 let mut app = App::new(options, &Config::default());
221 app.ui_locale = crate::localization::Locale::En;
222 app.api_provider = crate::config::ApiProvider::Deepseek;
223 app
224 }
225
226 #[test]
227 fn no_args_opens_picker_modal() {
228 let mut app = create_test_app();
229 let result = provider(&mut app, None);
230 assert!(result.message.is_none());
231 assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
232 }
233
234 #[test]
235 fn setup_subcommand_opens_provider_setup_catalog() {
236 let mut app = create_test_app();
237 let result = provider(&mut app, Some("setup"));
238 assert!(result.message.is_none());
239 assert_eq!(
240 result.action,
241 Some(AppAction::OpenProviderSetup { provider: None })
242 );
243 }
244
245 #[test]
246 fn setup_subcommand_can_focus_provider() {
247 let mut app = create_test_app();
248 let result = provider(&mut app, Some("setup anthropic"));
249 assert_eq!(
250 result.action,
251 Some(AppAction::OpenProviderSetup {
252 provider: Some(ApiProvider::Anthropic),
253 })
254 );
255 }
256
257 #[test]
258 fn setup_subcommand_rejects_unknown_provider() {
259 let mut app = create_test_app();
260 let result = provider(&mut app, Some("setup not-a-provider"));
261 let msg = result.message.expect("expected error message");
262 assert!(msg.contains("Unknown provider"));
263 assert!(msg.contains("openrouter"));
264 assert!(result.is_error);
265 }
266
267 #[test]
268 fn unknown_provider_returns_error() {
269 let mut app = create_test_app();
270 // "anthropic" became a real provider in #3014; probe with an id that
271 // stays unknown.
272 let result = provider(&mut app, Some("not-a-provider"));
273 let msg = result.message.expect("expected error message");
274 assert!(msg.contains("Unknown provider"));
275 assert!(msg.contains("openrouter"));
276 assert!(msg.contains("xiaomi-mimo"));
277 assert!(msg.contains("novita"));
278 assert!(result.action.is_none());
279 }
280
281 #[test]
282 fn switch_to_openrouter_emits_action() {
283 let mut app = create_test_app();
284 let result = provider(&mut app, Some("openrouter"));
285 match result.action {
286 Some(AppAction::SwitchProvider { provider, model }) => {
287 assert_eq!(provider, ApiProvider::Openrouter);
288 assert_eq!(model, None);
289 }
290 other => panic!("expected SwitchProvider, got {other:?}"),
291 }
292 }
293
294 #[test]
295 fn switch_to_xiaomi_mimo_emits_action() {
296 let mut app = create_test_app();
297 let result = provider(&mut app, Some("xiaomi-mimo"));
298 match result.action {
299 Some(AppAction::SwitchProvider { provider, model }) => {
300 assert_eq!(provider, ApiProvider::XiaomiMimo);
301 assert_eq!(model, None);
302 }
303 other => panic!("expected SwitchProvider, got {other:?}"),
304 }
305 }
306
307 #[test]
308 fn switch_to_xiaomi_mimo_accepts_tts_shorthands() {
309 let mut app = create_test_app();
310 let result = provider(&mut app, Some("xiaomi-mimo tts"));
311 match result.action {
312 Some(AppAction::SwitchProvider { provider, model }) => {
313 assert_eq!(provider, ApiProvider::XiaomiMimo);
314 assert_eq!(model.as_deref(), Some("mimo-v2.5-tts"));
315 }
316 other => panic!("expected SwitchProvider, got {other:?}"),
317 }
318
319 let result = provider(&mut app, Some("xiaomi-mimo voiceclone"));
320 match result.action {
321 Some(AppAction::SwitchProvider { provider, model }) => {
322 assert_eq!(provider, ApiProvider::XiaomiMimo);
323 assert_eq!(model.as_deref(), Some("mimo-v2.5-tts-voiceclone"));
324 }
325 other => panic!("expected SwitchProvider, got {other:?}"),
326 }
327 }
328
329 #[test]
330 fn switch_to_xiaomi_mimo_accepts_chat_shorthands() {
331 let mut app = create_test_app();
332 for (input, expected) in [
333 ("xiaomi-mimo pro-ultraspeed", "mimo-v2.5-pro-ultraspeed"),
334 ("xiaomi-mimo ultraspeed", "mimo-v2.5-pro-ultraspeed"),
335 ("xiaomi-mimo omni", "mimo-v2.5"),
336 ("xiaomi-mimo v2.5-omni", "mimo-v2.5"),
337 ] {
338 let result = provider(&mut app, Some(input));
339 match result.action {
340 Some(AppAction::SwitchProvider { provider, model }) => {
341 assert_eq!(provider, ApiProvider::XiaomiMimo);
342 assert_eq!(model.as_deref(), Some(expected));
343 }
344 other => panic!("expected SwitchProvider for {input}, got {other:?}"),
345 }
346 }
347 }
348
349 #[test]
350 fn switch_to_atlascloud_emits_action() {
351 let mut app = create_test_app();
352 let result = provider(&mut app, Some("atlascloud"));
353 match result.action {
354 Some(AppAction::SwitchProvider { provider, model }) => {
355 assert_eq!(provider, ApiProvider::Atlascloud);
356 assert_eq!(model, None);
357 }
358 other => panic!("expected SwitchProvider, got {other:?}"),
359 }
360 }
361
362 #[test]
363 fn switch_to_wanjie_ark_preserves_model_id() {
364 let mut app = create_test_app();
365 let result = provider(&mut app, Some("ark-wanjie account-model-id"));
366 match result.action {
367 Some(AppAction::SwitchProvider { provider, model }) => {
368 assert_eq!(provider, ApiProvider::WanjieArk);
369 assert_eq!(model.as_deref(), Some("account-model-id"));
370 }
371 other => panic!("expected SwitchProvider, got {other:?}"),
372 }
373 }
374
375 #[test]
376 fn switch_to_openai_preserves_dashscope_model_id() {
377 let mut app = create_test_app();
378 let result = provider(&mut app, Some("openai qwen-plus"));
379 match result.action {
380 Some(AppAction::SwitchProvider { provider, model }) => {
381 assert_eq!(provider, ApiProvider::Openai);
382 assert_eq!(model.as_deref(), Some("qwen-plus"));
383 }
384 other => panic!("expected SwitchProvider, got {other:?}"),
385 }
386 }
387
388 #[test]
389 fn switch_to_qianfan_preserves_model_id() {
390 let mut app = create_test_app();
391 let result = provider(&mut app, Some("qianfan custom-qianfan-service-id"));
392 match result.action {
393 Some(AppAction::SwitchProvider { provider, model }) => {
394 assert_eq!(provider, ApiProvider::Qianfan);
395 assert_eq!(model.as_deref(), Some("custom-qianfan-service-id"));
396 }
397 other => panic!("expected SwitchProvider, got {other:?}"),
398 }
399 }
400
401 #[test]
402 fn zhipu_aliases_fold_into_zai_and_canonicalize_glm() {
403 // Zhipu AI and Z.ai are the same vendor: `zhipu`/`zhipuai` select the
404 // single Zai provider and store the canonical GLM family id in Z.ai's own
405 // casing (`glm-5.2` → `GLM-5.2`).
406 let mut app = create_test_app();
407 let result = provider(&mut app, Some("zhipu glm-5.2"));
408 match result.action {
409 Some(AppAction::SwitchProvider { provider, model }) => {
410 assert_eq!(provider, ApiProvider::Zai);
411 assert_eq!(model.as_deref(), Some("GLM-5.2"));
412 }
413 other => panic!("expected SwitchProvider, got {other:?}"),
414 }
415
416 let result = provider(&mut app, Some("zhipuai glm-5-1"));
417 match result.action {
418 Some(AppAction::SwitchProvider { provider, model }) => {
419 assert_eq!(provider, ApiProvider::Zai);
420 assert_eq!(model.as_deref(), Some("GLM-5.1"));
421 }
422 other => panic!("expected SwitchProvider, got {other:?}"),
423 }
424 }
425
426 #[test]
427 fn switch_to_novita_emits_action() {
428 let mut app = create_test_app();
429 let result = provider(&mut app, Some("novita"));
430 match result.action {
431 Some(AppAction::SwitchProvider { provider, model }) => {
432 assert_eq!(provider, ApiProvider::Novita);
433 assert_eq!(model, None);
434 }
435 other => panic!("expected SwitchProvider, got {other:?}"),
436 }
437 }
438
439 #[test]
440 fn switch_to_fireworks_emits_action() {
441 let mut app = create_test_app();
442 let result = provider(&mut app, Some("fireworks pro"));
443 match result.action {
444 Some(AppAction::SwitchProvider { provider, model }) => {
445 assert_eq!(provider, ApiProvider::Fireworks);
446 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
447 }
448 other => panic!("expected SwitchProvider, got {other:?}"),
449 }
450 }
451
452 #[test]
453 fn switch_to_siliconflow_emits_action() {
454 let mut app = create_test_app();
455 let result = provider(&mut app, Some("siliconflow flash"));
456 match result.action {
457 Some(AppAction::SwitchProvider { provider, model }) => {
458 assert_eq!(provider, ApiProvider::Siliconflow);
459 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
460 }
461 other => panic!("expected SwitchProvider, got {other:?}"),
462 }
463 }
464
465 #[test]
466 fn switch_to_siliconflow_cn_emits_action() {
467 let mut app = create_test_app();
468 let result = provider(&mut app, Some("siliconflow-CN flash"));
469 match result.action {
470 Some(AppAction::SwitchProvider { provider, model }) => {
471 assert_eq!(provider, ApiProvider::SiliconflowCn);
472 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
473 }
474 other => panic!("expected SwitchProvider, got {other:?}"),
475 }
476 }
477
478 #[test]
479 fn switch_to_together_canonicalizes_deepseek_aliases() {
480 // Together is symmetric with the other DeepSeek-hosting routes: the
481 // canonical family id is stored and the route resolver performs the
482 // wire-id translation (deepseek-v4-pro → Together's catalog slug) at
483 // request time, rather than the command storing a wire slug.
484 let mut app = create_test_app();
485 let result = provider(&mut app, Some("together deepseek-v4-pro"));
486 match result.action {
487 Some(AppAction::SwitchProvider { provider, model }) => {
488 assert_eq!(provider, ApiProvider::Together);
489 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
490 }
491 other => panic!("expected SwitchProvider, got {other:?}"),
492 }
493
494 let result = provider(&mut app, Some("together flash"));
495 match result.action {
496 Some(AppAction::SwitchProvider { provider, model }) => {
497 assert_eq!(provider, ApiProvider::Together);
498 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
499 }
500 other => panic!("expected SwitchProvider, got {other:?}"),
501 }
502 }
503
504 #[test]
505 fn switch_to_sglang_flash_emits_action() {
506 let mut app = create_test_app();
507 let result = provider(&mut app, Some("sglang flash"));
508 match result.action {
509 Some(AppAction::SwitchProvider { provider, model }) => {
510 assert_eq!(provider, ApiProvider::Sglang);
511 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
512 }
513 other => panic!("expected SwitchProvider, got {other:?}"),
514 }
515 }
516
517 #[test]
518 fn switch_to_vllm_flash_emits_action() {
519 let mut app = create_test_app();
520 let result = provider(&mut app, Some("vllm flash"));
521 match result.action {
522 Some(AppAction::SwitchProvider { provider, model }) => {
523 assert_eq!(provider, ApiProvider::Vllm);
524 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
525 }
526 other => panic!("expected SwitchProvider, got {other:?}"),
527 }
528 }
529
530 #[test]
531 fn switch_to_ollama_preserves_model_tag() {
532 let mut app = create_test_app();
533 let result = provider(&mut app, Some("ollama qwen2.5-coder:7b"));
534 match result.action {
535 Some(AppAction::SwitchProvider { provider, model }) => {
536 assert_eq!(provider, ApiProvider::Ollama);
537 assert_eq!(model.as_deref(), Some("qwen2.5-coder:7b"));
538 }
539 other => panic!("expected SwitchProvider, got {other:?}"),
540 }
541 }
542
543 #[test]
544 fn switching_to_active_provider_without_model_is_a_noop() {
545 let mut app = create_test_app();
546 let result = provider(&mut app, Some("deepseek"));
547 let msg = result.message.expect("expected message");
548 assert!(msg.contains("Already on provider"));
549 assert!(result.action.is_none());
550 }
551
552 #[test]
553 fn switch_to_nim_emits_action_without_model_override() {
554 let mut app = create_test_app();
555 let result = provider(&mut app, Some("nvidia-nim"));
556 assert!(result.message.is_none());
557 match result.action {
558 Some(AppAction::SwitchProvider { provider, model }) => {
559 assert_eq!(provider, ApiProvider::NvidiaNim);
560 assert_eq!(model, None);
561 }
562 other => panic!("expected SwitchProvider action, got {other:?}"),
563 }
564 }
565
566 #[test]
567 fn nim_flash_shorthand_emits_action_with_model_override() {
568 let mut app = create_test_app();
569 let result = provider(&mut app, Some("nim flash"));
570 match result.action {
571 Some(AppAction::SwitchProvider { provider, model }) => {
572 assert_eq!(provider, ApiProvider::NvidiaNim);
573 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
574 }
575 other => panic!("expected SwitchProvider action, got {other:?}"),
576 }
577 }
578
579 #[test]
580 fn nim_pro_shorthand_emits_action_with_model_override() {
581 let mut app = create_test_app();
582 let result = provider(&mut app, Some("nim pro"));
583 match result.action {
584 Some(AppAction::SwitchProvider { provider, model }) => {
585 assert_eq!(provider, ApiProvider::NvidiaNim);
586 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
587 }
588 other => panic!("expected SwitchProvider action, got {other:?}"),
589 }
590 }
591
592 #[test]
593 fn switch_to_active_provider_with_new_model_still_emits_action() {
594 let mut app = create_test_app();
595 let result = provider(&mut app, Some("deepseek flash"));
596 match result.action {
597 Some(AppAction::SwitchProvider { provider, model }) => {
598 assert_eq!(provider, ApiProvider::Deepseek);
599 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
600 }
601 other => panic!("expected SwitchProvider action, got {other:?}"),
602 }
603 }
604
605 #[test]
606 fn switch_to_deepseek_canonicalizes_provider_prefixed_model_override() {
607 let mut app = create_test_app();
608 app.api_provider = ApiProvider::Openrouter;
609
610 let result = provider(&mut app, Some("deepseek deepseek/deepseek-v4-pro"));
611
612 match result.action {
613 Some(AppAction::SwitchProvider { provider, model }) => {
614 assert_eq!(provider, ApiProvider::Deepseek);
615 assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
616 }
617 other => panic!("expected SwitchProvider action, got {other:?}"),
618 }
619 }
620
621 #[test]
622 fn direct_deepseek_provider_commands_retire_aliases_at_official_wire_boundary() {
623 let mut app = create_test_app();
624 app.api_provider = ApiProvider::Openrouter;
625
626 for provider_name in ["deepseek", "deepseek-cn", "deepseek-anthropic"] {
627 for alias in ["deepseek-chat", "deepseek-reasoner"] {
628 let result = provider(&mut app, Some(&format!("{provider_name} {alias}")));
629 match result.action {
630 Some(AppAction::SwitchProvider { provider, model }) => {
631 assert!(matches!(
632 provider,
633 ApiProvider::Deepseek
634 | ApiProvider::DeepseekCN
635 | ApiProvider::DeepseekAnthropic
636 ));
637 assert_eq!(model.as_deref(), Some(alias));
638 let official_base_url = match provider {
639 ApiProvider::Deepseek => crate::config::DEFAULT_DEEPSEEK_BASE_URL,
640 ApiProvider::DeepseekCN => crate::config::DEFAULT_DEEPSEEKCN_BASE_URL,
641 ApiProvider::DeepseekAnthropic => {
642 crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
643 }
644 _ => unreachable!("asserted direct DeepSeek provider"),
645 };
646 assert_eq!(
647 crate::config::wire_model_for_provider_route(
648 provider,
649 official_base_url,
650 model.as_deref().expect("command model"),
651 ),
652 crate::config::DEEPSEEK_ALIAS_REPLACEMENT
653 );
654 app.reasoning_effort = crate::tui::app::ReasoningEffort::Max;
655 app.reasoning_effort_preference = None;
656 app.apply_provider_switch_reasoning_effort(
657 provider,
658 official_base_url,
659 model.as_deref(),
660 );
661 assert_eq!(
662 app.reasoning_effort,
663 if alias == "deepseek-chat" {
664 crate::tui::app::ReasoningEffort::Off
665 } else {
666 crate::tui::app::ReasoningEffort::High
667 },
668 "{provider:?} {alias}"
669 );
670 }
671 other => panic!("expected SwitchProvider action, got {other:?}"),
672 }
673 }
674 }
675
676 let wanjie = provider(&mut app, Some("wanjie-ark deepseek-reasoner"));
677 assert!(matches!(
678 wanjie.action,
679 Some(AppAction::SwitchProvider {
680 provider: ApiProvider::WanjieArk,
681 model: Some(ref model),
682 }) if model == "deepseek-reasoner"
683 ));
684 }
685
686 #[test]
687 fn provider_command_preserves_alias_owned_by_custom_deepseek_endpoint() {
688 let mut app = create_test_app();
689 app.model_ids_passthrough = true;
690
691 let result = provider(&mut app, Some("deepseek deepseek-reasoner"));
692 let Some(AppAction::SwitchProvider { provider, model }) = result.action else {
693 panic!("expected SwitchProvider action");
694 };
695 let model = model.expect("command model");
696
697 assert_eq!(provider, ApiProvider::Deepseek);
698 assert_eq!(model, "deepseek-reasoner");
699 assert_eq!(
700 crate::config::wire_model_for_provider_route(
701 provider,
702 "https://models.example/v1",
703 &model,
704 ),
705 "deepseek-reasoner"
706 );
707 app.reasoning_effort = crate::tui::app::ReasoningEffort::Max;
708 app.reasoning_effort_preference = None;
709 app.apply_provider_switch_reasoning_effort(
710 provider,
711 "https://models.example/v1",
712 Some(&model),
713 );
714 assert_eq!(
715 app.reasoning_effort,
716 crate::tui::app::ReasoningEffort::Max,
717 "custom endpoint owns alias semantics"
718 );
719
720 app.reasoning_effort_preference = Some(crate::tui::app::ReasoningEffort::Max);
721 app.apply_provider_switch_reasoning_effort(
722 provider,
723 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
724 Some(&model),
725 );
726 assert_eq!(
727 app.reasoning_effort,
728 crate::tui::app::ReasoningEffort::Max,
729 "explicit effort must beat compatibility inference"
730 );
731 }
732
733 #[test]
734 fn provider_fallback_status_and_reset_use_configured_chain() {
735 let mut app = create_test_app();
736 app.provider_chain = Some(codewhale_config::ProviderChain::new(
737 codewhale_config::ProviderKind::Deepseek,
738 &[codewhale_config::ProviderKind::Openrouter],
739 ));
740
741 let status = provider(&mut app, Some("fallback"));
742 let message = status.message.expect("fallback status");
743 assert!(message.contains("Current provider: deepseek"));
744 assert!(message.contains("[0] deepseek (primary) <- current"));
745 assert!(message.contains("[1] openrouter (fallback)"));
746
747 let reset = provider(&mut app, Some("fallback reset"));
748 assert!(reset.message.as_deref().unwrap_or("").contains("deepseek"));
749 assert!(matches!(
750 reset.action,
751 Some(AppAction::SwitchProvider {
752 provider: ApiProvider::Deepseek,
753 model: None
754 })
755 ));
756 }
757
758 /// #2574: `/provider fallback reset` returns to the *primary* (chain entry
759 /// 0), not to whatever fallback is currently active. The resolved
760 /// `SwitchProvider` action is the canonical restore path — it re-seats
761 /// `api_provider` and rebuilds the chain at position 0 (see
762 /// `switch_provider`), so a bare `ProviderChain::reset()` is not needed here.
763 #[test]
764 fn provider_fallback_reset_targets_primary_even_when_on_fallback() {
765 let _lock = lock_test_env();
766 let mut app = create_test_app();
767 app.api_provider = ApiProvider::Deepseek;
768 app.provider_chain = Some(codewhale_config::ProviderChain::new(
769 codewhale_config::ProviderKind::Deepseek,
770 &[codewhale_config::ProviderKind::Openrouter],
771 ));
772 // Simulate having already fallen back to the secondary provider.
773 // (Openrouter is treated as ready by default — no readiness snapshot.)
774 let advanced = app.advance_fallback("recoverable error");
775 assert_eq!(advanced, Some(ApiProvider::Openrouter));
776 assert_eq!(app.api_provider, ApiProvider::Openrouter);
777
778 let reset = provider(&mut app, Some("fallback reset"));
779 assert!(
780 reset
781 .message
782 .as_deref()
783 .unwrap_or("")
784 .contains("primary provider: deepseek")
785 );
786 assert!(matches!(
787 reset.action,
788 Some(AppAction::SwitchProvider {
789 provider: ApiProvider::Deepseek,
790 model: None
791 })
792 ));
793 }
794
795 #[test]
796 fn aggregator_passes_unrecognized_model_through() {
797 // Equal treatment: a non-DeepSeek id on a DeepSeek-hosting aggregator is
798 // not rejected — it passes through so the upstream API stays the
799 // authority on what it can serve.
800 let mut app = create_test_app();
801 let result = provider(&mut app, Some("nim gpt-4"));
802 assert!(result.message.is_none());
803 match result.action {
804 Some(AppAction::SwitchProvider { provider, model }) => {
805 assert_eq!(provider, ApiProvider::NvidiaNim);
806 assert_eq!(model.as_deref(), Some("gpt-4"));
807 }
808 other => panic!("expected SwitchProvider action, got {other:?}"),
809 }
810 }
811 }
812
812 lines RUST