| 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 |