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_LONGCAT_BASE_URL, DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
14    DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL,
15    DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL,
16    DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL,
17    DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL,
18    DEFAULT_OPENAI_CODEX_BASE_URL, DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL,
19    DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENMODEL_BASE_URL,
20    DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL,
21    DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL,
22    DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
23    DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
24    DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL,
25    DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL,
26    DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL,
27    DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL,
28    DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL,
29    DEFAULT_ZAI_MODEL, ProviderKind,
30};
31
32/// Wire protocol spoken by a provider.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum WireFormat {
36    /// OpenAI-compatible `/v1/chat/completions` style payloads.
37    ChatCompletions,
38    /// OpenAI Responses API (`/responses`).
39    Responses,
40    /// Native Anthropic Messages API (`/v1/messages`).
41    AnthropicMessages,
42}
43
44/// How a user obtains or supplies credentials for a built-in provider.
45///
46/// Keeping this typed prevents API-key onboarding from accidentally describing
47/// a local runtime, OAuth-only route, or user-defined endpoint as though it had
48/// a vendor key console.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CredentialAcquisition {
51    /// A provider-issued API key or access token.
52    ApiKey,
53    /// Either a provider-issued API key or the provider's supported OAuth path.
54    ApiKeyOrOAuth,
55    /// A self-hosted route that is keyless by default but can be configured with auth.
56    LocalOptional,
57    /// An OAuth-only route; Codewhale does not collect an API key for it.
58    OAuth,
59    /// A user-defined route whose credential source belongs in configuration.
60    Configuration,
61}
62
63impl CredentialAcquisition {
64    /// Stable machine-readable label for diagnostics.
65    #[must_use]
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::ApiKey => "api_key",
69            Self::ApiKeyOrOAuth => "api_key_or_oauth",
70            Self::LocalOptional => "local_optional",
71            Self::OAuth => "oauth",
72            Self::Configuration => "configuration",
73        }
74    }
75}
76
77/// Canonical, non-secret help for configuring one provider.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct CredentialHelp {
80    pub acquisition: CredentialAcquisition,
81    /// Stable provider-owned page for creating or locating credentials.
82    ///
83    /// `None` is deliberate for local, OAuth-only, and user-defined routes; UI
84    /// callers must show [`Self::guidance`] instead of guessing a URL.
85    pub credential_url: Option<&'static str>,
86    /// Provider-owned documentation when the repository already has a stable link.
87    pub docs_url: Option<&'static str>,
88    /// Concise fallback or qualification for non-key and mixed-auth routes.
89    pub guidance: &'static str,
90}
91
92/// Kimi Code's membership-plan key console.
93///
94/// This is intentionally distinct from Moonshot's direct API console.  The
95/// route-specific helper below owns the choice so a configured Kimi Code route
96/// is never described as a generic Moonshot route.
97pub const KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL: &str = "https://www.kimi.com/code/console";
98
99/// Static metadata for a built-in model provider.
100pub trait Provider: Send + Sync {
101    /// Provider enum variant represented by this entry.
102    fn kind(&self) -> ProviderKind;
103
104    /// Canonical provider identifier.
105    fn id(&self) -> &'static str {
106        self.kind().as_str()
107    }
108
109    /// Human-readable provider label for UIs and diagnostics.
110    fn display_name(&self) -> &'static str;
111
112    /// Default base URL used when no config/env/CLI override is present.
113    fn default_base_url(&self) -> &'static str;
114
115    /// Default model used when no config/env/CLI override is present.
116    fn default_model(&self) -> &'static str;
117
118    /// Environment variable candidates used for this provider's API key.
119    fn env_vars(&self) -> &'static [&'static str];
120
121    /// TOML table key under `[providers.<key>]`.
122    fn provider_config_key(&self) -> &'static str;
123
124    /// Alternate names accepted during provider resolution.
125    fn aliases(&self) -> &'static [&'static str] {
126        &[]
127    }
128
129    /// Wire format used by the provider.
130    fn wire(&self) -> WireFormat {
131        WireFormat::ChatCompletions
132    }
133
134    /// Credential acquisition metadata shared by onboarding, setup, diagnostics,
135    /// and provider-help surfaces.
136    fn credential_help(&self) -> CredentialHelp {
137        credential_help(self.kind())
138    }
139}
140
141/// Return the canonical credential-acquisition metadata for a provider kind.
142///
143/// URLs here are provider-owned links already documented in this repository.
144/// If no stable vendor credential page is known, the URL remains absent and the
145/// guidance explains the supported local, OAuth, or configuration path.
146/// This is provider-level fallback metadata: callers that know a concrete base
147/// URL must use [`credential_help_for_route`] so route-owned credentials do not
148/// inherit a default endpoint's console.
149#[must_use]
150pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
151    use CredentialAcquisition::{ApiKey, ApiKeyOrOAuth, Configuration, LocalOptional, OAuth};
152
153    match kind {
154        ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => CredentialHelp {
155            acquisition: ApiKey,
156            credential_url: Some("https://platform.deepseek.com/api_keys"),
157            docs_url: Some("https://api-docs.deepseek.com/"),
158            guidance: "Create an API key in the DeepSeek platform console.",
159        },
160        ProviderKind::NvidiaNim => CredentialHelp {
161            acquisition: ApiKey,
162            credential_url: Some("https://build.nvidia.com/settings/api-keys"),
163            docs_url: Some("https://build.nvidia.com/explore/discover"),
164            guidance: "Create an NVIDIA NIM key in the NVIDIA build console.",
165        },
166        ProviderKind::Openai => CredentialHelp {
167            acquisition: ApiKey,
168            credential_url: Some("https://platform.openai.com/api-keys"),
169            docs_url: Some("https://platform.openai.com/docs/api-reference"),
170            guidance: "Create an OpenAI API key, or configure the credential for your compatible endpoint.",
171        },
172        ProviderKind::Atlascloud => CredentialHelp {
173            acquisition: ApiKey,
174            credential_url: Some("https://atlascloud.ai/docs/en/api-keys"),
175            docs_url: Some("https://atlascloud.ai/docs/en/api-keys"),
176            guidance: "Follow Atlas Cloud's API Keys guide to create a credential.",
177        },
178        ProviderKind::WanjieArk => CredentialHelp {
179            acquisition: ApiKey,
180            credential_url: Some("https://docs.wanjiedata.com/maas/maas-openapi-v1.html"),
181            docs_url: Some("https://docs.wanjiedata.com/maas/maas-openapi-v1.html"),
182            guidance: "Follow Wanjie MaaS's APIKEY guide to create a credential.",
183        },
184        ProviderKind::Volcengine => CredentialHelp {
185            acquisition: ApiKey,
186            credential_url: Some("https://console.volcengine.com/ark/apiKey"),
187            docs_url: Some("https://www.volcengine.com/docs/82379/1541594"),
188            guidance: "Create a Volcengine Ark API key in the Ark console.",
189        },
190        ProviderKind::Openrouter => CredentialHelp {
191            acquisition: ApiKey,
192            credential_url: Some("https://openrouter.ai/settings/keys"),
193            docs_url: Some("https://openrouter.ai/docs/api/reference/authentication"),
194            guidance: "Create an OpenRouter key from account settings.",
195        },
196        ProviderKind::XiaomiMimo => CredentialHelp {
197            acquisition: ApiKey,
198            credential_url: Some("https://platform.xiaomimimo.com/token-plan"),
199            docs_url: Some("https://mimo.mi.com/docs/en-US/tokenplan/Token%20Plan/subscription"),
200            guidance: "Create a Xiaomi MiMo Token Plan or pay-as-you-go key and keep its matching base URL.",
201        },
202        ProviderKind::Novita => CredentialHelp {
203            acquisition: ApiKey,
204            credential_url: Some("https://novita.ai/en/settings/key-management"),
205            docs_url: Some("https://novita.ai/docs/guides/quickstart"),
206            guidance: "Create a Novita key in account Key Management.",
207        },
208        ProviderKind::Fireworks => CredentialHelp {
209            acquisition: ApiKey,
210            credential_url: Some("https://fireworks.ai/api-keys"),
211            docs_url: Some("https://docs.fireworks.ai/getting-started/quickstart"),
212            guidance: "Create a Fireworks API key before configuring the provider.",
213        },
214        ProviderKind::Siliconflow => CredentialHelp {
215            acquisition: ApiKey,
216            credential_url: Some("https://cloud.siliconflow.com/account/ak"),
217            docs_url: Some("https://docs.siliconflow.com/en/userguide/quickstart"),
218            guidance: "Use the global SiliconFlow console for the global endpoint.",
219        },
220        ProviderKind::SiliconflowCN => CredentialHelp {
221            acquisition: ApiKey,
222            credential_url: Some("https://cloud.siliconflow.cn/account/ak"),
223            docs_url: Some("https://docs.siliconflow.cn/en/userguide/quickstart"),
224            guidance: "Use the China SiliconFlow console for the China endpoint.",
225        },
226        ProviderKind::Arcee => CredentialHelp {
227            acquisition: ApiKey,
228            credential_url: Some("https://docs.arcee.ai/other/create-your-first-api-key"),
229            docs_url: Some("https://docs.arcee.ai/other/create-your-first-api-key"),
230            guidance: "Follow Arcee's API key guide to create a credential.",
231        },
232        ProviderKind::Moonshot => CredentialHelp {
233            acquisition: ApiKey,
234            credential_url: Some("https://platform.kimi.ai/console/api-keys"),
235            docs_url: Some("https://platform.kimi.ai/docs/overview"),
236            guidance: "For Moonshot's default direct API route, sign in to Kimi API Platform and create and copy an API key. A configured Kimi Code route uses a separate membership-plan console and never imports Kimi CLI credentials; first-class Kimi OAuth is not available.",
237        },
238        ProviderKind::Sglang => CredentialHelp {
239            acquisition: LocalOptional,
240            credential_url: None,
241            docs_url: Some("https://docs.sglang.ai/"),
242            guidance: "Self-hosted SGLang is keyless by default; configure a key only if your server requires one.",
243        },
244        ProviderKind::Vllm => CredentialHelp {
245            acquisition: LocalOptional,
246            credential_url: None,
247            docs_url: Some("https://docs.vllm.ai/en/stable/serving/openai_compatible_server/"),
248            guidance: "Self-hosted vLLM is keyless by default; configure a key only if your server requires one.",
249        },
250        ProviderKind::Ollama => CredentialHelp {
251            acquisition: LocalOptional,
252            credential_url: None,
253            docs_url: Some("https://docs.ollama.com/api"),
254            guidance: "Local Ollama is keyless by default; configure a key only if your server requires one.",
255        },
256        ProviderKind::Huggingface => CredentialHelp {
257            acquisition: ApiKey,
258            credential_url: Some("https://huggingface.co/settings/tokens"),
259            docs_url: Some("https://huggingface.co/docs/hub/en/security-tokens"),
260            guidance: "Create a scoped Hugging Face access token.",
261        },
262        ProviderKind::Together => CredentialHelp {
263            acquisition: ApiKey,
264            credential_url: Some("https://api.together.ai/settings/api-keys"),
265            docs_url: Some("https://docs.together.ai/docs/api-keys-authentication"),
266            guidance: "Create a Together API key from account settings.",
267        },
268        ProviderKind::Qianfan => CredentialHelp {
269            acquisition: ApiKey,
270            credential_url: Some("https://console.bce.baidu.com/iam/#/iam/accesslist"),
271            docs_url: Some("https://cloud.baidu.com/doc/qianfan/index.html"),
272            guidance: "Create Baidu Qianfan credentials in the Baidu Cloud console.",
273        },
274        ProviderKind::OpenaiCodex => CredentialHelp {
275            acquisition: OAuth,
276            credential_url: None,
277            docs_url: Some("https://developers.openai.com/codex/"),
278            guidance: "Run `codex login`, then explicitly grant Codewhale read-only access to that exact Codex credential file; or use a process-scoped token environment variable.",
279        },
280        ProviderKind::Anthropic => CredentialHelp {
281            acquisition: ApiKey,
282            credential_url: Some("https://console.anthropic.com/settings/keys"),
283            docs_url: Some("https://docs.anthropic.com/en/api/overview"),
284            guidance: "Create an Anthropic API key in the Anthropic Console.",
285        },
286        ProviderKind::Openmodel => CredentialHelp {
287            acquisition: ApiKey,
288            credential_url: Some("https://console.openmodel.ai/"),
289            docs_url: Some("https://docs.openmodel.ai/en/docs/getting-started/authentication"),
290            guidance: "Create an API key in the OpenModel console, then follow the authentication guide.",
291        },
292        ProviderKind::Zai => CredentialHelp {
293            acquisition: ApiKey,
294            credential_url: Some("https://z.ai/model-api"),
295            docs_url: Some("https://docs.z.ai/api-reference/introduction"),
296            guidance: "Create or manage a Z.ai API key from the Model API page.",
297        },
298        ProviderKind::Stepfun => CredentialHelp {
299            acquisition: ApiKey,
300            credential_url: Some("https://platform.stepfun.ai/"),
301            docs_url: Some("https://platform.stepfun.ai/docs/en/quickstart/overview"),
302            guidance: "Open Account Management, then Interface Keys, in the StepFun console.",
303        },
304        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => CredentialHelp {
305            acquisition: ApiKey,
306            credential_url: Some(
307                "https://platform.minimax.io/user-center/basic-information/interface-key",
308            ),
309            docs_url: Some("https://platform.minimax.io/docs/api-reference/api-overview"),
310            guidance: "Create a MiniMax API key or subscription-plan key in the user center.",
311        },
312        ProviderKind::Deepinfra => CredentialHelp {
313            acquisition: ApiKey,
314            credential_url: Some("https://deepinfra.com/dash/api_keys"),
315            docs_url: Some("https://docs.deepinfra.com/quickstart"),
316            guidance: "Create a DeepInfra API key from the dashboard.",
317        },
318        ProviderKind::Sakana => CredentialHelp {
319            acquisition: ApiKey,
320            credential_url: Some("https://console.sakana.ai/api-keys"),
321            docs_url: Some("https://console.sakana.ai/get-started"),
322            guidance: "Create a Sakana AI key in the console and copy it when shown.",
323        },
324        ProviderKind::LongCat => CredentialHelp {
325            acquisition: ApiKey,
326            credential_url: Some("https://longcat.chat/platform"),
327            docs_url: Some("https://longcat.chat/platform"),
328            guidance: "Sign up on the LongCat platform and create an API key.",
329        },
330        ProviderKind::OpencodeGo => CredentialHelp {
331            acquisition: ApiKey,
332            credential_url: Some("https://opencode.ai/zen/"),
333            docs_url: Some("https://opencode.ai/docs/go/"),
334            guidance: "Create or copy an OpenCode Go subscription key from OpenCode Zen.",
335        },
336        ProviderKind::Meta => CredentialHelp {
337            acquisition: ApiKey,
338            credential_url: Some("https://developer.meta.com/ai/"),
339            docs_url: Some("https://developer.meta.com/ai/resources/blog/build-with-muse-spark/"),
340            guidance: "Use the Meta developer portal to obtain Model API access and a key.",
341        },
342        ProviderKind::Xai => CredentialHelp {
343            acquisition: ApiKeyOrOAuth,
344            credential_url: Some("https://console.x.ai/"),
345            docs_url: None,
346            guidance: "Use an xAI Console API key or Codewhale's native device login. Reading an existing Grok CLI file requires explicit provider-scoped read-only consent.",
347        },
348        ProviderKind::Telecomjs => CredentialHelp {
349            acquisition: ApiKey,
350            credential_url: Some("https://aigw.telecomjs.com/"),
351            docs_url: None,
352            guidance: "Create a TelecomJS TokenHub API key, then use the provider's live model catalog to discover the models available to that key.",
353        },
354        ProviderKind::Custom => CredentialHelp {
355            acquisition: Configuration,
356            credential_url: None,
357            docs_url: None,
358            guidance: "Set this custom provider's base_url and api_key_env or api_key in configuration; no canonical vendor credential page exists.",
359        },
360    }
361}
362
363fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: &str) -> bool {
364    // URL schemes and host names are ASCII case-insensitive; paths are not.
365    // Do not lowercase the whole URL here: a differently-cased path is a
366    // neighboring route, not the official endpoint. Keep this intentionally
367    // dependency-free because provider metadata is used by low-level config
368    // callers that should not need URL parsing machinery just for this guard.
369    let trimmed = base_url.trim();
370    let normalized = trimmed.strip_suffix('/').unwrap_or(trimmed);
371    let Some((scheme, authority_and_path)) = normalized.split_once("://") else {
372        return false;
373    };
374    let Some((authority, path)) = authority_and_path.split_once('/') else {
375        return false;
376    };
377
378    scheme.eq_ignore_ascii_case("https")
379        && authority.eq_ignore_ascii_case(expected_authority)
380        && path == expected_path
381}
382
383/// Whether a configured route is exactly the official Kimi Code endpoint.
384///
385/// A trailing slash is insignificant, but neighboring Kimi-hosted paths must
386/// not inherit membership-plan credentials merely because they share a host.
387#[must_use]
388pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool {
389    if kind != ProviderKind::Moonshot {
390        return false;
391    }
392
393    is_exact_https_route(base_url, "api.kimi.com", "coding/v1")
394}
395
396/// Whether a configured route is exactly Moonshot's direct API endpoint.
397///
398/// Direct K3 owns a different reasoning-control dialect from the Kimi Code
399/// membership endpoint. Keep this route guard exact so custom gateways and
400/// neighboring Moonshot paths do not inherit direct-K3 wire semantics.
401#[must_use]
402pub fn is_exact_moonshot_platform_route(kind: ProviderKind, base_url: &str) -> bool {
403    kind == ProviderKind::Moonshot && is_exact_https_route(base_url, "api.moonshot.ai", "v1")
404}
405
406/// Return credential help for one concrete provider route.
407///
408/// This protects non-UI callers such as diagnostics and command surfaces from
409/// presenting Moonshot's direct API console for a Kimi Code membership-plan
410/// endpoint. It performs no discovery, credential lookup, or network I/O.
411#[must_use]
412pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> CredentialHelp {
413    if is_exact_kimi_code_route(kind, base_url) {
414        return CredentialHelp {
415            acquisition: CredentialAcquisition::ApiKey,
416            credential_url: Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL),
417            docs_url: None,
418            guidance: "Create a Kimi Code membership-plan API key in the Kimi Code console. This route uses api.kimi.com/coding/v1; Codewhale does not import Kimi CLI credentials.",
419        };
420    }
421
422    credential_help(kind)
423}
424
425macro_rules! provider {
426    (
427        $struct_name:ident,
428        $kind:ident,
429        $id:literal,
430        $display_name:literal,
431        $base_url:ident,
432        $model:ident,
433        [$($env_var:literal),* $(,)?],
434        $config_key:literal,
435        aliases: [$($alias:literal),* $(,)?]
436    ) => {
437        /// Zero-sized metadata entry for this built-in provider.
438        pub struct $struct_name;
439
440        impl Provider for $struct_name {
441            fn id(&self) -> &'static str {
442                $id
443            }
444
445            fn kind(&self) -> ProviderKind {
446                ProviderKind::$kind
447            }
448
449            fn display_name(&self) -> &'static str {
450                $display_name
451            }
452
453            fn default_base_url(&self) -> &'static str {
454                $base_url
455            }
456
457            fn default_model(&self) -> &'static str {
458                $model
459            }
460
461            fn env_vars(&self) -> &'static [&'static str] {
462                &[$($env_var),*]
463            }
464
465            fn provider_config_key(&self) -> &'static str {
466                $config_key
467            }
468
469            fn aliases(&self) -> &'static [&'static str] {
470                &[$($alias),*]
471            }
472        }
473    };
474}
475
476provider!(
477    Deepseek,
478    Deepseek,
479    "deepseek",
480    "DeepSeek",
481    DEFAULT_DEEPSEEK_BASE_URL,
482    DEFAULT_DEEPSEEK_MODEL,
483    ["DEEPSEEK_API_KEY"],
484    "deepseek",
485    aliases: ["deep-seek", "deepseek-cn", "deepseek_china", "deepseekcn", "deepseek-china"]
486);
487
488/// Opt-in DeepSeek route that speaks the Anthropic Messages wire protocol.
489pub struct DeepseekAnthropic;
490
491impl Provider for DeepseekAnthropic {
492    fn id(&self) -> &'static str {
493        "deepseek-anthropic"
494    }
495
496    fn kind(&self) -> ProviderKind {
497        ProviderKind::DeepseekAnthropic
498    }
499
500    fn display_name(&self) -> &'static str {
501        "DeepSeek (Anthropic-compatible)"
502    }
503
504    fn default_base_url(&self) -> &'static str {
505        DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
506    }
507
508    fn default_model(&self) -> &'static str {
509        DEFAULT_DEEPSEEK_ANTHROPIC_MODEL
510    }
511
512    fn env_vars(&self) -> &'static [&'static str] {
513        &["DEEPSEEK_API_KEY"]
514    }
515
516    fn provider_config_key(&self) -> &'static str {
517        "deepseek_anthropic"
518    }
519
520    fn aliases(&self) -> &'static [&'static str] {
521        &["deepseek_anthropic", "deepseek-claude", "deepseek_claude"]
522    }
523
524    fn wire(&self) -> WireFormat {
525        WireFormat::AnthropicMessages
526    }
527}
528provider!(
529    NvidiaNim,
530    NvidiaNim,
531    "nvidia-nim",
532    "NVIDIA NIM",
533    DEFAULT_NVIDIA_NIM_BASE_URL,
534    DEFAULT_NVIDIA_NIM_MODEL,
535    ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
536    "nvidia_nim",
537    aliases: ["nvidia", "nvidia_nim", "nim"]
538);
539provider!(
540    Openai,
541    Openai,
542    "openai",
543    "OpenAI-compatible",
544    DEFAULT_OPENAI_BASE_URL,
545    DEFAULT_OPENAI_MODEL,
546    ["OPENAI_API_KEY"],
547    "openai",
548    aliases: ["open-ai"]
549);
550provider!(
551    Atlascloud,
552    Atlascloud,
553    "atlascloud",
554    "AtlasCloud",
555    DEFAULT_ATLASCLOUD_BASE_URL,
556    DEFAULT_ATLASCLOUD_MODEL,
557    ["ATLASCLOUD_API_KEY"],
558    "atlascloud",
559    aliases: ["atlas-cloud", "atlas_cloud", "atlas"]
560);
561provider!(
562    WanjieArk,
563    WanjieArk,
564    "wanjie-ark",
565    "Wanjie Ark",
566    DEFAULT_WANJIE_ARK_BASE_URL,
567    DEFAULT_WANJIE_ARK_MODEL,
568    [
569        "WANJIE_ARK_API_KEY",
570        "WANJIE_API_KEY",
571        "WANJIE_MAAS_API_KEY"
572    ],
573    "wanjie_ark",
574    aliases: ["wanjie", "wanjie_ark", "ark-wanjie", "ark_wanjie", "wanjieark", "wanjie-maas", "wanjie_maas", "wanjiemaas"]
575);
576provider!(
577    Volcengine,
578    Volcengine,
579    "volcengine",
580    "Volcengine Ark",
581    DEFAULT_VOLCENGINE_BASE_URL,
582    DEFAULT_VOLCENGINE_MODEL,
583    [
584        "VOLCENGINE_API_KEY",
585        "VOLCENGINE_ARK_API_KEY",
586        "ARK_API_KEY"
587    ],
588    "volcengine",
589    aliases: ["volcengine-ark", "volcengine_ark", "ark", "volc-ark", "volcengineark"]
590);
591provider!(
592    Openrouter,
593    Openrouter,
594    "openrouter",
595    "OpenRouter",
596    DEFAULT_OPENROUTER_BASE_URL,
597    DEFAULT_OPENROUTER_MODEL,
598    ["OPENROUTER_API_KEY"],
599    "openrouter",
600    aliases: ["open_router"]
601);
602provider!(
603    XiaomiMimo,
604    XiaomiMimo,
605    "xiaomi-mimo",
606    "Xiaomi MiMo",
607    DEFAULT_XIAOMI_MIMO_BASE_URL,
608    DEFAULT_XIAOMI_MIMO_MODEL,
609    [
610        "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
611        "MIMO_TOKEN_PLAN_API_KEY",
612        "XIAOMI_MIMO_API_KEY",
613        "XIAOMI_API_KEY",
614        "MIMO_API_KEY",
615    ],
616    "xiaomi_mimo",
617    aliases: ["xiaomi_mimo", "xiaomimimo", "mimo", "xiaomi"]
618);
619provider!(
620    Novita,
621    Novita,
622    "novita",
623    "Novita AI",
624    DEFAULT_NOVITA_BASE_URL,
625    DEFAULT_NOVITA_MODEL,
626    ["NOVITA_API_KEY"],
627    "novita",
628    // `novita-ai` is the id Models.dev publishes for this provider; without it a
629    // live/full Models.dev catalog row keyed `novita-ai` would fail to normalize
630    // onto ProviderKind::Novita (Refs #4186).
631    aliases: ["novita-ai", "novita_ai"]
632);
633provider!(
634    Fireworks,
635    Fireworks,
636    "fireworks",
637    "Fireworks AI",
638    DEFAULT_FIREWORKS_BASE_URL,
639    DEFAULT_FIREWORKS_MODEL,
640    ["FIREWORKS_API_KEY"],
641    "fireworks",
642    aliases: ["fireworks-ai"]
643);
644provider!(
645    Siliconflow,
646    Siliconflow,
647    "siliconflow",
648    "SiliconFlow",
649    DEFAULT_SILICONFLOW_BASE_URL,
650    DEFAULT_SILICONFLOW_MODEL,
651    ["SILICONFLOW_API_KEY"],
652    "siliconflow",
653    aliases: ["silicon-flow", "silicon_flow"]
654);
655provider!(
656    SiliconflowCN,
657    SiliconflowCN,
658    "siliconflow-CN",
659    "SiliconFlow (China)",
660    DEFAULT_SILICONFLOW_CN_BASE_URL,
661    DEFAULT_SILICONFLOW_MODEL,
662    ["SILICONFLOW_API_KEY"],
663    "siliconflow_cn",
664    aliases: [
665        "silicon-flow-cn",
666        "silicon-flow-CN",
667        "silicon_flow_cn",
668        "silicon_flow_CN",
669        "siliconflow-china",
670    ]
671);
672provider!(
673    Arcee,
674    Arcee,
675    "arcee",
676    "Arcee AI",
677    DEFAULT_ARCEE_BASE_URL,
678    DEFAULT_ARCEE_MODEL,
679    ["ARCEE_API_KEY"],
680    "arcee",
681    aliases: ["arcee-ai", "arcee_ai"]
682);
683provider!(
684    Moonshot,
685    Moonshot,
686    "moonshot",
687    "Moonshot/Kimi",
688    DEFAULT_MOONSHOT_BASE_URL,
689    DEFAULT_MOONSHOT_MODEL,
690    ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
691    "moonshot",
692    // `moonshotai` is the id Models.dev publishes for Moonshot/Kimi; without
693    // it a live/full Models.dev catalog row keyed `moonshotai` would fail to
694    // normalize onto ProviderKind::Moonshot (Refs #4186).
695    aliases: ["moonshot-ai", "moonshotai", "moonshot_ai", "kimi", "kimi-k2"]
696);
697provider!(
698    Sglang,
699    Sglang,
700    "sglang",
701    "SGLang",
702    DEFAULT_SGLANG_BASE_URL,
703    DEFAULT_SGLANG_MODEL,
704    ["SGLANG_API_KEY"],
705    "sglang",
706    aliases: ["sg-lang"]
707);
708provider!(
709    Vllm,
710    Vllm,
711    "vllm",
712    "vLLM",
713    DEFAULT_VLLM_BASE_URL,
714    DEFAULT_VLLM_MODEL,
715    ["VLLM_API_KEY"],
716    "vllm",
717    aliases: ["v-llm"]
718);
719provider!(
720    Ollama,
721    Ollama,
722    "ollama",
723    "Ollama",
724    DEFAULT_OLLAMA_BASE_URL,
725    DEFAULT_OLLAMA_MODEL,
726    ["OLLAMA_API_KEY"],
727    "ollama",
728    aliases: ["ollama-local"]
729);
730provider!(
731    Huggingface,
732    Huggingface,
733    "huggingface",
734    "Hugging Face",
735    DEFAULT_HUGGINGFACE_BASE_URL,
736    DEFAULT_HUGGINGFACE_MODEL,
737    ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
738    "huggingface",
739    aliases: ["hugging-face", "hugging_face", "hf"]
740);
741provider!(
742    Together,
743    Together,
744    "together",
745    "Together AI",
746    DEFAULT_TOGETHER_BASE_URL,
747    DEFAULT_TOGETHER_MODEL,
748    ["TOGETHER_API_KEY"],
749    "together",
750    // `togetherai` (no separator) is the id Models.dev publishes for Together;
751    // the hyphen/underscore spellings are legacy config aliases. All three must
752    // normalize onto ProviderKind::Together so live-catalog rows keyed
753    // `togetherai` resolve to the right kind (Refs #4186).
754    aliases: ["together-ai", "together_ai", "togetherai"]
755);
756provider!(
757    Qianfan,
758    Qianfan,
759    "qianfan",
760    "Baidu Qianfan",
761    DEFAULT_QIANFAN_BASE_URL,
762    DEFAULT_QIANFAN_MODEL,
763    ["QIANFAN_API_KEY", "BAIDU_QIANFAN_API_KEY"],
764    "qianfan",
765    aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
766);
767
768/// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
769pub struct OpenaiCodex;
770
771impl Provider for OpenaiCodex {
772    fn id(&self) -> &'static str {
773        "openai-codex"
774    }
775
776    fn kind(&self) -> ProviderKind {
777        ProviderKind::OpenaiCodex
778    }
779
780    fn display_name(&self) -> &'static str {
781        "OpenAI Codex (ChatGPT)"
782    }
783
784    fn default_base_url(&self) -> &'static str {
785        DEFAULT_OPENAI_CODEX_BASE_URL
786    }
787
788    fn default_model(&self) -> &'static str {
789        DEFAULT_OPENAI_CODEX_MODEL
790    }
791
792    fn env_vars(&self) -> &'static [&'static str] {
793        &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
794    }
795
796    fn provider_config_key(&self) -> &'static str {
797        "openai_codex"
798    }
799
800    fn aliases(&self) -> &'static [&'static str] {
801        &[
802            "openai_codex",
803            "openaicodex",
804            "codex",
805            "chatgpt",
806            "chatgpt-codex",
807            "chatgpt_codex",
808            "chatgptcodex",
809        ]
810    }
811
812    fn wire(&self) -> WireFormat {
813        WireFormat::Responses
814    }
815}
816
817/// Native Anthropic Messages API provider (#3014).
818pub struct Anthropic;
819
820impl Provider for Anthropic {
821    fn id(&self) -> &'static str {
822        "anthropic"
823    }
824
825    fn kind(&self) -> ProviderKind {
826        ProviderKind::Anthropic
827    }
828
829    fn display_name(&self) -> &'static str {
830        "Anthropic"
831    }
832
833    fn default_base_url(&self) -> &'static str {
834        crate::DEFAULT_ANTHROPIC_BASE_URL
835    }
836
837    fn default_model(&self) -> &'static str {
838        crate::DEFAULT_ANTHROPIC_MODEL
839    }
840
841    fn env_vars(&self) -> &'static [&'static str] {
842        &["ANTHROPIC_API_KEY"]
843    }
844
845    fn provider_config_key(&self) -> &'static str {
846        "anthropic"
847    }
848
849    fn wire(&self) -> WireFormat {
850        WireFormat::AnthropicMessages
851    }
852}
853
854/// OpenModel Anthropic-compatible Messages API provider.
855pub struct Openmodel;
856
857impl Provider for Openmodel {
858    fn id(&self) -> &'static str {
859        "openmodel"
860    }
861
862    fn kind(&self) -> ProviderKind {
863        ProviderKind::Openmodel
864    }
865
866    fn display_name(&self) -> &'static str {
867        "OpenModel"
868    }
869
870    fn default_base_url(&self) -> &'static str {
871        DEFAULT_OPENMODEL_BASE_URL
872    }
873
874    fn default_model(&self) -> &'static str {
875        DEFAULT_OPENMODEL_MODEL
876    }
877
878    fn env_vars(&self) -> &'static [&'static str] {
879        &["OPENMODEL_API_KEY"]
880    }
881
882    fn provider_config_key(&self) -> &'static str {
883        "openmodel"
884    }
885
886    fn aliases(&self) -> &'static [&'static str] {
887        &["open-model", "open_model"]
888    }
889
890    fn wire(&self) -> WireFormat {
891        WireFormat::AnthropicMessages
892    }
893}
894
895provider!(
896    Zai,
897    Zai,
898    "zai",
899    "Zhipu AI / Z.ai",
900    DEFAULT_ZAI_BASE_URL,
901    DEFAULT_ZAI_MODEL,
902    ["ZAI_API_KEY", "Z_AI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
903    "zai",
904    aliases: ["z-ai", "z_ai", "z.ai", "zhipu", "zhipuai", "bigmodel", "big-model"]
905);
906
907provider!(
908    Stepfun,
909    Stepfun,
910    "stepfun",
911    "StepFun / StepFlash",
912    DEFAULT_STEPFUN_BASE_URL,
913    DEFAULT_STEPFUN_MODEL,
914    ["STEPFUN_API_KEY", "STEP_API_KEY"],
915    "stepfun",
916    aliases: ["step-fun", "step_fun", "stepflash", "step-flash", "step_flash"]
917);
918
919provider!(
920    Minimax,
921    Minimax,
922    "minimax",
923    "MiniMax",
924    DEFAULT_MINIMAX_BASE_URL,
925    DEFAULT_MINIMAX_MODEL,
926    ["MINIMAX_API_KEY"],
927    "minimax",
928    aliases: ["mini-max", "mini_max"]
929);
930
931/// MiniMax route that speaks the Anthropic Messages wire protocol.
932pub struct MinimaxAnthropic;
933
934impl Provider for MinimaxAnthropic {
935    fn id(&self) -> &'static str {
936        "minimax-anthropic"
937    }
938
939    fn kind(&self) -> ProviderKind {
940        ProviderKind::MinimaxAnthropic
941    }
942
943    fn display_name(&self) -> &'static str {
944        "MiniMax (Anthropic-compatible)"
945    }
946
947    fn default_base_url(&self) -> &'static str {
948        DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
949    }
950
951    fn default_model(&self) -> &'static str {
952        DEFAULT_MINIMAX_MODEL
953    }
954
955    fn env_vars(&self) -> &'static [&'static str] {
956        &["MINIMAX_API_KEY"]
957    }
958
959    fn provider_config_key(&self) -> &'static str {
960        "minimax_anthropic"
961    }
962
963    fn aliases(&self) -> &'static [&'static str] {
964        &[
965            "minimax_anthropic",
966            "mini-max-anthropic",
967            "mini_max_anthropic",
968        ]
969    }
970
971    fn wire(&self) -> WireFormat {
972        WireFormat::AnthropicMessages
973    }
974}
975
976provider!(
977    Deepinfra,
978    Deepinfra,
979    "deepinfra",
980    "DeepInfra",
981    DEFAULT_DEEPINFRA_BASE_URL,
982    DEFAULT_DEEPINFRA_MODEL,
983    ["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
984    "deepinfra",
985    aliases: ["deep-infra", "deep_infra"]
986);
987
988provider!(
989    Sakana,
990    Sakana,
991    "sakana",
992    "Sakana AI (Fugu)",
993    DEFAULT_SAKANA_BASE_URL,
994    DEFAULT_SAKANA_MODEL,
995    ["FUGU_API_KEY", "SAKANA_API_KEY"],
996    "sakana",
997    aliases: ["sakana-ai", "sakana_ai", "fugu"]
998);
999
1000provider!(
1001    LongCat,
1002    LongCat,
1003    "longcat",
1004    "Meituan LongCat",
1005    DEFAULT_LONGCAT_BASE_URL,
1006    DEFAULT_LONGCAT_MODEL,
1007    ["LONGCAT_API_KEY"],
1008    "longcat",
1009    aliases: ["long-cat", "meituan-longcat", "meituan"]
1010);
1011
1012provider!(
1013    OpencodeGo,
1014    OpencodeGo,
1015    "opencode-go",
1016    "OpenCode Go",
1017    DEFAULT_OPENCODE_GO_BASE_URL,
1018    DEFAULT_OPENCODE_GO_MODEL,
1019    ["OPENCODE_GO_API_KEY"],
1020    "opencode_go",
1021    aliases: ["opencode_go", "opencodego"]
1022);
1023
1024provider!(
1025    Meta,
1026    Meta,
1027    "meta",
1028    "Meta Model API",
1029    DEFAULT_META_BASE_URL,
1030    DEFAULT_META_MODEL,
1031    ["META_MODEL_API_KEY", "MODEL_API_KEY"],
1032    "meta",
1033    aliases: [
1034        "meta-ai",
1035        "meta_ai",
1036        "meta-model-api",
1037        "meta_model_api",
1038        "muse",
1039        "muse-spark"
1040    ]
1041);
1042
1043provider!(
1044    Xai,
1045    Xai,
1046    "xai",
1047    "xAI",
1048    DEFAULT_XAI_BASE_URL,
1049    DEFAULT_XAI_MODEL,
1050    ["XAI_API_KEY"],
1051    "xai",
1052    aliases: ["x-ai", "x_ai", "grok"]
1053);
1054
1055provider!(
1056    Telecomjs,
1057    Telecomjs,
1058    "telecomjs",
1059    "TelecomJS TokenHub",
1060    DEFAULT_TELECOMJS_BASE_URL,
1061    DEFAULT_TELECOMJS_MODEL,
1062    ["TELECOMJS_API_KEY"],
1063    "telecomjs",
1064    aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"]
1065);
1066
1067/// User-defined OpenAI-compatible endpoint (#1519).
1068///
1069/// A single dynamic provider identity for arbitrary `[providers.<name>]
1070/// kind="openai-compatible"` config entries. Unlike the built-in providers it
1071/// carries no real default base URL/model/env var: the concrete endpoint, model
1072/// id, and auth env var all arrive from the named `[providers.<name>]` config
1073/// table at route time. The placeholder base URL/model here exist only so the
1074/// descriptor stays well-formed (non-empty) for conformance; runtime routing
1075/// always supplies a `base_url_override` and a wire model id, so these
1076/// placeholders are never used to reach the network.
1077pub struct Custom;
1078
1079impl Provider for Custom {
1080    fn id(&self) -> &'static str {
1081        "custom"
1082    }
1083
1084    fn kind(&self) -> ProviderKind {
1085        ProviderKind::Custom
1086    }
1087
1088    fn display_name(&self) -> &'static str {
1089        "Custom (OpenAI-compatible)"
1090    }
1091
1092    fn default_base_url(&self) -> &'static str {
1093        // Placeholder only; the real endpoint comes from the named config table
1094        // via the route's base_url_override. Loopback so a misconfigured custom
1095        // provider fails closed locally rather than reaching a public host.
1096        "http://localhost/v1"
1097    }
1098
1099    fn default_model(&self) -> &'static str {
1100        // Placeholder only; the real model id comes from config and is preserved
1101        // verbatim as the wire model id.
1102        "custom-model"
1103    }
1104
1105    fn env_vars(&self) -> &'static [&'static str] {
1106        // No built-in env var: the auth env var is named per-entry via
1107        // `[providers.<name>] api_key_env = "..."`.
1108        &[]
1109    }
1110
1111    fn provider_config_key(&self) -> &'static str {
1112        "custom"
1113    }
1114
1115    fn wire(&self) -> WireFormat {
1116        WireFormat::ChatCompletions
1117    }
1118}
1119
1120static DEEPSEEK: Deepseek = Deepseek;
1121static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic;
1122static NVIDIA_NIM: NvidiaNim = NvidiaNim;
1123static OPENAI: Openai = Openai;
1124static ATLASCLOUD: Atlascloud = Atlascloud;
1125static WANJIE_ARK: WanjieArk = WanjieArk;
1126static VOLCENGINE: Volcengine = Volcengine;
1127static OPENROUTER: Openrouter = Openrouter;
1128static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
1129static NOVITA: Novita = Novita;
1130static FIREWORKS: Fireworks = Fireworks;
1131static SILICONFLOW: Siliconflow = Siliconflow;
1132static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN;
1133static ARCEE: Arcee = Arcee;
1134static MOONSHOT: Moonshot = Moonshot;
1135static SGLANG: Sglang = Sglang;
1136static VLLM: Vllm = Vllm;
1137static OLLAMA: Ollama = Ollama;
1138static HUGGINGFACE: Huggingface = Huggingface;
1139static TOGETHER: Together = Together;
1140static QIANFAN: Qianfan = Qianfan;
1141static OPENAI_CODEX: OpenaiCodex = OpenaiCodex;
1142static ANTHROPIC: Anthropic = Anthropic;
1143static OPENMODEL: Openmodel = Openmodel;
1144static ZAI: Zai = Zai;
1145static STEPFUN: Stepfun = Stepfun;
1146static MINIMAX: Minimax = Minimax;
1147static MINIMAX_ANTHROPIC: MinimaxAnthropic = MinimaxAnthropic;
1148static DEEPINFRA: Deepinfra = Deepinfra;
1149static SAKANA: Sakana = Sakana;
1150static LONGCAT: LongCat = LongCat;
1151static OPENCODE_GO: OpencodeGo = OpencodeGo;
1152static META: Meta = Meta;
1153static XAI: Xai = Xai;
1154static TELECOMJS: Telecomjs = Telecomjs;
1155static CUSTOM: Custom = Custom;
1156
1157static PROVIDER_REGISTRY: [&dyn Provider; 36] = [
1158    &DEEPSEEK,
1159    &DEEPSEEK_ANTHROPIC,
1160    &NVIDIA_NIM,
1161    &OPENAI,
1162    &ATLASCLOUD,
1163    &WANJIE_ARK,
1164    &VOLCENGINE,
1165    &OPENROUTER,
1166    &XIAOMI_MIMO,
1167    &NOVITA,
1168    &FIREWORKS,
1169    &SILICONFLOW,
1170    &ARCEE,
1171    &SILICONFLOW_CN,
1172    &MOONSHOT,
1173    &SGLANG,
1174    &VLLM,
1175    &OLLAMA,
1176    &HUGGINGFACE,
1177    &TOGETHER,
1178    &QIANFAN,
1179    &OPENAI_CODEX,
1180    &ANTHROPIC,
1181    &OPENMODEL,
1182    &ZAI,
1183    &STEPFUN,
1184    &MINIMAX,
1185    &MINIMAX_ANTHROPIC,
1186    &DEEPINFRA,
1187    &SAKANA,
1188    &LONGCAT,
1189    &OPENCODE_GO,
1190    &META,
1191    &XAI,
1192    &TELECOMJS,
1193    &CUSTOM,
1194];
1195
1196/// Return all built-in provider metadata entries in `ProviderKind::ALL` order.
1197///
1198/// This insertion order is the stable order used for internal parsing and
1199/// default selection. It is intentionally NOT the order user-facing UI should
1200/// render; for browsing/picker surfaces use [`providers_sorted_for_display`].
1201#[must_use]
1202pub fn all_providers() -> &'static [&'static dyn Provider] {
1203    &PROVIDER_REGISTRY
1204}
1205
1206/// Return all built-in providers ordered for user-facing display.
1207///
1208/// Providers are sorted alphabetically (case-insensitively) by
1209/// [`Provider::display_name`] so model/provider browsing surfaces present a
1210/// neutral, predictable list rather than leading with whichever provider
1211/// happens to sit first in [`ProviderKind::ALL`] (historically DeepSeek). The
1212/// ordering policy intentionally differs from internal parsing/default order:
1213///
1214/// - [`all_providers`] / [`ProviderKind::ALL`] — stable order for internal
1215///   matching, parsing, and default selection. Do not reorder.
1216/// - [`providers_sorted_for_display`] — neutral alphabetical order for UI
1217///   browsing. DeepSeek stays present and searchable but is not hard-coded
1218///   first; a caller may still highlight/pin the active provider separately.
1219///
1220/// Returns an owned `Vec` because the sorted order is computed, not static.
1221#[must_use]
1222pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
1223    let mut providers = all_providers().to_vec();
1224    providers.sort_by(|a, b| {
1225        a.display_name()
1226            .to_ascii_lowercase()
1227            .cmp(&b.display_name().to_ascii_lowercase())
1228    });
1229    providers
1230}
1231
1232/// Find a provider by canonical id only.
1233#[must_use]
1234pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
1235    let id = id.trim();
1236    all_providers()
1237        .iter()
1238        .copied()
1239        .find(|provider| provider.id() == id)
1240}
1241
1242/// Resolve a provider by canonical id or supported legacy alias.
1243#[must_use]
1244pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
1245    ProviderKind::parse(id_or_alias).map(provider_for_kind)
1246}
1247
1248/// Return metadata for a known provider kind.
1249#[must_use]
1250pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
1251    PROVIDER_REGISTRY
1252        .iter()
1253        .find(|p| p.kind() == kind)
1254        .copied()
1255        .expect("ProviderKind variant missing from PROVIDER_REGISTRY")
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260    use super::*;
1261
1262    #[test]
1263    fn credential_help_covers_every_provider_without_guessing_non_key_urls() {
1264        for provider in all_providers() {
1265            let help = provider.credential_help();
1266            assert!(
1267                !help.guidance.trim().is_empty(),
1268                "{} credential guidance must not be empty",
1269                provider.id()
1270            );
1271
1272            match help.acquisition {
1273                CredentialAcquisition::ApiKey | CredentialAcquisition::ApiKeyOrOAuth => {
1274                    assert!(
1275                        help.credential_url.is_some(),
1276                        "{} needs a stable provider-owned credential link",
1277                        provider.id()
1278                    );
1279                }
1280                CredentialAcquisition::LocalOptional
1281                | CredentialAcquisition::OAuth
1282                | CredentialAcquisition::Configuration => assert!(
1283                    help.credential_url.is_none(),
1284                    "{} must explain its non-key route instead of inventing a credential link",
1285                    provider.id()
1286                ),
1287            }
1288        }
1289    }
1290
1291    #[test]
1292    fn kimi_credential_help_uses_the_durable_api_key_console_only() {
1293        let help = provider_for_kind(ProviderKind::Moonshot).credential_help();
1294
1295        assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
1296        assert_eq!(
1297            help.credential_url,
1298            Some("https://platform.kimi.ai/console/api-keys")
1299        );
1300        assert_eq!(
1301            help.docs_url,
1302            Some("https://platform.kimi.ai/docs/overview")
1303        );
1304        assert!(help.guidance.contains("create and copy an API key"));
1305        assert!(help.guidance.contains("OAuth is not available"));
1306    }
1307
1308    #[test]
1309    fn kimi_code_route_credential_help_is_distinct_from_direct_moonshot() {
1310        let direct = credential_help_for_route(ProviderKind::Moonshot, DEFAULT_MOONSHOT_BASE_URL);
1311        let kimi_code =
1312            credential_help_for_route(ProviderKind::Moonshot, "https://api.kimi.com/coding/v1/");
1313
1314        assert_eq!(
1315            direct.credential_url,
1316            Some("https://platform.kimi.ai/console/api-keys")
1317        );
1318        assert_eq!(
1319            kimi_code.credential_url,
1320            Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
1321        );
1322        assert_eq!(kimi_code.docs_url, None);
1323        assert!(kimi_code.guidance.contains("membership-plan API key"));
1324        assert!(
1325            kimi_code
1326                .guidance
1327                .contains("does not import Kimi CLI credentials")
1328        );
1329        assert!(!is_exact_kimi_code_route(
1330            ProviderKind::Moonshot,
1331            "https://api.kimi.com/coding/v1/preview"
1332        ));
1333
1334        // Scheme and hostname casing are insignificant, but the endpoint
1335        // path is a route identifier and must remain exact.
1336        assert!(is_exact_kimi_code_route(
1337            ProviderKind::Moonshot,
1338            "HTTPS://API.KIMI.COM/coding/v1/"
1339        ));
1340        for neighboring_route in [
1341            "https://api.kimi.com/CODING/v1",
1342            "https://api.kimi.com/coding/V1",
1343            "http://api.kimi.com/coding/v1",
1344            "https://api.kimi.com:443/coding/v1",
1345            "https://api.kimi.com/coding/v1?preview=1",
1346            "https://api.kimi.com/coding/v1#fragment",
1347            "https://api.kimi.com/coding/v1//",
1348        ] {
1349            assert!(
1350                !is_exact_kimi_code_route(ProviderKind::Moonshot, neighboring_route),
1351                "{neighboring_route} must not inherit Kimi Code membership semantics"
1352            );
1353        }
1354    }
1355
1356    #[test]
1357    fn direct_moonshot_route_matching_is_exact() {
1358        assert!(is_exact_moonshot_platform_route(
1359            ProviderKind::Moonshot,
1360            "HTTPS://API.MOONSHOT.AI/v1/"
1361        ));
1362        for neighboring_route in [
1363            "https://api.moonshot.ai/V1",
1364            "http://api.moonshot.ai/v1",
1365            "https://api.moonshot.ai:443/v1",
1366            "https://api.moonshot.ai/v1?preview=1",
1367            "https://api.moonshot.ai/v1#fragment",
1368            "https://api.moonshot.ai/v1//",
1369            "https://api.moonshot.ai/v1/chat/completions",
1370            "https://api.kimi.com/coding/v1",
1371        ] {
1372            assert!(
1373                !is_exact_moonshot_platform_route(ProviderKind::Moonshot, neighboring_route),
1374                "{neighboring_route} must not inherit direct Moonshot semantics"
1375            );
1376        }
1377        assert!(!is_exact_moonshot_platform_route(
1378            ProviderKind::Openai,
1379            DEFAULT_MOONSHOT_BASE_URL
1380        ));
1381    }
1382
1383    #[test]
1384    fn non_key_and_mixed_routes_are_typed_explicitly() {
1385        for kind in [
1386            ProviderKind::Sglang,
1387            ProviderKind::Vllm,
1388            ProviderKind::Ollama,
1389        ] {
1390            assert_eq!(
1391                provider_for_kind(kind).credential_help().acquisition,
1392                CredentialAcquisition::LocalOptional
1393            );
1394        }
1395        assert_eq!(
1396            provider_for_kind(ProviderKind::OpenaiCodex)
1397                .credential_help()
1398                .acquisition,
1399            CredentialAcquisition::OAuth
1400        );
1401        assert_eq!(
1402            provider_for_kind(ProviderKind::Xai)
1403                .credential_help()
1404                .acquisition,
1405            CredentialAcquisition::ApiKeyOrOAuth
1406        );
1407        assert_eq!(
1408            provider_for_kind(ProviderKind::Custom)
1409                .credential_help()
1410                .acquisition,
1411            CredentialAcquisition::Configuration
1412        );
1413    }
1414
1415    #[test]
1416    fn live_verified_console_replacements_do_not_regress_to_404_links() {
1417        let openmodel = provider_for_kind(ProviderKind::Openmodel).credential_help();
1418        assert_eq!(
1419            openmodel.credential_url,
1420            Some("https://console.openmodel.ai/")
1421        );
1422        assert_eq!(
1423            openmodel.docs_url,
1424            Some("https://docs.openmodel.ai/en/docs/getting-started/authentication")
1425        );
1426
1427        let sakana = provider_for_kind(ProviderKind::Sakana).credential_help();
1428        assert_eq!(
1429            sakana.credential_url,
1430            Some("https://console.sakana.ai/api-keys")
1431        );
1432        assert_eq!(
1433            sakana.docs_url,
1434            Some("https://console.sakana.ai/get-started")
1435        );
1436    }
1437
1438    #[test]
1439    fn display_order_is_alphabetical_by_display_name() {
1440        let display = providers_sorted_for_display();
1441        let names: Vec<String> = display
1442            .iter()
1443            .map(|p| p.display_name().to_ascii_lowercase())
1444            .collect();
1445        let mut sorted = names.clone();
1446        sorted.sort();
1447        assert_eq!(
1448            names, sorted,
1449            "providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
1450        );
1451    }
1452
1453    #[test]
1454    fn display_order_differs_from_internal_all_order() {
1455        // The whole point of the helper is that UI ordering is NOT the
1456        // internal ProviderKind::ALL / all_providers() insertion order.
1457        let display_ids: Vec<&str> = providers_sorted_for_display()
1458            .iter()
1459            .map(|p| p.id())
1460            .collect();
1461        let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
1462        assert_ne!(
1463            display_ids, internal_ids,
1464            "display order should not match internal ALL order"
1465        );
1466    }
1467
1468    #[test]
1469    fn display_order_is_complete_and_unique() {
1470        // No provider is dropped or duplicated by the sort.
1471        let display = providers_sorted_for_display();
1472        assert_eq!(
1473            display.len(),
1474            all_providers().len(),
1475            "display order must include every built-in provider"
1476        );
1477        let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
1478        ids.sort_unstable();
1479        let before = ids.len();
1480        ids.dedup();
1481        assert_eq!(
1482            before,
1483            ids.len(),
1484            "display order must not contain duplicates"
1485        );
1486    }
1487
1488    #[test]
1489    fn deepseek_is_present_but_not_first_in_display_order() {
1490        // Acceptance: DeepSeek stays searchable but is no longer hard-coded
1491        // first in provider browsing UI. (It is first in internal ALL order.)
1492        let display = providers_sorted_for_display();
1493        assert_eq!(
1494            all_providers()[0].kind(),
1495            ProviderKind::Deepseek,
1496            "DeepSeek is expected to remain first in the stable internal order"
1497        );
1498        assert!(
1499            display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
1500            "DeepSeek must remain present in display order"
1501        );
1502        assert_ne!(
1503            display[0].kind(),
1504            ProviderKind::Deepseek,
1505            "DeepSeek must not be hard-coded first in display order"
1506        );
1507        // Anthropic ('Anthropic') sorts before 'DeepSeek' alphabetically, so it
1508        // is a stable check that the neutral ordering actually took effect.
1509        assert_eq!(
1510            display[0].display_name(),
1511            "Anthropic",
1512            "alphabetical display order should lead with Anthropic"
1513        );
1514    }
1515}