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