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