返回 CodeWhale
provider.rs
根目录 / crates / config / src / provider.rs
1 //! Built-in provider metadata.
2 //!
3 //! This module is a metadata foundation for collapsing provider drift over
4 //! time. It deliberately does not mutate request bodies or choose fallback
5 //! providers; `ConfigToml::resolve_runtime_options` now mints the executable
6 //! route through `RouteResolver` (Phase 1). Auth/key resolution stays here.
7
8 use super::{
9 DEFAULT_ANTIGRAVITY_BASE_URL, DEFAULT_ANTIGRAVITY_MODEL, DEFAULT_ARCEE_BASE_URL,
10 DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL, DEFAULT_ATLASCLOUD_MODEL,
11 DEFAULT_CODEWHALE_BASE_URL, DEFAULT_CODEWHALE_MODEL, DEFAULT_CONCENTRATE_BASE_URL,
12 DEFAULT_CONCENTRATE_MODEL, DEFAULT_CSDN_BASE_URL, DEFAULT_CSDN_MODEL,
13 DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
14 DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL,
15 DEFAULT_EDENAI_BASE_URL, DEFAULT_EDENAI_MODEL, DEFAULT_FIREWORKS_BASE_URL,
16 DEFAULT_FIREWORKS_MODEL, DEFAULT_GOOGLE_BASE_URL, DEFAULT_GOOGLE_MODEL,
17 DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL, DEFAULT_LONGCAT_BASE_URL,
18 DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
19 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL,
20 DEFAULT_MISTRAL_BASE_URL, DEFAULT_MISTRAL_MODEL, DEFAULT_MODELSCOPE_BASE_URL,
21 DEFAULT_MODELSCOPE_MODEL, DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
22 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
23 DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL,
24 DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL,
25 DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_CLOUD_BASE_URL, DEFAULT_OLLAMA_CLOUD_MODEL,
26 DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL,
27 DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENCODE_GO_BASE_URL,
28 DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENCODE_ZEN_BASE_URL, DEFAULT_OPENCODE_ZEN_MODEL,
29 DEFAULT_OPENMODEL_BASE_URL, DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL,
30 DEFAULT_OPENROUTER_MODEL, DEFAULT_ORCAROUTER_BASE_URL, DEFAULT_ORCAROUTER_MODEL,
31 DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL,
32 DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
33 DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
34 DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL,
35 DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL,
36 DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL,
37 DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL,
38 DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL,
39 DEFAULT_ZAI_MODEL, DEFAULT_ZENMUX_BASE_URL, DEFAULT_ZENMUX_MODEL,
40 MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL, MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
41 ProviderKind,
42 };
43
44 /// Wire protocol spoken by a provider.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46 #[serde(rename_all = "snake_case")]
47 pub enum WireFormat {
48 /// OpenAI-compatible `/v1/chat/completions` style payloads.
49 ChatCompletions,
50 /// OpenAI Responses API (`/responses`).
51 Responses,
52 /// Native Anthropic Messages API (`/v1/messages`).
53 AnthropicMessages,
54 }
55
56 /// How a user obtains or supplies credentials for a built-in provider.
57 ///
58 /// Keeping this typed prevents API-key onboarding from accidentally describing
59 /// a local runtime, OAuth-only route, or user-defined endpoint as though it had
60 /// a vendor key console.
61 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
62 pub enum CredentialAcquisition {
63 /// A provider-issued API key or access token.
64 ApiKey,
65 /// Either a provider-issued API key or the provider's supported OAuth path.
66 ApiKeyOrOAuth,
67 /// A self-hosted route that is keyless by default but can be configured with auth.
68 LocalOptional,
69 /// An OAuth-only route; Codewhale does not collect an API key for it.
70 OAuth,
71 /// A user-defined route whose credential source belongs in configuration.
72 Configuration,
73 }
74
75 impl CredentialAcquisition {
76 /// Stable machine-readable label for diagnostics.
77 #[must_use]
78 pub const fn as_str(self) -> &'static str {
79 match self {
80 Self::ApiKey => "api_key",
81 Self::ApiKeyOrOAuth => "api_key_or_oauth",
82 Self::LocalOptional => "local_optional",
83 Self::OAuth => "oauth",
84 Self::Configuration => "configuration",
85 }
86 }
87 }
88
89 /// How a provider selects its request wire format.
90 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
91 pub enum WirePolicy {
92 /// Every model served by the provider uses the same wire format.
93 Fixed(WireFormat),
94 /// The provider catalog selects a wire format per model/endpoint.
95 ModelAware,
96 }
97
98 impl WirePolicy {
99 /// Return the fixed format, or `None` for model-aware providers.
100 #[must_use]
101 pub const fn fixed(self) -> Option<WireFormat> {
102 match self {
103 Self::Fixed(format) => Some(format),
104 Self::ModelAware => None,
105 }
106 }
107
108 /// Resolve a concrete format from an offering endpoint key.
109 #[must_use]
110 pub fn resolve(self, endpoint_key: &str) -> Option<WireFormat> {
111 if let Self::Fixed(format) = self {
112 return Some(format);
113 }
114
115 match endpoint_key.trim().to_ascii_lowercase().as_str() {
116 "chat" | "chat_completions" | "chat-completions" => Some(WireFormat::ChatCompletions),
117 "responses" => Some(WireFormat::Responses),
118 "messages" | "anthropic_messages" | "anthropic-messages" => {
119 Some(WireFormat::AnthropicMessages)
120 }
121 _ => None,
122 }
123 }
124 }
125
126 /// Canonical, non-secret help for configuring one provider.
127 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
128 pub struct CredentialHelp {
129 pub acquisition: CredentialAcquisition,
130 /// Stable provider-owned page for creating or locating credentials.
131 ///
132 /// `None` is deliberate for local, OAuth-only, and user-defined routes; UI
133 /// callers must show [`Self::guidance`] instead of guessing a URL.
134 pub credential_url: Option<&'static str>,
135 /// Provider-owned documentation when the repository already has a stable link.
136 pub docs_url: Option<&'static str>,
137 /// Concise fallback or qualification for non-key and mixed-auth routes.
138 pub guidance: &'static str,
139 }
140
141 /// Kimi Code's membership-plan key console.
142 ///
143 /// This is intentionally distinct from Moonshot's direct API console. The
144 /// route-specific helper below owns the choice so a configured Kimi Code route
145 /// is never described as a generic Moonshot route.
146 pub const KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL: &str = "https://www.kimi.com/code/console";
147
148 /// Ollama's account page for creating API keys used by the hosted API.
149 pub const OLLAMA_CLOUD_API_KEY_URL: &str = "https://ollama.com/settings/keys";
150
151 /// Codewhale account page for minting a `cwc_key_…` API key.
152 ///
153 /// The Codewhale API route needs a key carrying the `models:infer` scope; the
154 /// same page is both the credential console and the scope documentation.
155 pub const CODEWHALE_API_KEY_URL: &str = "https://app.codewhale.net/settings?section=api";
156
157 /// Environment variable that overrides the Codewhale API base URL.
158 ///
159 /// Mirrors `CODEWHALE_CLOUD_API_BASE` for the account control plane: HTTPS is
160 /// required except for loopback HTTP, so a test harness can point the route at
161 /// a local stub without ever enabling cleartext to a remote host.
162 pub const CODEWHALE_API_BASE_ENV: &str = "CODEWHALE_API_BASE";
163
164 /// Resolve the Codewhale API base URL from the environment.
165 ///
166 /// Returns `None` when the variable is unset, empty, or names an origin this
167 /// route refuses to send a `cwc_key_…` bearer to. A bearer token has no replay
168 /// protection, so cleartext is allowed only on loopback — the same rule the
169 /// account control plane applies to `CODEWHALE_CLOUD_API_BASE`.
170 #[must_use]
171 pub fn codewhale_api_base_from_env() -> Option<String> {
172 let raw = std::env::var(CODEWHALE_API_BASE_ENV).ok()?;
173 codewhale_api_base(&raw)
174 }
175
176 /// Validate one candidate Codewhale API base URL. See [`codewhale_api_base_from_env`].
177 #[must_use]
178 pub fn codewhale_api_base(raw: &str) -> Option<String> {
179 let trimmed = raw.trim().trim_end_matches('/');
180 if trimmed.is_empty() {
181 return None;
182 }
183 let (scheme, host, has_credentials) = crate::device_code::url_scheme_and_host(trimmed).ok()?;
184 if has_credentials {
185 return None;
186 }
187 let allowed =
188 scheme == "https" || (scheme == "http" && crate::device_code::is_loopback_host(&host));
189 allowed.then(|| trimmed.to_string())
190 }
191
192 /// Ollama Cloud's exact OpenAI-compatible API base URL.
193 pub const OLLAMA_CLOUD_BASE_URL: &str = DEFAULT_OLLAMA_CLOUD_BASE_URL;
194
195 /// OpenAI's default model for its first-party API endpoint.
196 ///
197 /// Public consumers should use this provider-owned value instead of copying
198 /// the default into another configuration layer.
199 pub const OPENAI_DEFAULT_MODEL: &str = DEFAULT_OPENAI_MODEL;
200
201 /// Static metadata for a built-in model provider.
202 pub trait Provider: Send + Sync {
203 /// Provider enum variant represented by this entry.
204 fn kind(&self) -> ProviderKind;
205
206 /// Canonical provider identifier.
207 fn id(&self) -> &'static str {
208 self.kind().as_str()
209 }
210
211 /// Human-readable provider label for UIs and diagnostics.
212 fn display_name(&self) -> &'static str;
213
214 /// Default base URL used when no config/env/CLI override is present.
215 fn default_base_url(&self) -> &'static str;
216
217 /// Default model used when no config/env/CLI override is present.
218 fn default_model(&self) -> &'static str;
219
220 /// Environment variable candidates used for this provider's API key.
221 fn env_vars(&self) -> &'static [&'static str];
222
223 /// TOML table key under `[providers.<key>]`.
224 fn provider_config_key(&self) -> &'static str;
225
226 /// Alternate names accepted during provider resolution.
227 fn aliases(&self) -> &'static [&'static str] {
228 &[]
229 }
230
231 /// Policy used to select the request wire format.
232 fn wire_policy(&self) -> WirePolicy {
233 WirePolicy::Fixed(WireFormat::ChatCompletions)
234 }
235
236 /// Credential acquisition metadata shared by onboarding, setup, diagnostics,
237 /// and provider-help surfaces.
238 fn credential_help(&self) -> CredentialHelp {
239 credential_help(self.kind())
240 }
241 }
242
243 /// Return the canonical credential-acquisition metadata for a provider kind.
244 ///
245 /// URLs here are provider-owned links already documented in this repository.
246 /// If no stable vendor credential page is known, the URL remains absent and the
247 /// guidance explains the supported local, OAuth, or configuration path.
248 /// This is provider-level fallback metadata: callers that know a concrete base
249 /// URL must use [`credential_help_for_route`] so route-owned credentials do not
250 /// inherit a default endpoint's console.
251 #[must_use]
252 pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
253 use CredentialAcquisition::{ApiKey, ApiKeyOrOAuth, Configuration, LocalOptional, OAuth};
254
255 match kind {
256 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => CredentialHelp {
257 acquisition: ApiKey,
258 credential_url: Some("https://platform.deepseek.com/api_keys"),
259 docs_url: Some("https://api-docs.deepseek.com/"),
260 guidance: "Create an API key in the DeepSeek platform console.",
261 },
262 ProviderKind::NvidiaNim => CredentialHelp {
263 acquisition: ApiKey,
264 credential_url: Some("https://build.nvidia.com/settings/api-keys"),
265 docs_url: Some("https://build.nvidia.com/explore/discover"),
266 guidance: "Create an NVIDIA NIM key in the NVIDIA build console.",
267 },
268 ProviderKind::Openai => CredentialHelp {
269 acquisition: ApiKey,
270 credential_url: Some("https://platform.openai.com/api-keys"),
271 docs_url: Some("https://platform.openai.com/docs/api-reference"),
272 guidance: "Create an OpenAI API key, or configure the credential for your compatible endpoint.",
273 },
274 ProviderKind::Atlascloud => CredentialHelp {
275 acquisition: ApiKey,
276 credential_url: Some("https://atlascloud.ai/docs/en/api-keys"),
277 docs_url: Some("https://atlascloud.ai/docs/en/api-keys"),
278 guidance: "Follow Atlas Cloud's API Keys guide to create a credential.",
279 },
280 ProviderKind::WanjieArk => CredentialHelp {
281 acquisition: ApiKey,
282 credential_url: Some("https://docs.wanjiedata.com/maas/maas-openapi-v1.html"),
283 docs_url: Some("https://docs.wanjiedata.com/maas/maas-openapi-v1.html"),
284 guidance: "Follow Wanjie MaaS's APIKEY guide to create a credential.",
285 },
286 ProviderKind::Volcengine => CredentialHelp {
287 acquisition: ApiKey,
288 credential_url: Some("https://console.volcengine.com/ark/apiKey"),
289 docs_url: Some("https://www.volcengine.com/docs/82379/1541594"),
290 guidance: "Create a Volcengine Ark API key in the Ark console.",
291 },
292 ProviderKind::Openrouter => CredentialHelp {
293 acquisition: ApiKey,
294 credential_url: Some("https://openrouter.ai/settings/keys"),
295 docs_url: Some("https://openrouter.ai/docs/api/reference/authentication"),
296 guidance: "Create an OpenRouter key from account settings.",
297 },
298 ProviderKind::Orcarouter => CredentialHelp {
299 acquisition: ApiKey,
300 credential_url: Some("https://www.orcarouter.ai"),
301 docs_url: Some("https://www.orcarouter.ai"),
302 guidance: "Create an OrcaRouter API key from the OrcaRouter dashboard.",
303 },
304 ProviderKind::XiaomiMimo => CredentialHelp {
305 acquisition: ApiKey,
306 credential_url: Some("https://platform.xiaomimimo.com/token-plan"),
307 docs_url: Some("https://mimo.mi.com/docs/en-US/tokenplan/Token%20Plan/subscription"),
308 guidance: "Create a Xiaomi MiMo Token Plan or pay-as-you-go key and keep its matching base URL.",
309 },
310 ProviderKind::Novita => CredentialHelp {
311 acquisition: ApiKey,
312 credential_url: Some("https://novita.ai/en/settings/key-management"),
313 docs_url: Some("https://novita.ai/docs/guides/quickstart"),
314 guidance: "Create a Novita key in account Key Management.",
315 },
316 ProviderKind::Fireworks => CredentialHelp {
317 acquisition: ApiKey,
318 credential_url: Some("https://fireworks.ai/api-keys"),
319 docs_url: Some("https://docs.fireworks.ai/getting-started/quickstart"),
320 guidance: "Create a Fireworks API key before configuring the provider.",
321 },
322 ProviderKind::Siliconflow => CredentialHelp {
323 acquisition: ApiKey,
324 credential_url: Some("https://cloud.siliconflow.com/account/ak"),
325 docs_url: Some("https://docs.siliconflow.com/en/userguide/quickstart"),
326 guidance: "Use the global SiliconFlow console for the global endpoint.",
327 },
328 ProviderKind::SiliconflowCN => CredentialHelp {
329 acquisition: ApiKey,
330 credential_url: Some("https://cloud.siliconflow.cn/account/ak"),
331 docs_url: Some("https://docs.siliconflow.cn/en/userguide/quickstart"),
332 guidance: "Use the China SiliconFlow console for the China endpoint.",
333 },
334 ProviderKind::Arcee => CredentialHelp {
335 acquisition: ApiKey,
336 credential_url: Some("https://docs.arcee.ai/other/create-your-first-api-key"),
337 docs_url: Some("https://docs.arcee.ai/other/create-your-first-api-key"),
338 guidance: "Follow Arcee's API key guide to create a credential.",
339 },
340 ProviderKind::Moonshot => CredentialHelp {
341 acquisition: ApiKey,
342 credential_url: Some("https://platform.kimi.ai/console/api-keys"),
343 docs_url: Some("https://platform.kimi.ai/docs/overview"),
344 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.",
345 },
346 ProviderKind::Sglang => CredentialHelp {
347 acquisition: LocalOptional,
348 credential_url: None,
349 docs_url: Some("https://docs.sglang.ai/"),
350 guidance: "Self-hosted SGLang is keyless by default; configure a key only if your server requires one.",
351 },
352 ProviderKind::Vllm => CredentialHelp {
353 acquisition: LocalOptional,
354 credential_url: None,
355 docs_url: Some("https://docs.vllm.ai/en/stable/serving/openai_compatible_server/"),
356 guidance: "Self-hosted vLLM is keyless by default; configure a key only if your server requires one.",
357 },
358 ProviderKind::Ollama => CredentialHelp {
359 acquisition: LocalOptional,
360 credential_url: None,
361 docs_url: Some("https://docs.ollama.com/api"),
362 guidance: "Local Ollama is keyless by default; configure a key only if your server requires one.",
363 },
364 ProviderKind::OllamaCloud => CredentialHelp {
365 acquisition: ApiKey,
366 credential_url: Some(OLLAMA_CLOUD_API_KEY_URL),
367 docs_url: Some("https://docs.ollama.com/api/authentication"),
368 guidance: "Ollama Cloud requires an API key. Save it for the ollama-cloud provider, set OLLAMA_CLOUD_API_KEY for Pi compatibility, or set Ollama's official OLLAMA_API_KEY.",
369 },
370 ProviderKind::Huggingface => CredentialHelp {
371 acquisition: ApiKey,
372 credential_url: Some("https://huggingface.co/settings/tokens"),
373 docs_url: Some("https://huggingface.co/docs/hub/en/security-tokens"),
374 guidance: "Create a scoped Hugging Face access token.",
375 },
376 ProviderKind::Modelscope => CredentialHelp {
377 acquisition: ApiKey,
378 credential_url: Some("https://modelscope.cn/my/settings/token"),
379 docs_url: None,
380 guidance: "Create an SDK token in ModelScope account settings.",
381 },
382 ProviderKind::Together => CredentialHelp {
383 acquisition: ApiKey,
384 credential_url: Some("https://api.together.ai/settings/api-keys"),
385 docs_url: Some("https://docs.together.ai/docs/api-keys-authentication"),
386 guidance: "Create a Together API key from account settings.",
387 },
388 ProviderKind::Qianfan => CredentialHelp {
389 acquisition: ApiKey,
390 credential_url: Some("https://console.bce.baidu.com/iam/#/iam/accesslist"),
391 docs_url: Some("https://cloud.baidu.com/doc/qianfan/index.html"),
392 guidance: "Create Baidu Qianfan credentials in the Baidu Cloud console.",
393 },
394 ProviderKind::OpenaiCodex => CredentialHelp {
395 acquisition: OAuth,
396 credential_url: None,
397 docs_url: Some("https://developers.openai.com/codex/"),
398 guidance: "Sign in with ChatGPT via `codewhale auth chatgpt` (subscription billing, Codewhale-owned tokens). The openai API-key route is a different billing owner. Codex CLI import remains an explicit alternative after `codex login` plus `codewhale auth external-consent`.",
399 },
400 ProviderKind::Anthropic => CredentialHelp {
401 acquisition: ApiKey,
402 credential_url: Some("https://console.anthropic.com/settings/keys"),
403 docs_url: Some("https://docs.anthropic.com/en/api/overview"),
404 guidance: "Create an Anthropic API key in the Anthropic Console.",
405 },
406 ProviderKind::Openmodel => CredentialHelp {
407 acquisition: ApiKey,
408 credential_url: Some("https://console.openmodel.ai/"),
409 docs_url: Some("https://docs.openmodel.ai/en/docs/getting-started/authentication"),
410 guidance: "Create an API key in the OpenModel console, then follow the authentication guide.",
411 },
412 ProviderKind::Zai => CredentialHelp {
413 acquisition: ApiKey,
414 credential_url: Some("https://z.ai/model-api"),
415 docs_url: Some("https://docs.z.ai/api-reference/introduction"),
416 guidance: "Create or manage a Z.ai API key from the Model API page.",
417 },
418 ProviderKind::Stepfun => CredentialHelp {
419 acquisition: ApiKey,
420 credential_url: Some("https://platform.stepfun.ai/"),
421 docs_url: Some("https://platform.stepfun.ai/docs/en/quickstart/overview"),
422 guidance: "Open Account Management, then Interface Keys, in the StepFun console.",
423 },
424 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => CredentialHelp {
425 acquisition: ApiKey,
426 credential_url: Some(
427 "https://platform.minimax.io/user-center/basic-information/interface-key",
428 ),
429 docs_url: Some("https://platform.minimax.io/docs/api-reference/api-overview"),
430 guidance: "Create a MiniMax API key or subscription-plan key in the user center.",
431 },
432 ProviderKind::Deepinfra => CredentialHelp {
433 acquisition: ApiKey,
434 credential_url: Some("https://deepinfra.com/dash/api_keys"),
435 docs_url: Some("https://docs.deepinfra.com/quickstart"),
436 guidance: "Create a DeepInfra API key from the dashboard.",
437 },
438 ProviderKind::Sakana => CredentialHelp {
439 acquisition: ApiKey,
440 credential_url: Some("https://console.sakana.ai/api-keys"),
441 docs_url: Some("https://console.sakana.ai/get-started"),
442 guidance: "Create a Sakana AI key in the console and copy it when shown.",
443 },
444 ProviderKind::LongCat => CredentialHelp {
445 acquisition: ApiKey,
446 credential_url: Some("https://longcat.chat/platform"),
447 docs_url: Some("https://longcat.chat/platform"),
448 guidance: "Sign up on the LongCat platform and create an API key.",
449 },
450 ProviderKind::OpencodeGo => CredentialHelp {
451 acquisition: ApiKey,
452 credential_url: Some("https://opencode.ai/zen/"),
453 docs_url: Some("https://opencode.ai/docs/go/"),
454 guidance: "Create or copy an OpenCode Go subscription key from OpenCode Zen.",
455 },
456 ProviderKind::OpencodeZen => CredentialHelp {
457 acquisition: ApiKey,
458 credential_url: Some("https://opencode.ai/zen/"),
459 docs_url: Some("https://opencode.ai/docs/zen/"),
460 guidance: "Create or copy an OpenCode Zen API key from OpenCode Zen.",
461 },
462 ProviderKind::Meta => CredentialHelp {
463 acquisition: ApiKey,
464 credential_url: Some("https://developer.meta.com/ai/"),
465 docs_url: Some("https://developer.meta.com/ai/resources/blog/build-with-muse-spark/"),
466 guidance: "Use the Meta developer portal to obtain Model API access and a key.",
467 },
468 ProviderKind::Xai => CredentialHelp {
469 acquisition: ApiKeyOrOAuth,
470 credential_url: Some("https://console.x.ai/"),
471 docs_url: None,
472 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.",
473 },
474 ProviderKind::Mistral => CredentialHelp {
475 acquisition: ApiKey,
476 credential_url: Some("https://console.mistral.ai/api-keys"),
477 docs_url: Some("https://docs.mistral.ai/"),
478 guidance: "Create a Mistral API key in the Mistral Console (la Plateforme).",
479 },
480 ProviderKind::Telecomjs => CredentialHelp {
481 acquisition: ApiKey,
482 credential_url: Some("https://aigw.telecomjs.com/"),
483 docs_url: None,
484 guidance: "Create a TelecomJS TokenHub API key, then use the provider's live model catalog to discover the models available to that key.",
485 },
486 ProviderKind::Edenai => CredentialHelp {
487 acquisition: ApiKey,
488 credential_url: Some("https://app.edenai.run/settings/api-keys"),
489 docs_url: Some("https://www.edenai.co/docs"),
490 guidance: "Create an Eden AI API key from the Eden AI dashboard, then select models by their provider/model namespaced id.",
491 },
492 ProviderKind::Zenmux => CredentialHelp {
493 acquisition: ApiKey,
494 credential_url: Some("https://zenmux.ai/platform/pay-as-you-go"),
495 docs_url: Some("https://zenmux.ai/docs/"),
496 guidance: "Create a ZenMux API key from the Pay As You Go management page, then select models by their provider/model namespaced id. The catalog at https://zenmux.ai/api/v1/models is keyless-readable.",
497 },
498 ProviderKind::Csdn => CredentialHelp {
499 acquisition: ApiKey,
500 credential_url: Some("https://ai.csdn.net/workbench/api-key"),
501 docs_url: Some("https://ai.csdn.net/coding-plan"),
502 guidance: "Create an API key in the CSDN console — choose the Coding Plan key type so glm_for_coding calls bill against plan quota; a general key bills metered.",
503 },
504 ProviderKind::Codewhale => CredentialHelp {
505 acquisition: ApiKey,
506 credential_url: Some(CODEWHALE_API_KEY_URL),
507 docs_url: Some("https://app.codewhale.net/settings?section=api"),
508 guidance: "Create an API key with the models:infer scope at https://app.codewhale.net/settings?section=api",
509 },
510 ProviderKind::Concentrate => CredentialHelp {
511 acquisition: ApiKey,
512 credential_url: Some("https://concentrate.ai/"),
513 docs_url: Some("https://concentrate.ai/docs/api-reference/introduction"),
514 guidance: "Create a Universal API key in the Concentrate dashboard (API Keys → Create API Key). Codewhale sends it only to the Concentrate base URL and never stores or forwards it elsewhere.",
515 },
516 ProviderKind::ModelstudioTokenPlan
517 | ProviderKind::ModelstudioTokenPlanAnthropic
518 | ProviderKind::ModelstudioCodingPlan
519 | ProviderKind::ModelstudioCodingPlanAnthropic => CredentialHelp {
520 acquisition: ApiKey,
521 credential_url: Some("https://bailian.console.aliyun.com/"),
522 docs_url: Some("https://www.alibabacloud.com/help/en/model-studio/"),
523 guidance: "Sign in to Alibaba Cloud Model Studio (Bailian console), create or copy an API key, and select the plan endpoint matching your subscription (Token Plan or Coding Plan).",
524 },
525 ProviderKind::Antigravity => CredentialHelp {
526 acquisition: Configuration,
527 credential_url: None,
528 docs_url: None,
529 guidance: "Legacy configuration only; this route is disabled. Run `codewhale auth clear --provider antigravity` to clear only Codewhale-owned legacy state, then use provider `google` with `GEMINI_API_KEY` for Gemini.",
530 },
531 ProviderKind::Google => CredentialHelp {
532 acquisition: ApiKey,
533 credential_url: Some("https://aistudio.google.com/apikey"),
534 docs_url: Some("https://ai.google.dev/gemini-api/docs/openai"),
535 guidance: "Create a Google AI Studio API key. Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files.",
536 },
537 ProviderKind::Custom => CredentialHelp {
538 acquisition: Configuration,
539 credential_url: None,
540 docs_url: None,
541 guidance: "Set this custom provider's base_url and api_key_env or api_key in configuration; no canonical vendor credential page exists.",
542 },
543 }
544 }
545
546 fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: &str) -> bool {
547 // URL schemes and host names are ASCII case-insensitive; paths are not.
548 // Do not lowercase the whole URL here: a differently-cased path is a
549 // neighboring route, not the official endpoint. Keep this intentionally
550 // dependency-free because provider metadata is used by low-level config
551 // callers that should not need URL parsing machinery just for this guard.
552 let trimmed = base_url.trim();
553 let normalized = trimmed.strip_suffix('/').unwrap_or(trimmed);
554 let Some((scheme, authority_and_path)) = normalized.split_once("://") else {
555 return false;
556 };
557 let Some((authority, path)) = authority_and_path.split_once('/') else {
558 return false;
559 };
560
561 scheme.eq_ignore_ascii_case("https")
562 && authority.eq_ignore_ascii_case(expected_authority)
563 && path == expected_path
564 }
565
566 /// Whether a configured route is exactly the official Kimi Code endpoint.
567 ///
568 /// A trailing slash is insignificant, but neighboring Kimi-hosted paths must
569 /// not inherit membership-plan credentials merely because they share a host.
570 #[must_use]
571 pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool {
572 if kind != ProviderKind::Moonshot {
573 return false;
574 }
575
576 is_exact_https_route(base_url, "api.kimi.com", "coding/v1")
577 }
578
579 /// Whether a configured Ollama route is exactly the hosted OpenAI-compatible
580 /// endpoint.
581 ///
582 /// Local Ollama remains keyless. Neighboring paths, HTTP downgrades, and
583 /// lookalike hosts remain custom routes so they cannot inherit an Ollama Cloud
584 /// credential or durable secret-store slot.
585 #[must_use]
586 pub fn is_exact_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> bool {
587 matches!(kind, ProviderKind::Ollama | ProviderKind::OllamaCloud)
588 && is_exact_https_route(base_url, "ollama.com", "v1")
589 }
590
591 /// In-memory compatibility classifier for the released route-sensitive shape.
592 ///
593 /// Only the old `ollama` identity at the exact hosted endpoint migrates. This
594 /// deliberately rejects neighboring paths, HTTP downgrades, and lookalike
595 /// hosts so no local/custom route can consume Ollama Cloud credentials.
596 #[must_use]
597 pub fn migrates_legacy_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> bool {
598 kind == ProviderKind::Ollama && is_exact_ollama_cloud_route(kind, base_url)
599 }
600
601 /// Whether a configured route is exactly Moonshot's direct API endpoint.
602 ///
603 /// Direct K3 owns a different reasoning-control dialect from the Kimi Code
604 /// membership endpoint. Keep this route guard exact so custom gateways and
605 /// neighboring Moonshot paths do not inherit direct-K3 wire semantics.
606 #[must_use]
607 pub fn is_exact_moonshot_platform_route(kind: ProviderKind, base_url: &str) -> bool {
608 kind == ProviderKind::Moonshot
609 && (is_exact_https_route(base_url, "api.moonshot.ai", "v1")
610 || is_exact_https_route(base_url, "api.moonshot.cn", "v1"))
611 }
612
613 /// Whether a configured route is exactly xAI's first-party OpenAI-compatible
614 /// API endpoint.
615 ///
616 /// Grok-specific request fields must not leak to a custom compatible gateway
617 /// merely because the operator selected the `xai` provider identity.
618 #[must_use]
619 pub fn is_exact_xai_platform_route(kind: ProviderKind, base_url: &str) -> bool {
620 kind == ProviderKind::Xai && is_exact_https_route(base_url, "api.x.ai", "v1")
621 }
622
623 /// Whether a configured route is one of Z.ai's exact first-party Chat
624 /// Completions endpoints.
625 ///
626 /// Z.ai-only request fields must not leak to compatible gateways merely
627 /// because they expose the same model id. Both api.z.ai products (Coding
628 /// Plan and general platform) and BigModel's general platform endpoint are
629 /// first-party: `open.bigmodel.cn/api/paas/v4` is the same open platform
630 /// whose docs prescribe the same `thinking` / `reasoning_effort` dialect
631 /// (including the forced-thinking GLM-5.3 family), and the bundled catalog
632 /// already lists it as the Z.ai catalog API. Neighboring paths — including
633 /// BigModel's `/preview` — remain distinct, mirroring the web-search and
634 /// official-endpoint families.
635 #[must_use]
636 pub fn is_exact_zai_chat_route(kind: ProviderKind, base_url: &str) -> bool {
637 kind == ProviderKind::Zai
638 && (is_exact_https_route(base_url, "api.z.ai", "api/coding/paas/v4")
639 || is_exact_https_route(base_url, "api.z.ai", "api/paas/v4")
640 || is_exact_https_route(base_url, "open.bigmodel.cn", "api/paas/v4"))
641 }
642
643 /// Whether a configured route is one of MiniMax's exact first-party OpenAI
644 /// Chat Completions endpoints.
645 ///
646 /// This deliberately excludes the `/anthropic` routes: those use the native
647 /// Messages adapter and do not share Chat Completions token-limit fields.
648 #[must_use]
649 pub fn is_exact_minimax_chat_route(kind: ProviderKind, base_url: &str) -> bool {
650 kind == ProviderKind::Minimax
651 && (is_exact_https_route(base_url, "api.minimax.io", "v1")
652 || is_exact_https_route(base_url, "api.minimaxi.com", "v1"))
653 }
654
655 /// Whether a configured route is one of MiniMax's exact first-party
656 /// Anthropic-compatible Messages endpoints.
657 ///
658 /// M3 exposes only adaptive/disabled thinking on these routes; it does not
659 /// expose distinct effort tiers. Keep the guard exact so a compatible gateway
660 /// cannot inherit first-party effective-state claims from its provider label.
661 #[must_use]
662 pub fn is_exact_minimax_anthropic_route(kind: ProviderKind, base_url: &str) -> bool {
663 kind == ProviderKind::MinimaxAnthropic
664 && (is_exact_https_route(base_url, "api.minimax.io", "anthropic")
665 || is_exact_https_route(base_url, "api.minimaxi.com", "anthropic"))
666 }
667
668 /// Whether a configured route is exactly CSDN 星图's official OpenAI-compatible
669 /// platform endpoint.
670 ///
671 /// Coding Plan keys and general marketplace keys share this one endpoint, so
672 /// the URL proves neither product — only that the route is first-party.
673 /// Neighboring paths, HTTP downgrades, and lookalike hosts must not inherit
674 /// CSDN billing or wire semantics.
675 #[must_use]
676 pub fn is_exact_csdn_platform_route(kind: ProviderKind, base_url: &str) -> bool {
677 kind == ProviderKind::Csdn && is_exact_https_route(base_url, "ai.csdn.net", "api/model/v1")
678 }
679
680 /// Return credential help for one concrete provider route.
681 ///
682 /// This protects non-UI callers such as diagnostics and command surfaces from
683 /// presenting Moonshot's direct API console for a Kimi Code membership-plan
684 /// endpoint. It performs no discovery, credential lookup, or network I/O.
685 #[must_use]
686 pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> CredentialHelp {
687 if is_exact_ollama_cloud_route(kind, base_url) {
688 return CredentialHelp {
689 acquisition: CredentialAcquisition::ApiKey,
690 credential_url: Some(OLLAMA_CLOUD_API_KEY_URL),
691 docs_url: Some("https://docs.ollama.com/api/authentication"),
692 guidance: "Ollama Cloud requires an API key. Create one in Ollama account settings, then save it for the ollama-cloud provider, set OLLAMA_CLOUD_API_KEY for Pi compatibility, or set Ollama's official OLLAMA_API_KEY.",
693 };
694 }
695
696 if is_exact_kimi_code_route(kind, base_url) {
697 return CredentialHelp {
698 acquisition: CredentialAcquisition::ApiKey,
699 credential_url: Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL),
700 docs_url: None,
701 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.",
702 };
703 }
704
705 credential_help(kind)
706 }
707
708 macro_rules! provider {
709 (
710 $struct_name:ident,
711 $kind:ident,
712 $id:literal,
713 $display_name:literal,
714 $base_url:ident,
715 $model:ident,
716 [$($env_var:literal),* $(,)?],
717 $config_key:literal,
718 aliases: [$($alias:literal),* $(,)?]
719 $(, wire_policy: $wire_policy:expr)?
720 ) => {
721 /// Zero-sized metadata entry for this built-in provider.
722 pub struct $struct_name;
723
724 impl Provider for $struct_name {
725 fn id(&self) -> &'static str {
726 $id
727 }
728
729 fn kind(&self) -> ProviderKind {
730 ProviderKind::$kind
731 }
732
733 fn display_name(&self) -> &'static str {
734 $display_name
735 }
736
737 fn default_base_url(&self) -> &'static str {
738 $base_url
739 }
740
741 fn default_model(&self) -> &'static str {
742 $model
743 }
744
745 fn env_vars(&self) -> &'static [&'static str] {
746 &[$($env_var),*]
747 }
748
749 fn provider_config_key(&self) -> &'static str {
750 $config_key
751 }
752
753 fn aliases(&self) -> &'static [&'static str] {
754 &[$($alias),*]
755 }
756
757 $(fn wire_policy(&self) -> WirePolicy {
758 $wire_policy
759 })?
760 }
761 };
762 }
763
764 /// Official DeepSeek route.
765 ///
766 /// DeepSeek-V4-Flash-0731 is served over the Responses API while V4 Pro
767 /// remains on Chat Completions until DeepSeek enables Responses support for
768 /// it. Keep this provider model-aware so selecting Flash changes the actual
769 /// wire contract instead of only changing the `model` string.
770 pub struct Deepseek;
771
772 impl Provider for Deepseek {
773 fn id(&self) -> &'static str {
774 "deepseek"
775 }
776
777 fn kind(&self) -> ProviderKind {
778 ProviderKind::Deepseek
779 }
780
781 fn display_name(&self) -> &'static str {
782 "DeepSeek"
783 }
784
785 fn default_base_url(&self) -> &'static str {
786 DEFAULT_DEEPSEEK_BASE_URL
787 }
788
789 fn default_model(&self) -> &'static str {
790 DEFAULT_DEEPSEEK_MODEL
791 }
792
793 fn env_vars(&self) -> &'static [&'static str] {
794 &["DEEPSEEK_API_KEY"]
795 }
796
797 fn provider_config_key(&self) -> &'static str {
798 "deepseek"
799 }
800
801 fn aliases(&self) -> &'static [&'static str] {
802 &[
803 "deep-seek",
804 "deepseek-cn",
805 "deepseek_china",
806 "deepseekcn",
807 "deepseek-china",
808 // Dialect is wire=anthropic on this provider, not a second catalog row.
809 "deepseek-anthropic",
810 "deepseek_anthropic",
811 "deepseek-claude",
812 "deepseek_claude",
813 ]
814 }
815
816 fn wire_policy(&self) -> WirePolicy {
817 WirePolicy::ModelAware
818 }
819 }
820
821 /// Opt-in DeepSeek route that speaks the Anthropic Messages wire protocol.
822 ///
823 /// Legacy kind kept for serde; parse/catalog collapse onto [`Deepseek`].
824 pub struct DeepseekAnthropic;
825
826 impl Provider for DeepseekAnthropic {
827 fn id(&self) -> &'static str {
828 "deepseek-anthropic"
829 }
830
831 fn kind(&self) -> ProviderKind {
832 ProviderKind::DeepseekAnthropic
833 }
834
835 fn display_name(&self) -> &'static str {
836 // Legacy dialect kind — catalog surface is "DeepSeek" with wire=anthropic.
837 "DeepSeek"
838 }
839
840 fn default_base_url(&self) -> &'static str {
841 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
842 }
843
844 fn default_model(&self) -> &'static str {
845 DEFAULT_DEEPSEEK_ANTHROPIC_MODEL
846 }
847
848 fn env_vars(&self) -> &'static [&'static str] {
849 &["DEEPSEEK_API_KEY"]
850 }
851
852 fn provider_config_key(&self) -> &'static str {
853 "deepseek_anthropic"
854 }
855
856 fn aliases(&self) -> &'static [&'static str] {
857 &[]
858 }
859
860 fn wire_policy(&self) -> WirePolicy {
861 WirePolicy::Fixed(WireFormat::AnthropicMessages)
862 }
863 }
864 provider!(
865 NvidiaNim,
866 NvidiaNim,
867 "nvidia-nim",
868 "NVIDIA NIM",
869 DEFAULT_NVIDIA_NIM_BASE_URL,
870 DEFAULT_NVIDIA_NIM_MODEL,
871 // DEEPSEEK_API_KEY was listed here as a third fallback and silently
872 // transmitted a DeepSeek credential to NVIDIA's endpoint when a user
873 // with that variable exported switched providers. Removed (#5588);
874 // the legacy root api_key compatibility path stays DeepSeek-scoped.
875 ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
876 "nvidia_nim",
877 aliases: ["nvidia", "nvidia_nim", "nim"]
878 );
879 provider!(
880 Openai,
881 Openai,
882 "openai",
883 "OpenAI-compatible",
884 DEFAULT_OPENAI_BASE_URL,
885 DEFAULT_OPENAI_MODEL,
886 ["OPENAI_API_KEY"],
887 "openai",
888 aliases: ["open-ai"]
889 );
890 provider!(
891 Atlascloud,
892 Atlascloud,
893 "atlascloud",
894 "AtlasCloud",
895 DEFAULT_ATLASCLOUD_BASE_URL,
896 DEFAULT_ATLASCLOUD_MODEL,
897 ["ATLASCLOUD_API_KEY"],
898 "atlascloud",
899 aliases: ["atlas-cloud", "atlas_cloud", "atlas"]
900 );
901 provider!(
902 WanjieArk,
903 WanjieArk,
904 "wanjie-ark",
905 "Wanjie Ark",
906 DEFAULT_WANJIE_ARK_BASE_URL,
907 DEFAULT_WANJIE_ARK_MODEL,
908 [
909 "WANJIE_ARK_API_KEY",
910 "WANJIE_API_KEY",
911 "WANJIE_MAAS_API_KEY"
912 ],
913 "wanjie_ark",
914 aliases: ["wanjie", "wanjie_ark", "ark-wanjie", "ark_wanjie", "wanjieark", "wanjie-maas", "wanjie_maas", "wanjiemaas"]
915 );
916 provider!(
917 Volcengine,
918 Volcengine,
919 "volcengine",
920 "Volcengine Ark",
921 DEFAULT_VOLCENGINE_BASE_URL,
922 DEFAULT_VOLCENGINE_MODEL,
923 [
924 "VOLCENGINE_API_KEY",
925 "VOLCENGINE_ARK_API_KEY",
926 "ARK_API_KEY"
927 ],
928 "volcengine",
929 aliases: ["volcengine-ark", "volcengine_ark", "ark", "volc-ark", "volcengineark"]
930 );
931 provider!(
932 Openrouter,
933 Openrouter,
934 "openrouter",
935 "OpenRouter",
936 DEFAULT_OPENROUTER_BASE_URL,
937 DEFAULT_OPENROUTER_MODEL,
938 ["OPENROUTER_API_KEY"],
939 "openrouter",
940 aliases: ["open_router"]
941 );
942 provider!(
943 Orcarouter,
944 Orcarouter,
945 "orcarouter",
946 "OrcaRouter",
947 DEFAULT_ORCAROUTER_BASE_URL,
948 DEFAULT_ORCAROUTER_MODEL,
949 ["ORCAROUTER_API_KEY"],
950 "orcarouter",
951 aliases: ["orca_router"]
952 );
953 provider!(
954 XiaomiMimo,
955 XiaomiMimo,
956 "xiaomi-mimo",
957 "Xiaomi MiMo",
958 DEFAULT_XIAOMI_MIMO_BASE_URL,
959 DEFAULT_XIAOMI_MIMO_MODEL,
960 [
961 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
962 "MIMO_TOKEN_PLAN_API_KEY",
963 "XIAOMI_MIMO_API_KEY",
964 "XIAOMI_API_KEY",
965 "MIMO_API_KEY",
966 ],
967 "xiaomi_mimo",
968 aliases: ["xiaomi_mimo", "xiaomimimo", "mimo", "xiaomi"]
969 );
970 provider!(
971 Novita,
972 Novita,
973 "novita",
974 "Novita AI",
975 DEFAULT_NOVITA_BASE_URL,
976 DEFAULT_NOVITA_MODEL,
977 ["NOVITA_API_KEY"],
978 "novita",
979 // `novita-ai` is the id Models.dev publishes for this provider; without it a
980 // live/full Models.dev catalog row keyed `novita-ai` would fail to normalize
981 // onto ProviderKind::Novita (Refs #4186).
982 aliases: ["novita-ai", "novita_ai"]
983 );
984 provider!(
985 Fireworks,
986 Fireworks,
987 "fireworks",
988 "Fireworks AI",
989 DEFAULT_FIREWORKS_BASE_URL,
990 DEFAULT_FIREWORKS_MODEL,
991 ["FIREWORKS_API_KEY"],
992 "fireworks",
993 aliases: ["fireworks-ai"]
994 );
995 provider!(
996 Siliconflow,
997 Siliconflow,
998 "siliconflow",
999 "SiliconFlow",
1000 DEFAULT_SILICONFLOW_BASE_URL,
1001 DEFAULT_SILICONFLOW_MODEL,
1002 ["SILICONFLOW_API_KEY"],
1003 "siliconflow",
1004 aliases: ["silicon-flow", "silicon_flow"]
1005 );
1006 provider!(
1007 SiliconflowCN,
1008 SiliconflowCN,
1009 "siliconflow-CN",
1010 "SiliconFlow (China)",
1011 DEFAULT_SILICONFLOW_CN_BASE_URL,
1012 DEFAULT_SILICONFLOW_MODEL,
1013 ["SILICONFLOW_API_KEY"],
1014 "siliconflow_cn",
1015 aliases: [
1016 "silicon-flow-cn",
1017 "silicon-flow-CN",
1018 "silicon_flow_cn",
1019 "silicon_flow_CN",
1020 "siliconflow-china",
1021 ]
1022 );
1023 provider!(
1024 Arcee,
1025 Arcee,
1026 "arcee",
1027 "Arcee AI",
1028 DEFAULT_ARCEE_BASE_URL,
1029 DEFAULT_ARCEE_MODEL,
1030 ["ARCEE_API_KEY"],
1031 "arcee",
1032 aliases: ["arcee-ai", "arcee_ai"]
1033 );
1034 provider!(
1035 Moonshot,
1036 Moonshot,
1037 "moonshot",
1038 "Moonshot/Kimi",
1039 DEFAULT_MOONSHOT_BASE_URL,
1040 DEFAULT_MOONSHOT_MODEL,
1041 ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1042 "moonshot",
1043 // `moonshotai` is the id Models.dev publishes for Moonshot/Kimi; without
1044 // it a live/full Models.dev catalog row keyed `moonshotai` would fail to
1045 // normalize onto ProviderKind::Moonshot (Refs #4186).
1046 aliases: ["moonshot-ai", "moonshotai", "moonshot_ai", "kimi", "kimi-k2"]
1047 );
1048 provider!(
1049 Sglang,
1050 Sglang,
1051 "sglang",
1052 "SGLang",
1053 DEFAULT_SGLANG_BASE_URL,
1054 DEFAULT_SGLANG_MODEL,
1055 ["SGLANG_API_KEY"],
1056 "sglang",
1057 aliases: ["sg-lang"]
1058 );
1059 provider!(
1060 Vllm,
1061 Vllm,
1062 "vllm",
1063 "vLLM",
1064 DEFAULT_VLLM_BASE_URL,
1065 DEFAULT_VLLM_MODEL,
1066 ["VLLM_API_KEY"],
1067 "vllm",
1068 aliases: ["v-llm"]
1069 );
1070 provider!(
1071 Ollama,
1072 Ollama,
1073 "ollama",
1074 "Ollama",
1075 DEFAULT_OLLAMA_BASE_URL,
1076 DEFAULT_OLLAMA_MODEL,
1077 ["OLLAMA_API_KEY"],
1078 "ollama",
1079 aliases: ["ollama-local"]
1080 );
1081 provider!(
1082 OllamaCloud,
1083 OllamaCloud,
1084 "ollama-cloud",
1085 "Ollama Cloud",
1086 DEFAULT_OLLAMA_CLOUD_BASE_URL,
1087 DEFAULT_OLLAMA_CLOUD_MODEL,
1088 ["OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY"],
1089 "ollama_cloud",
1090 aliases: ["ollama_cloud"]
1091 );
1092 provider!(
1093 Huggingface,
1094 Huggingface,
1095 "huggingface",
1096 "Hugging Face",
1097 DEFAULT_HUGGINGFACE_BASE_URL,
1098 DEFAULT_HUGGINGFACE_MODEL,
1099 ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
1100 "huggingface",
1101 aliases: ["hugging-face", "hugging_face", "hf"]
1102 );
1103 provider!(
1104 Modelscope,
1105 Modelscope,
1106 "modelscope",
1107 "ModelScope",
1108 DEFAULT_MODELSCOPE_BASE_URL,
1109 DEFAULT_MODELSCOPE_MODEL,
1110 ["MODELSCOPE_API_KEY"],
1111 "modelscope",
1112 aliases: ["model-scope", "model_scope", "modelscope-cn", "modelscope_cn"]
1113 );
1114 provider!(
1115 Together,
1116 Together,
1117 "together",
1118 "Together AI",
1119 DEFAULT_TOGETHER_BASE_URL,
1120 DEFAULT_TOGETHER_MODEL,
1121 ["TOGETHER_API_KEY"],
1122 "together",
1123 // `togetherai` (no separator) is the id Models.dev publishes for Together;
1124 // the hyphen/underscore spellings are legacy config aliases. All three must
1125 // normalize onto ProviderKind::Together so live-catalog rows keyed
1126 // `togetherai` resolve to the right kind (Refs #4186).
1127 aliases: ["together-ai", "together_ai", "togetherai"]
1128 );
1129 provider!(
1130 Qianfan,
1131 Qianfan,
1132 "qianfan",
1133 "Baidu Qianfan",
1134 DEFAULT_QIANFAN_BASE_URL,
1135 DEFAULT_QIANFAN_MODEL,
1136 ["QIANFAN_API_KEY", "BAIDU_QIANFAN_API_KEY"],
1137 "qianfan",
1138 aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
1139 );
1140 provider!(
1141 Mistral,
1142 Mistral,
1143 "mistral",
1144 "Mistral AI",
1145 DEFAULT_MISTRAL_BASE_URL,
1146 DEFAULT_MISTRAL_MODEL,
1147 ["MISTRAL_API_KEY"],
1148 "mistral",
1149 aliases: ["mistral-ai", "mistral_ai", "mistralai", "la-plateforme", "la_plateforme"]
1150 );
1151
1152 provider!(
1153 Antigravity,
1154 Antigravity,
1155 "antigravity",
1156 "Antigravity (legacy, disabled)",
1157 DEFAULT_ANTIGRAVITY_BASE_URL,
1158 DEFAULT_ANTIGRAVITY_MODEL,
1159 [],
1160 "antigravity",
1161 aliases: ["agy"]
1162 );
1163
1164 provider!(
1165 Google,
1166 Google,
1167 "google",
1168 "Google Gemini",
1169 DEFAULT_GOOGLE_BASE_URL,
1170 DEFAULT_GOOGLE_MODEL,
1171 ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
1172 "google",
1173 aliases: ["google-gemini", "google_gemini", "gemini", "google-ai", "google_ai", "ai-studio", "aistudio"]
1174 );
1175
1176 /// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
1177 pub struct OpenaiCodex;
1178
1179 impl Provider for OpenaiCodex {
1180 fn id(&self) -> &'static str {
1181 "openai-codex"
1182 }
1183
1184 fn kind(&self) -> ProviderKind {
1185 ProviderKind::OpenaiCodex
1186 }
1187
1188 fn display_name(&self) -> &'static str {
1189 "OpenAI Codex (ChatGPT)"
1190 }
1191
1192 fn default_base_url(&self) -> &'static str {
1193 DEFAULT_OPENAI_CODEX_BASE_URL
1194 }
1195
1196 fn default_model(&self) -> &'static str {
1197 DEFAULT_OPENAI_CODEX_MODEL
1198 }
1199
1200 fn env_vars(&self) -> &'static [&'static str] {
1201 &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
1202 }
1203
1204 fn provider_config_key(&self) -> &'static str {
1205 "openai_codex"
1206 }
1207
1208 fn aliases(&self) -> &'static [&'static str] {
1209 &[
1210 "openai_codex",
1211 "openaicodex",
1212 "codex",
1213 "chatgpt",
1214 "chatgpt-codex",
1215 "chatgpt_codex",
1216 "chatgptcodex",
1217 ]
1218 }
1219
1220 fn wire_policy(&self) -> WirePolicy {
1221 WirePolicy::Fixed(WireFormat::Responses)
1222 }
1223 }
1224
1225 /// Native Anthropic Messages API provider (#3014).
1226 pub struct Anthropic;
1227
1228 impl Provider for Anthropic {
1229 fn id(&self) -> &'static str {
1230 "anthropic"
1231 }
1232
1233 fn kind(&self) -> ProviderKind {
1234 ProviderKind::Anthropic
1235 }
1236
1237 fn display_name(&self) -> &'static str {
1238 "Anthropic"
1239 }
1240
1241 fn default_base_url(&self) -> &'static str {
1242 crate::DEFAULT_ANTHROPIC_BASE_URL
1243 }
1244
1245 fn default_model(&self) -> &'static str {
1246 crate::DEFAULT_ANTHROPIC_MODEL
1247 }
1248
1249 fn env_vars(&self) -> &'static [&'static str] {
1250 &["ANTHROPIC_API_KEY"]
1251 }
1252
1253 fn provider_config_key(&self) -> &'static str {
1254 "anthropic"
1255 }
1256
1257 fn wire_policy(&self) -> WirePolicy {
1258 WirePolicy::Fixed(WireFormat::AnthropicMessages)
1259 }
1260 }
1261
1262 /// OpenModel Anthropic-compatible Messages API provider.
1263 pub struct Openmodel;
1264
1265 impl Provider for Openmodel {
1266 fn id(&self) -> &'static str {
1267 "openmodel"
1268 }
1269
1270 fn kind(&self) -> ProviderKind {
1271 ProviderKind::Openmodel
1272 }
1273
1274 fn display_name(&self) -> &'static str {
1275 "OpenModel"
1276 }
1277
1278 fn default_base_url(&self) -> &'static str {
1279 DEFAULT_OPENMODEL_BASE_URL
1280 }
1281
1282 fn default_model(&self) -> &'static str {
1283 DEFAULT_OPENMODEL_MODEL
1284 }
1285
1286 fn env_vars(&self) -> &'static [&'static str] {
1287 &["OPENMODEL_API_KEY"]
1288 }
1289
1290 fn provider_config_key(&self) -> &'static str {
1291 "openmodel"
1292 }
1293
1294 fn aliases(&self) -> &'static [&'static str] {
1295 &["open-model", "open_model"]
1296 }
1297
1298 fn wire_policy(&self) -> WirePolicy {
1299 WirePolicy::Fixed(WireFormat::AnthropicMessages)
1300 }
1301 }
1302
1303 provider!(
1304 Zai,
1305 Zai,
1306 "zai",
1307 "Zhipu AI / Z.ai",
1308 DEFAULT_ZAI_BASE_URL,
1309 DEFAULT_ZAI_MODEL,
1310 ["ZAI_API_KEY", "Z_AI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
1311 "zai",
1312 aliases: ["z-ai", "z_ai", "z.ai", "zhipu", "zhipuai", "bigmodel", "big-model"]
1313 );
1314
1315 provider!(
1316 Stepfun,
1317 Stepfun,
1318 "stepfun",
1319 "StepFun / StepFlash",
1320 DEFAULT_STEPFUN_BASE_URL,
1321 DEFAULT_STEPFUN_MODEL,
1322 ["STEPFUN_API_KEY", "STEP_API_KEY"],
1323 "stepfun",
1324 aliases: ["step-fun", "step_fun", "stepflash", "step-flash", "step_flash"]
1325 );
1326
1327 provider!(
1328 Minimax,
1329 Minimax,
1330 "minimax",
1331 "MiniMax",
1332 DEFAULT_MINIMAX_BASE_URL,
1333 DEFAULT_MINIMAX_MODEL,
1334 ["MINIMAX_API_KEY"],
1335 "minimax",
1336 // Anthropic dialect is wire=anthropic on this provider, not a second row.
1337 aliases: ["mini-max", "mini_max", "minimax-anthropic", "minimax_anthropic", "mini-max-anthropic", "mini_max_anthropic"]
1338 );
1339
1340 /// MiniMax route that speaks the Anthropic Messages wire protocol.
1341 pub struct MinimaxAnthropic;
1342
1343 impl Provider for MinimaxAnthropic {
1344 fn id(&self) -> &'static str {
1345 "minimax-anthropic"
1346 }
1347
1348 fn kind(&self) -> ProviderKind {
1349 ProviderKind::MinimaxAnthropic
1350 }
1351
1352 fn display_name(&self) -> &'static str {
1353 // Legacy dialect kind — catalog surface is "MiniMax" with wire=anthropic.
1354 "MiniMax"
1355 }
1356
1357 fn default_base_url(&self) -> &'static str {
1358 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
1359 }
1360
1361 fn default_model(&self) -> &'static str {
1362 DEFAULT_MINIMAX_MODEL
1363 }
1364
1365 fn env_vars(&self) -> &'static [&'static str] {
1366 &["MINIMAX_API_KEY"]
1367 }
1368
1369 fn provider_config_key(&self) -> &'static str {
1370 "minimax_anthropic"
1371 }
1372
1373 fn aliases(&self) -> &'static [&'static str] {
1374 &[]
1375 }
1376
1377 fn wire_policy(&self) -> WirePolicy {
1378 WirePolicy::Fixed(WireFormat::AnthropicMessages)
1379 }
1380 }
1381
1382 provider!(
1383 Deepinfra,
1384 Deepinfra,
1385 "deepinfra",
1386 "DeepInfra",
1387 DEFAULT_DEEPINFRA_BASE_URL,
1388 DEFAULT_DEEPINFRA_MODEL,
1389 ["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1390 "deepinfra",
1391 aliases: ["deep-infra", "deep_infra"]
1392 );
1393
1394 provider!(
1395 Sakana,
1396 Sakana,
1397 "sakana",
1398 "Sakana AI (Fugu)",
1399 DEFAULT_SAKANA_BASE_URL,
1400 DEFAULT_SAKANA_MODEL,
1401 ["FUGU_API_KEY", "SAKANA_API_KEY"],
1402 "sakana",
1403 aliases: ["sakana-ai", "sakana_ai", "fugu"]
1404 );
1405
1406 provider!(
1407 LongCat,
1408 LongCat,
1409 "longcat",
1410 "Meituan LongCat",
1411 DEFAULT_LONGCAT_BASE_URL,
1412 DEFAULT_LONGCAT_MODEL,
1413 ["LONGCAT_API_KEY"],
1414 "longcat",
1415 aliases: ["long-cat", "meituan-longcat", "meituan"]
1416 );
1417
1418 provider!(
1419 OpencodeGo,
1420 OpencodeGo,
1421 "opencode-go",
1422 "OpenCode Go",
1423 DEFAULT_OPENCODE_GO_BASE_URL,
1424 DEFAULT_OPENCODE_GO_MODEL,
1425 ["OPENCODE_GO_API_KEY"],
1426 "opencode_go",
1427 aliases: ["opencode_go", "opencodego"],
1428 wire_policy: WirePolicy::ModelAware
1429 );
1430
1431 /// OpenCode Zen gateway with a model-scoped wire protocol.
1432 pub struct OpencodeZen;
1433
1434 impl Provider for OpencodeZen {
1435 fn id(&self) -> &'static str {
1436 "opencode-zen"
1437 }
1438
1439 fn kind(&self) -> ProviderKind {
1440 ProviderKind::OpencodeZen
1441 }
1442
1443 fn display_name(&self) -> &'static str {
1444 "OpenCode Zen"
1445 }
1446
1447 fn default_base_url(&self) -> &'static str {
1448 DEFAULT_OPENCODE_ZEN_BASE_URL
1449 }
1450
1451 fn default_model(&self) -> &'static str {
1452 DEFAULT_OPENCODE_ZEN_MODEL
1453 }
1454
1455 fn env_vars(&self) -> &'static [&'static str] {
1456 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1457 }
1458
1459 fn provider_config_key(&self) -> &'static str {
1460 "opencode_zen"
1461 }
1462
1463 fn aliases(&self) -> &'static [&'static str] {
1464 &["opencode_zen", "opencodezen", "zen", "opencode"]
1465 }
1466
1467 fn wire_policy(&self) -> WirePolicy {
1468 WirePolicy::ModelAware
1469 }
1470 }
1471
1472 /// Codewhale API — account-backed model access with a model-scoped wire.
1473 ///
1474 /// One base URL and one `cwc_key_…` account API key with the `models:infer`
1475 /// scope. The account's authenticated `GET {base}/models` is the catalog
1476 /// authority: each row is `provider/model` and carries the protocol
1477 /// (`chat-completions` → `{base}/chat/completions`, `anthropic-messages` →
1478 /// `{base}/messages`, `responses` → `{base}/responses`). Every protocol
1479 /// authenticates with `Authorization: Bearer`; the Anthropic passthrough
1480 /// deliberately does not take `x-api-key`.
1481 pub struct Codewhale;
1482
1483 impl Provider for Codewhale {
1484 fn id(&self) -> &'static str {
1485 "codewhale"
1486 }
1487
1488 fn kind(&self) -> ProviderKind {
1489 ProviderKind::Codewhale
1490 }
1491
1492 fn display_name(&self) -> &'static str {
1493 "Codewhale"
1494 }
1495
1496 fn default_base_url(&self) -> &'static str {
1497 DEFAULT_CODEWHALE_BASE_URL
1498 }
1499
1500 fn default_model(&self) -> &'static str {
1501 DEFAULT_CODEWHALE_MODEL
1502 }
1503
1504 fn env_vars(&self) -> &'static [&'static str] {
1505 &["CODEWHALE_API_KEY"]
1506 }
1507
1508 fn provider_config_key(&self) -> &'static str {
1509 "codewhale"
1510 }
1511
1512 fn aliases(&self) -> &'static [&'static str] {
1513 &[
1514 "codewhale-api",
1515 "codewhale_api",
1516 "cw-api",
1517 "codewhale-cloud",
1518 ]
1519 }
1520
1521 fn wire_policy(&self) -> WirePolicy {
1522 WirePolicy::ModelAware
1523 }
1524 }
1525
1526 provider!(
1527 Meta,
1528 Meta,
1529 "meta",
1530 "Meta Model API",
1531 DEFAULT_META_BASE_URL,
1532 DEFAULT_META_MODEL,
1533 ["META_MODEL_API_KEY", "MODEL_API_KEY"],
1534 "meta",
1535 aliases: [
1536 "meta-ai",
1537 "meta_ai",
1538 "meta-model-api",
1539 "meta_model_api",
1540 "muse",
1541 "muse-spark"
1542 ]
1543 );
1544
1545 provider!(
1546 Xai,
1547 Xai,
1548 "xai",
1549 "xAI",
1550 DEFAULT_XAI_BASE_URL,
1551 DEFAULT_XAI_MODEL,
1552 ["XAI_API_KEY"],
1553 "xai",
1554 aliases: ["x-ai", "x_ai", "grok"]
1555 );
1556
1557 provider!(
1558 Telecomjs,
1559 Telecomjs,
1560 "telecomjs",
1561 "TelecomJS TokenHub",
1562 DEFAULT_TELECOMJS_BASE_URL,
1563 DEFAULT_TELECOMJS_MODEL,
1564 ["TELECOMJS_API_KEY"],
1565 "telecomjs",
1566 aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"]
1567 );
1568 provider!(
1569 Edenai,
1570 Edenai,
1571 "edenai",
1572 "Eden AI",
1573 DEFAULT_EDENAI_BASE_URL,
1574 DEFAULT_EDENAI_MODEL,
1575 ["EDENAI_API_KEY"],
1576 "edenai",
1577 aliases: ["eden-ai", "eden_ai"]
1578 );
1579 provider!(
1580 Zenmux,
1581 Zenmux,
1582 "zenmux",
1583 "ZenMux",
1584 DEFAULT_ZENMUX_BASE_URL,
1585 DEFAULT_ZENMUX_MODEL,
1586 ["ZENMUX_API_KEY"],
1587 "zenmux",
1588 aliases: ["zen-mux", "zen_mux"]
1589 );
1590 provider!(
1591 Csdn,
1592 Csdn,
1593 "csdn",
1594 "CSDN",
1595 DEFAULT_CSDN_BASE_URL,
1596 DEFAULT_CSDN_MODEL,
1597 ["CSDN_API_KEY"],
1598 "csdn",
1599 aliases: [
1600 "csdn-ai",
1601 "csdn_ai",
1602 "csdn-coding-plan",
1603 "csdn_coding_plan",
1604 "starmap"
1605 ]
1606 );
1607
1608 /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator).
1609 ///
1610 /// `provider!()` fixes every macro provider on Chat Completions. Concentrate
1611 /// documents the Responses API as its production surface ("For production
1612 /// use, we recommend using the Responses API"), so it carries a fixed
1613 /// Responses wire policy here instead. Contract:
1614 /// <https://concentrate.ai/docs/api-reference/introduction>. Commercial
1615 /// boundary: its Terms of Service forbid resale, white-label, and
1616 /// service-bureau use without written consent, so this route is BYOK only —
1617 /// the user's own key, their own bill, no Codewhale fee or managed default.
1618 pub struct Concentrate;
1619
1620 impl Provider for Concentrate {
1621 fn id(&self) -> &'static str {
1622 "concentrate"
1623 }
1624
1625 fn kind(&self) -> ProviderKind {
1626 ProviderKind::Concentrate
1627 }
1628
1629 fn display_name(&self) -> &'static str {
1630 "Concentrate"
1631 }
1632
1633 fn default_base_url(&self) -> &'static str {
1634 DEFAULT_CONCENTRATE_BASE_URL
1635 }
1636
1637 fn default_model(&self) -> &'static str {
1638 DEFAULT_CONCENTRATE_MODEL
1639 }
1640
1641 fn env_vars(&self) -> &'static [&'static str] {
1642 &["CONCENTRATE_API_KEY"]
1643 }
1644
1645 fn provider_config_key(&self) -> &'static str {
1646 "concentrate"
1647 }
1648
1649 fn aliases(&self) -> &'static [&'static str] {
1650 &["concentrate-ai", "concentrate_ai", "concentrateai"]
1651 }
1652
1653 fn wire_policy(&self) -> WirePolicy {
1654 WirePolicy::Fixed(WireFormat::Responses)
1655 }
1656 }
1657
1658 /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions).
1659 ///
1660 /// Token Plan Personal and Team share the same regional endpoint. The default
1661 /// region is Asia-Pacific (Singapore); official docs list the same URL for
1662 /// both personal and team plans.
1663 pub struct ModelstudioTokenPlan;
1664
1665 impl Provider for ModelstudioTokenPlan {
1666 fn id(&self) -> &'static str {
1667 "modelstudio-token-plan"
1668 }
1669
1670 fn kind(&self) -> ProviderKind {
1671 ProviderKind::ModelstudioTokenPlan
1672 }
1673
1674 fn display_name(&self) -> &'static str {
1675 // One vendor row. Plan (token vs coding) is `mode` / base_url; wire
1676 // dialect (OpenAI vs Anthropic Messages) is `wire` — never separate
1677 // catalog identities (same product rule as Z.ai / Xiaomi for plans).
1678 "Alibaba Cloud Model Studio"
1679 }
1680
1681 fn default_base_url(&self) -> &'static str {
1682 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL
1683 }
1684
1685 fn default_model(&self) -> &'static str {
1686 DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL
1687 }
1688
1689 fn env_vars(&self) -> &'static [&'static str] {
1690 &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"]
1691 }
1692
1693 fn provider_config_key(&self) -> &'static str {
1694 "modelstudio_token_plan"
1695 }
1696
1697 fn aliases(&self) -> &'static [&'static str] {
1698 // Plan and dialect aliases collapse onto this primary identity.
1699 // Config fields: mode = token-plan|coding-plan, wire = openai|anthropic.
1700 &[
1701 "modelstudio-token-plan",
1702 "modelstudio_token_plan",
1703 "modelstudio",
1704 "alibaba-token-plan",
1705 "dashscope-token-plan",
1706 "alibaba",
1707 "dashscope",
1708 // Legacy plan/dialect kinds — keep resolving so old configs and
1709 // CLI flags do not break; they no longer appear as catalog rows.
1710 "modelstudio-coding-plan",
1711 "modelstudio_coding_plan",
1712 "alibaba-coding-plan",
1713 "dashscope-coding-plan",
1714 "modelstudio-token-plan-anthropic",
1715 "modelstudio_token_plan_anthropic",
1716 "alibaba-token-plan-anthropic",
1717 "modelstudio-coding-plan-anthropic",
1718 "modelstudio_coding_plan_anthropic",
1719 "alibaba-coding-plan-anthropic",
1720 ]
1721 }
1722 }
1723
1724 /// Legacy Model Studio Anthropic dialect kind.
1725 ///
1726 /// Kept for serde / provider_for_kind only. Catalog surface and parse aliases
1727 /// collapse onto [`ModelstudioTokenPlan`] with `wire = "anthropic"`.
1728 pub struct ModelstudioTokenPlanAnthropic;
1729
1730 impl Provider for ModelstudioTokenPlanAnthropic {
1731 fn id(&self) -> &'static str {
1732 "modelstudio-token-plan-anthropic"
1733 }
1734
1735 fn kind(&self) -> ProviderKind {
1736 ProviderKind::ModelstudioTokenPlanAnthropic
1737 }
1738
1739 fn display_name(&self) -> &'static str {
1740 "Alibaba Cloud Model Studio"
1741 }
1742
1743 fn default_base_url(&self) -> &'static str {
1744 MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL
1745 }
1746
1747 fn default_model(&self) -> &'static str {
1748 DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL
1749 }
1750
1751 fn env_vars(&self) -> &'static [&'static str] {
1752 &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"]
1753 }
1754
1755 fn provider_config_key(&self) -> &'static str {
1756 "modelstudio_token_plan_anthropic"
1757 }
1758
1759 fn aliases(&self) -> &'static [&'static str] {
1760 // Empty: aliases live on the primary so parse collapses to it.
1761 &[]
1762 }
1763
1764 fn wire_policy(&self) -> WirePolicy {
1765 WirePolicy::Fixed(WireFormat::AnthropicMessages)
1766 }
1767 }
1768
1769 /// Legacy Model Studio Coding Plan kind (OpenAI wire).
1770 ///
1771 /// Catalog/parse collapse onto [`ModelstudioTokenPlan`] with `mode = "coding-plan"`.
1772 pub struct ModelstudioCodingPlan;
1773
1774 impl Provider for ModelstudioCodingPlan {
1775 fn id(&self) -> &'static str {
1776 "modelstudio-coding-plan"
1777 }
1778
1779 fn kind(&self) -> ProviderKind {
1780 ProviderKind::ModelstudioCodingPlan
1781 }
1782
1783 fn display_name(&self) -> &'static str {
1784 "Alibaba Cloud Model Studio"
1785 }
1786
1787 fn default_base_url(&self) -> &'static str {
1788 DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL
1789 }
1790
1791 fn default_model(&self) -> &'static str {
1792 DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL
1793 }
1794
1795 fn env_vars(&self) -> &'static [&'static str] {
1796 &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"]
1797 }
1798
1799 fn provider_config_key(&self) -> &'static str {
1800 "modelstudio_coding_plan"
1801 }
1802
1803 fn aliases(&self) -> &'static [&'static str] {
1804 &[]
1805 }
1806 }
1807
1808 /// Legacy Model Studio Coding Plan Anthropic dialect kind.
1809 pub struct ModelstudioCodingPlanAnthropic;
1810
1811 impl Provider for ModelstudioCodingPlanAnthropic {
1812 fn id(&self) -> &'static str {
1813 "modelstudio-coding-plan-anthropic"
1814 }
1815
1816 fn kind(&self) -> ProviderKind {
1817 ProviderKind::ModelstudioCodingPlanAnthropic
1818 }
1819
1820 fn display_name(&self) -> &'static str {
1821 "Alibaba Cloud Model Studio"
1822 }
1823
1824 fn default_base_url(&self) -> &'static str {
1825 MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL
1826 }
1827
1828 fn default_model(&self) -> &'static str {
1829 DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL
1830 }
1831
1832 fn env_vars(&self) -> &'static [&'static str] {
1833 &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"]
1834 }
1835
1836 fn provider_config_key(&self) -> &'static str {
1837 "modelstudio_coding_plan_anthropic"
1838 }
1839
1840 fn aliases(&self) -> &'static [&'static str] {
1841 &[]
1842 }
1843
1844 fn wire_policy(&self) -> WirePolicy {
1845 WirePolicy::Fixed(WireFormat::AnthropicMessages)
1846 }
1847 }
1848
1849 /// User-defined OpenAI-compatible endpoint (#1519).
1850 ///
1851 /// A single dynamic provider identity for arbitrary `[providers.<name>]
1852 /// kind="openai-compatible"` config entries. Unlike the built-in providers it
1853 /// carries no real default base URL/model/env var: the concrete endpoint, model
1854 /// id, and auth env var all arrive from the named `[providers.<name>]` config
1855 /// table at route time. The placeholder base URL/model here exist only so the
1856 /// descriptor stays well-formed (non-empty) for conformance; runtime routing
1857 /// always supplies a `base_url_override` and a wire model id, so these
1858 /// placeholders are never used to reach the network.
1859 pub struct Custom;
1860
1861 impl Provider for Custom {
1862 fn id(&self) -> &'static str {
1863 "custom"
1864 }
1865
1866 fn kind(&self) -> ProviderKind {
1867 ProviderKind::Custom
1868 }
1869
1870 fn display_name(&self) -> &'static str {
1871 "Custom (OpenAI-compatible)"
1872 }
1873
1874 fn default_base_url(&self) -> &'static str {
1875 // Placeholder only; the real endpoint comes from the named config table
1876 // via the route's base_url_override. Loopback so a misconfigured custom
1877 // provider fails closed locally rather than reaching a public host.
1878 "http://localhost/v1"
1879 }
1880
1881 fn default_model(&self) -> &'static str {
1882 // Placeholder only; the real model id comes from config and is preserved
1883 // verbatim as the wire model id.
1884 "custom-model"
1885 }
1886
1887 fn env_vars(&self) -> &'static [&'static str] {
1888 // No built-in env var: the auth env var is named per-entry via
1889 // `[providers.<name>] api_key_env = "..."`.
1890 &[]
1891 }
1892
1893 fn provider_config_key(&self) -> &'static str {
1894 "custom"
1895 }
1896
1897 fn wire_policy(&self) -> WirePolicy {
1898 // Static default remains Chat Completions for backward compatibility.
1899 // Per-config `wire = "responses" | "anthropic" | "chat"` overrides are
1900 // honored in `crates/tui/src/client.rs::provider_wire_format_for_config`
1901 // and `crates/tui/src/config.rs::provider_capability`, which read
1902 // `ProviderConfig::wire` for the `Custom` catalog identity. This keeps
1903 // the `Provider` trait `Fixed` while giving custom endpoints the same
1904 // three-way switch (`responses` / `anthropic` / `chat`) as built-ins.
1905 WirePolicy::Fixed(WireFormat::ChatCompletions)
1906 }
1907 }
1908
1909 static DEEPSEEK: Deepseek = Deepseek;
1910 static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic;
1911 static NVIDIA_NIM: NvidiaNim = NvidiaNim;
1912 static OPENAI: Openai = Openai;
1913 static ATLASCLOUD: Atlascloud = Atlascloud;
1914 static WANJIE_ARK: WanjieArk = WanjieArk;
1915 static VOLCENGINE: Volcengine = Volcengine;
1916 static OPENROUTER: Openrouter = Openrouter;
1917 static ORCAROUTER: Orcarouter = Orcarouter;
1918 static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
1919 static NOVITA: Novita = Novita;
1920 static FIREWORKS: Fireworks = Fireworks;
1921 static SILICONFLOW: Siliconflow = Siliconflow;
1922 static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN;
1923 static ARCEE: Arcee = Arcee;
1924 static MOONSHOT: Moonshot = Moonshot;
1925 static SGLANG: Sglang = Sglang;
1926 static VLLM: Vllm = Vllm;
1927 static OLLAMA: Ollama = Ollama;
1928 static OLLAMA_CLOUD: OllamaCloud = OllamaCloud;
1929 static HUGGINGFACE: Huggingface = Huggingface;
1930 static MODELSCOPE: Modelscope = Modelscope;
1931 static TOGETHER: Together = Together;
1932 static QIANFAN: Qianfan = Qianfan;
1933 static OPENAI_CODEX: OpenaiCodex = OpenaiCodex;
1934 static ANTHROPIC: Anthropic = Anthropic;
1935 static OPENMODEL: Openmodel = Openmodel;
1936 static ZAI: Zai = Zai;
1937 static STEPFUN: Stepfun = Stepfun;
1938 static MINIMAX: Minimax = Minimax;
1939 static MINIMAX_ANTHROPIC: MinimaxAnthropic = MinimaxAnthropic;
1940 static DEEPINFRA: Deepinfra = Deepinfra;
1941 static SAKANA: Sakana = Sakana;
1942 static LONGCAT: LongCat = LongCat;
1943 static OPENCODE_GO: OpencodeGo = OpencodeGo;
1944 static OPENCODE_ZEN: OpencodeZen = OpencodeZen;
1945 static META: Meta = Meta;
1946 static XAI: Xai = Xai;
1947 static MISTRAL: Mistral = Mistral;
1948 static ANTIGRAVITY: Antigravity = Antigravity;
1949 static TELECOMJS: Telecomjs = Telecomjs;
1950 static EDENAI: Edenai = Edenai;
1951 static ZENMUX: Zenmux = Zenmux;
1952 static CSDN: Csdn = Csdn;
1953 static CONCENTRATE: Concentrate = Concentrate;
1954 static CODEWHALE: Codewhale = Codewhale;
1955 static MODELSTUDIO_TOKEN_PLAN: ModelstudioTokenPlan = ModelstudioTokenPlan;
1956 static MODELSTUDIO_TOKEN_PLAN_ANTHROPIC: ModelstudioTokenPlanAnthropic =
1957 ModelstudioTokenPlanAnthropic;
1958 static MODELSTUDIO_CODING_PLAN: ModelstudioCodingPlan = ModelstudioCodingPlan;
1959 static MODELSTUDIO_CODING_PLAN_ANTHROPIC: ModelstudioCodingPlanAnthropic =
1960 ModelstudioCodingPlanAnthropic;
1961 static CUSTOM: Custom = Custom;
1962
1963 static PROVIDER_REGISTRY: [&dyn Provider; 52] = [
1964 &DEEPSEEK,
1965 &DEEPSEEK_ANTHROPIC,
1966 &NVIDIA_NIM,
1967 &OPENAI,
1968 &ATLASCLOUD,
1969 &WANJIE_ARK,
1970 &VOLCENGINE,
1971 &OPENROUTER,
1972 &ORCAROUTER,
1973 &XIAOMI_MIMO,
1974 &NOVITA,
1975 &FIREWORKS,
1976 &SILICONFLOW,
1977 &ARCEE,
1978 &SILICONFLOW_CN,
1979 &MOONSHOT,
1980 &SGLANG,
1981 &VLLM,
1982 &OLLAMA,
1983 &OLLAMA_CLOUD,
1984 &HUGGINGFACE,
1985 &MODELSCOPE,
1986 &TOGETHER,
1987 &QIANFAN,
1988 &OPENAI_CODEX,
1989 &ANTHROPIC,
1990 &OPENMODEL,
1991 &ZAI,
1992 &STEPFUN,
1993 &MINIMAX,
1994 &MINIMAX_ANTHROPIC,
1995 &DEEPINFRA,
1996 &SAKANA,
1997 &LONGCAT,
1998 &OPENCODE_GO,
1999 &OPENCODE_ZEN,
2000 &META,
2001 &XAI,
2002 &MISTRAL,
2003 &TELECOMJS,
2004 &EDENAI,
2005 &ZENMUX,
2006 &CSDN,
2007 &CONCENTRATE,
2008 &CODEWHALE,
2009 &MODELSTUDIO_TOKEN_PLAN,
2010 &MODELSTUDIO_TOKEN_PLAN_ANTHROPIC,
2011 &MODELSTUDIO_CODING_PLAN,
2012 &MODELSTUDIO_CODING_PLAN_ANTHROPIC,
2013 &Google,
2014 &ANTIGRAVITY,
2015 &CUSTOM,
2016 ];
2017
2018 /// Return all built-in and legacy provider metadata entries.
2019 ///
2020 /// The full registry retains legacy entries needed to read old configuration.
2021 /// It is intentionally NOT a user-facing provider list; for browsing/picker
2022 /// surfaces use [`providers_sorted_for_display`].
2023 #[must_use]
2024 pub fn all_providers() -> &'static [&'static dyn Provider] {
2025 &PROVIDER_REGISTRY
2026 }
2027
2028 /// Return all built-in providers ordered for user-facing display.
2029 ///
2030 /// Providers are sorted alphabetically (case-insensitively) by
2031 /// [`Provider::display_name`] so model/provider browsing surfaces present a
2032 /// neutral, predictable list rather than leading with whichever provider
2033 /// happens to sit first in [`ProviderKind::ALL`] (historically DeepSeek). The
2034 /// ordering policy intentionally differs from internal parsing/default order:
2035 ///
2036 /// - [`all_providers`] — full compatibility registry for internal identity
2037 /// matching, including legacy entries.
2038 /// - [`ProviderKind::ALL`] — stable selectable catalog order. Do not reorder.
2039 /// - [`providers_sorted_for_display`] — neutral alphabetical order for UI
2040 /// browsing, with legacy tombstones omitted. DeepSeek stays present and
2041 /// searchable but is not hard-coded first; a caller may still highlight/pin
2042 /// the active provider separately.
2043 ///
2044 /// Returns an owned `Vec` because the sorted order is computed, not static.
2045 #[must_use]
2046 pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
2047 let mut providers: Vec<_> = all_providers()
2048 .iter()
2049 .copied()
2050 .filter(|provider| provider.kind() != ProviderKind::Antigravity)
2051 .collect();
2052 providers.sort_by(|a, b| {
2053 a.display_name()
2054 .to_ascii_lowercase()
2055 .cmp(&b.display_name().to_ascii_lowercase())
2056 });
2057 providers
2058 }
2059
2060 /// Find a provider by canonical id only.
2061 #[must_use]
2062 pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
2063 let id = id.trim();
2064 all_providers()
2065 .iter()
2066 .copied()
2067 .find(|provider| provider.id() == id)
2068 }
2069
2070 /// Resolve a provider by canonical id or supported legacy alias.
2071 #[must_use]
2072 pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
2073 ProviderKind::parse(id_or_alias).map(provider_for_kind)
2074 }
2075
2076 /// Return metadata for a known provider kind.
2077 #[must_use]
2078 pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
2079 PROVIDER_REGISTRY
2080 .iter()
2081 .find(|p| p.kind() == kind)
2082 .copied()
2083 .expect("ProviderKind variant missing from PROVIDER_REGISTRY")
2084 }
2085
2086 #[cfg(test)]
2087 mod tests {
2088 use super::*;
2089
2090 #[test]
2091 fn credential_help_covers_every_provider_without_guessing_non_key_urls() {
2092 for provider in all_providers() {
2093 let help = provider.credential_help();
2094 assert!(
2095 !help.guidance.trim().is_empty(),
2096 "{} credential guidance must not be empty",
2097 provider.id()
2098 );
2099
2100 match help.acquisition {
2101 CredentialAcquisition::ApiKey | CredentialAcquisition::ApiKeyOrOAuth => {
2102 assert!(
2103 help.credential_url.is_some(),
2104 "{} needs a stable provider-owned credential link",
2105 provider.id()
2106 );
2107 }
2108 CredentialAcquisition::LocalOptional
2109 | CredentialAcquisition::OAuth
2110 | CredentialAcquisition::Configuration => assert!(
2111 help.credential_url.is_none(),
2112 "{} must explain its non-key route instead of inventing a credential link",
2113 provider.id()
2114 ),
2115 }
2116 }
2117 }
2118
2119 #[test]
2120 fn kimi_credential_help_uses_the_durable_api_key_console_only() {
2121 let help = provider_for_kind(ProviderKind::Moonshot).credential_help();
2122
2123 assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
2124 assert_eq!(
2125 help.credential_url,
2126 Some("https://platform.kimi.ai/console/api-keys")
2127 );
2128 assert_eq!(
2129 help.docs_url,
2130 Some("https://platform.kimi.ai/docs/overview")
2131 );
2132 assert!(help.guidance.contains("create and copy an API key"));
2133 assert!(help.guidance.contains("OAuth is not available"));
2134 }
2135
2136 #[test]
2137 fn kimi_code_route_credential_help_is_distinct_from_direct_moonshot() {
2138 let direct = credential_help_for_route(ProviderKind::Moonshot, DEFAULT_MOONSHOT_BASE_URL);
2139 let kimi_code =
2140 credential_help_for_route(ProviderKind::Moonshot, "https://api.kimi.com/coding/v1/");
2141
2142 assert_eq!(
2143 direct.credential_url,
2144 Some("https://platform.kimi.ai/console/api-keys")
2145 );
2146 assert_eq!(
2147 kimi_code.credential_url,
2148 Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
2149 );
2150 assert_eq!(kimi_code.docs_url, None);
2151 assert!(kimi_code.guidance.contains("membership-plan API key"));
2152 assert!(
2153 kimi_code
2154 .guidance
2155 .contains("does not import Kimi CLI credentials")
2156 );
2157 assert!(!is_exact_kimi_code_route(
2158 ProviderKind::Moonshot,
2159 "https://api.kimi.com/coding/v1/preview"
2160 ));
2161
2162 // Scheme and hostname casing are insignificant, but the endpoint
2163 // path is a route identifier and must remain exact.
2164 assert!(is_exact_kimi_code_route(
2165 ProviderKind::Moonshot,
2166 "HTTPS://API.KIMI.COM/coding/v1/"
2167 ));
2168 for neighboring_route in [
2169 "https://api.kimi.com/CODING/v1",
2170 "https://api.kimi.com/coding/V1",
2171 "http://api.kimi.com/coding/v1",
2172 "https://api.kimi.com:443/coding/v1",
2173 "https://api.kimi.com/coding/v1?preview=1",
2174 "https://api.kimi.com/coding/v1#fragment",
2175 "https://api.kimi.com/coding/v1//",
2176 ] {
2177 assert!(
2178 !is_exact_kimi_code_route(ProviderKind::Moonshot, neighboring_route),
2179 "{neighboring_route} must not inherit Kimi Code membership semantics"
2180 );
2181 }
2182 }
2183
2184 #[test]
2185 fn ollama_cloud_route_is_exact_and_requires_its_own_key() {
2186 for base_url in [
2187 OLLAMA_CLOUD_BASE_URL,
2188 "https://ollama.com/v1/",
2189 " HTTPS://OLLAMA.COM/v1/ ",
2190 ] {
2191 for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
2192 assert!(is_exact_ollama_cloud_route(provider, base_url));
2193 let help = credential_help_for_route(provider, base_url);
2194 assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
2195 assert_eq!(help.credential_url, Some(OLLAMA_CLOUD_API_KEY_URL));
2196 assert_eq!(
2197 help.docs_url,
2198 Some("https://docs.ollama.com/api/authentication")
2199 );
2200 assert!(help.guidance.contains("OLLAMA_CLOUD_API_KEY"));
2201 assert!(help.guidance.contains("OLLAMA_API_KEY"));
2202 }
2203 }
2204
2205 for base_url in [
2206 "http://ollama.com/v1",
2207 "https://ollama.com",
2208 "https://ollama.com/api",
2209 "https://ollama.com/v1/preview",
2210 "https://ollama.com.evil.example/v1",
2211 "https://api.ollama.com/v1",
2212 "https://ollama.com/v1?tenant=other",
2213 ] {
2214 assert!(!is_exact_ollama_cloud_route(ProviderKind::Ollama, base_url));
2215 assert!(!is_exact_ollama_cloud_route(
2216 ProviderKind::OllamaCloud,
2217 base_url
2218 ));
2219 }
2220 assert!(!is_exact_ollama_cloud_route(
2221 ProviderKind::Openai,
2222 OLLAMA_CLOUD_BASE_URL
2223 ));
2224
2225 let local = credential_help_for_route(ProviderKind::Ollama, DEFAULT_OLLAMA_BASE_URL);
2226 assert_eq!(local.acquisition, CredentialAcquisition::LocalOptional);
2227 assert_eq!(local.credential_url, None);
2228 assert!(local.guidance.contains("keyless by default"));
2229 }
2230
2231 #[test]
2232 fn direct_moonshot_route_matching_is_exact() {
2233 for route in ["HTTPS://API.MOONSHOT.AI/v1/", "HTTPS://API.MOONSHOT.CN/v1/"] {
2234 assert!(is_exact_moonshot_platform_route(
2235 ProviderKind::Moonshot,
2236 route
2237 ));
2238 }
2239 for neighboring_route in [
2240 "https://api.moonshot.ai/V1",
2241 "http://api.moonshot.ai/v1",
2242 "https://api.moonshot.ai:443/v1",
2243 "https://api.moonshot.ai/v1?preview=1",
2244 "https://api.moonshot.ai/v1#fragment",
2245 "https://api.moonshot.ai/v1//",
2246 "https://api.moonshot.ai/v1/chat/completions",
2247 "https://api.moonshot.cn/v1/chat/completions",
2248 "https://api.kimi.com/coding/v1",
2249 ] {
2250 assert!(
2251 !is_exact_moonshot_platform_route(ProviderKind::Moonshot, neighboring_route),
2252 "{neighboring_route} must not inherit direct Moonshot semantics"
2253 );
2254 }
2255 assert!(!is_exact_moonshot_platform_route(
2256 ProviderKind::Openai,
2257 crate::MOONSHOT_CN_BASE_URL
2258 ));
2259 }
2260
2261 #[test]
2262 fn direct_xai_route_matching_is_exact() {
2263 assert!(is_exact_xai_platform_route(
2264 ProviderKind::Xai,
2265 "HTTPS://API.X.AI/v1/"
2266 ));
2267 for neighboring_route in [
2268 "https://api.x.ai/V1",
2269 "http://api.x.ai/v1",
2270 "https://api.x.ai:443/v1",
2271 "https://api.x.ai/v1?preview=1",
2272 "https://api.x.ai/v1#fragment",
2273 "https://api.x.ai/v1//",
2274 "https://api.x.ai/v1/chat/completions",
2275 "https://gateway.example/v1",
2276 ] {
2277 assert!(
2278 !is_exact_xai_platform_route(ProviderKind::Xai, neighboring_route),
2279 "{neighboring_route} must not inherit xAI-only request fields"
2280 );
2281 }
2282 assert!(!is_exact_xai_platform_route(
2283 ProviderKind::Openai,
2284 DEFAULT_XAI_BASE_URL
2285 ));
2286 }
2287
2288 #[test]
2289 fn zai_chat_route_matching_is_exact() {
2290 for route in [
2291 "https://api.z.ai/api/coding/paas/v4",
2292 "https://api.z.ai/api/paas/v4/",
2293 "HTTPS://API.Z.AI/api/paas/v4",
2294 // BigModel's general platform endpoint is the same first-party
2295 // open platform; authority case stays insignificant.
2296 "https://open.bigmodel.cn/api/paas/v4",
2297 "https://open.bigmodel.cn/api/paas/v4/",
2298 "HTTPS://OPEN.BIGMODEL.CN/api/paas/v4",
2299 ] {
2300 assert!(is_exact_zai_chat_route(ProviderKind::Zai, route), "{route}");
2301 }
2302 for neighboring_route in [
2303 "http://api.z.ai/api/paas/v4",
2304 "https://api.z.ai:443/api/paas/v4",
2305 "https://api.z.ai/API/paas/v4",
2306 "https://api.z.ai/api/paas/v4?preview=1",
2307 "https://api.z.ai/api/paas/v4#fragment",
2308 "https://api.z.ai/api/paas/v4//",
2309 "https://api.z.ai/api/paas/v4/chat/completions",
2310 // BigModel neighbors: the undocumented coding path and the
2311 // preview product stay fail-closed, like the official-endpoint
2312 // and web-search families.
2313 "https://open.bigmodel.cn/api/paas/v4/preview",
2314 "https://open.bigmodel.cn/api/coding/paas/v4",
2315 "http://open.bigmodel.cn/api/paas/v4",
2316 "https://open.bigmodel.cn/API/paas/v4",
2317 "https://gateway.example/v1",
2318 ] {
2319 assert!(
2320 !is_exact_zai_chat_route(ProviderKind::Zai, neighboring_route),
2321 "{neighboring_route} must not inherit Z.ai-only request fields"
2322 );
2323 }
2324 assert!(!is_exact_zai_chat_route(
2325 ProviderKind::Openai,
2326 DEFAULT_ZAI_BASE_URL
2327 ));
2328 assert!(!is_exact_zai_chat_route(
2329 ProviderKind::Openai,
2330 "https://open.bigmodel.cn/api/paas/v4"
2331 ));
2332 }
2333
2334 #[test]
2335 fn minimax_chat_route_matching_is_exact_and_excludes_messages() {
2336 for route in [
2337 "https://api.minimax.io/v1",
2338 "https://api.minimaxi.com/v1/",
2339 "HTTPS://API.MINIMAX.IO/v1",
2340 ] {
2341 assert!(
2342 is_exact_minimax_chat_route(ProviderKind::Minimax, route),
2343 "{route}"
2344 );
2345 }
2346 for neighboring_route in [
2347 "http://api.minimax.io/v1",
2348 "https://api.minimax.io:443/v1",
2349 "https://api.minimax.io/V1",
2350 "https://api.minimax.io/v1?preview=1",
2351 "https://api.minimax.io/v1#fragment",
2352 "https://api.minimax.io/v1//",
2353 "https://api.minimax.io/v1/chat/completions",
2354 "https://api.minimax.io/anthropic",
2355 "https://api.minimaxi.com/anthropic",
2356 "https://gateway.example/v1",
2357 ] {
2358 assert!(
2359 !is_exact_minimax_chat_route(ProviderKind::Minimax, neighboring_route),
2360 "{neighboring_route} must not inherit MiniMax Chat request fields"
2361 );
2362 }
2363 assert!(!is_exact_minimax_chat_route(
2364 ProviderKind::MinimaxAnthropic,
2365 DEFAULT_MINIMAX_BASE_URL
2366 ));
2367 }
2368
2369 #[test]
2370 fn minimax_anthropic_route_matching_is_exact_and_excludes_chat() {
2371 for route in [
2372 "https://api.minimax.io/anthropic",
2373 "https://api.minimaxi.com/anthropic/",
2374 "HTTPS://API.MINIMAX.IO/anthropic",
2375 ] {
2376 assert!(
2377 is_exact_minimax_anthropic_route(ProviderKind::MinimaxAnthropic, route),
2378 "{route}"
2379 );
2380 }
2381 for neighboring_route in [
2382 "http://api.minimax.io/anthropic",
2383 "https://api.minimax.io:443/anthropic",
2384 "https://api.minimax.io/Anthropic",
2385 "https://api.minimax.io/anthropic?preview=1",
2386 "https://api.minimax.io/anthropic#fragment",
2387 "https://api.minimax.io/anthropic//",
2388 "https://api.minimax.io/anthropic/v1/messages",
2389 "https://api.minimax.io/v1",
2390 "https://gateway.example/anthropic",
2391 ] {
2392 assert!(
2393 !is_exact_minimax_anthropic_route(
2394 ProviderKind::MinimaxAnthropic,
2395 neighboring_route
2396 ),
2397 "{neighboring_route} must not inherit MiniMax Messages semantics"
2398 );
2399 }
2400 assert!(!is_exact_minimax_anthropic_route(
2401 ProviderKind::Minimax,
2402 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
2403 ));
2404 }
2405
2406 #[test]
2407 fn non_key_and_mixed_routes_are_typed_explicitly() {
2408 for kind in [
2409 ProviderKind::Sglang,
2410 ProviderKind::Vllm,
2411 ProviderKind::Ollama,
2412 ] {
2413 assert_eq!(
2414 provider_for_kind(kind).credential_help().acquisition,
2415 CredentialAcquisition::LocalOptional
2416 );
2417 }
2418 assert_eq!(
2419 provider_for_kind(ProviderKind::OpenaiCodex)
2420 .credential_help()
2421 .acquisition,
2422 CredentialAcquisition::OAuth
2423 );
2424 assert_eq!(
2425 provider_for_kind(ProviderKind::Xai)
2426 .credential_help()
2427 .acquisition,
2428 CredentialAcquisition::ApiKeyOrOAuth
2429 );
2430 assert_eq!(
2431 provider_for_kind(ProviderKind::Custom)
2432 .credential_help()
2433 .acquisition,
2434 CredentialAcquisition::Configuration
2435 );
2436 }
2437
2438 #[test]
2439 fn antigravity_registry_entry_is_a_non_runnable_legacy_tombstone() {
2440 let legacy = provider_for_kind(ProviderKind::Antigravity);
2441 assert_eq!(legacy.id(), "antigravity");
2442 assert!(legacy.env_vars().is_empty());
2443 assert!(legacy.default_base_url().ends_with(".invalid"));
2444 assert_eq!(legacy.default_model(), "legacy-antigravity-disabled");
2445
2446 let help = legacy.credential_help();
2447 assert_eq!(help.acquisition, CredentialAcquisition::Configuration);
2448 assert_eq!(help.credential_url, None);
2449 assert_eq!(help.docs_url, None);
2450 assert!(
2451 help.guidance
2452 .contains("codewhale auth clear --provider antigravity")
2453 );
2454 assert!(help.guidance.contains("provider `google`"));
2455 assert!(help.guidance.contains("GEMINI_API_KEY"));
2456 }
2457
2458 #[test]
2459 fn live_verified_console_replacements_do_not_regress_to_404_links() {
2460 let openmodel = provider_for_kind(ProviderKind::Openmodel).credential_help();
2461 assert_eq!(
2462 openmodel.credential_url,
2463 Some("https://console.openmodel.ai/")
2464 );
2465 assert_eq!(
2466 openmodel.docs_url,
2467 Some("https://docs.openmodel.ai/en/docs/getting-started/authentication")
2468 );
2469
2470 let sakana = provider_for_kind(ProviderKind::Sakana).credential_help();
2471 assert_eq!(
2472 sakana.credential_url,
2473 Some("https://console.sakana.ai/api-keys")
2474 );
2475 assert_eq!(
2476 sakana.docs_url,
2477 Some("https://console.sakana.ai/get-started")
2478 );
2479 }
2480
2481 #[test]
2482 fn model_aware_wire_policy_resolves_only_supported_endpoint_keys() {
2483 let policy = WirePolicy::ModelAware;
2484 assert_eq!(policy.resolve("chat"), Some(WireFormat::ChatCompletions));
2485 assert_eq!(policy.resolve("responses"), Some(WireFormat::Responses));
2486 assert_eq!(
2487 policy.resolve("messages"),
2488 Some(WireFormat::AnthropicMessages)
2489 );
2490 assert_eq!(policy.resolve("models/gemini-3.1-pro"), None);
2491 assert_eq!(policy.resolve(""), None);
2492 }
2493
2494 #[test]
2495 fn fixed_wire_policy_ignores_catalog_endpoint_keys() {
2496 let policy = WirePolicy::Fixed(WireFormat::Responses);
2497 assert_eq!(policy.resolve("chat"), Some(WireFormat::Responses));
2498 assert_eq!(policy.resolve("unknown"), Some(WireFormat::Responses));
2499 }
2500
2501 #[test]
2502 fn display_order_is_alphabetical_by_display_name() {
2503 let display = providers_sorted_for_display();
2504 let names: Vec<String> = display
2505 .iter()
2506 .map(|p| p.display_name().to_ascii_lowercase())
2507 .collect();
2508 let mut sorted = names.clone();
2509 sorted.sort();
2510 assert_eq!(
2511 names, sorted,
2512 "providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
2513 );
2514 }
2515
2516 #[test]
2517 fn display_order_differs_from_internal_all_order() {
2518 // The whole point of the helper is that UI ordering is NOT the
2519 // internal compatibility-registry insertion order.
2520 let display_ids: Vec<&str> = providers_sorted_for_display()
2521 .iter()
2522 .map(|p| p.id())
2523 .collect();
2524 let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
2525 assert_ne!(
2526 display_ids, internal_ids,
2527 "display order should not match internal ALL order"
2528 );
2529 }
2530
2531 #[test]
2532 fn display_order_is_complete_and_unique() {
2533 // Every selectable provider is retained exactly once; legacy
2534 // configuration tombstones stay in the internal registry only.
2535 let display = providers_sorted_for_display();
2536 assert_eq!(
2537 display.len(),
2538 all_providers().len() - 1,
2539 "display order must include every selectable built-in provider"
2540 );
2541 assert!(
2542 all_providers()
2543 .iter()
2544 .any(|provider| provider.kind() == ProviderKind::Antigravity),
2545 "legacy config identity must remain in the internal registry"
2546 );
2547 assert!(
2548 display
2549 .iter()
2550 .all(|provider| provider.kind() != ProviderKind::Antigravity),
2551 "legacy Antigravity tombstone must not appear in provider pickers"
2552 );
2553 let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
2554 ids.sort_unstable();
2555 let before = ids.len();
2556 ids.dedup();
2557 assert_eq!(
2558 before,
2559 ids.len(),
2560 "display order must not contain duplicates"
2561 );
2562 }
2563
2564 #[test]
2565 fn deepseek_is_present_but_not_first_in_display_order() {
2566 // Acceptance: DeepSeek stays searchable but is no longer hard-coded
2567 // first in provider browsing UI. (It is first in internal ALL order.)
2568 let display = providers_sorted_for_display();
2569 assert_eq!(
2570 all_providers()[0].kind(),
2571 ProviderKind::Deepseek,
2572 "DeepSeek is expected to remain first in the stable internal order"
2573 );
2574 assert!(
2575 display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
2576 "DeepSeek must remain present in display order"
2577 );
2578 assert_ne!(
2579 display[0].kind(),
2580 ProviderKind::Deepseek,
2581 "DeepSeek must not be hard-coded first in display order"
2582 );
2583 // Alibaba Cloud Model Studio sorts before 'Anthropic' and 'DeepSeek'
2584 // alphabetically, so it is a stable check that the neutral ordering
2585 // actually took effect.
2586 assert_eq!(
2587 display[0].display_name(),
2588 "Alibaba Cloud Model Studio",
2589 "alphabetical display order should lead with Alibaba Cloud Model Studio"
2590 );
2591 }
2592 }
2593
2593 lines RUST