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