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