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