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