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