Skip to main content

codewhale_config/
provider.rs

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