返回 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 crate::ProviderKind;
9 pub use crate::descriptors::defaults::{
10 CODEWHALE_API_BASE_ENV, CODEWHALE_API_KEY_URL, KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL,
11 OLLAMA_CLOUD_API_KEY_URL, OLLAMA_CLOUD_BASE_URL, OPENAI_DEFAULT_MODEL,
12 };
13 use crate::descriptors::{self, BuiltinProviderDescriptor};
14 use std::sync::OnceLock;
15
16 /// Wire protocol spoken by a provider.
17 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18 #[serde(rename_all = "snake_case")]
19 pub enum WireFormat {
20 /// OpenAI-compatible `/v1/chat/completions` style payloads.
21 ChatCompletions,
22 /// OpenAI Responses API (`/responses`).
23 Responses,
24 /// Native Anthropic Messages API (`/v1/messages`).
25 AnthropicMessages,
26 }
27
28 /// How a user obtains or supplies credentials for a built-in provider.
29 ///
30 /// Keeping this typed prevents API-key onboarding from accidentally describing
31 /// a local runtime, OAuth-only route, or user-defined endpoint as though it had
32 /// a vendor key console.
33 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34 pub enum CredentialAcquisition {
35 /// A provider-issued API key or access token.
36 ApiKey,
37 /// Either a provider-issued API key or the provider's supported OAuth path.
38 ApiKeyOrOAuth,
39 /// A self-hosted route that is keyless by default but can be configured with auth.
40 LocalOptional,
41 /// An OAuth-only route; Codewhale does not collect an API key for it.
42 OAuth,
43 /// A user-defined route whose credential source belongs in configuration.
44 Configuration,
45 }
46
47 impl CredentialAcquisition {
48 /// Stable machine-readable label for diagnostics.
49 #[must_use]
50 pub const fn as_str(self) -> &'static str {
51 match self {
52 Self::ApiKey => "api_key",
53 Self::ApiKeyOrOAuth => "api_key_or_oauth",
54 Self::LocalOptional => "local_optional",
55 Self::OAuth => "oauth",
56 Self::Configuration => "configuration",
57 }
58 }
59 }
60
61 /// How a provider selects its request wire format.
62 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
63 pub enum WirePolicy {
64 /// Every model served by the provider uses the same wire format.
65 Fixed(WireFormat),
66 /// The provider catalog selects a wire format per model/endpoint.
67 ModelAware,
68 }
69
70 impl WirePolicy {
71 /// Return the fixed format, or `None` for model-aware providers.
72 #[must_use]
73 pub const fn fixed(self) -> Option<WireFormat> {
74 match self {
75 Self::Fixed(format) => Some(format),
76 Self::ModelAware => None,
77 }
78 }
79
80 /// Resolve a concrete format from an offering endpoint key.
81 #[must_use]
82 pub fn resolve(self, endpoint_key: &str) -> Option<WireFormat> {
83 if let Self::Fixed(format) = self {
84 return Some(format);
85 }
86
87 match endpoint_key.trim().to_ascii_lowercase().as_str() {
88 "chat" | "chat_completions" | "chat-completions" => Some(WireFormat::ChatCompletions),
89 "responses" => Some(WireFormat::Responses),
90 "messages" | "anthropic_messages" | "anthropic-messages" => {
91 Some(WireFormat::AnthropicMessages)
92 }
93 _ => None,
94 }
95 }
96 }
97
98 /// Canonical, non-secret help for configuring one provider.
99 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
100 pub struct CredentialHelp {
101 pub acquisition: CredentialAcquisition,
102 /// Stable provider-owned page for creating or locating credentials.
103 ///
104 /// `None` is deliberate for local, OAuth-only, and user-defined routes; UI
105 /// callers must show [`Self::guidance`] instead of guessing a URL.
106 pub credential_url: Option<&'static str>,
107 /// Provider-owned documentation when the repository already has a stable link.
108 pub docs_url: Option<&'static str>,
109 /// Concise fallback or qualification for non-key and mixed-auth routes.
110 pub guidance: &'static str,
111 }
112
113 /// Resolve the Codewhale API base URL from the environment.
114 ///
115 /// Returns `None` when the variable is unset, empty, or names an origin this
116 /// route refuses to send a `cwc_key_…` bearer to. A bearer token has no replay
117 /// protection, so cleartext is allowed only on loopback — the same rule the
118 /// account control plane applies to `CODEWHALE_CLOUD_API_BASE`.
119 #[must_use]
120 pub fn codewhale_api_base_from_env() -> Option<String> {
121 let raw = std::env::var(CODEWHALE_API_BASE_ENV).ok()?;
122 codewhale_api_base(&raw)
123 }
124
125 /// Validate one candidate Codewhale API base URL. See [`codewhale_api_base_from_env`].
126 #[must_use]
127 pub fn codewhale_api_base(raw: &str) -> Option<String> {
128 let trimmed = raw.trim().trim_end_matches('/');
129 if trimmed.is_empty() {
130 return None;
131 }
132 let (scheme, host, has_credentials) = crate::device_code::url_scheme_and_host(trimmed).ok()?;
133 if has_credentials {
134 return None;
135 }
136 let allowed =
137 scheme == "https" || (scheme == "http" && crate::device_code::is_loopback_host(&host));
138 allowed.then(|| trimmed.to_string())
139 }
140
141 /// Static metadata for a built-in model provider.
142 pub trait Provider: Send + Sync {
143 /// Provider enum variant represented by this entry.
144 fn kind(&self) -> ProviderKind;
145
146 /// Canonical provider identifier.
147 fn id(&self) -> &'static str {
148 self.kind().as_str()
149 }
150
151 /// Human-readable provider label for UIs and diagnostics.
152 fn display_name(&self) -> &'static str;
153
154 /// Default base URL used when no config/env/CLI override is present.
155 fn default_base_url(&self) -> &'static str;
156
157 /// Default model used when no config/env/CLI override is present.
158 fn default_model(&self) -> &'static str;
159
160 /// Environment variable candidates used for this provider's API key.
161 fn env_vars(&self) -> &'static [&'static str];
162
163 /// TOML table key under `[providers.<key>]`.
164 fn provider_config_key(&self) -> &'static str;
165
166 /// Alternate names accepted during provider resolution.
167 fn aliases(&self) -> &'static [&'static str] {
168 &[]
169 }
170
171 /// Policy used to select the request wire format.
172 fn wire_policy(&self) -> WirePolicy {
173 WirePolicy::Fixed(WireFormat::ChatCompletions)
174 }
175
176 /// Credential acquisition metadata shared by onboarding, setup, diagnostics,
177 /// and provider-help surfaces.
178 fn credential_help(&self) -> CredentialHelp {
179 credential_help(self.kind())
180 }
181 }
182
183 /// Return the canonical credential-acquisition metadata for a provider kind.
184 ///
185 /// URLs here are provider-owned links already documented in this repository.
186 /// If no stable vendor credential page is known, the URL remains absent and the
187 /// guidance explains the supported local, OAuth, or configuration path.
188 /// This is provider-level fallback metadata: callers that know a concrete base
189 /// URL must use [`credential_help_for_route`] so route-owned credentials do not
190 /// inherit a default endpoint's console.
191 #[must_use]
192 pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
193 descriptors::builtin_provider_descriptor(kind).credential_help
194 }
195
196 fn is_exact_https_route(base_url: &str, expected_authority: &str, expected_path: &str) -> bool {
197 // URL schemes and host names are ASCII case-insensitive; paths are not.
198 // Do not lowercase the whole URL here: a differently-cased path is a
199 // neighboring route, not the official endpoint. Keep this intentionally
200 // dependency-free because provider metadata is used by low-level config
201 // callers that should not need URL parsing machinery just for this guard.
202 let trimmed = base_url.trim();
203 let normalized = trimmed.strip_suffix('/').unwrap_or(trimmed);
204 let Some((scheme, authority_and_path)) = normalized.split_once("://") else {
205 return false;
206 };
207 let Some((authority, path)) = authority_and_path.split_once('/') else {
208 return false;
209 };
210
211 scheme.eq_ignore_ascii_case("https")
212 && authority.eq_ignore_ascii_case(expected_authority)
213 && path == expected_path
214 }
215
216 /// Whether a configured route is exactly the official Kimi Code endpoint.
217 ///
218 /// A trailing slash is insignificant, but neighboring Kimi-hosted paths must
219 /// not inherit membership-plan credentials merely because they share a host.
220 #[must_use]
221 pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool {
222 if kind != ProviderKind::Moonshot {
223 return false;
224 }
225
226 is_exact_https_route(base_url, "api.kimi.com", "coding/v1")
227 }
228
229 /// Whether a configured Ollama route is exactly the hosted OpenAI-compatible
230 /// endpoint.
231 ///
232 /// Local Ollama remains keyless. Neighboring paths, HTTP downgrades, and
233 /// lookalike hosts remain custom routes so they cannot inherit an Ollama Cloud
234 /// credential or durable secret-store slot.
235 #[must_use]
236 pub fn is_exact_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> bool {
237 matches!(kind, ProviderKind::Ollama | ProviderKind::OllamaCloud)
238 && is_exact_https_route(base_url, "ollama.com", "v1")
239 }
240
241 /// In-memory compatibility classifier for the released route-sensitive shape.
242 ///
243 /// Only the old `ollama` identity at the exact hosted endpoint migrates. This
244 /// deliberately rejects neighboring paths, HTTP downgrades, and lookalike
245 /// hosts so no local/custom route can consume Ollama Cloud credentials.
246 #[must_use]
247 pub fn migrates_legacy_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> bool {
248 kind == ProviderKind::Ollama && is_exact_ollama_cloud_route(kind, base_url)
249 }
250
251 /// Whether a configured route is exactly Moonshot's direct API endpoint.
252 ///
253 /// Direct K3 owns a different reasoning-control dialect from the Kimi Code
254 /// membership endpoint. Keep this route guard exact so custom gateways and
255 /// neighboring Moonshot paths do not inherit direct-K3 wire semantics.
256 #[must_use]
257 pub fn is_exact_moonshot_platform_route(kind: ProviderKind, base_url: &str) -> bool {
258 kind == ProviderKind::Moonshot
259 && (is_exact_https_route(base_url, "api.moonshot.ai", "v1")
260 || is_exact_https_route(base_url, "api.moonshot.cn", "v1"))
261 }
262
263 /// Whether a configured route is exactly xAI's first-party OpenAI-compatible
264 /// API endpoint.
265 ///
266 /// Grok-specific request fields must not leak to a custom compatible gateway
267 /// merely because the operator selected the `xai` provider identity.
268 #[must_use]
269 pub fn is_exact_xai_platform_route(kind: ProviderKind, base_url: &str) -> bool {
270 kind == ProviderKind::Xai && is_exact_https_route(base_url, "api.x.ai", "v1")
271 }
272
273 /// Whether a configured route is one of Z.ai's exact first-party Chat
274 /// Completions endpoints.
275 ///
276 /// Z.ai-only request fields must not leak to compatible gateways merely
277 /// because they expose the same model id. Both api.z.ai products (Coding
278 /// Plan and general platform) and BigModel's general platform endpoint are
279 /// first-party: `open.bigmodel.cn/api/paas/v4` is the same open platform
280 /// whose docs prescribe the same `thinking` / `reasoning_effort` dialect
281 /// (including the forced-thinking GLM-5.3 family), and the bundled catalog
282 /// already lists it as the Z.ai catalog API. Neighboring paths — including
283 /// BigModel's `/preview` — remain distinct, mirroring the web-search and
284 /// official-endpoint families.
285 #[must_use]
286 pub fn is_exact_zai_chat_route(kind: ProviderKind, base_url: &str) -> bool {
287 kind == ProviderKind::Zai
288 && (is_exact_https_route(base_url, "api.z.ai", "api/coding/paas/v4")
289 || is_exact_https_route(base_url, "api.z.ai", "api/paas/v4")
290 || is_exact_https_route(base_url, "open.bigmodel.cn", "api/paas/v4"))
291 }
292
293 /// Whether a configured route is one of MiniMax's exact first-party OpenAI
294 /// Chat Completions endpoints.
295 ///
296 /// This deliberately excludes the `/anthropic` routes: those use the native
297 /// Messages adapter and do not share Chat Completions token-limit fields.
298 #[must_use]
299 pub fn is_exact_minimax_chat_route(kind: ProviderKind, base_url: &str) -> bool {
300 kind == ProviderKind::Minimax
301 && (is_exact_https_route(base_url, "api.minimax.io", "v1")
302 || is_exact_https_route(base_url, "api.minimaxi.com", "v1"))
303 }
304
305 /// Whether a configured route is one of MiniMax's exact first-party
306 /// Anthropic-compatible Messages endpoints.
307 ///
308 /// M3 exposes only adaptive/disabled thinking on these routes; it does not
309 /// expose distinct effort tiers. Keep the guard exact so a compatible gateway
310 /// cannot inherit first-party effective-state claims from its provider label.
311 #[must_use]
312 pub fn is_exact_minimax_anthropic_route(kind: ProviderKind, base_url: &str) -> bool {
313 kind == ProviderKind::MinimaxAnthropic
314 && (is_exact_https_route(base_url, "api.minimax.io", "anthropic")
315 || is_exact_https_route(base_url, "api.minimaxi.com", "anthropic"))
316 }
317
318 /// Whether a configured route is exactly CSDN 星图's official OpenAI-compatible
319 /// platform endpoint.
320 ///
321 /// Coding Plan keys and general marketplace keys share this one endpoint, so
322 /// the URL proves neither product — only that the route is first-party.
323 /// Neighboring paths, HTTP downgrades, and lookalike hosts must not inherit
324 /// CSDN billing or wire semantics.
325 #[must_use]
326 pub fn is_exact_csdn_platform_route(kind: ProviderKind, base_url: &str) -> bool {
327 kind == ProviderKind::Csdn && is_exact_https_route(base_url, "ai.csdn.net", "api/model/v1")
328 }
329
330 /// Return credential help for one concrete provider route.
331 ///
332 /// This protects non-UI callers such as diagnostics and command surfaces from
333 /// presenting Moonshot's direct API console for a Kimi Code membership-plan
334 /// endpoint. It performs no discovery, credential lookup, or network I/O.
335 #[must_use]
336 pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> CredentialHelp {
337 if is_exact_ollama_cloud_route(kind, base_url) {
338 return descriptors::OLLAMA_CLOUD_CREDENTIAL_HELP;
339 }
340 if is_exact_kimi_code_route(kind, base_url) {
341 return descriptors::KIMI_CODE_CREDENTIAL_HELP;
342 }
343 credential_help(kind)
344 }
345
346 impl Provider for BuiltinProviderDescriptor {
347 fn kind(&self) -> ProviderKind {
348 self.kind
349 }
350 fn id(&self) -> &'static str {
351 self.id
352 }
353 fn display_name(&self) -> &'static str {
354 self.label
355 }
356 fn default_base_url(&self) -> &'static str {
357 self.base_url
358 }
359 fn default_model(&self) -> &'static str {
360 self.default_model
361 }
362 fn env_vars(&self) -> &'static [&'static str] {
363 self.env_vars
364 }
365 fn provider_config_key(&self) -> &'static str {
366 self.config_key
367 }
368 fn aliases(&self) -> &'static [&'static str] {
369 self.aliases
370 }
371 fn wire_policy(&self) -> WirePolicy {
372 self.wire_policy
373 }
374 fn credential_help(&self) -> CredentialHelp {
375 self.credential_help
376 }
377 }
378
379 static PROVIDER_REGISTRY: OnceLock<Vec<&'static dyn Provider>> = OnceLock::new();
380
381 /// Return all built-in and legacy provider metadata entries.
382 ///
383 /// The full registry retains legacy entries needed to read old configuration.
384 /// It is intentionally NOT a user-facing provider list; for browsing/picker
385 /// surfaces use [`providers_sorted_for_display`].
386 #[must_use]
387 pub fn all_providers() -> &'static [&'static dyn Provider] {
388 PROVIDER_REGISTRY
389 .get_or_init(|| {
390 descriptors::BUILTIN_DESCRIPTORS
391 .iter()
392 .map(|row| row as &dyn Provider)
393 .collect()
394 })
395 .as_slice()
396 }
397
398 /// Return all built-in providers ordered for user-facing display.
399 ///
400 /// Providers are sorted alphabetically (case-insensitively) by
401 /// [`Provider::display_name`] so model/provider browsing surfaces present a
402 /// neutral, predictable list rather than leading with whichever provider
403 /// happens to sit first in [`ProviderKind::ALL`] (historically DeepSeek). The
404 /// ordering policy intentionally differs from internal parsing/default order:
405 ///
406 /// - [`all_providers`] — full compatibility registry for internal identity
407 /// matching, including legacy entries.
408 /// - [`ProviderKind::ALL`] — stable selectable catalog order. Do not reorder.
409 /// - [`providers_sorted_for_display`] — neutral alphabetical order for UI
410 /// browsing, with legacy tombstones omitted. DeepSeek stays present and
411 /// searchable but is not hard-coded first; a caller may still highlight/pin
412 /// the active provider separately.
413 ///
414 /// Returns an owned `Vec` because the sorted order is computed, not static.
415 #[must_use]
416 pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
417 let mut providers: Vec<_> = all_providers()
418 .iter()
419 .copied()
420 .filter(|provider| !descriptors::builtin_provider_descriptor(provider.kind()).retired)
421 .collect();
422 providers.sort_by(|a, b| {
423 a.display_name()
424 .to_ascii_lowercase()
425 .cmp(&b.display_name().to_ascii_lowercase())
426 });
427 providers
428 }
429
430 /// Find a provider by canonical id only.
431 #[must_use]
432 pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
433 let id = id.trim();
434 all_providers()
435 .iter()
436 .copied()
437 .find(|provider| provider.id() == id)
438 }
439
440 /// Resolve a provider by canonical id or supported legacy alias.
441 #[must_use]
442 pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
443 ProviderKind::parse(id_or_alias).map(provider_for_kind)
444 }
445
446 /// Return metadata for a known provider kind.
447 #[must_use]
448 pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
449 descriptors::builtin_provider_descriptor(kind)
450 }
451
452 #[cfg(test)]
453 mod tests {
454 use super::*;
455 use crate::descriptors::defaults::*;
456
457 #[test]
458 fn credential_help_covers_every_provider_without_guessing_non_key_urls() {
459 for provider in all_providers() {
460 let help = provider.credential_help();
461 assert!(
462 !help.guidance.trim().is_empty(),
463 "{} credential guidance must not be empty",
464 provider.id()
465 );
466
467 match help.acquisition {
468 CredentialAcquisition::ApiKey | CredentialAcquisition::ApiKeyOrOAuth => {
469 assert!(
470 help.credential_url.is_some(),
471 "{} needs a stable provider-owned credential link",
472 provider.id()
473 );
474 }
475 CredentialAcquisition::LocalOptional
476 | CredentialAcquisition::OAuth
477 | CredentialAcquisition::Configuration => assert!(
478 help.credential_url.is_none(),
479 "{} must explain its non-key route instead of inventing a credential link",
480 provider.id()
481 ),
482 }
483 }
484 }
485
486 #[test]
487 fn kimi_credential_help_uses_the_durable_api_key_console_only() {
488 let help = provider_for_kind(ProviderKind::Moonshot).credential_help();
489
490 assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
491 assert_eq!(
492 help.credential_url,
493 Some("https://platform.kimi.ai/console/api-keys")
494 );
495 assert_eq!(
496 help.docs_url,
497 Some("https://platform.kimi.ai/docs/overview")
498 );
499 assert!(help.guidance.contains("create and copy an API key"));
500 assert!(help.guidance.contains("OAuth is not available"));
501 }
502
503 #[test]
504 fn kimi_code_route_credential_help_is_distinct_from_direct_moonshot() {
505 let direct = credential_help_for_route(ProviderKind::Moonshot, DEFAULT_MOONSHOT_BASE_URL);
506 let kimi_code =
507 credential_help_for_route(ProviderKind::Moonshot, "https://api.kimi.com/coding/v1/");
508
509 assert_eq!(
510 direct.credential_url,
511 Some("https://platform.kimi.ai/console/api-keys")
512 );
513 assert_eq!(
514 kimi_code.credential_url,
515 Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
516 );
517 assert_eq!(kimi_code.docs_url, None);
518 assert!(kimi_code.guidance.contains("membership-plan API key"));
519 assert!(
520 kimi_code
521 .guidance
522 .contains("does not import Kimi CLI credentials")
523 );
524 assert!(!is_exact_kimi_code_route(
525 ProviderKind::Moonshot,
526 "https://api.kimi.com/coding/v1/preview"
527 ));
528
529 // Scheme and hostname casing are insignificant, but the endpoint
530 // path is a route identifier and must remain exact.
531 assert!(is_exact_kimi_code_route(
532 ProviderKind::Moonshot,
533 "HTTPS://API.KIMI.COM/coding/v1/"
534 ));
535 for neighboring_route in [
536 "https://api.kimi.com/CODING/v1",
537 "https://api.kimi.com/coding/V1",
538 "http://api.kimi.com/coding/v1",
539 "https://api.kimi.com:443/coding/v1",
540 "https://api.kimi.com/coding/v1?preview=1",
541 "https://api.kimi.com/coding/v1#fragment",
542 "https://api.kimi.com/coding/v1//",
543 ] {
544 assert!(
545 !is_exact_kimi_code_route(ProviderKind::Moonshot, neighboring_route),
546 "{neighboring_route} must not inherit Kimi Code membership semantics"
547 );
548 }
549 }
550
551 #[test]
552 fn ollama_cloud_route_is_exact_and_requires_its_own_key() {
553 for base_url in [
554 OLLAMA_CLOUD_BASE_URL,
555 "https://ollama.com/v1/",
556 " HTTPS://OLLAMA.COM/v1/ ",
557 ] {
558 for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
559 assert!(is_exact_ollama_cloud_route(provider, base_url));
560 let help = credential_help_for_route(provider, base_url);
561 assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
562 assert_eq!(help.credential_url, Some(OLLAMA_CLOUD_API_KEY_URL));
563 assert_eq!(
564 help.docs_url,
565 Some("https://docs.ollama.com/api/authentication")
566 );
567 assert!(help.guidance.contains("OLLAMA_CLOUD_API_KEY"));
568 assert!(help.guidance.contains("OLLAMA_API_KEY"));
569 }
570 }
571
572 for base_url in [
573 "http://ollama.com/v1",
574 "https://ollama.com",
575 "https://ollama.com/api",
576 "https://ollama.com/v1/preview",
577 "https://ollama.com.evil.example/v1",
578 "https://api.ollama.com/v1",
579 "https://ollama.com/v1?tenant=other",
580 ] {
581 assert!(!is_exact_ollama_cloud_route(ProviderKind::Ollama, base_url));
582 assert!(!is_exact_ollama_cloud_route(
583 ProviderKind::OllamaCloud,
584 base_url
585 ));
586 }
587 assert!(!is_exact_ollama_cloud_route(
588 ProviderKind::Openai,
589 OLLAMA_CLOUD_BASE_URL
590 ));
591
592 let local = credential_help_for_route(ProviderKind::Ollama, DEFAULT_OLLAMA_BASE_URL);
593 assert_eq!(local.acquisition, CredentialAcquisition::LocalOptional);
594 assert_eq!(local.credential_url, None);
595 assert!(local.guidance.contains("keyless by default"));
596 }
597
598 #[test]
599 fn direct_moonshot_route_matching_is_exact() {
600 for route in ["HTTPS://API.MOONSHOT.AI/v1/", "HTTPS://API.MOONSHOT.CN/v1/"] {
601 assert!(is_exact_moonshot_platform_route(
602 ProviderKind::Moonshot,
603 route
604 ));
605 }
606 for neighboring_route in [
607 "https://api.moonshot.ai/V1",
608 "http://api.moonshot.ai/v1",
609 "https://api.moonshot.ai:443/v1",
610 "https://api.moonshot.ai/v1?preview=1",
611 "https://api.moonshot.ai/v1#fragment",
612 "https://api.moonshot.ai/v1//",
613 "https://api.moonshot.ai/v1/chat/completions",
614 "https://api.moonshot.cn/v1/chat/completions",
615 "https://api.kimi.com/coding/v1",
616 ] {
617 assert!(
618 !is_exact_moonshot_platform_route(ProviderKind::Moonshot, neighboring_route),
619 "{neighboring_route} must not inherit direct Moonshot semantics"
620 );
621 }
622 assert!(!is_exact_moonshot_platform_route(
623 ProviderKind::Openai,
624 crate::MOONSHOT_CN_BASE_URL
625 ));
626 }
627
628 #[test]
629 fn direct_xai_route_matching_is_exact() {
630 assert!(is_exact_xai_platform_route(
631 ProviderKind::Xai,
632 "HTTPS://API.X.AI/v1/"
633 ));
634 for neighboring_route in [
635 "https://api.x.ai/V1",
636 "http://api.x.ai/v1",
637 "https://api.x.ai:443/v1",
638 "https://api.x.ai/v1?preview=1",
639 "https://api.x.ai/v1#fragment",
640 "https://api.x.ai/v1//",
641 "https://api.x.ai/v1/chat/completions",
642 "https://gateway.example/v1",
643 ] {
644 assert!(
645 !is_exact_xai_platform_route(ProviderKind::Xai, neighboring_route),
646 "{neighboring_route} must not inherit xAI-only request fields"
647 );
648 }
649 assert!(!is_exact_xai_platform_route(
650 ProviderKind::Openai,
651 DEFAULT_XAI_BASE_URL
652 ));
653 }
654
655 #[test]
656 fn zai_chat_route_matching_is_exact() {
657 for route in [
658 "https://api.z.ai/api/coding/paas/v4",
659 "https://api.z.ai/api/paas/v4/",
660 "HTTPS://API.Z.AI/api/paas/v4",
661 // BigModel's general platform endpoint is the same first-party
662 // open platform; authority case stays insignificant.
663 "https://open.bigmodel.cn/api/paas/v4",
664 "https://open.bigmodel.cn/api/paas/v4/",
665 "HTTPS://OPEN.BIGMODEL.CN/api/paas/v4",
666 ] {
667 assert!(is_exact_zai_chat_route(ProviderKind::Zai, route), "{route}");
668 }
669 for neighboring_route in [
670 "http://api.z.ai/api/paas/v4",
671 "https://api.z.ai:443/api/paas/v4",
672 "https://api.z.ai/API/paas/v4",
673 "https://api.z.ai/api/paas/v4?preview=1",
674 "https://api.z.ai/api/paas/v4#fragment",
675 "https://api.z.ai/api/paas/v4//",
676 "https://api.z.ai/api/paas/v4/chat/completions",
677 // BigModel neighbors: the undocumented coding path and the
678 // preview product stay fail-closed, like the official-endpoint
679 // and web-search families.
680 "https://open.bigmodel.cn/api/paas/v4/preview",
681 "https://open.bigmodel.cn/api/coding/paas/v4",
682 "http://open.bigmodel.cn/api/paas/v4",
683 "https://open.bigmodel.cn/API/paas/v4",
684 "https://gateway.example/v1",
685 ] {
686 assert!(
687 !is_exact_zai_chat_route(ProviderKind::Zai, neighboring_route),
688 "{neighboring_route} must not inherit Z.ai-only request fields"
689 );
690 }
691 assert!(!is_exact_zai_chat_route(
692 ProviderKind::Openai,
693 DEFAULT_ZAI_BASE_URL
694 ));
695 assert!(!is_exact_zai_chat_route(
696 ProviderKind::Openai,
697 "https://open.bigmodel.cn/api/paas/v4"
698 ));
699 }
700
701 #[test]
702 fn minimax_chat_route_matching_is_exact_and_excludes_messages() {
703 for route in [
704 "https://api.minimax.io/v1",
705 "https://api.minimaxi.com/v1/",
706 "HTTPS://API.MINIMAX.IO/v1",
707 ] {
708 assert!(
709 is_exact_minimax_chat_route(ProviderKind::Minimax, route),
710 "{route}"
711 );
712 }
713 for neighboring_route in [
714 "http://api.minimax.io/v1",
715 "https://api.minimax.io:443/v1",
716 "https://api.minimax.io/V1",
717 "https://api.minimax.io/v1?preview=1",
718 "https://api.minimax.io/v1#fragment",
719 "https://api.minimax.io/v1//",
720 "https://api.minimax.io/v1/chat/completions",
721 "https://api.minimax.io/anthropic",
722 "https://api.minimaxi.com/anthropic",
723 "https://gateway.example/v1",
724 ] {
725 assert!(
726 !is_exact_minimax_chat_route(ProviderKind::Minimax, neighboring_route),
727 "{neighboring_route} must not inherit MiniMax Chat request fields"
728 );
729 }
730 assert!(!is_exact_minimax_chat_route(
731 ProviderKind::MinimaxAnthropic,
732 DEFAULT_MINIMAX_BASE_URL
733 ));
734 }
735
736 #[test]
737 fn minimax_anthropic_route_matching_is_exact_and_excludes_chat() {
738 for route in [
739 "https://api.minimax.io/anthropic",
740 "https://api.minimaxi.com/anthropic/",
741 "HTTPS://API.MINIMAX.IO/anthropic",
742 ] {
743 assert!(
744 is_exact_minimax_anthropic_route(ProviderKind::MinimaxAnthropic, route),
745 "{route}"
746 );
747 }
748 for neighboring_route in [
749 "http://api.minimax.io/anthropic",
750 "https://api.minimax.io:443/anthropic",
751 "https://api.minimax.io/Anthropic",
752 "https://api.minimax.io/anthropic?preview=1",
753 "https://api.minimax.io/anthropic#fragment",
754 "https://api.minimax.io/anthropic//",
755 "https://api.minimax.io/anthropic/v1/messages",
756 "https://api.minimax.io/v1",
757 "https://gateway.example/anthropic",
758 ] {
759 assert!(
760 !is_exact_minimax_anthropic_route(
761 ProviderKind::MinimaxAnthropic,
762 neighboring_route
763 ),
764 "{neighboring_route} must not inherit MiniMax Messages semantics"
765 );
766 }
767 assert!(!is_exact_minimax_anthropic_route(
768 ProviderKind::Minimax,
769 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL
770 ));
771 }
772
773 #[test]
774 fn non_key_and_mixed_routes_are_typed_explicitly() {
775 for kind in [
776 ProviderKind::Sglang,
777 ProviderKind::Vllm,
778 ProviderKind::Ollama,
779 ] {
780 assert_eq!(
781 provider_for_kind(kind).credential_help().acquisition,
782 CredentialAcquisition::LocalOptional
783 );
784 }
785 assert_eq!(
786 provider_for_kind(ProviderKind::OpenaiCodex)
787 .credential_help()
788 .acquisition,
789 CredentialAcquisition::OAuth
790 );
791 assert_eq!(
792 provider_for_kind(ProviderKind::Xai)
793 .credential_help()
794 .acquisition,
795 CredentialAcquisition::ApiKeyOrOAuth
796 );
797 assert_eq!(
798 provider_for_kind(ProviderKind::Custom)
799 .credential_help()
800 .acquisition,
801 CredentialAcquisition::Configuration
802 );
803 }
804
805 #[test]
806 fn antigravity_registry_entry_is_a_non_runnable_legacy_tombstone() {
807 let legacy = provider_for_kind(ProviderKind::Antigravity);
808 assert_eq!(legacy.id(), "antigravity");
809 assert!(legacy.env_vars().is_empty());
810 assert!(legacy.default_base_url().ends_with(".invalid"));
811 assert_eq!(legacy.default_model(), "legacy-antigravity-disabled");
812
813 let help = legacy.credential_help();
814 assert_eq!(help.acquisition, CredentialAcquisition::Configuration);
815 assert_eq!(help.credential_url, None);
816 assert_eq!(help.docs_url, None);
817 assert!(
818 help.guidance
819 .contains("codewhale auth clear --provider antigravity")
820 );
821 assert!(help.guidance.contains("provider `google`"));
822 assert!(help.guidance.contains("GEMINI_API_KEY"));
823 }
824
825 #[test]
826 fn live_verified_console_replacements_do_not_regress_to_404_links() {
827 let openmodel = provider_for_kind(ProviderKind::Openmodel).credential_help();
828 assert_eq!(
829 openmodel.credential_url,
830 Some("https://console.openmodel.ai/")
831 );
832 assert_eq!(
833 openmodel.docs_url,
834 Some("https://docs.openmodel.ai/en/docs/getting-started/authentication")
835 );
836
837 let sakana = provider_for_kind(ProviderKind::Sakana).credential_help();
838 assert_eq!(
839 sakana.credential_url,
840 Some("https://console.sakana.ai/api-keys")
841 );
842 assert_eq!(
843 sakana.docs_url,
844 Some("https://console.sakana.ai/get-started")
845 );
846 }
847
848 #[test]
849 fn model_aware_wire_policy_resolves_only_supported_endpoint_keys() {
850 let policy = WirePolicy::ModelAware;
851 assert_eq!(policy.resolve("chat"), Some(WireFormat::ChatCompletions));
852 assert_eq!(policy.resolve("responses"), Some(WireFormat::Responses));
853 assert_eq!(
854 policy.resolve("messages"),
855 Some(WireFormat::AnthropicMessages)
856 );
857 assert_eq!(policy.resolve("models/gemini-3.1-pro"), None);
858 assert_eq!(policy.resolve(""), None);
859 }
860
861 #[test]
862 fn fixed_wire_policy_ignores_catalog_endpoint_keys() {
863 let policy = WirePolicy::Fixed(WireFormat::Responses);
864 assert_eq!(policy.resolve("chat"), Some(WireFormat::Responses));
865 assert_eq!(policy.resolve("unknown"), Some(WireFormat::Responses));
866 }
867
868 #[test]
869 fn display_order_is_alphabetical_by_display_name() {
870 let display = providers_sorted_for_display();
871 let names: Vec<String> = display
872 .iter()
873 .map(|p| p.display_name().to_ascii_lowercase())
874 .collect();
875 let mut sorted = names.clone();
876 sorted.sort();
877 assert_eq!(
878 names, sorted,
879 "providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
880 );
881 }
882
883 #[test]
884 fn display_order_differs_from_internal_all_order() {
885 // The whole point of the helper is that UI ordering is NOT the
886 // internal compatibility-registry insertion order.
887 let display_ids: Vec<&str> = providers_sorted_for_display()
888 .iter()
889 .map(|p| p.id())
890 .collect();
891 let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
892 assert_ne!(
893 display_ids, internal_ids,
894 "display order should not match internal ALL order"
895 );
896 }
897
898 #[test]
899 fn display_order_is_complete_and_unique() {
900 // Every selectable provider is retained exactly once; legacy
901 // configuration tombstones stay in the internal registry only.
902 let display = providers_sorted_for_display();
903 assert_eq!(
904 display.len(),
905 all_providers().len() - 1,
906 "display order must include every selectable built-in provider"
907 );
908 assert!(
909 all_providers()
910 .iter()
911 .any(|provider| provider.kind() == ProviderKind::Antigravity),
912 "legacy config identity must remain in the internal registry"
913 );
914 assert!(
915 display
916 .iter()
917 .all(|provider| provider.kind() != ProviderKind::Antigravity),
918 "legacy Antigravity tombstone must not appear in provider pickers"
919 );
920 let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
921 ids.sort_unstable();
922 let before = ids.len();
923 ids.dedup();
924 assert_eq!(
925 before,
926 ids.len(),
927 "display order must not contain duplicates"
928 );
929 }
930
931 #[test]
932 fn deepseek_is_present_but_not_first_in_display_order() {
933 // Acceptance: DeepSeek stays searchable but is no longer hard-coded
934 // first in provider browsing UI. (It is first in internal ALL order.)
935 let display = providers_sorted_for_display();
936 assert_eq!(
937 all_providers()[0].kind(),
938 ProviderKind::Deepseek,
939 "DeepSeek is expected to remain first in the stable internal order"
940 );
941 assert!(
942 display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
943 "DeepSeek must remain present in display order"
944 );
945 assert_ne!(
946 display[0].kind(),
947 ProviderKind::Deepseek,
948 "DeepSeek must not be hard-coded first in display order"
949 );
950 // Alibaba Cloud Model Studio sorts before 'Anthropic' and 'DeepSeek'
951 // alphabetically, so it is a stable check that the neutral ordering
952 // actually took effect.
953 assert_eq!(
954 display[0].display_name(),
955 "Alibaba Cloud Model Studio",
956 "alphabetical display order should lead with Alibaba Cloud Model Studio"
957 );
958 }
959 }
960
960 lines RUST