Skip to main content

codewhale_config/
provider.rs

1//! Built-in provider metadata.
2//!
3//! This module is a metadata foundation for collapsing provider drift over
4//! time. It deliberately does not mutate request bodies or choose fallback
5//! providers; runtime routing remains in `ConfigToml::resolve_runtime_options`.
6
7use super::{
8    DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL,
9    DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL,
10    DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
11    DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, DEFAULT_FIREWORKS_BASE_URL,
12    DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL,
13    DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL, DEFAULT_MOONSHOT_BASE_URL,
14    DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL, DEFAULT_NOVITA_MODEL,
15    DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_OLLAMA_BASE_URL,
16    DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL,
17    DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENROUTER_BASE_URL,
18    DEFAULT_OPENROUTER_MODEL, DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL,
19    DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
20    DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
21    DEFAULT_STEPFUN_MODEL, DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL,
22    DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL,
23    DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL,
24    DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL,
25    DEFAULT_ZAI_MODEL, ProviderKind,
26};
27
28/// Wire protocol spoken by a provider.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum WireFormat {
32    /// OpenAI-compatible `/v1/chat/completions` style payloads.
33    ChatCompletions,
34    /// OpenAI Responses API (`/responses`).
35    Responses,
36    /// Native Anthropic Messages API (`/v1/messages`).
37    AnthropicMessages,
38}
39
40/// Static metadata for a built-in model provider.
41pub trait Provider: Send + Sync {
42    /// Provider enum variant represented by this entry.
43    fn kind(&self) -> ProviderKind;
44
45    /// Canonical provider identifier.
46    fn id(&self) -> &'static str {
47        self.kind().as_str()
48    }
49
50    /// Human-readable provider label for UIs and diagnostics.
51    fn display_name(&self) -> &'static str;
52
53    /// Default base URL used when no config/env/CLI override is present.
54    fn default_base_url(&self) -> &'static str;
55
56    /// Default model used when no config/env/CLI override is present.
57    fn default_model(&self) -> &'static str;
58
59    /// Environment variable candidates used for this provider's API key.
60    fn env_vars(&self) -> &'static [&'static str];
61
62    /// TOML table key under `[providers.<key>]`.
63    fn provider_config_key(&self) -> &'static str;
64
65    /// Alternate names accepted during provider resolution.
66    fn aliases(&self) -> &'static [&'static str] {
67        &[]
68    }
69
70    /// Wire format used by the provider.
71    fn wire(&self) -> WireFormat {
72        WireFormat::ChatCompletions
73    }
74}
75
76macro_rules! provider {
77    (
78        $struct_name:ident,
79        $kind:ident,
80        $id:literal,
81        $display_name:literal,
82        $base_url:ident,
83        $model:ident,
84        [$($env_var:literal),* $(,)?],
85        $config_key:literal,
86        aliases: [$($alias:literal),* $(,)?]
87    ) => {
88        /// Zero-sized metadata entry for this built-in provider.
89        pub struct $struct_name;
90
91        impl Provider for $struct_name {
92            fn id(&self) -> &'static str {
93                $id
94            }
95
96            fn kind(&self) -> ProviderKind {
97                ProviderKind::$kind
98            }
99
100            fn display_name(&self) -> &'static str {
101                $display_name
102            }
103
104            fn default_base_url(&self) -> &'static str {
105                $base_url
106            }
107
108            fn default_model(&self) -> &'static str {
109                $model
110            }
111
112            fn env_vars(&self) -> &'static [&'static str] {
113                &[$($env_var),*]
114            }
115
116            fn provider_config_key(&self) -> &'static str {
117                $config_key
118            }
119
120            fn aliases(&self) -> &'static [&'static str] {
121                &[$($alias),*]
122            }
123        }
124    };
125}
126
127provider!(
128    Deepseek,
129    Deepseek,
130    "deepseek",
131    "DeepSeek",
132    DEFAULT_DEEPSEEK_BASE_URL,
133    DEFAULT_DEEPSEEK_MODEL,
134    ["DEEPSEEK_API_KEY"],
135    "deepseek",
136    aliases: ["deep-seek", "deepseek-cn", "deepseek_china", "deepseekcn", "deepseek-china"]
137);
138
139/// Opt-in DeepSeek route that speaks the Anthropic Messages wire protocol.
140pub struct DeepseekAnthropic;
141
142impl Provider for DeepseekAnthropic {
143    fn id(&self) -> &'static str {
144        "deepseek-anthropic"
145    }
146
147    fn kind(&self) -> ProviderKind {
148        ProviderKind::DeepseekAnthropic
149    }
150
151    fn display_name(&self) -> &'static str {
152        "DeepSeek (Anthropic-compatible)"
153    }
154
155    fn default_base_url(&self) -> &'static str {
156        DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
157    }
158
159    fn default_model(&self) -> &'static str {
160        DEFAULT_DEEPSEEK_ANTHROPIC_MODEL
161    }
162
163    fn env_vars(&self) -> &'static [&'static str] {
164        &["DEEPSEEK_API_KEY"]
165    }
166
167    fn provider_config_key(&self) -> &'static str {
168        "deepseek_anthropic"
169    }
170
171    fn aliases(&self) -> &'static [&'static str] {
172        &["deepseek_anthropic", "deepseek-claude", "deepseek_claude"]
173    }
174
175    fn wire(&self) -> WireFormat {
176        WireFormat::AnthropicMessages
177    }
178}
179provider!(
180    NvidiaNim,
181    NvidiaNim,
182    "nvidia-nim",
183    "NVIDIA NIM",
184    DEFAULT_NVIDIA_NIM_BASE_URL,
185    DEFAULT_NVIDIA_NIM_MODEL,
186    ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
187    "nvidia_nim",
188    aliases: ["nvidia", "nvidia_nim", "nim"]
189);
190provider!(
191    Openai,
192    Openai,
193    "openai",
194    "OpenAI-compatible",
195    DEFAULT_OPENAI_BASE_URL,
196    DEFAULT_OPENAI_MODEL,
197    ["OPENAI_API_KEY"],
198    "openai",
199    aliases: ["open-ai"]
200);
201provider!(
202    Atlascloud,
203    Atlascloud,
204    "atlascloud",
205    "AtlasCloud",
206    DEFAULT_ATLASCLOUD_BASE_URL,
207    DEFAULT_ATLASCLOUD_MODEL,
208    ["ATLASCLOUD_API_KEY"],
209    "atlascloud",
210    aliases: ["atlas-cloud", "atlas_cloud", "atlas"]
211);
212provider!(
213    WanjieArk,
214    WanjieArk,
215    "wanjie-ark",
216    "Wanjie Ark",
217    DEFAULT_WANJIE_ARK_BASE_URL,
218    DEFAULT_WANJIE_ARK_MODEL,
219    [
220        "WANJIE_ARK_API_KEY",
221        "WANJIE_API_KEY",
222        "WANJIE_MAAS_API_KEY"
223    ],
224    "wanjie_ark",
225    aliases: ["wanjie", "wanjie_ark", "ark-wanjie", "ark_wanjie", "wanjieark", "wanjie-maas", "wanjie_maas", "wanjiemaas"]
226);
227provider!(
228    Volcengine,
229    Volcengine,
230    "volcengine",
231    "Volcengine Ark",
232    DEFAULT_VOLCENGINE_BASE_URL,
233    DEFAULT_VOLCENGINE_MODEL,
234    [
235        "VOLCENGINE_API_KEY",
236        "VOLCENGINE_ARK_API_KEY",
237        "ARK_API_KEY"
238    ],
239    "volcengine",
240    aliases: ["volcengine-ark", "volcengine_ark", "ark", "volc-ark", "volcengineark"]
241);
242provider!(
243    Openrouter,
244    Openrouter,
245    "openrouter",
246    "OpenRouter",
247    DEFAULT_OPENROUTER_BASE_URL,
248    DEFAULT_OPENROUTER_MODEL,
249    ["OPENROUTER_API_KEY"],
250    "openrouter",
251    aliases: ["open_router"]
252);
253provider!(
254    XiaomiMimo,
255    XiaomiMimo,
256    "xiaomi-mimo",
257    "Xiaomi MiMo",
258    DEFAULT_XIAOMI_MIMO_BASE_URL,
259    DEFAULT_XIAOMI_MIMO_MODEL,
260    [
261        "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
262        "MIMO_TOKEN_PLAN_API_KEY",
263        "XIAOMI_MIMO_API_KEY",
264        "XIAOMI_API_KEY",
265        "MIMO_API_KEY",
266    ],
267    "xiaomi_mimo",
268    aliases: ["xiaomi_mimo", "xiaomimimo", "mimo", "xiaomi"]
269);
270provider!(
271    Novita,
272    Novita,
273    "novita",
274    "Novita AI",
275    DEFAULT_NOVITA_BASE_URL,
276    DEFAULT_NOVITA_MODEL,
277    ["NOVITA_API_KEY"],
278    "novita",
279    aliases: []
280);
281provider!(
282    Fireworks,
283    Fireworks,
284    "fireworks",
285    "Fireworks AI",
286    DEFAULT_FIREWORKS_BASE_URL,
287    DEFAULT_FIREWORKS_MODEL,
288    ["FIREWORKS_API_KEY"],
289    "fireworks",
290    aliases: ["fireworks-ai"]
291);
292provider!(
293    Siliconflow,
294    Siliconflow,
295    "siliconflow",
296    "SiliconFlow",
297    DEFAULT_SILICONFLOW_BASE_URL,
298    DEFAULT_SILICONFLOW_MODEL,
299    ["SILICONFLOW_API_KEY"],
300    "siliconflow",
301    aliases: ["silicon-flow", "silicon_flow"]
302);
303provider!(
304    SiliconflowCN,
305    SiliconflowCN,
306    "siliconflow-CN",
307    "SiliconFlow (China)",
308    DEFAULT_SILICONFLOW_CN_BASE_URL,
309    DEFAULT_SILICONFLOW_MODEL,
310    ["SILICONFLOW_API_KEY"],
311    "siliconflow_cn",
312    aliases: [
313        "silicon-flow-cn",
314        "silicon-flow-CN",
315        "silicon_flow_cn",
316        "silicon_flow_CN",
317        "siliconflow-china",
318    ]
319);
320provider!(
321    Arcee,
322    Arcee,
323    "arcee",
324    "Arcee AI",
325    DEFAULT_ARCEE_BASE_URL,
326    DEFAULT_ARCEE_MODEL,
327    ["ARCEE_API_KEY"],
328    "arcee",
329    aliases: ["arcee-ai", "arcee_ai"]
330);
331provider!(
332    Moonshot,
333    Moonshot,
334    "moonshot",
335    "Moonshot/Kimi",
336    DEFAULT_MOONSHOT_BASE_URL,
337    DEFAULT_MOONSHOT_MODEL,
338    ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
339    "moonshot",
340    aliases: ["moonshot-ai", "kimi", "kimi-k2"]
341);
342provider!(
343    Sglang,
344    Sglang,
345    "sglang",
346    "SGLang",
347    DEFAULT_SGLANG_BASE_URL,
348    DEFAULT_SGLANG_MODEL,
349    ["SGLANG_API_KEY"],
350    "sglang",
351    aliases: ["sg-lang"]
352);
353provider!(
354    Vllm,
355    Vllm,
356    "vllm",
357    "vLLM",
358    DEFAULT_VLLM_BASE_URL,
359    DEFAULT_VLLM_MODEL,
360    ["VLLM_API_KEY"],
361    "vllm",
362    aliases: ["v-llm"]
363);
364provider!(
365    Ollama,
366    Ollama,
367    "ollama",
368    "Ollama",
369    DEFAULT_OLLAMA_BASE_URL,
370    DEFAULT_OLLAMA_MODEL,
371    ["OLLAMA_API_KEY"],
372    "ollama",
373    aliases: ["ollama-local"]
374);
375provider!(
376    Huggingface,
377    Huggingface,
378    "huggingface",
379    "Hugging Face",
380    DEFAULT_HUGGINGFACE_BASE_URL,
381    DEFAULT_HUGGINGFACE_MODEL,
382    ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
383    "huggingface",
384    aliases: ["hugging-face", "hugging_face", "hf"]
385);
386provider!(
387    Together,
388    Together,
389    "together",
390    "Together AI",
391    DEFAULT_TOGETHER_BASE_URL,
392    DEFAULT_TOGETHER_MODEL,
393    ["TOGETHER_API_KEY"],
394    "together",
395    aliases: ["together-ai", "together_ai"]
396);
397provider!(
398    Qianfan,
399    Qianfan,
400    "qianfan",
401    "Baidu Qianfan",
402    DEFAULT_QIANFAN_BASE_URL,
403    DEFAULT_QIANFAN_MODEL,
404    ["QIANFAN_API_KEY", "BAIDU_QIANFAN_API_KEY"],
405    "qianfan",
406    aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
407);
408
409/// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
410pub struct OpenaiCodex;
411
412impl Provider for OpenaiCodex {
413    fn id(&self) -> &'static str {
414        "openai-codex"
415    }
416
417    fn kind(&self) -> ProviderKind {
418        ProviderKind::OpenaiCodex
419    }
420
421    fn display_name(&self) -> &'static str {
422        "OpenAI Codex (ChatGPT)"
423    }
424
425    fn default_base_url(&self) -> &'static str {
426        DEFAULT_OPENAI_CODEX_BASE_URL
427    }
428
429    fn default_model(&self) -> &'static str {
430        DEFAULT_OPENAI_CODEX_MODEL
431    }
432
433    fn env_vars(&self) -> &'static [&'static str] {
434        &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
435    }
436
437    fn provider_config_key(&self) -> &'static str {
438        "openai_codex"
439    }
440
441    fn aliases(&self) -> &'static [&'static str] {
442        &[
443            "openai_codex",
444            "openaicodex",
445            "codex",
446            "chatgpt",
447            "chatgpt-codex",
448            "chatgpt_codex",
449            "chatgptcodex",
450        ]
451    }
452
453    fn wire(&self) -> WireFormat {
454        WireFormat::Responses
455    }
456}
457
458/// Native Anthropic Messages API provider (#3014).
459pub struct Anthropic;
460
461impl Provider for Anthropic {
462    fn id(&self) -> &'static str {
463        "anthropic"
464    }
465
466    fn kind(&self) -> ProviderKind {
467        ProviderKind::Anthropic
468    }
469
470    fn display_name(&self) -> &'static str {
471        "Anthropic"
472    }
473
474    fn default_base_url(&self) -> &'static str {
475        crate::DEFAULT_ANTHROPIC_BASE_URL
476    }
477
478    fn default_model(&self) -> &'static str {
479        crate::DEFAULT_ANTHROPIC_MODEL
480    }
481
482    fn env_vars(&self) -> &'static [&'static str] {
483        &["ANTHROPIC_API_KEY"]
484    }
485
486    fn provider_config_key(&self) -> &'static str {
487        "anthropic"
488    }
489
490    fn wire(&self) -> WireFormat {
491        WireFormat::AnthropicMessages
492    }
493}
494
495provider!(
496    Zai,
497    Zai,
498    "zai",
499    "Zhipu AI / Z.ai",
500    DEFAULT_ZAI_BASE_URL,
501    DEFAULT_ZAI_MODEL,
502    ["ZAI_API_KEY", "Z_AI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
503    "zai",
504    aliases: ["z-ai", "z_ai", "z.ai", "zhipu", "zhipuai", "bigmodel", "big-model"]
505);
506
507provider!(
508    Stepfun,
509    Stepfun,
510    "stepfun",
511    "StepFun / StepFlash",
512    DEFAULT_STEPFUN_BASE_URL,
513    DEFAULT_STEPFUN_MODEL,
514    ["STEPFUN_API_KEY", "STEP_API_KEY"],
515    "stepfun",
516    aliases: ["step-fun", "step_fun", "stepflash", "step-flash", "step_flash"]
517);
518
519provider!(
520    Minimax,
521    Minimax,
522    "minimax",
523    "MiniMax",
524    DEFAULT_MINIMAX_BASE_URL,
525    DEFAULT_MINIMAX_MODEL,
526    ["MINIMAX_API_KEY"],
527    "minimax",
528    aliases: ["mini-max", "mini_max"]
529);
530
531provider!(
532    Deepinfra,
533    Deepinfra,
534    "deepinfra",
535    "DeepInfra",
536    DEFAULT_DEEPINFRA_BASE_URL,
537    DEFAULT_DEEPINFRA_MODEL,
538    ["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
539    "deepinfra",
540    aliases: ["deep-infra", "deep_infra"]
541);
542
543/// User-defined OpenAI-compatible endpoint (#1519).
544///
545/// A single dynamic provider identity for arbitrary `[providers.<name>]
546/// kind="openai-compatible"` config entries. Unlike the built-in providers it
547/// carries no real default base URL/model/env var: the concrete endpoint, model
548/// id, and auth env var all arrive from the named `[providers.<name>]` config
549/// table at route time. The placeholder base URL/model here exist only so the
550/// descriptor stays well-formed (non-empty) for conformance; runtime routing
551/// always supplies a `base_url_override` and a wire model id, so these
552/// placeholders are never used to reach the network.
553pub struct Custom;
554
555impl Provider for Custom {
556    fn id(&self) -> &'static str {
557        "custom"
558    }
559
560    fn kind(&self) -> ProviderKind {
561        ProviderKind::Custom
562    }
563
564    fn display_name(&self) -> &'static str {
565        "Custom (OpenAI-compatible)"
566    }
567
568    fn default_base_url(&self) -> &'static str {
569        // Placeholder only; the real endpoint comes from the named config table
570        // via the route's base_url_override. Loopback so a misconfigured custom
571        // provider fails closed locally rather than reaching a public host.
572        "http://localhost/v1"
573    }
574
575    fn default_model(&self) -> &'static str {
576        // Placeholder only; the real model id comes from config and is preserved
577        // verbatim as the wire model id.
578        "custom-model"
579    }
580
581    fn env_vars(&self) -> &'static [&'static str] {
582        // No built-in env var: the auth env var is named per-entry via
583        // `[providers.<name>] api_key_env = "..."`.
584        &[]
585    }
586
587    fn provider_config_key(&self) -> &'static str {
588        "custom"
589    }
590
591    fn wire(&self) -> WireFormat {
592        WireFormat::ChatCompletions
593    }
594}
595
596static DEEPSEEK: Deepseek = Deepseek;
597static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic;
598static NVIDIA_NIM: NvidiaNim = NvidiaNim;
599static OPENAI: Openai = Openai;
600static ATLASCLOUD: Atlascloud = Atlascloud;
601static WANJIE_ARK: WanjieArk = WanjieArk;
602static VOLCENGINE: Volcengine = Volcengine;
603static OPENROUTER: Openrouter = Openrouter;
604static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
605static NOVITA: Novita = Novita;
606static FIREWORKS: Fireworks = Fireworks;
607static SILICONFLOW: Siliconflow = Siliconflow;
608static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN;
609static ARCEE: Arcee = Arcee;
610static MOONSHOT: Moonshot = Moonshot;
611static SGLANG: Sglang = Sglang;
612static VLLM: Vllm = Vllm;
613static OLLAMA: Ollama = Ollama;
614static HUGGINGFACE: Huggingface = Huggingface;
615static TOGETHER: Together = Together;
616static QIANFAN: Qianfan = Qianfan;
617static OPENAI_CODEX: OpenaiCodex = OpenaiCodex;
618static ANTHROPIC: Anthropic = Anthropic;
619static ZAI: Zai = Zai;
620static STEPFUN: Stepfun = Stepfun;
621static MINIMAX: Minimax = Minimax;
622static DEEPINFRA: Deepinfra = Deepinfra;
623static CUSTOM: Custom = Custom;
624
625static PROVIDER_REGISTRY: [&dyn Provider; 28] = [
626    &DEEPSEEK,
627    &DEEPSEEK_ANTHROPIC,
628    &NVIDIA_NIM,
629    &OPENAI,
630    &ATLASCLOUD,
631    &WANJIE_ARK,
632    &VOLCENGINE,
633    &OPENROUTER,
634    &XIAOMI_MIMO,
635    &NOVITA,
636    &FIREWORKS,
637    &SILICONFLOW,
638    &ARCEE,
639    &SILICONFLOW_CN,
640    &MOONSHOT,
641    &SGLANG,
642    &VLLM,
643    &OLLAMA,
644    &HUGGINGFACE,
645    &TOGETHER,
646    &QIANFAN,
647    &OPENAI_CODEX,
648    &ANTHROPIC,
649    &ZAI,
650    &STEPFUN,
651    &MINIMAX,
652    &DEEPINFRA,
653    &CUSTOM,
654];
655
656/// Return all built-in provider metadata entries in `ProviderKind::ALL` order.
657///
658/// This insertion order is the stable order used for internal parsing and
659/// default selection. It is intentionally NOT the order user-facing UI should
660/// render; for browsing/picker surfaces use [`providers_sorted_for_display`].
661#[must_use]
662pub fn all_providers() -> &'static [&'static dyn Provider] {
663    &PROVIDER_REGISTRY
664}
665
666/// Return all built-in providers ordered for user-facing display.
667///
668/// Providers are sorted alphabetically (case-insensitively) by
669/// [`Provider::display_name`] so model/provider browsing surfaces present a
670/// neutral, predictable list rather than leading with whichever provider
671/// happens to sit first in [`ProviderKind::ALL`] (historically DeepSeek). The
672/// ordering policy intentionally differs from internal parsing/default order:
673///
674/// - [`all_providers`] / [`ProviderKind::ALL`] — stable order for internal
675///   matching, parsing, and default selection. Do not reorder.
676/// - [`providers_sorted_for_display`] — neutral alphabetical order for UI
677///   browsing. DeepSeek stays present and searchable but is not hard-coded
678///   first; a caller may still highlight/pin the active provider separately.
679///
680/// Returns an owned `Vec` because the sorted order is computed, not static.
681#[must_use]
682pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
683    let mut providers = all_providers().to_vec();
684    providers.sort_by(|a, b| {
685        a.display_name()
686            .to_ascii_lowercase()
687            .cmp(&b.display_name().to_ascii_lowercase())
688    });
689    providers
690}
691
692/// Find a provider by canonical id only.
693#[must_use]
694pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
695    let id = id.trim();
696    all_providers()
697        .iter()
698        .copied()
699        .find(|provider| provider.id() == id)
700}
701
702/// Resolve a provider by canonical id or supported legacy alias.
703#[must_use]
704pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
705    ProviderKind::parse(id_or_alias).map(provider_for_kind)
706}
707
708/// Return metadata for a known provider kind.
709#[must_use]
710pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
711    PROVIDER_REGISTRY
712        .iter()
713        .find(|p| p.kind() == kind)
714        .copied()
715        .expect("ProviderKind variant missing from PROVIDER_REGISTRY")
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn display_order_is_alphabetical_by_display_name() {
724        let display = providers_sorted_for_display();
725        let names: Vec<String> = display
726            .iter()
727            .map(|p| p.display_name().to_ascii_lowercase())
728            .collect();
729        let mut sorted = names.clone();
730        sorted.sort();
731        assert_eq!(
732            names, sorted,
733            "providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
734        );
735    }
736
737    #[test]
738    fn display_order_differs_from_internal_all_order() {
739        // The whole point of the helper is that UI ordering is NOT the
740        // internal ProviderKind::ALL / all_providers() insertion order.
741        let display_ids: Vec<&str> = providers_sorted_for_display()
742            .iter()
743            .map(|p| p.id())
744            .collect();
745        let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
746        assert_ne!(
747            display_ids, internal_ids,
748            "display order should not match internal ALL order"
749        );
750    }
751
752    #[test]
753    fn display_order_is_complete_and_unique() {
754        // No provider is dropped or duplicated by the sort.
755        let display = providers_sorted_for_display();
756        assert_eq!(
757            display.len(),
758            all_providers().len(),
759            "display order must include every built-in provider"
760        );
761        let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
762        ids.sort_unstable();
763        let before = ids.len();
764        ids.dedup();
765        assert_eq!(
766            before,
767            ids.len(),
768            "display order must not contain duplicates"
769        );
770    }
771
772    #[test]
773    fn deepseek_is_present_but_not_first_in_display_order() {
774        // Acceptance: DeepSeek stays searchable but is no longer hard-coded
775        // first in provider browsing UI. (It is first in internal ALL order.)
776        let display = providers_sorted_for_display();
777        assert_eq!(
778            all_providers()[0].kind(),
779            ProviderKind::Deepseek,
780            "DeepSeek is expected to remain first in the stable internal order"
781        );
782        assert!(
783            display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
784            "DeepSeek must remain present in display order"
785        );
786        assert_ne!(
787            display[0].kind(),
788            ProviderKind::Deepseek,
789            "DeepSeek must not be hard-coded first in display order"
790        );
791        // Anthropic ('Anthropic') sorts before 'DeepSeek' alphabetically, so it
792        // is a stable check that the neutral ordering actually took effect.
793        assert_eq!(
794            display[0].display_name(),
795            "Anthropic",
796            "alphabetical display order should lead with Anthropic"
797        );
798    }
799}