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_OPENMODEL_BASE_URL,
20 DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL,
21 DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL,
22 DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
23 DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
24 DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL,
25 DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL,
26 DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL,
27 DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL,
28 DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL,
29 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 struct CredentialHelp {
80 pub acquisition: CredentialAcquisition,
81 pub credential_url: Option<&'static str>,
86 pub docs_url: Option<&'static str>,
88 pub guidance: &'static str,
90}
91
92pub const KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL: &str = "https://www.kimi.com/code/console";
98
99pub trait Provider: Send + Sync {
101 fn kind(&self) -> ProviderKind;
103
104 fn id(&self) -> &'static str {
106 self.kind().as_str()
107 }
108
109 fn display_name(&self) -> &'static str;
111
112 fn default_base_url(&self) -> &'static str;
114
115 fn default_model(&self) -> &'static str;
117
118 fn env_vars(&self) -> &'static [&'static str];
120
121 fn provider_config_key(&self) -> &'static str;
123
124 fn aliases(&self) -> &'static [&'static str] {
126 &[]
127 }
128
129 fn wire(&self) -> WireFormat {
131 WireFormat::ChatCompletions
132 }
133
134 fn credential_help(&self) -> CredentialHelp {
137 credential_help(self.kind())
138 }
139}
140
141#[must_use]
150pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
151 use CredentialAcquisition::{ApiKey, ApiKeyOrOAuth, Configuration, LocalOptional, OAuth};
152
153 match kind {
154 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => CredentialHelp {
155 acquisition: ApiKey,
156 credential_url: Some("https://platform.deepseek.com/api_keys"),
157 docs_url: Some("https://api-docs.deepseek.com/"),
158 guidance: "Create an API key in the DeepSeek platform console.",
159 },
160 ProviderKind::NvidiaNim => CredentialHelp {
161 acquisition: ApiKey,
162 credential_url: Some("https://build.nvidia.com/settings/api-keys"),
163 docs_url: Some("https://build.nvidia.com/explore/discover"),
164 guidance: "Create an NVIDIA NIM key in the NVIDIA build console.",
165 },
166 ProviderKind::Openai => CredentialHelp {
167 acquisition: ApiKey,
168 credential_url: Some("https://platform.openai.com/api-keys"),
169 docs_url: Some("https://platform.openai.com/docs/api-reference"),
170 guidance: "Create an OpenAI API key, or configure the credential for your compatible endpoint.",
171 },
172 ProviderKind::Atlascloud => CredentialHelp {
173 acquisition: ApiKey,
174 credential_url: Some("https://atlascloud.ai/docs/en/api-keys"),
175 docs_url: Some("https://atlascloud.ai/docs/en/api-keys"),
176 guidance: "Follow Atlas Cloud's API Keys guide to create a credential.",
177 },
178 ProviderKind::WanjieArk => CredentialHelp {
179 acquisition: ApiKey,
180 credential_url: Some("https://docs.wanjiedata.com/maas/maas-openapi-v1.html"),
181 docs_url: Some("https://docs.wanjiedata.com/maas/maas-openapi-v1.html"),
182 guidance: "Follow Wanjie MaaS's APIKEY guide to create a credential.",
183 },
184 ProviderKind::Volcengine => CredentialHelp {
185 acquisition: ApiKey,
186 credential_url: Some("https://console.volcengine.com/ark/apiKey"),
187 docs_url: Some("https://www.volcengine.com/docs/82379/1541594"),
188 guidance: "Create a Volcengine Ark API key in the Ark console.",
189 },
190 ProviderKind::Openrouter => CredentialHelp {
191 acquisition: ApiKey,
192 credential_url: Some("https://openrouter.ai/settings/keys"),
193 docs_url: Some("https://openrouter.ai/docs/api/reference/authentication"),
194 guidance: "Create an OpenRouter key from account settings.",
195 },
196 ProviderKind::XiaomiMimo => CredentialHelp {
197 acquisition: ApiKey,
198 credential_url: Some("https://platform.xiaomimimo.com/token-plan"),
199 docs_url: Some("https://mimo.mi.com/docs/en-US/tokenplan/Token%20Plan/subscription"),
200 guidance: "Create a Xiaomi MiMo Token Plan or pay-as-you-go key and keep its matching base URL.",
201 },
202 ProviderKind::Novita => CredentialHelp {
203 acquisition: ApiKey,
204 credential_url: Some("https://novita.ai/en/settings/key-management"),
205 docs_url: Some("https://novita.ai/docs/guides/quickstart"),
206 guidance: "Create a Novita key in account Key Management.",
207 },
208 ProviderKind::Fireworks => CredentialHelp {
209 acquisition: ApiKey,
210 credential_url: Some("https://fireworks.ai/api-keys"),
211 docs_url: Some("https://docs.fireworks.ai/getting-started/quickstart"),
212 guidance: "Create a Fireworks API key before configuring the provider.",
213 },
214 ProviderKind::Siliconflow => CredentialHelp {
215 acquisition: ApiKey,
216 credential_url: Some("https://cloud.siliconflow.com/account/ak"),
217 docs_url: Some("https://docs.siliconflow.com/en/userguide/quickstart"),
218 guidance: "Use the global SiliconFlow console for the global endpoint.",
219 },
220 ProviderKind::SiliconflowCN => CredentialHelp {
221 acquisition: ApiKey,
222 credential_url: Some("https://cloud.siliconflow.cn/account/ak"),
223 docs_url: Some("https://docs.siliconflow.cn/en/userguide/quickstart"),
224 guidance: "Use the China SiliconFlow console for the China endpoint.",
225 },
226 ProviderKind::Arcee => CredentialHelp {
227 acquisition: ApiKey,
228 credential_url: Some("https://docs.arcee.ai/other/create-your-first-api-key"),
229 docs_url: Some("https://docs.arcee.ai/other/create-your-first-api-key"),
230 guidance: "Follow Arcee's API key guide to create a credential.",
231 },
232 ProviderKind::Moonshot => CredentialHelp {
233 acquisition: ApiKey,
234 credential_url: Some("https://platform.kimi.ai/console/api-keys"),
235 docs_url: Some("https://platform.kimi.ai/docs/overview"),
236 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.",
237 },
238 ProviderKind::Sglang => CredentialHelp {
239 acquisition: LocalOptional,
240 credential_url: None,
241 docs_url: Some("https://docs.sglang.ai/"),
242 guidance: "Self-hosted SGLang is keyless by default; configure a key only if your server requires one.",
243 },
244 ProviderKind::Vllm => CredentialHelp {
245 acquisition: LocalOptional,
246 credential_url: None,
247 docs_url: Some("https://docs.vllm.ai/en/stable/serving/openai_compatible_server/"),
248 guidance: "Self-hosted vLLM is keyless by default; configure a key only if your server requires one.",
249 },
250 ProviderKind::Ollama => CredentialHelp {
251 acquisition: LocalOptional,
252 credential_url: None,
253 docs_url: Some("https://docs.ollama.com/api"),
254 guidance: "Local Ollama is keyless by default; configure a key only if your server requires one.",
255 },
256 ProviderKind::Huggingface => CredentialHelp {
257 acquisition: ApiKey,
258 credential_url: Some("https://huggingface.co/settings/tokens"),
259 docs_url: Some("https://huggingface.co/docs/hub/en/security-tokens"),
260 guidance: "Create a scoped Hugging Face access token.",
261 },
262 ProviderKind::Together => CredentialHelp {
263 acquisition: ApiKey,
264 credential_url: Some("https://api.together.ai/settings/api-keys"),
265 docs_url: Some("https://docs.together.ai/docs/api-keys-authentication"),
266 guidance: "Create a Together API key from account settings.",
267 },
268 ProviderKind::Qianfan => CredentialHelp {
269 acquisition: ApiKey,
270 credential_url: Some("https://console.bce.baidu.com/iam/#/iam/accesslist"),
271 docs_url: Some("https://cloud.baidu.com/doc/qianfan/index.html"),
272 guidance: "Create Baidu Qianfan credentials in the Baidu Cloud console.",
273 },
274 ProviderKind::OpenaiCodex => CredentialHelp {
275 acquisition: OAuth,
276 credential_url: None,
277 docs_url: Some("https://developers.openai.com/codex/"),
278 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.",
279 },
280 ProviderKind::Anthropic => CredentialHelp {
281 acquisition: ApiKey,
282 credential_url: Some("https://console.anthropic.com/settings/keys"),
283 docs_url: Some("https://docs.anthropic.com/en/api/overview"),
284 guidance: "Create an Anthropic API key in the Anthropic Console.",
285 },
286 ProviderKind::Openmodel => CredentialHelp {
287 acquisition: ApiKey,
288 credential_url: Some("https://console.openmodel.ai/"),
289 docs_url: Some("https://docs.openmodel.ai/en/docs/getting-started/authentication"),
290 guidance: "Create an API key in the OpenModel console, then follow the authentication guide.",
291 },
292 ProviderKind::Zai => CredentialHelp {
293 acquisition: ApiKey,
294 credential_url: Some("https://z.ai/model-api"),
295 docs_url: Some("https://docs.z.ai/api-reference/introduction"),
296 guidance: "Create or manage a Z.ai API key from the Model API page.",
297 },
298 ProviderKind::Stepfun => CredentialHelp {
299 acquisition: ApiKey,
300 credential_url: Some("https://platform.stepfun.ai/"),
301 docs_url: Some("https://platform.stepfun.ai/docs/en/quickstart/overview"),
302 guidance: "Open Account Management, then Interface Keys, in the StepFun console.",
303 },
304 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => CredentialHelp {
305 acquisition: ApiKey,
306 credential_url: Some(
307 "https://platform.minimax.io/user-center/basic-information/interface-key",
308 ),
309 docs_url: Some("https://platform.minimax.io/docs/api-reference/api-overview"),
310 guidance: "Create a MiniMax API key or subscription-plan key in the user center.",
311 },
312 ProviderKind::Deepinfra => CredentialHelp {
313 acquisition: ApiKey,
314 credential_url: Some("https://deepinfra.com/dash/api_keys"),
315 docs_url: Some("https://docs.deepinfra.com/quickstart"),
316 guidance: "Create a DeepInfra API key from the dashboard.",
317 },
318 ProviderKind::Sakana => CredentialHelp {
319 acquisition: ApiKey,
320 credential_url: Some("https://console.sakana.ai/api-keys"),
321 docs_url: Some("https://console.sakana.ai/get-started"),
322 guidance: "Create a Sakana AI key in the console and copy it when shown.",
323 },
324 ProviderKind::LongCat => CredentialHelp {
325 acquisition: ApiKey,
326 credential_url: Some("https://longcat.chat/platform"),
327 docs_url: Some("https://longcat.chat/platform"),
328 guidance: "Sign up on the LongCat platform and create an API key.",
329 },
330 ProviderKind::OpencodeGo => CredentialHelp {
331 acquisition: ApiKey,
332 credential_url: Some("https://opencode.ai/zen/"),
333 docs_url: Some("https://opencode.ai/docs/go/"),
334 guidance: "Create or copy an OpenCode Go subscription key from OpenCode Zen.",
335 },
336 ProviderKind::Meta => CredentialHelp {
337 acquisition: ApiKey,
338 credential_url: Some("https://developer.meta.com/ai/"),
339 docs_url: Some("https://developer.meta.com/ai/resources/blog/build-with-muse-spark/"),
340 guidance: "Use the Meta developer portal to obtain Model API access and a key.",
341 },
342 ProviderKind::Xai => CredentialHelp {
343 acquisition: ApiKeyOrOAuth,
344 credential_url: Some("https://console.x.ai/"),
345 docs_url: None,
346 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.",
347 },
348 ProviderKind::Telecomjs => CredentialHelp {
349 acquisition: ApiKey,
350 credential_url: Some("https://aigw.telecomjs.com/"),
351 docs_url: None,
352 guidance: "Create a TelecomJS TokenHub API key, then use the provider's live model catalog to discover the models available to that key.",
353 },
354 ProviderKind::Custom => CredentialHelp {
355 acquisition: Configuration,
356 credential_url: None,
357 docs_url: None,
358 guidance: "Set this custom provider's base_url and api_key_env or api_key in configuration; no canonical vendor credential page exists.",
359 },
360 }
361}
362
363fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: &str) -> bool {
364 let trimmed = base_url.trim();
370 let normalized = trimmed.strip_suffix('/').unwrap_or(trimmed);
371 let Some((scheme, authority_and_path)) = normalized.split_once("://") else {
372 return false;
373 };
374 let Some((authority, path)) = authority_and_path.split_once('/') else {
375 return false;
376 };
377
378 scheme.eq_ignore_ascii_case("https")
379 && authority.eq_ignore_ascii_case(expected_authority)
380 && path == expected_path
381}
382
383#[must_use]
388pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool {
389 if kind != ProviderKind::Moonshot {
390 return false;
391 }
392
393 is_exact_https_route(base_url, "api.kimi.com", "coding/v1")
394}
395
396#[must_use]
402pub fn is_exact_moonshot_platform_route(kind: ProviderKind, base_url: &str) -> bool {
403 kind == ProviderKind::Moonshot && is_exact_https_route(base_url, "api.moonshot.ai", "v1")
404}
405
406#[must_use]
412pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> CredentialHelp {
413 if is_exact_kimi_code_route(kind, base_url) {
414 return CredentialHelp {
415 acquisition: CredentialAcquisition::ApiKey,
416 credential_url: Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL),
417 docs_url: None,
418 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.",
419 };
420 }
421
422 credential_help(kind)
423}
424
425macro_rules! provider {
426 (
427 $struct_name:ident,
428 $kind:ident,
429 $id:literal,
430 $display_name:literal,
431 $base_url:ident,
432 $model:ident,
433 [$($env_var:literal),* $(,)?],
434 $config_key:literal,
435 aliases: [$($alias:literal),* $(,)?]
436 ) => {
437 pub struct $struct_name;
439
440 impl Provider for $struct_name {
441 fn id(&self) -> &'static str {
442 $id
443 }
444
445 fn kind(&self) -> ProviderKind {
446 ProviderKind::$kind
447 }
448
449 fn display_name(&self) -> &'static str {
450 $display_name
451 }
452
453 fn default_base_url(&self) -> &'static str {
454 $base_url
455 }
456
457 fn default_model(&self) -> &'static str {
458 $model
459 }
460
461 fn env_vars(&self) -> &'static [&'static str] {
462 &[$($env_var),*]
463 }
464
465 fn provider_config_key(&self) -> &'static str {
466 $config_key
467 }
468
469 fn aliases(&self) -> &'static [&'static str] {
470 &[$($alias),*]
471 }
472 }
473 };
474}
475
476provider!(
477 Deepseek,
478 Deepseek,
479 "deepseek",
480 "DeepSeek",
481 DEFAULT_DEEPSEEK_BASE_URL,
482 DEFAULT_DEEPSEEK_MODEL,
483 ["DEEPSEEK_API_KEY"],
484 "deepseek",
485 aliases: ["deep-seek", "deepseek-cn", "deepseek_china", "deepseekcn", "deepseek-china"]
486);
487
488pub struct DeepseekAnthropic;
490
491impl Provider for DeepseekAnthropic {
492 fn id(&self) -> &'static str {
493 "deepseek-anthropic"
494 }
495
496 fn kind(&self) -> ProviderKind {
497 ProviderKind::DeepseekAnthropic
498 }
499
500 fn display_name(&self) -> &'static str {
501 "DeepSeek (Anthropic-compatible)"
502 }
503
504 fn default_base_url(&self) -> &'static str {
505 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
506 }
507
508 fn default_model(&self) -> &'static str {
509 DEFAULT_DEEPSEEK_ANTHROPIC_MODEL
510 }
511
512 fn env_vars(&self) -> &'static [&'static str] {
513 &["DEEPSEEK_API_KEY"]
514 }
515
516 fn provider_config_key(&self) -> &'static str {
517 "deepseek_anthropic"
518 }
519
520 fn aliases(&self) -> &'static [&'static str] {
521 &["deepseek_anthropic", "deepseek-claude", "deepseek_claude"]
522 }
523
524 fn wire(&self) -> WireFormat {
525 WireFormat::AnthropicMessages
526 }
527}
528provider!(
529 NvidiaNim,
530 NvidiaNim,
531 "nvidia-nim",
532 "NVIDIA NIM",
533 DEFAULT_NVIDIA_NIM_BASE_URL,
534 DEFAULT_NVIDIA_NIM_MODEL,
535 ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
536 "nvidia_nim",
537 aliases: ["nvidia", "nvidia_nim", "nim"]
538);
539provider!(
540 Openai,
541 Openai,
542 "openai",
543 "OpenAI-compatible",
544 DEFAULT_OPENAI_BASE_URL,
545 DEFAULT_OPENAI_MODEL,
546 ["OPENAI_API_KEY"],
547 "openai",
548 aliases: ["open-ai"]
549);
550provider!(
551 Atlascloud,
552 Atlascloud,
553 "atlascloud",
554 "AtlasCloud",
555 DEFAULT_ATLASCLOUD_BASE_URL,
556 DEFAULT_ATLASCLOUD_MODEL,
557 ["ATLASCLOUD_API_KEY"],
558 "atlascloud",
559 aliases: ["atlas-cloud", "atlas_cloud", "atlas"]
560);
561provider!(
562 WanjieArk,
563 WanjieArk,
564 "wanjie-ark",
565 "Wanjie Ark",
566 DEFAULT_WANJIE_ARK_BASE_URL,
567 DEFAULT_WANJIE_ARK_MODEL,
568 [
569 "WANJIE_ARK_API_KEY",
570 "WANJIE_API_KEY",
571 "WANJIE_MAAS_API_KEY"
572 ],
573 "wanjie_ark",
574 aliases: ["wanjie", "wanjie_ark", "ark-wanjie", "ark_wanjie", "wanjieark", "wanjie-maas", "wanjie_maas", "wanjiemaas"]
575);
576provider!(
577 Volcengine,
578 Volcengine,
579 "volcengine",
580 "Volcengine Ark",
581 DEFAULT_VOLCENGINE_BASE_URL,
582 DEFAULT_VOLCENGINE_MODEL,
583 [
584 "VOLCENGINE_API_KEY",
585 "VOLCENGINE_ARK_API_KEY",
586 "ARK_API_KEY"
587 ],
588 "volcengine",
589 aliases: ["volcengine-ark", "volcengine_ark", "ark", "volc-ark", "volcengineark"]
590);
591provider!(
592 Openrouter,
593 Openrouter,
594 "openrouter",
595 "OpenRouter",
596 DEFAULT_OPENROUTER_BASE_URL,
597 DEFAULT_OPENROUTER_MODEL,
598 ["OPENROUTER_API_KEY"],
599 "openrouter",
600 aliases: ["open_router"]
601);
602provider!(
603 XiaomiMimo,
604 XiaomiMimo,
605 "xiaomi-mimo",
606 "Xiaomi MiMo",
607 DEFAULT_XIAOMI_MIMO_BASE_URL,
608 DEFAULT_XIAOMI_MIMO_MODEL,
609 [
610 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
611 "MIMO_TOKEN_PLAN_API_KEY",
612 "XIAOMI_MIMO_API_KEY",
613 "XIAOMI_API_KEY",
614 "MIMO_API_KEY",
615 ],
616 "xiaomi_mimo",
617 aliases: ["xiaomi_mimo", "xiaomimimo", "mimo", "xiaomi"]
618);
619provider!(
620 Novita,
621 Novita,
622 "novita",
623 "Novita AI",
624 DEFAULT_NOVITA_BASE_URL,
625 DEFAULT_NOVITA_MODEL,
626 ["NOVITA_API_KEY"],
627 "novita",
628 aliases: ["novita-ai", "novita_ai"]
632);
633provider!(
634 Fireworks,
635 Fireworks,
636 "fireworks",
637 "Fireworks AI",
638 DEFAULT_FIREWORKS_BASE_URL,
639 DEFAULT_FIREWORKS_MODEL,
640 ["FIREWORKS_API_KEY"],
641 "fireworks",
642 aliases: ["fireworks-ai"]
643);
644provider!(
645 Siliconflow,
646 Siliconflow,
647 "siliconflow",
648 "SiliconFlow",
649 DEFAULT_SILICONFLOW_BASE_URL,
650 DEFAULT_SILICONFLOW_MODEL,
651 ["SILICONFLOW_API_KEY"],
652 "siliconflow",
653 aliases: ["silicon-flow", "silicon_flow"]
654);
655provider!(
656 SiliconflowCN,
657 SiliconflowCN,
658 "siliconflow-CN",
659 "SiliconFlow (China)",
660 DEFAULT_SILICONFLOW_CN_BASE_URL,
661 DEFAULT_SILICONFLOW_MODEL,
662 ["SILICONFLOW_API_KEY"],
663 "siliconflow_cn",
664 aliases: [
665 "silicon-flow-cn",
666 "silicon-flow-CN",
667 "silicon_flow_cn",
668 "silicon_flow_CN",
669 "siliconflow-china",
670 ]
671);
672provider!(
673 Arcee,
674 Arcee,
675 "arcee",
676 "Arcee AI",
677 DEFAULT_ARCEE_BASE_URL,
678 DEFAULT_ARCEE_MODEL,
679 ["ARCEE_API_KEY"],
680 "arcee",
681 aliases: ["arcee-ai", "arcee_ai"]
682);
683provider!(
684 Moonshot,
685 Moonshot,
686 "moonshot",
687 "Moonshot/Kimi",
688 DEFAULT_MOONSHOT_BASE_URL,
689 DEFAULT_MOONSHOT_MODEL,
690 ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
691 "moonshot",
692 aliases: ["moonshot-ai", "moonshotai", "moonshot_ai", "kimi", "kimi-k2"]
696);
697provider!(
698 Sglang,
699 Sglang,
700 "sglang",
701 "SGLang",
702 DEFAULT_SGLANG_BASE_URL,
703 DEFAULT_SGLANG_MODEL,
704 ["SGLANG_API_KEY"],
705 "sglang",
706 aliases: ["sg-lang"]
707);
708provider!(
709 Vllm,
710 Vllm,
711 "vllm",
712 "vLLM",
713 DEFAULT_VLLM_BASE_URL,
714 DEFAULT_VLLM_MODEL,
715 ["VLLM_API_KEY"],
716 "vllm",
717 aliases: ["v-llm"]
718);
719provider!(
720 Ollama,
721 Ollama,
722 "ollama",
723 "Ollama",
724 DEFAULT_OLLAMA_BASE_URL,
725 DEFAULT_OLLAMA_MODEL,
726 ["OLLAMA_API_KEY"],
727 "ollama",
728 aliases: ["ollama-local"]
729);
730provider!(
731 Huggingface,
732 Huggingface,
733 "huggingface",
734 "Hugging Face",
735 DEFAULT_HUGGINGFACE_BASE_URL,
736 DEFAULT_HUGGINGFACE_MODEL,
737 ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
738 "huggingface",
739 aliases: ["hugging-face", "hugging_face", "hf"]
740);
741provider!(
742 Together,
743 Together,
744 "together",
745 "Together AI",
746 DEFAULT_TOGETHER_BASE_URL,
747 DEFAULT_TOGETHER_MODEL,
748 ["TOGETHER_API_KEY"],
749 "together",
750 aliases: ["together-ai", "together_ai", "togetherai"]
755);
756provider!(
757 Qianfan,
758 Qianfan,
759 "qianfan",
760 "Baidu Qianfan",
761 DEFAULT_QIANFAN_BASE_URL,
762 DEFAULT_QIANFAN_MODEL,
763 ["QIANFAN_API_KEY", "BAIDU_QIANFAN_API_KEY"],
764 "qianfan",
765 aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
766);
767
768pub struct OpenaiCodex;
770
771impl Provider for OpenaiCodex {
772 fn id(&self) -> &'static str {
773 "openai-codex"
774 }
775
776 fn kind(&self) -> ProviderKind {
777 ProviderKind::OpenaiCodex
778 }
779
780 fn display_name(&self) -> &'static str {
781 "OpenAI Codex (ChatGPT)"
782 }
783
784 fn default_base_url(&self) -> &'static str {
785 DEFAULT_OPENAI_CODEX_BASE_URL
786 }
787
788 fn default_model(&self) -> &'static str {
789 DEFAULT_OPENAI_CODEX_MODEL
790 }
791
792 fn env_vars(&self) -> &'static [&'static str] {
793 &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
794 }
795
796 fn provider_config_key(&self) -> &'static str {
797 "openai_codex"
798 }
799
800 fn aliases(&self) -> &'static [&'static str] {
801 &[
802 "openai_codex",
803 "openaicodex",
804 "codex",
805 "chatgpt",
806 "chatgpt-codex",
807 "chatgpt_codex",
808 "chatgptcodex",
809 ]
810 }
811
812 fn wire(&self) -> WireFormat {
813 WireFormat::Responses
814 }
815}
816
817pub struct Anthropic;
819
820impl Provider for Anthropic {
821 fn id(&self) -> &'static str {
822 "anthropic"
823 }
824
825 fn kind(&self) -> ProviderKind {
826 ProviderKind::Anthropic
827 }
828
829 fn display_name(&self) -> &'static str {
830 "Anthropic"
831 }
832
833 fn default_base_url(&self) -> &'static str {
834 crate::DEFAULT_ANTHROPIC_BASE_URL
835 }
836
837 fn default_model(&self) -> &'static str {
838 crate::DEFAULT_ANTHROPIC_MODEL
839 }
840
841 fn env_vars(&self) -> &'static [&'static str] {
842 &["ANTHROPIC_API_KEY"]
843 }
844
845 fn provider_config_key(&self) -> &'static str {
846 "anthropic"
847 }
848
849 fn wire(&self) -> WireFormat {
850 WireFormat::AnthropicMessages
851 }
852}
853
854pub struct Openmodel;
856
857impl Provider for Openmodel {
858 fn id(&self) -> &'static str {
859 "openmodel"
860 }
861
862 fn kind(&self) -> ProviderKind {
863 ProviderKind::Openmodel
864 }
865
866 fn display_name(&self) -> &'static str {
867 "OpenModel"
868 }
869
870 fn default_base_url(&self) -> &'static str {
871 DEFAULT_OPENMODEL_BASE_URL
872 }
873
874 fn default_model(&self) -> &'static str {
875 DEFAULT_OPENMODEL_MODEL
876 }
877
878 fn env_vars(&self) -> &'static [&'static str] {
879 &["OPENMODEL_API_KEY"]
880 }
881
882 fn provider_config_key(&self) -> &'static str {
883 "openmodel"
884 }
885
886 fn aliases(&self) -> &'static [&'static str] {
887 &["open-model", "open_model"]
888 }
889
890 fn wire(&self) -> WireFormat {
891 WireFormat::AnthropicMessages
892 }
893}
894
895provider!(
896 Zai,
897 Zai,
898 "zai",
899 "Zhipu AI / Z.ai",
900 DEFAULT_ZAI_BASE_URL,
901 DEFAULT_ZAI_MODEL,
902 ["ZAI_API_KEY", "Z_AI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
903 "zai",
904 aliases: ["z-ai", "z_ai", "z.ai", "zhipu", "zhipuai", "bigmodel", "big-model"]
905);
906
907provider!(
908 Stepfun,
909 Stepfun,
910 "stepfun",
911 "StepFun / StepFlash",
912 DEFAULT_STEPFUN_BASE_URL,
913 DEFAULT_STEPFUN_MODEL,
914 ["STEPFUN_API_KEY", "STEP_API_KEY"],
915 "stepfun",
916 aliases: ["step-fun", "step_fun", "stepflash", "step-flash", "step_flash"]
917);
918
919provider!(
920 Minimax,
921 Minimax,
922 "minimax",
923 "MiniMax",
924 DEFAULT_MINIMAX_BASE_URL,
925 DEFAULT_MINIMAX_MODEL,
926 ["MINIMAX_API_KEY"],
927 "minimax",
928 aliases: ["mini-max", "mini_max"]
929);
930
931pub struct MinimaxAnthropic;
933
934impl Provider for MinimaxAnthropic {
935 fn id(&self) -> &'static str {
936 "minimax-anthropic"
937 }
938
939 fn kind(&self) -> ProviderKind {
940 ProviderKind::MinimaxAnthropic
941 }
942
943 fn display_name(&self) -> &'static str {
944 "MiniMax (Anthropic-compatible)"
945 }
946
947 fn default_base_url(&self) -> &'static str {
948 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
949 }
950
951 fn default_model(&self) -> &'static str {
952 DEFAULT_MINIMAX_MODEL
953 }
954
955 fn env_vars(&self) -> &'static [&'static str] {
956 &["MINIMAX_API_KEY"]
957 }
958
959 fn provider_config_key(&self) -> &'static str {
960 "minimax_anthropic"
961 }
962
963 fn aliases(&self) -> &'static [&'static str] {
964 &[
965 "minimax_anthropic",
966 "mini-max-anthropic",
967 "mini_max_anthropic",
968 ]
969 }
970
971 fn wire(&self) -> WireFormat {
972 WireFormat::AnthropicMessages
973 }
974}
975
976provider!(
977 Deepinfra,
978 Deepinfra,
979 "deepinfra",
980 "DeepInfra",
981 DEFAULT_DEEPINFRA_BASE_URL,
982 DEFAULT_DEEPINFRA_MODEL,
983 ["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
984 "deepinfra",
985 aliases: ["deep-infra", "deep_infra"]
986);
987
988provider!(
989 Sakana,
990 Sakana,
991 "sakana",
992 "Sakana AI (Fugu)",
993 DEFAULT_SAKANA_BASE_URL,
994 DEFAULT_SAKANA_MODEL,
995 ["FUGU_API_KEY", "SAKANA_API_KEY"],
996 "sakana",
997 aliases: ["sakana-ai", "sakana_ai", "fugu"]
998);
999
1000provider!(
1001 LongCat,
1002 LongCat,
1003 "longcat",
1004 "Meituan LongCat",
1005 DEFAULT_LONGCAT_BASE_URL,
1006 DEFAULT_LONGCAT_MODEL,
1007 ["LONGCAT_API_KEY"],
1008 "longcat",
1009 aliases: ["long-cat", "meituan-longcat", "meituan"]
1010);
1011
1012provider!(
1013 OpencodeGo,
1014 OpencodeGo,
1015 "opencode-go",
1016 "OpenCode Go",
1017 DEFAULT_OPENCODE_GO_BASE_URL,
1018 DEFAULT_OPENCODE_GO_MODEL,
1019 ["OPENCODE_GO_API_KEY"],
1020 "opencode_go",
1021 aliases: ["opencode_go", "opencodego"]
1022);
1023
1024provider!(
1025 Meta,
1026 Meta,
1027 "meta",
1028 "Meta Model API",
1029 DEFAULT_META_BASE_URL,
1030 DEFAULT_META_MODEL,
1031 ["META_MODEL_API_KEY", "MODEL_API_KEY"],
1032 "meta",
1033 aliases: [
1034 "meta-ai",
1035 "meta_ai",
1036 "meta-model-api",
1037 "meta_model_api",
1038 "muse",
1039 "muse-spark"
1040 ]
1041);
1042
1043provider!(
1044 Xai,
1045 Xai,
1046 "xai",
1047 "xAI",
1048 DEFAULT_XAI_BASE_URL,
1049 DEFAULT_XAI_MODEL,
1050 ["XAI_API_KEY"],
1051 "xai",
1052 aliases: ["x-ai", "x_ai", "grok"]
1053);
1054
1055provider!(
1056 Telecomjs,
1057 Telecomjs,
1058 "telecomjs",
1059 "TelecomJS TokenHub",
1060 DEFAULT_TELECOMJS_BASE_URL,
1061 DEFAULT_TELECOMJS_MODEL,
1062 ["TELECOMJS_API_KEY"],
1063 "telecomjs",
1064 aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"]
1065);
1066
1067pub struct Custom;
1078
1079impl Provider for Custom {
1080 fn id(&self) -> &'static str {
1081 "custom"
1082 }
1083
1084 fn kind(&self) -> ProviderKind {
1085 ProviderKind::Custom
1086 }
1087
1088 fn display_name(&self) -> &'static str {
1089 "Custom (OpenAI-compatible)"
1090 }
1091
1092 fn default_base_url(&self) -> &'static str {
1093 "http://localhost/v1"
1097 }
1098
1099 fn default_model(&self) -> &'static str {
1100 "custom-model"
1103 }
1104
1105 fn env_vars(&self) -> &'static [&'static str] {
1106 &[]
1109 }
1110
1111 fn provider_config_key(&self) -> &'static str {
1112 "custom"
1113 }
1114
1115 fn wire(&self) -> WireFormat {
1116 WireFormat::ChatCompletions
1117 }
1118}
1119
1120static DEEPSEEK: Deepseek = Deepseek;
1121static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic;
1122static NVIDIA_NIM: NvidiaNim = NvidiaNim;
1123static OPENAI: Openai = Openai;
1124static ATLASCLOUD: Atlascloud = Atlascloud;
1125static WANJIE_ARK: WanjieArk = WanjieArk;
1126static VOLCENGINE: Volcengine = Volcengine;
1127static OPENROUTER: Openrouter = Openrouter;
1128static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
1129static NOVITA: Novita = Novita;
1130static FIREWORKS: Fireworks = Fireworks;
1131static SILICONFLOW: Siliconflow = Siliconflow;
1132static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN;
1133static ARCEE: Arcee = Arcee;
1134static MOONSHOT: Moonshot = Moonshot;
1135static SGLANG: Sglang = Sglang;
1136static VLLM: Vllm = Vllm;
1137static OLLAMA: Ollama = Ollama;
1138static HUGGINGFACE: Huggingface = Huggingface;
1139static TOGETHER: Together = Together;
1140static QIANFAN: Qianfan = Qianfan;
1141static OPENAI_CODEX: OpenaiCodex = OpenaiCodex;
1142static ANTHROPIC: Anthropic = Anthropic;
1143static OPENMODEL: Openmodel = Openmodel;
1144static ZAI: Zai = Zai;
1145static STEPFUN: Stepfun = Stepfun;
1146static MINIMAX: Minimax = Minimax;
1147static MINIMAX_ANTHROPIC: MinimaxAnthropic = MinimaxAnthropic;
1148static DEEPINFRA: Deepinfra = Deepinfra;
1149static SAKANA: Sakana = Sakana;
1150static LONGCAT: LongCat = LongCat;
1151static OPENCODE_GO: OpencodeGo = OpencodeGo;
1152static META: Meta = Meta;
1153static XAI: Xai = Xai;
1154static TELECOMJS: Telecomjs = Telecomjs;
1155static CUSTOM: Custom = Custom;
1156
1157static PROVIDER_REGISTRY: [&dyn Provider; 36] = [
1158 &DEEPSEEK,
1159 &DEEPSEEK_ANTHROPIC,
1160 &NVIDIA_NIM,
1161 &OPENAI,
1162 &ATLASCLOUD,
1163 &WANJIE_ARK,
1164 &VOLCENGINE,
1165 &OPENROUTER,
1166 &XIAOMI_MIMO,
1167 &NOVITA,
1168 &FIREWORKS,
1169 &SILICONFLOW,
1170 &ARCEE,
1171 &SILICONFLOW_CN,
1172 &MOONSHOT,
1173 &SGLANG,
1174 &VLLM,
1175 &OLLAMA,
1176 &HUGGINGFACE,
1177 &TOGETHER,
1178 &QIANFAN,
1179 &OPENAI_CODEX,
1180 &ANTHROPIC,
1181 &OPENMODEL,
1182 &ZAI,
1183 &STEPFUN,
1184 &MINIMAX,
1185 &MINIMAX_ANTHROPIC,
1186 &DEEPINFRA,
1187 &SAKANA,
1188 &LONGCAT,
1189 &OPENCODE_GO,
1190 &META,
1191 &XAI,
1192 &TELECOMJS,
1193 &CUSTOM,
1194];
1195
1196#[must_use]
1202pub fn all_providers() -> &'static [&'static dyn Provider] {
1203 &PROVIDER_REGISTRY
1204}
1205
1206#[must_use]
1222pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
1223 let mut providers = all_providers().to_vec();
1224 providers.sort_by(|a, b| {
1225 a.display_name()
1226 .to_ascii_lowercase()
1227 .cmp(&b.display_name().to_ascii_lowercase())
1228 });
1229 providers
1230}
1231
1232#[must_use]
1234pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
1235 let id = id.trim();
1236 all_providers()
1237 .iter()
1238 .copied()
1239 .find(|provider| provider.id() == id)
1240}
1241
1242#[must_use]
1244pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
1245 ProviderKind::parse(id_or_alias).map(provider_for_kind)
1246}
1247
1248#[must_use]
1250pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
1251 PROVIDER_REGISTRY
1252 .iter()
1253 .find(|p| p.kind() == kind)
1254 .copied()
1255 .expect("ProviderKind variant missing from PROVIDER_REGISTRY")
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261
1262 #[test]
1263 fn credential_help_covers_every_provider_without_guessing_non_key_urls() {
1264 for provider in all_providers() {
1265 let help = provider.credential_help();
1266 assert!(
1267 !help.guidance.trim().is_empty(),
1268 "{} credential guidance must not be empty",
1269 provider.id()
1270 );
1271
1272 match help.acquisition {
1273 CredentialAcquisition::ApiKey | CredentialAcquisition::ApiKeyOrOAuth => {
1274 assert!(
1275 help.credential_url.is_some(),
1276 "{} needs a stable provider-owned credential link",
1277 provider.id()
1278 );
1279 }
1280 CredentialAcquisition::LocalOptional
1281 | CredentialAcquisition::OAuth
1282 | CredentialAcquisition::Configuration => assert!(
1283 help.credential_url.is_none(),
1284 "{} must explain its non-key route instead of inventing a credential link",
1285 provider.id()
1286 ),
1287 }
1288 }
1289 }
1290
1291 #[test]
1292 fn kimi_credential_help_uses_the_durable_api_key_console_only() {
1293 let help = provider_for_kind(ProviderKind::Moonshot).credential_help();
1294
1295 assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
1296 assert_eq!(
1297 help.credential_url,
1298 Some("https://platform.kimi.ai/console/api-keys")
1299 );
1300 assert_eq!(
1301 help.docs_url,
1302 Some("https://platform.kimi.ai/docs/overview")
1303 );
1304 assert!(help.guidance.contains("create and copy an API key"));
1305 assert!(help.guidance.contains("OAuth is not available"));
1306 }
1307
1308 #[test]
1309 fn kimi_code_route_credential_help_is_distinct_from_direct_moonshot() {
1310 let direct = credential_help_for_route(ProviderKind::Moonshot, DEFAULT_MOONSHOT_BASE_URL);
1311 let kimi_code =
1312 credential_help_for_route(ProviderKind::Moonshot, "https://api.kimi.com/coding/v1/");
1313
1314 assert_eq!(
1315 direct.credential_url,
1316 Some("https://platform.kimi.ai/console/api-keys")
1317 );
1318 assert_eq!(
1319 kimi_code.credential_url,
1320 Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
1321 );
1322 assert_eq!(kimi_code.docs_url, None);
1323 assert!(kimi_code.guidance.contains("membership-plan API key"));
1324 assert!(
1325 kimi_code
1326 .guidance
1327 .contains("does not import Kimi CLI credentials")
1328 );
1329 assert!(!is_exact_kimi_code_route(
1330 ProviderKind::Moonshot,
1331 "https://api.kimi.com/coding/v1/preview"
1332 ));
1333
1334 assert!(is_exact_kimi_code_route(
1337 ProviderKind::Moonshot,
1338 "HTTPS://API.KIMI.COM/coding/v1/"
1339 ));
1340 for neighboring_route in [
1341 "https://api.kimi.com/CODING/v1",
1342 "https://api.kimi.com/coding/V1",
1343 "http://api.kimi.com/coding/v1",
1344 "https://api.kimi.com:443/coding/v1",
1345 "https://api.kimi.com/coding/v1?preview=1",
1346 "https://api.kimi.com/coding/v1#fragment",
1347 "https://api.kimi.com/coding/v1//",
1348 ] {
1349 assert!(
1350 !is_exact_kimi_code_route(ProviderKind::Moonshot, neighboring_route),
1351 "{neighboring_route} must not inherit Kimi Code membership semantics"
1352 );
1353 }
1354 }
1355
1356 #[test]
1357 fn direct_moonshot_route_matching_is_exact() {
1358 assert!(is_exact_moonshot_platform_route(
1359 ProviderKind::Moonshot,
1360 "HTTPS://API.MOONSHOT.AI/v1/"
1361 ));
1362 for neighboring_route in [
1363 "https://api.moonshot.ai/V1",
1364 "http://api.moonshot.ai/v1",
1365 "https://api.moonshot.ai:443/v1",
1366 "https://api.moonshot.ai/v1?preview=1",
1367 "https://api.moonshot.ai/v1#fragment",
1368 "https://api.moonshot.ai/v1//",
1369 "https://api.moonshot.ai/v1/chat/completions",
1370 "https://api.kimi.com/coding/v1",
1371 ] {
1372 assert!(
1373 !is_exact_moonshot_platform_route(ProviderKind::Moonshot, neighboring_route),
1374 "{neighboring_route} must not inherit direct Moonshot semantics"
1375 );
1376 }
1377 assert!(!is_exact_moonshot_platform_route(
1378 ProviderKind::Openai,
1379 DEFAULT_MOONSHOT_BASE_URL
1380 ));
1381 }
1382
1383 #[test]
1384 fn non_key_and_mixed_routes_are_typed_explicitly() {
1385 for kind in [
1386 ProviderKind::Sglang,
1387 ProviderKind::Vllm,
1388 ProviderKind::Ollama,
1389 ] {
1390 assert_eq!(
1391 provider_for_kind(kind).credential_help().acquisition,
1392 CredentialAcquisition::LocalOptional
1393 );
1394 }
1395 assert_eq!(
1396 provider_for_kind(ProviderKind::OpenaiCodex)
1397 .credential_help()
1398 .acquisition,
1399 CredentialAcquisition::OAuth
1400 );
1401 assert_eq!(
1402 provider_for_kind(ProviderKind::Xai)
1403 .credential_help()
1404 .acquisition,
1405 CredentialAcquisition::ApiKeyOrOAuth
1406 );
1407 assert_eq!(
1408 provider_for_kind(ProviderKind::Custom)
1409 .credential_help()
1410 .acquisition,
1411 CredentialAcquisition::Configuration
1412 );
1413 }
1414
1415 #[test]
1416 fn live_verified_console_replacements_do_not_regress_to_404_links() {
1417 let openmodel = provider_for_kind(ProviderKind::Openmodel).credential_help();
1418 assert_eq!(
1419 openmodel.credential_url,
1420 Some("https://console.openmodel.ai/")
1421 );
1422 assert_eq!(
1423 openmodel.docs_url,
1424 Some("https://docs.openmodel.ai/en/docs/getting-started/authentication")
1425 );
1426
1427 let sakana = provider_for_kind(ProviderKind::Sakana).credential_help();
1428 assert_eq!(
1429 sakana.credential_url,
1430 Some("https://console.sakana.ai/api-keys")
1431 );
1432 assert_eq!(
1433 sakana.docs_url,
1434 Some("https://console.sakana.ai/get-started")
1435 );
1436 }
1437
1438 #[test]
1439 fn display_order_is_alphabetical_by_display_name() {
1440 let display = providers_sorted_for_display();
1441 let names: Vec<String> = display
1442 .iter()
1443 .map(|p| p.display_name().to_ascii_lowercase())
1444 .collect();
1445 let mut sorted = names.clone();
1446 sorted.sort();
1447 assert_eq!(
1448 names, sorted,
1449 "providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
1450 );
1451 }
1452
1453 #[test]
1454 fn display_order_differs_from_internal_all_order() {
1455 let display_ids: Vec<&str> = providers_sorted_for_display()
1458 .iter()
1459 .map(|p| p.id())
1460 .collect();
1461 let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
1462 assert_ne!(
1463 display_ids, internal_ids,
1464 "display order should not match internal ALL order"
1465 );
1466 }
1467
1468 #[test]
1469 fn display_order_is_complete_and_unique() {
1470 let display = providers_sorted_for_display();
1472 assert_eq!(
1473 display.len(),
1474 all_providers().len(),
1475 "display order must include every built-in provider"
1476 );
1477 let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
1478 ids.sort_unstable();
1479 let before = ids.len();
1480 ids.dedup();
1481 assert_eq!(
1482 before,
1483 ids.len(),
1484 "display order must not contain duplicates"
1485 );
1486 }
1487
1488 #[test]
1489 fn deepseek_is_present_but_not_first_in_display_order() {
1490 let display = providers_sorted_for_display();
1493 assert_eq!(
1494 all_providers()[0].kind(),
1495 ProviderKind::Deepseek,
1496 "DeepSeek is expected to remain first in the stable internal order"
1497 );
1498 assert!(
1499 display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
1500 "DeepSeek must remain present in display order"
1501 );
1502 assert_ne!(
1503 display[0].kind(),
1504 ProviderKind::Deepseek,
1505 "DeepSeek must not be hard-coded first in display order"
1506 );
1507 assert_eq!(
1510 display[0].display_name(),
1511 "Anthropic",
1512 "alphabetical display order should lead with Anthropic"
1513 );
1514 }
1515}