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
557provider!(
558 Deepseek,
559 Deepseek,
560 "deepseek",
561 "DeepSeek",
562 DEFAULT_DEEPSEEK_BASE_URL,
563 DEFAULT_DEEPSEEK_MODEL,
564 ["DEEPSEEK_API_KEY"],
565 "deepseek",
566 aliases: ["deep-seek", "deepseek-cn", "deepseek_china", "deepseekcn", "deepseek-china"]
567);
568
569pub struct DeepseekAnthropic;
571
572impl Provider for DeepseekAnthropic {
573 fn id(&self) -> &'static str {
574 "deepseek-anthropic"
575 }
576
577 fn kind(&self) -> ProviderKind {
578 ProviderKind::DeepseekAnthropic
579 }
580
581 fn display_name(&self) -> &'static str {
582 "DeepSeek (Anthropic-compatible)"
583 }
584
585 fn default_base_url(&self) -> &'static str {
586 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
587 }
588
589 fn default_model(&self) -> &'static str {
590 DEFAULT_DEEPSEEK_ANTHROPIC_MODEL
591 }
592
593 fn env_vars(&self) -> &'static [&'static str] {
594 &["DEEPSEEK_API_KEY"]
595 }
596
597 fn provider_config_key(&self) -> &'static str {
598 "deepseek_anthropic"
599 }
600
601 fn aliases(&self) -> &'static [&'static str] {
602 &["deepseek_anthropic", "deepseek-claude", "deepseek_claude"]
603 }
604
605 fn wire_policy(&self) -> WirePolicy {
606 WirePolicy::Fixed(WireFormat::AnthropicMessages)
607 }
608}
609provider!(
610 NvidiaNim,
611 NvidiaNim,
612 "nvidia-nim",
613 "NVIDIA NIM",
614 DEFAULT_NVIDIA_NIM_BASE_URL,
615 DEFAULT_NVIDIA_NIM_MODEL,
616 ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
617 "nvidia_nim",
618 aliases: ["nvidia", "nvidia_nim", "nim"]
619);
620provider!(
621 Openai,
622 Openai,
623 "openai",
624 "OpenAI-compatible",
625 DEFAULT_OPENAI_BASE_URL,
626 DEFAULT_OPENAI_MODEL,
627 ["OPENAI_API_KEY"],
628 "openai",
629 aliases: ["open-ai"]
630);
631provider!(
632 Atlascloud,
633 Atlascloud,
634 "atlascloud",
635 "AtlasCloud",
636 DEFAULT_ATLASCLOUD_BASE_URL,
637 DEFAULT_ATLASCLOUD_MODEL,
638 ["ATLASCLOUD_API_KEY"],
639 "atlascloud",
640 aliases: ["atlas-cloud", "atlas_cloud", "atlas"]
641);
642provider!(
643 WanjieArk,
644 WanjieArk,
645 "wanjie-ark",
646 "Wanjie Ark",
647 DEFAULT_WANJIE_ARK_BASE_URL,
648 DEFAULT_WANJIE_ARK_MODEL,
649 [
650 "WANJIE_ARK_API_KEY",
651 "WANJIE_API_KEY",
652 "WANJIE_MAAS_API_KEY"
653 ],
654 "wanjie_ark",
655 aliases: ["wanjie", "wanjie_ark", "ark-wanjie", "ark_wanjie", "wanjieark", "wanjie-maas", "wanjie_maas", "wanjiemaas"]
656);
657provider!(
658 Volcengine,
659 Volcengine,
660 "volcengine",
661 "Volcengine Ark",
662 DEFAULT_VOLCENGINE_BASE_URL,
663 DEFAULT_VOLCENGINE_MODEL,
664 [
665 "VOLCENGINE_API_KEY",
666 "VOLCENGINE_ARK_API_KEY",
667 "ARK_API_KEY"
668 ],
669 "volcengine",
670 aliases: ["volcengine-ark", "volcengine_ark", "ark", "volc-ark", "volcengineark"]
671);
672provider!(
673 Openrouter,
674 Openrouter,
675 "openrouter",
676 "OpenRouter",
677 DEFAULT_OPENROUTER_BASE_URL,
678 DEFAULT_OPENROUTER_MODEL,
679 ["OPENROUTER_API_KEY"],
680 "openrouter",
681 aliases: ["open_router"]
682);
683provider!(
684 XiaomiMimo,
685 XiaomiMimo,
686 "xiaomi-mimo",
687 "Xiaomi MiMo",
688 DEFAULT_XIAOMI_MIMO_BASE_URL,
689 DEFAULT_XIAOMI_MIMO_MODEL,
690 [
691 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
692 "MIMO_TOKEN_PLAN_API_KEY",
693 "XIAOMI_MIMO_API_KEY",
694 "XIAOMI_API_KEY",
695 "MIMO_API_KEY",
696 ],
697 "xiaomi_mimo",
698 aliases: ["xiaomi_mimo", "xiaomimimo", "mimo", "xiaomi"]
699);
700provider!(
701 Novita,
702 Novita,
703 "novita",
704 "Novita AI",
705 DEFAULT_NOVITA_BASE_URL,
706 DEFAULT_NOVITA_MODEL,
707 ["NOVITA_API_KEY"],
708 "novita",
709 aliases: ["novita-ai", "novita_ai"]
713);
714provider!(
715 Fireworks,
716 Fireworks,
717 "fireworks",
718 "Fireworks AI",
719 DEFAULT_FIREWORKS_BASE_URL,
720 DEFAULT_FIREWORKS_MODEL,
721 ["FIREWORKS_API_KEY"],
722 "fireworks",
723 aliases: ["fireworks-ai"]
724);
725provider!(
726 Siliconflow,
727 Siliconflow,
728 "siliconflow",
729 "SiliconFlow",
730 DEFAULT_SILICONFLOW_BASE_URL,
731 DEFAULT_SILICONFLOW_MODEL,
732 ["SILICONFLOW_API_KEY"],
733 "siliconflow",
734 aliases: ["silicon-flow", "silicon_flow"]
735);
736provider!(
737 SiliconflowCN,
738 SiliconflowCN,
739 "siliconflow-CN",
740 "SiliconFlow (China)",
741 DEFAULT_SILICONFLOW_CN_BASE_URL,
742 DEFAULT_SILICONFLOW_MODEL,
743 ["SILICONFLOW_API_KEY"],
744 "siliconflow_cn",
745 aliases: [
746 "silicon-flow-cn",
747 "silicon-flow-CN",
748 "silicon_flow_cn",
749 "silicon_flow_CN",
750 "siliconflow-china",
751 ]
752);
753provider!(
754 Arcee,
755 Arcee,
756 "arcee",
757 "Arcee AI",
758 DEFAULT_ARCEE_BASE_URL,
759 DEFAULT_ARCEE_MODEL,
760 ["ARCEE_API_KEY"],
761 "arcee",
762 aliases: ["arcee-ai", "arcee_ai"]
763);
764provider!(
765 Moonshot,
766 Moonshot,
767 "moonshot",
768 "Moonshot/Kimi",
769 DEFAULT_MOONSHOT_BASE_URL,
770 DEFAULT_MOONSHOT_MODEL,
771 ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
772 "moonshot",
773 aliases: ["moonshot-ai", "moonshotai", "moonshot_ai", "kimi", "kimi-k2"]
777);
778provider!(
779 Sglang,
780 Sglang,
781 "sglang",
782 "SGLang",
783 DEFAULT_SGLANG_BASE_URL,
784 DEFAULT_SGLANG_MODEL,
785 ["SGLANG_API_KEY"],
786 "sglang",
787 aliases: ["sg-lang"]
788);
789provider!(
790 Vllm,
791 Vllm,
792 "vllm",
793 "vLLM",
794 DEFAULT_VLLM_BASE_URL,
795 DEFAULT_VLLM_MODEL,
796 ["VLLM_API_KEY"],
797 "vllm",
798 aliases: ["v-llm"]
799);
800provider!(
801 Ollama,
802 Ollama,
803 "ollama",
804 "Ollama",
805 DEFAULT_OLLAMA_BASE_URL,
806 DEFAULT_OLLAMA_MODEL,
807 ["OLLAMA_API_KEY"],
808 "ollama",
809 aliases: ["ollama-local"]
810);
811provider!(
812 Huggingface,
813 Huggingface,
814 "huggingface",
815 "Hugging Face",
816 DEFAULT_HUGGINGFACE_BASE_URL,
817 DEFAULT_HUGGINGFACE_MODEL,
818 ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
819 "huggingface",
820 aliases: ["hugging-face", "hugging_face", "hf"]
821);
822provider!(
823 Together,
824 Together,
825 "together",
826 "Together AI",
827 DEFAULT_TOGETHER_BASE_URL,
828 DEFAULT_TOGETHER_MODEL,
829 ["TOGETHER_API_KEY"],
830 "together",
831 aliases: ["together-ai", "together_ai", "togetherai"]
836);
837provider!(
838 Qianfan,
839 Qianfan,
840 "qianfan",
841 "Baidu Qianfan",
842 DEFAULT_QIANFAN_BASE_URL,
843 DEFAULT_QIANFAN_MODEL,
844 ["QIANFAN_API_KEY", "BAIDU_QIANFAN_API_KEY"],
845 "qianfan",
846 aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
847);
848
849pub struct OpenaiCodex;
851
852impl Provider for OpenaiCodex {
853 fn id(&self) -> &'static str {
854 "openai-codex"
855 }
856
857 fn kind(&self) -> ProviderKind {
858 ProviderKind::OpenaiCodex
859 }
860
861 fn display_name(&self) -> &'static str {
862 "OpenAI Codex (ChatGPT)"
863 }
864
865 fn default_base_url(&self) -> &'static str {
866 DEFAULT_OPENAI_CODEX_BASE_URL
867 }
868
869 fn default_model(&self) -> &'static str {
870 DEFAULT_OPENAI_CODEX_MODEL
871 }
872
873 fn env_vars(&self) -> &'static [&'static str] {
874 &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
875 }
876
877 fn provider_config_key(&self) -> &'static str {
878 "openai_codex"
879 }
880
881 fn aliases(&self) -> &'static [&'static str] {
882 &[
883 "openai_codex",
884 "openaicodex",
885 "codex",
886 "chatgpt",
887 "chatgpt-codex",
888 "chatgpt_codex",
889 "chatgptcodex",
890 ]
891 }
892
893 fn wire_policy(&self) -> WirePolicy {
894 WirePolicy::Fixed(WireFormat::Responses)
895 }
896}
897
898pub struct Anthropic;
900
901impl Provider for Anthropic {
902 fn id(&self) -> &'static str {
903 "anthropic"
904 }
905
906 fn kind(&self) -> ProviderKind {
907 ProviderKind::Anthropic
908 }
909
910 fn display_name(&self) -> &'static str {
911 "Anthropic"
912 }
913
914 fn default_base_url(&self) -> &'static str {
915 crate::DEFAULT_ANTHROPIC_BASE_URL
916 }
917
918 fn default_model(&self) -> &'static str {
919 crate::DEFAULT_ANTHROPIC_MODEL
920 }
921
922 fn env_vars(&self) -> &'static [&'static str] {
923 &["ANTHROPIC_API_KEY"]
924 }
925
926 fn provider_config_key(&self) -> &'static str {
927 "anthropic"
928 }
929
930 fn wire_policy(&self) -> WirePolicy {
931 WirePolicy::Fixed(WireFormat::AnthropicMessages)
932 }
933}
934
935pub struct Openmodel;
937
938impl Provider for Openmodel {
939 fn id(&self) -> &'static str {
940 "openmodel"
941 }
942
943 fn kind(&self) -> ProviderKind {
944 ProviderKind::Openmodel
945 }
946
947 fn display_name(&self) -> &'static str {
948 "OpenModel"
949 }
950
951 fn default_base_url(&self) -> &'static str {
952 DEFAULT_OPENMODEL_BASE_URL
953 }
954
955 fn default_model(&self) -> &'static str {
956 DEFAULT_OPENMODEL_MODEL
957 }
958
959 fn env_vars(&self) -> &'static [&'static str] {
960 &["OPENMODEL_API_KEY"]
961 }
962
963 fn provider_config_key(&self) -> &'static str {
964 "openmodel"
965 }
966
967 fn aliases(&self) -> &'static [&'static str] {
968 &["open-model", "open_model"]
969 }
970
971 fn wire_policy(&self) -> WirePolicy {
972 WirePolicy::Fixed(WireFormat::AnthropicMessages)
973 }
974}
975
976provider!(
977 Zai,
978 Zai,
979 "zai",
980 "Zhipu AI / Z.ai",
981 DEFAULT_ZAI_BASE_URL,
982 DEFAULT_ZAI_MODEL,
983 ["ZAI_API_KEY", "Z_AI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
984 "zai",
985 aliases: ["z-ai", "z_ai", "z.ai", "zhipu", "zhipuai", "bigmodel", "big-model"]
986);
987
988provider!(
989 Stepfun,
990 Stepfun,
991 "stepfun",
992 "StepFun / StepFlash",
993 DEFAULT_STEPFUN_BASE_URL,
994 DEFAULT_STEPFUN_MODEL,
995 ["STEPFUN_API_KEY", "STEP_API_KEY"],
996 "stepfun",
997 aliases: ["step-fun", "step_fun", "stepflash", "step-flash", "step_flash"]
998);
999
1000provider!(
1001 Minimax,
1002 Minimax,
1003 "minimax",
1004 "MiniMax",
1005 DEFAULT_MINIMAX_BASE_URL,
1006 DEFAULT_MINIMAX_MODEL,
1007 ["MINIMAX_API_KEY"],
1008 "minimax",
1009 aliases: ["mini-max", "mini_max"]
1010);
1011
1012pub struct MinimaxAnthropic;
1014
1015impl Provider for MinimaxAnthropic {
1016 fn id(&self) -> &'static str {
1017 "minimax-anthropic"
1018 }
1019
1020 fn kind(&self) -> ProviderKind {
1021 ProviderKind::MinimaxAnthropic
1022 }
1023
1024 fn display_name(&self) -> &'static str {
1025 "MiniMax (Anthropic-compatible)"
1026 }
1027
1028 fn default_base_url(&self) -> &'static str {
1029 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
1030 }
1031
1032 fn default_model(&self) -> &'static str {
1033 DEFAULT_MINIMAX_MODEL
1034 }
1035
1036 fn env_vars(&self) -> &'static [&'static str] {
1037 &["MINIMAX_API_KEY"]
1038 }
1039
1040 fn provider_config_key(&self) -> &'static str {
1041 "minimax_anthropic"
1042 }
1043
1044 fn aliases(&self) -> &'static [&'static str] {
1045 &[
1046 "minimax_anthropic",
1047 "mini-max-anthropic",
1048 "mini_max_anthropic",
1049 ]
1050 }
1051
1052 fn wire_policy(&self) -> WirePolicy {
1053 WirePolicy::Fixed(WireFormat::AnthropicMessages)
1054 }
1055}
1056
1057provider!(
1058 Deepinfra,
1059 Deepinfra,
1060 "deepinfra",
1061 "DeepInfra",
1062 DEFAULT_DEEPINFRA_BASE_URL,
1063 DEFAULT_DEEPINFRA_MODEL,
1064 ["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1065 "deepinfra",
1066 aliases: ["deep-infra", "deep_infra"]
1067);
1068
1069provider!(
1070 Sakana,
1071 Sakana,
1072 "sakana",
1073 "Sakana AI (Fugu)",
1074 DEFAULT_SAKANA_BASE_URL,
1075 DEFAULT_SAKANA_MODEL,
1076 ["FUGU_API_KEY", "SAKANA_API_KEY"],
1077 "sakana",
1078 aliases: ["sakana-ai", "sakana_ai", "fugu"]
1079);
1080
1081provider!(
1082 LongCat,
1083 LongCat,
1084 "longcat",
1085 "Meituan LongCat",
1086 DEFAULT_LONGCAT_BASE_URL,
1087 DEFAULT_LONGCAT_MODEL,
1088 ["LONGCAT_API_KEY"],
1089 "longcat",
1090 aliases: ["long-cat", "meituan-longcat", "meituan"]
1091);
1092
1093provider!(
1094 OpencodeGo,
1095 OpencodeGo,
1096 "opencode-go",
1097 "OpenCode Go",
1098 DEFAULT_OPENCODE_GO_BASE_URL,
1099 DEFAULT_OPENCODE_GO_MODEL,
1100 ["OPENCODE_GO_API_KEY"],
1101 "opencode_go",
1102 aliases: ["opencode_go", "opencodego"]
1103);
1104
1105pub struct OpencodeZen;
1107
1108impl Provider for OpencodeZen {
1109 fn id(&self) -> &'static str {
1110 "opencode-zen"
1111 }
1112
1113 fn kind(&self) -> ProviderKind {
1114 ProviderKind::OpencodeZen
1115 }
1116
1117 fn display_name(&self) -> &'static str {
1118 "OpenCode Zen"
1119 }
1120
1121 fn default_base_url(&self) -> &'static str {
1122 DEFAULT_OPENCODE_ZEN_BASE_URL
1123 }
1124
1125 fn default_model(&self) -> &'static str {
1126 DEFAULT_OPENCODE_ZEN_MODEL
1127 }
1128
1129 fn env_vars(&self) -> &'static [&'static str] {
1130 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1131 }
1132
1133 fn provider_config_key(&self) -> &'static str {
1134 "opencode_zen"
1135 }
1136
1137 fn aliases(&self) -> &'static [&'static str] {
1138 &["opencode_zen", "opencodezen", "zen", "opencode"]
1139 }
1140
1141 fn wire_policy(&self) -> WirePolicy {
1142 WirePolicy::ModelAware
1143 }
1144}
1145
1146provider!(
1147 Meta,
1148 Meta,
1149 "meta",
1150 "Meta Model API",
1151 DEFAULT_META_BASE_URL,
1152 DEFAULT_META_MODEL,
1153 ["META_MODEL_API_KEY", "MODEL_API_KEY"],
1154 "meta",
1155 aliases: [
1156 "meta-ai",
1157 "meta_ai",
1158 "meta-model-api",
1159 "meta_model_api",
1160 "muse",
1161 "muse-spark"
1162 ]
1163);
1164
1165provider!(
1166 Xai,
1167 Xai,
1168 "xai",
1169 "xAI",
1170 DEFAULT_XAI_BASE_URL,
1171 DEFAULT_XAI_MODEL,
1172 ["XAI_API_KEY"],
1173 "xai",
1174 aliases: ["x-ai", "x_ai", "grok"]
1175);
1176
1177provider!(
1178 Telecomjs,
1179 Telecomjs,
1180 "telecomjs",
1181 "TelecomJS TokenHub",
1182 DEFAULT_TELECOMJS_BASE_URL,
1183 DEFAULT_TELECOMJS_MODEL,
1184 ["TELECOMJS_API_KEY"],
1185 "telecomjs",
1186 aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"]
1187);
1188
1189pub struct Custom;
1200
1201impl Provider for Custom {
1202 fn id(&self) -> &'static str {
1203 "custom"
1204 }
1205
1206 fn kind(&self) -> ProviderKind {
1207 ProviderKind::Custom
1208 }
1209
1210 fn display_name(&self) -> &'static str {
1211 "Custom (OpenAI-compatible)"
1212 }
1213
1214 fn default_base_url(&self) -> &'static str {
1215 "http://localhost/v1"
1219 }
1220
1221 fn default_model(&self) -> &'static str {
1222 "custom-model"
1225 }
1226
1227 fn env_vars(&self) -> &'static [&'static str] {
1228 &[]
1231 }
1232
1233 fn provider_config_key(&self) -> &'static str {
1234 "custom"
1235 }
1236
1237 fn wire_policy(&self) -> WirePolicy {
1238 WirePolicy::Fixed(WireFormat::ChatCompletions)
1239 }
1240}
1241
1242static DEEPSEEK: Deepseek = Deepseek;
1243static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic;
1244static NVIDIA_NIM: NvidiaNim = NvidiaNim;
1245static OPENAI: Openai = Openai;
1246static ATLASCLOUD: Atlascloud = Atlascloud;
1247static WANJIE_ARK: WanjieArk = WanjieArk;
1248static VOLCENGINE: Volcengine = Volcengine;
1249static OPENROUTER: Openrouter = Openrouter;
1250static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
1251static NOVITA: Novita = Novita;
1252static FIREWORKS: Fireworks = Fireworks;
1253static SILICONFLOW: Siliconflow = Siliconflow;
1254static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN;
1255static ARCEE: Arcee = Arcee;
1256static MOONSHOT: Moonshot = Moonshot;
1257static SGLANG: Sglang = Sglang;
1258static VLLM: Vllm = Vllm;
1259static OLLAMA: Ollama = Ollama;
1260static HUGGINGFACE: Huggingface = Huggingface;
1261static TOGETHER: Together = Together;
1262static QIANFAN: Qianfan = Qianfan;
1263static OPENAI_CODEX: OpenaiCodex = OpenaiCodex;
1264static ANTHROPIC: Anthropic = Anthropic;
1265static OPENMODEL: Openmodel = Openmodel;
1266static ZAI: Zai = Zai;
1267static STEPFUN: Stepfun = Stepfun;
1268static MINIMAX: Minimax = Minimax;
1269static MINIMAX_ANTHROPIC: MinimaxAnthropic = MinimaxAnthropic;
1270static DEEPINFRA: Deepinfra = Deepinfra;
1271static SAKANA: Sakana = Sakana;
1272static LONGCAT: LongCat = LongCat;
1273static OPENCODE_GO: OpencodeGo = OpencodeGo;
1274static OPENCODE_ZEN: OpencodeZen = OpencodeZen;
1275static META: Meta = Meta;
1276static XAI: Xai = Xai;
1277static TELECOMJS: Telecomjs = Telecomjs;
1278static CUSTOM: Custom = Custom;
1279
1280static PROVIDER_REGISTRY: [&dyn Provider; 37] = [
1281 &DEEPSEEK,
1282 &DEEPSEEK_ANTHROPIC,
1283 &NVIDIA_NIM,
1284 &OPENAI,
1285 &ATLASCLOUD,
1286 &WANJIE_ARK,
1287 &VOLCENGINE,
1288 &OPENROUTER,
1289 &XIAOMI_MIMO,
1290 &NOVITA,
1291 &FIREWORKS,
1292 &SILICONFLOW,
1293 &ARCEE,
1294 &SILICONFLOW_CN,
1295 &MOONSHOT,
1296 &SGLANG,
1297 &VLLM,
1298 &OLLAMA,
1299 &HUGGINGFACE,
1300 &TOGETHER,
1301 &QIANFAN,
1302 &OPENAI_CODEX,
1303 &ANTHROPIC,
1304 &OPENMODEL,
1305 &ZAI,
1306 &STEPFUN,
1307 &MINIMAX,
1308 &MINIMAX_ANTHROPIC,
1309 &DEEPINFRA,
1310 &SAKANA,
1311 &LONGCAT,
1312 &OPENCODE_GO,
1313 &OPENCODE_ZEN,
1314 &META,
1315 &XAI,
1316 &TELECOMJS,
1317 &CUSTOM,
1318];
1319
1320#[must_use]
1326pub fn all_providers() -> &'static [&'static dyn Provider] {
1327 &PROVIDER_REGISTRY
1328}
1329
1330#[must_use]
1346pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
1347 let mut providers = all_providers().to_vec();
1348 providers.sort_by(|a, b| {
1349 a.display_name()
1350 .to_ascii_lowercase()
1351 .cmp(&b.display_name().to_ascii_lowercase())
1352 });
1353 providers
1354}
1355
1356#[must_use]
1358pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
1359 let id = id.trim();
1360 all_providers()
1361 .iter()
1362 .copied()
1363 .find(|provider| provider.id() == id)
1364}
1365
1366#[must_use]
1368pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
1369 ProviderKind::parse(id_or_alias).map(provider_for_kind)
1370}
1371
1372#[must_use]
1374pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
1375 PROVIDER_REGISTRY
1376 .iter()
1377 .find(|p| p.kind() == kind)
1378 .copied()
1379 .expect("ProviderKind variant missing from PROVIDER_REGISTRY")
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385
1386 #[test]
1387 fn credential_help_covers_every_provider_without_guessing_non_key_urls() {
1388 for provider in all_providers() {
1389 let help = provider.credential_help();
1390 assert!(
1391 !help.guidance.trim().is_empty(),
1392 "{} credential guidance must not be empty",
1393 provider.id()
1394 );
1395
1396 match help.acquisition {
1397 CredentialAcquisition::ApiKey | CredentialAcquisition::ApiKeyOrOAuth => {
1398 assert!(
1399 help.credential_url.is_some(),
1400 "{} needs a stable provider-owned credential link",
1401 provider.id()
1402 );
1403 }
1404 CredentialAcquisition::LocalOptional
1405 | CredentialAcquisition::OAuth
1406 | CredentialAcquisition::Configuration => assert!(
1407 help.credential_url.is_none(),
1408 "{} must explain its non-key route instead of inventing a credential link",
1409 provider.id()
1410 ),
1411 }
1412 }
1413 }
1414
1415 #[test]
1416 fn kimi_credential_help_uses_the_durable_api_key_console_only() {
1417 let help = provider_for_kind(ProviderKind::Moonshot).credential_help();
1418
1419 assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
1420 assert_eq!(
1421 help.credential_url,
1422 Some("https://platform.kimi.ai/console/api-keys")
1423 );
1424 assert_eq!(
1425 help.docs_url,
1426 Some("https://platform.kimi.ai/docs/overview")
1427 );
1428 assert!(help.guidance.contains("create and copy an API key"));
1429 assert!(help.guidance.contains("OAuth is not available"));
1430 }
1431
1432 #[test]
1433 fn kimi_code_route_credential_help_is_distinct_from_direct_moonshot() {
1434 let direct = credential_help_for_route(ProviderKind::Moonshot, DEFAULT_MOONSHOT_BASE_URL);
1435 let kimi_code =
1436 credential_help_for_route(ProviderKind::Moonshot, "https://api.kimi.com/coding/v1/");
1437
1438 assert_eq!(
1439 direct.credential_url,
1440 Some("https://platform.kimi.ai/console/api-keys")
1441 );
1442 assert_eq!(
1443 kimi_code.credential_url,
1444 Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
1445 );
1446 assert_eq!(kimi_code.docs_url, None);
1447 assert!(kimi_code.guidance.contains("membership-plan API key"));
1448 assert!(
1449 kimi_code
1450 .guidance
1451 .contains("does not import Kimi CLI credentials")
1452 );
1453 assert!(!is_exact_kimi_code_route(
1454 ProviderKind::Moonshot,
1455 "https://api.kimi.com/coding/v1/preview"
1456 ));
1457
1458 assert!(is_exact_kimi_code_route(
1461 ProviderKind::Moonshot,
1462 "HTTPS://API.KIMI.COM/coding/v1/"
1463 ));
1464 for neighboring_route in [
1465 "https://api.kimi.com/CODING/v1",
1466 "https://api.kimi.com/coding/V1",
1467 "http://api.kimi.com/coding/v1",
1468 "https://api.kimi.com:443/coding/v1",
1469 "https://api.kimi.com/coding/v1?preview=1",
1470 "https://api.kimi.com/coding/v1#fragment",
1471 "https://api.kimi.com/coding/v1//",
1472 ] {
1473 assert!(
1474 !is_exact_kimi_code_route(ProviderKind::Moonshot, neighboring_route),
1475 "{neighboring_route} must not inherit Kimi Code membership semantics"
1476 );
1477 }
1478 }
1479
1480 #[test]
1481 fn direct_moonshot_route_matching_is_exact() {
1482 assert!(is_exact_moonshot_platform_route(
1483 ProviderKind::Moonshot,
1484 "HTTPS://API.MOONSHOT.AI/v1/"
1485 ));
1486 for neighboring_route in [
1487 "https://api.moonshot.ai/V1",
1488 "http://api.moonshot.ai/v1",
1489 "https://api.moonshot.ai:443/v1",
1490 "https://api.moonshot.ai/v1?preview=1",
1491 "https://api.moonshot.ai/v1#fragment",
1492 "https://api.moonshot.ai/v1//",
1493 "https://api.moonshot.ai/v1/chat/completions",
1494 "https://api.kimi.com/coding/v1",
1495 ] {
1496 assert!(
1497 !is_exact_moonshot_platform_route(ProviderKind::Moonshot, neighboring_route),
1498 "{neighboring_route} must not inherit direct Moonshot semantics"
1499 );
1500 }
1501 assert!(!is_exact_moonshot_platform_route(
1502 ProviderKind::Openai,
1503 DEFAULT_MOONSHOT_BASE_URL
1504 ));
1505 }
1506
1507 #[test]
1508 fn zai_chat_route_matching_is_exact() {
1509 for route in [
1510 "https://api.z.ai/api/coding/paas/v4",
1511 "https://api.z.ai/api/paas/v4/",
1512 "HTTPS://API.Z.AI/api/paas/v4",
1513 ] {
1514 assert!(is_exact_zai_chat_route(ProviderKind::Zai, route), "{route}");
1515 }
1516 for neighboring_route in [
1517 "http://api.z.ai/api/paas/v4",
1518 "https://api.z.ai:443/api/paas/v4",
1519 "https://api.z.ai/API/paas/v4",
1520 "https://api.z.ai/api/paas/v4?preview=1",
1521 "https://api.z.ai/api/paas/v4#fragment",
1522 "https://api.z.ai/api/paas/v4//",
1523 "https://api.z.ai/api/paas/v4/chat/completions",
1524 "https://gateway.example/v1",
1525 ] {
1526 assert!(
1527 !is_exact_zai_chat_route(ProviderKind::Zai, neighboring_route),
1528 "{neighboring_route} must not inherit Z.ai-only request fields"
1529 );
1530 }
1531 assert!(!is_exact_zai_chat_route(
1532 ProviderKind::Openai,
1533 DEFAULT_ZAI_BASE_URL
1534 ));
1535 }
1536
1537 #[test]
1538 fn minimax_chat_route_matching_is_exact_and_excludes_messages() {
1539 for route in [
1540 "https://api.minimax.io/v1",
1541 "https://api.minimaxi.com/v1/",
1542 "HTTPS://API.MINIMAX.IO/v1",
1543 ] {
1544 assert!(
1545 is_exact_minimax_chat_route(ProviderKind::Minimax, route),
1546 "{route}"
1547 );
1548 }
1549 for neighboring_route in [
1550 "http://api.minimax.io/v1",
1551 "https://api.minimax.io:443/v1",
1552 "https://api.minimax.io/V1",
1553 "https://api.minimax.io/v1?preview=1",
1554 "https://api.minimax.io/v1#fragment",
1555 "https://api.minimax.io/v1//",
1556 "https://api.minimax.io/v1/chat/completions",
1557 "https://api.minimax.io/anthropic",
1558 "https://api.minimaxi.com/anthropic",
1559 "https://gateway.example/v1",
1560 ] {
1561 assert!(
1562 !is_exact_minimax_chat_route(ProviderKind::Minimax, neighboring_route),
1563 "{neighboring_route} must not inherit MiniMax Chat request fields"
1564 );
1565 }
1566 assert!(!is_exact_minimax_chat_route(
1567 ProviderKind::MinimaxAnthropic,
1568 DEFAULT_MINIMAX_BASE_URL
1569 ));
1570 }
1571
1572 #[test]
1573 fn minimax_anthropic_route_matching_is_exact_and_excludes_chat() {
1574 for route in [
1575 "https://api.minimax.io/anthropic",
1576 "https://api.minimaxi.com/anthropic/",
1577 "HTTPS://API.MINIMAX.IO/anthropic",
1578 ] {
1579 assert!(
1580 is_exact_minimax_anthropic_route(ProviderKind::MinimaxAnthropic, route),
1581 "{route}"
1582 );
1583 }
1584 for neighboring_route in [
1585 "http://api.minimax.io/anthropic",
1586 "https://api.minimax.io:443/anthropic",
1587 "https://api.minimax.io/Anthropic",
1588 "https://api.minimax.io/anthropic?preview=1",
1589 "https://api.minimax.io/anthropic#fragment",
1590 "https://api.minimax.io/anthropic//",
1591 "https://api.minimax.io/anthropic/v1/messages",
1592 "https://api.minimax.io/v1",
1593 "https://gateway.example/anthropic",
1594 ] {
1595 assert!(
1596 !is_exact_minimax_anthropic_route(
1597 ProviderKind::MinimaxAnthropic,
1598 neighboring_route
1599 ),
1600 "{neighboring_route} must not inherit MiniMax Messages semantics"
1601 );
1602 }
1603 assert!(!is_exact_minimax_anthropic_route(
1604 ProviderKind::Minimax,
1605 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
1606 ));
1607 }
1608
1609 #[test]
1610 fn non_key_and_mixed_routes_are_typed_explicitly() {
1611 for kind in [
1612 ProviderKind::Sglang,
1613 ProviderKind::Vllm,
1614 ProviderKind::Ollama,
1615 ] {
1616 assert_eq!(
1617 provider_for_kind(kind).credential_help().acquisition,
1618 CredentialAcquisition::LocalOptional
1619 );
1620 }
1621 assert_eq!(
1622 provider_for_kind(ProviderKind::OpenaiCodex)
1623 .credential_help()
1624 .acquisition,
1625 CredentialAcquisition::OAuth
1626 );
1627 assert_eq!(
1628 provider_for_kind(ProviderKind::Xai)
1629 .credential_help()
1630 .acquisition,
1631 CredentialAcquisition::ApiKeyOrOAuth
1632 );
1633 assert_eq!(
1634 provider_for_kind(ProviderKind::Custom)
1635 .credential_help()
1636 .acquisition,
1637 CredentialAcquisition::Configuration
1638 );
1639 }
1640
1641 #[test]
1642 fn live_verified_console_replacements_do_not_regress_to_404_links() {
1643 let openmodel = provider_for_kind(ProviderKind::Openmodel).credential_help();
1644 assert_eq!(
1645 openmodel.credential_url,
1646 Some("https://console.openmodel.ai/")
1647 );
1648 assert_eq!(
1649 openmodel.docs_url,
1650 Some("https://docs.openmodel.ai/en/docs/getting-started/authentication")
1651 );
1652
1653 let sakana = provider_for_kind(ProviderKind::Sakana).credential_help();
1654 assert_eq!(
1655 sakana.credential_url,
1656 Some("https://console.sakana.ai/api-keys")
1657 );
1658 assert_eq!(
1659 sakana.docs_url,
1660 Some("https://console.sakana.ai/get-started")
1661 );
1662 }
1663
1664 #[test]
1665 fn model_aware_wire_policy_resolves_only_supported_endpoint_keys() {
1666 let policy = WirePolicy::ModelAware;
1667 assert_eq!(policy.resolve("chat"), Some(WireFormat::ChatCompletions));
1668 assert_eq!(policy.resolve("responses"), Some(WireFormat::Responses));
1669 assert_eq!(
1670 policy.resolve("messages"),
1671 Some(WireFormat::AnthropicMessages)
1672 );
1673 assert_eq!(policy.resolve("models/gemini-3.1-pro"), None);
1674 assert_eq!(policy.resolve(""), None);
1675 }
1676
1677 #[test]
1678 fn fixed_wire_policy_ignores_catalog_endpoint_keys() {
1679 let policy = WirePolicy::Fixed(WireFormat::Responses);
1680 assert_eq!(policy.resolve("chat"), Some(WireFormat::Responses));
1681 assert_eq!(policy.resolve("unknown"), Some(WireFormat::Responses));
1682 }
1683
1684 #[test]
1685 fn display_order_is_alphabetical_by_display_name() {
1686 let display = providers_sorted_for_display();
1687 let names: Vec<String> = display
1688 .iter()
1689 .map(|p| p.display_name().to_ascii_lowercase())
1690 .collect();
1691 let mut sorted = names.clone();
1692 sorted.sort();
1693 assert_eq!(
1694 names, sorted,
1695 "providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
1696 );
1697 }
1698
1699 #[test]
1700 fn display_order_differs_from_internal_all_order() {
1701 let display_ids: Vec<&str> = providers_sorted_for_display()
1704 .iter()
1705 .map(|p| p.id())
1706 .collect();
1707 let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
1708 assert_ne!(
1709 display_ids, internal_ids,
1710 "display order should not match internal ALL order"
1711 );
1712 }
1713
1714 #[test]
1715 fn display_order_is_complete_and_unique() {
1716 let display = providers_sorted_for_display();
1718 assert_eq!(
1719 display.len(),
1720 all_providers().len(),
1721 "display order must include every built-in provider"
1722 );
1723 let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
1724 ids.sort_unstable();
1725 let before = ids.len();
1726 ids.dedup();
1727 assert_eq!(
1728 before,
1729 ids.len(),
1730 "display order must not contain duplicates"
1731 );
1732 }
1733
1734 #[test]
1735 fn deepseek_is_present_but_not_first_in_display_order() {
1736 let display = providers_sorted_for_display();
1739 assert_eq!(
1740 all_providers()[0].kind(),
1741 ProviderKind::Deepseek,
1742 "DeepSeek is expected to remain first in the stable internal order"
1743 );
1744 assert!(
1745 display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
1746 "DeepSeek must remain present in display order"
1747 );
1748 assert_ne!(
1749 display[0].kind(),
1750 ProviderKind::Deepseek,
1751 "DeepSeek must not be hard-coded first in display order"
1752 );
1753 assert_eq!(
1756 display[0].display_name(),
1757 "Anthropic",
1758 "alphabetical display order should lead with Anthropic"
1759 );
1760 }
1761}