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