| 1 | //! Owned, serializable sibling of [`super::ProviderDescriptor`]. |
| 2 | //! |
| 3 | //! `ProviderDescriptor` holds `&'static dyn Provider` and is deliberately not |
| 4 | //! `Serialize`. This module is the export seam: `codewhale providers export |
| 5 | //! --json` and the cwc generated catalog both consume [`ProvidersExport`]. |
| 6 | |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | |
| 9 | use crate::provider::{self, WireFormat, WirePolicy}; |
| 10 | use crate::{ProviderKind, catalog::bundled_catalog_offerings}; |
| 11 | |
| 12 | use super::auth::AuthMethodExport; |
| 13 | use super::descriptor::{ProviderDescriptor, TransportKind}; |
| 14 | use super::ids::RouteId; |
| 15 | |
| 16 | /// Schema version of the providers export document. |
| 17 | pub const PROVIDERS_EXPORT_SCHEMA_VERSION: u32 = 1; |
| 18 | |
| 19 | /// Top-level `codewhale providers export --json` document. |
| 20 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 21 | #[serde(rename_all = "camelCase")] |
| 22 | pub struct ProvidersExport { |
| 23 | /// Document schema. |
| 24 | pub schema_version: u32, |
| 25 | /// Runtime version that produced this export. |
| 26 | pub runtime_version: String, |
| 27 | /// One row per addressable route id. |
| 28 | pub routes: Vec<RouteExport>, |
| 29 | } |
| 30 | |
| 31 | /// One exported route. This is the owned sibling of [`ProviderDescriptor`]. |
| 32 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 33 | #[serde(rename_all = "camelCase")] |
| 34 | pub struct RouteExport { |
| 35 | /// Flat kebab route id (open string). |
| 36 | pub id: String, |
| 37 | /// Display-grouping family (not a second identity). |
| 38 | pub family: String, |
| 39 | /// Human label. |
| 40 | pub label: String, |
| 41 | /// Default endpoint URL. |
| 42 | pub endpoint: String, |
| 43 | /// Wire format spoken at the default endpoint. |
| 44 | pub wire: String, |
| 45 | /// Default wire model id. |
| 46 | pub default_model: String, |
| 47 | /// Environment variable candidates for the API key. |
| 48 | pub env_vars: Vec<String>, |
| 49 | /// Declared auth methods. OAuth is a type only. |
| 50 | pub auth: Vec<AuthMethodExport>, |
| 51 | /// Bundled catalog models for this route, when any exist. |
| 52 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 53 | pub models: Vec<RouteModelExport>, |
| 54 | /// Bespoke-transport classification. Catalog rows share `chat-completions`. |
| 55 | pub transport: TransportKind, |
| 56 | } |
| 57 | |
| 58 | /// One bundled model advertised on a route. |
| 59 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 60 | #[serde(rename_all = "camelCase")] |
| 61 | pub struct RouteModelExport { |
| 62 | /// Provider-owned wire id. |
| 63 | pub id: String, |
| 64 | /// Canonical model id when a join exists. |
| 65 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 66 | pub canonical: Option<String>, |
| 67 | /// Whether this is the route default. |
| 68 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
| 69 | pub default: bool, |
| 70 | } |
| 71 | |
| 72 | impl ProvidersExport { |
| 73 | /// Build the export from the live descriptor registry. |
| 74 | #[must_use] |
| 75 | pub fn from_registry(runtime_version: impl Into<String>) -> Self { |
| 76 | let offerings = bundled_catalog_offerings(); |
| 77 | let mut routes: Vec<RouteExport> = provider::all_providers() |
| 78 | .iter() |
| 79 | .filter(|entry| entry.kind() != ProviderKind::Antigravity) |
| 80 | .map(|entry| { |
| 81 | let descriptor = ProviderDescriptor::for_kind(entry.kind()); |
| 82 | route_export(&descriptor, &offerings) |
| 83 | }) |
| 84 | .collect(); |
| 85 | routes.sort_by(|a, b| a.id.cmp(&b.id)); |
| 86 | Self { |
| 87 | schema_version: PROVIDERS_EXPORT_SCHEMA_VERSION, |
| 88 | runtime_version: runtime_version.into(), |
| 89 | routes, |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// Stable committed route-id list. Removing or respelling an id is a CI failure. |
| 94 | #[must_use] |
| 95 | pub fn route_ids(&self) -> Vec<&str> { |
| 96 | self.routes.iter().map(|route| route.id.as_str()).collect() |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | fn route_export( |
| 101 | descriptor: &ProviderDescriptor, |
| 102 | offerings: &[crate::catalog::CatalogOffering], |
| 103 | ) -> RouteExport { |
| 104 | let id = descriptor.route_id(); |
| 105 | let models = offerings |
| 106 | .iter() |
| 107 | .filter(|row| row.provider == id.as_str()) |
| 108 | .map(|row| RouteModelExport { |
| 109 | id: row.wire_model_id.clone(), |
| 110 | canonical: row.canonical_model.clone(), |
| 111 | default: row.default_for_provider, |
| 112 | }) |
| 113 | .collect(); |
| 114 | RouteExport { |
| 115 | id: id.as_str().to_string(), |
| 116 | family: descriptor.family().to_string(), |
| 117 | label: descriptor.inner.display_name().to_string(), |
| 118 | endpoint: descriptor.default_base_url().to_string(), |
| 119 | wire: wire_label(descriptor.wire_policy()), |
| 120 | default_model: descriptor.default_wire_model().as_str().to_string(), |
| 121 | env_vars: descriptor |
| 122 | .env_vars() |
| 123 | .iter() |
| 124 | .map(|value| (*value).to_string()) |
| 125 | .collect(), |
| 126 | auth: descriptor |
| 127 | .auth_methods() |
| 128 | .iter() |
| 129 | .copied() |
| 130 | .map(AuthMethodExport::from) |
| 131 | .collect(), |
| 132 | models, |
| 133 | transport: descriptor.transport(), |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | fn wire_label(policy: WirePolicy) -> String { |
| 138 | match policy { |
| 139 | WirePolicy::Fixed(WireFormat::ChatCompletions) => "chat-completions".to_string(), |
| 140 | WirePolicy::Fixed(WireFormat::Responses) => "responses".to_string(), |
| 141 | WirePolicy::Fixed(WireFormat::AnthropicMessages) => "anthropic-messages".to_string(), |
| 142 | WirePolicy::ModelAware => "model-aware".to_string(), |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | /// Parse a CLI `--provider` / route-id string against the catalog. |
| 147 | /// |
| 148 | /// Replaces the closed `ProviderArg` enum. Any catalog route id or documented |
| 149 | /// alias resolves; unknown ids fail. |
| 150 | #[must_use] |
| 151 | pub fn parse_route_kind(value: &str) -> Option<ProviderKind> { |
| 152 | let trimmed = value.trim(); |
| 153 | if trimmed.is_empty() { |
| 154 | return None; |
| 155 | } |
| 156 | let folded = trimmed.replace('_', "-"); |
| 157 | exact_config_identity(trimmed) |
| 158 | .or_else(|| exact_config_identity(&folded)) |
| 159 | .or_else(|| clap_compat_alias(trimmed)) |
| 160 | .or_else(|| ProviderKind::parse(trimmed)) |
| 161 | .or_else(|| ProviderKind::parse(&folded.to_ascii_lowercase())) |
| 162 | .filter(|kind| *kind != ProviderKind::Antigravity) |
| 163 | } |
| 164 | |
| 165 | /// Exact id / config-table key only. Does not collapse dialect aliases onto |
| 166 | /// the vendor primary — that is what orphaned `[providers.minimax_anthropic]` |
| 167 | /// tables. Call [`ProviderKind::parse`] only after this and clap-compat aliases. |
| 168 | fn exact_config_identity(value: &str) -> Option<ProviderKind> { |
| 169 | crate::provider::all_providers() |
| 170 | .iter() |
| 171 | .find(|entry| { |
| 172 | value.eq_ignore_ascii_case(entry.id()) |
| 173 | || value.eq_ignore_ascii_case(entry.provider_config_key()) |
| 174 | }) |
| 175 | .map(|entry| entry.kind()) |
| 176 | } |
| 177 | |
| 178 | fn clap_compat_alias(value: &str) -> Option<ProviderKind> { |
| 179 | let key = value.replace('_', "-").to_ascii_lowercase(); |
| 180 | Some(match key.as_str() { |
| 181 | "opencodego" | "opencode-go" => ProviderKind::OpencodeGo, |
| 182 | "ollama-cloud" => ProviderKind::OllamaCloud, |
| 183 | "mini-max-anthropic" => ProviderKind::MinimaxAnthropic, |
| 184 | "siliconflow-china" | "silicon-flow-cn" => ProviderKind::SiliconflowCN, |
| 185 | "deep-infra" => ProviderKind::Deepinfra, |
| 186 | "fugu" | "sakana-ai" => ProviderKind::Sakana, |
| 187 | "long-cat" | "meituan-longcat" | "meituan" => ProviderKind::LongCat, |
| 188 | "meta-ai" | "meta-model-api" | "muse" | "muse-spark" => ProviderKind::Meta, |
| 189 | "x-ai" | "grok" => ProviderKind::Xai, |
| 190 | "mistral-ai" | "mistralai" | "la-plateforme" => ProviderKind::Mistral, |
| 191 | "eden-ai" => ProviderKind::Edenai, |
| 192 | "zen-mux" => ProviderKind::Zenmux, |
| 193 | _ => return None, |
| 194 | }) |
| 195 | } |
| 196 | |
| 197 | /// [`RouteId`] for a known kind. |
| 198 | #[must_use] |
| 199 | pub fn route_id_for(kind: ProviderKind) -> RouteId { |
| 200 | RouteId::from(kind.as_str()) |
| 201 | } |
| 202 | |
| 203 | #[cfg(test)] |
| 204 | mod tests { |
| 205 | use super::*; |
| 206 | |
| 207 | #[test] |
| 208 | fn export_covers_every_registry_route_exactly_once() { |
| 209 | let export = ProvidersExport::from_registry("0.9.12"); |
| 210 | let ids = export.route_ids(); |
| 211 | let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect(); |
| 212 | assert_eq!(unique.len(), ids.len(), "route ids must be unique"); |
| 213 | assert_eq!(ids.len(), provider::all_providers().len() - 1); |
| 214 | assert!(ids.contains(&"deepseek")); |
| 215 | assert!(ids.contains(&"custom")); |
| 216 | assert!(!ids.contains(&"antigravity")); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn export_has_no_artificial_analysis_fields() { |
| 221 | let json = serde_json::to_string(&ProvidersExport::from_registry("0.9.12")).unwrap(); |
| 222 | let lowered = json.to_ascii_lowercase(); |
| 223 | assert!( |
| 224 | !lowered.contains("artificialanalysis") |
| 225 | && !lowered.contains("artificial_analysis") |
| 226 | && !lowered.contains("artificial-analysis"), |
| 227 | "export must never carry Artificial Analysis fields" |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | #[test] |
| 232 | fn parse_route_kind_accepts_aliases() { |
| 233 | assert_eq!( |
| 234 | parse_route_kind("deepseek-anthropic"), |
| 235 | Some(ProviderKind::DeepseekAnthropic) |
| 236 | ); |
| 237 | assert_eq!( |
| 238 | parse_route_kind("siliconflow-CN"), |
| 239 | Some(ProviderKind::SiliconflowCN) |
| 240 | ); |
| 241 | assert_eq!( |
| 242 | parse_route_kind("minimax_anthropic"), |
| 243 | Some(ProviderKind::MinimaxAnthropic) |
| 244 | ); |
| 245 | assert_eq!( |
| 246 | parse_route_kind("mini-max-anthropic"), |
| 247 | Some(ProviderKind::MinimaxAnthropic) |
| 248 | ); |
| 249 | assert_eq!(parse_route_kind("antigravity"), None); |
| 250 | assert_eq!(parse_route_kind("agy"), None); |
| 251 | assert_eq!( |
| 252 | ProviderKind::parse_config_identity("antigravity"), |
| 253 | Some(ProviderKind::Antigravity) |
| 254 | ); |
| 255 | assert_eq!( |
| 256 | ProviderKind::parse_config_identity("agy"), |
| 257 | Some(ProviderKind::Antigravity) |
| 258 | ); |
| 259 | assert_eq!(parse_route_kind(""), None); |
| 260 | assert_eq!(parse_route_kind("not-a-provider"), None); |
| 261 | } |
| 262 | |
| 263 | /// Runtime version stamped into every golden fixture. |
| 264 | /// |
| 265 | /// `codewhale providers export --json` stamps the real build version |
| 266 | /// (`env!("CODEWHALE_BUILD_VERSION")` at the call site in `crates/cli`). |
| 267 | /// The fixture tracks this crate's version so the committed golden can |
| 268 | /// never disagree with the tree it ships in. The route ids are the |
| 269 | /// contract; the stamped version only has to be true. |
| 270 | const GOLDEN_RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION"); |
| 271 | |
| 272 | #[test] |
| 273 | fn golden_route_ids_are_stable() { |
| 274 | let export = ProvidersExport::from_registry(GOLDEN_RUNTIME_VERSION); |
| 275 | let actual: Vec<&str> = export.route_ids(); |
| 276 | let expected: Vec<&str> = include_str!("golden_route_ids.txt") |
| 277 | .lines() |
| 278 | .map(str::trim) |
| 279 | .filter(|line| !line.is_empty() && !line.starts_with('#')) |
| 280 | .collect(); |
| 281 | assert_eq!( |
| 282 | actual, expected, |
| 283 | "route ids are a committed contract; do not remove or respell an id" |
| 284 | ); |
| 285 | } |
| 286 | |
| 287 | #[test] |
| 288 | #[ignore = "set WRITE_GOLDEN=1 to regenerate providers-export.golden.json"] |
| 289 | fn write_golden_providers_export_when_requested() { |
| 290 | if std::env::var("WRITE_GOLDEN").ok().as_deref() != Some("1") { |
| 291 | return; |
| 292 | } |
| 293 | let export = ProvidersExport::from_registry(GOLDEN_RUNTIME_VERSION); |
| 294 | let path = concat!( |
| 295 | env!("CARGO_MANIFEST_DIR"), |
| 296 | "/src/route/providers-export.golden.json" |
| 297 | ); |
| 298 | std::fs::write( |
| 299 | path, |
| 300 | format!("{}\n", serde_json::to_string_pretty(&export).unwrap()), |
| 301 | ) |
| 302 | .expect("write providers-export.golden.json"); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn golden_providers_export_matches_registry() { |
| 307 | let export = ProvidersExport::from_registry(GOLDEN_RUNTIME_VERSION); |
| 308 | let golden: ProvidersExport = |
| 309 | serde_json::from_str(include_str!("providers-export.golden.json")) |
| 310 | .expect("providers-export.golden.json must parse"); |
| 311 | assert_eq!( |
| 312 | export.routes, golden.routes, |
| 313 | "update providers-export.golden.json from ProvidersExport::from_registry" |
| 314 | ); |
| 315 | // The route rows are the contract, but a golden stamped with a version |
| 316 | // the tree has moved past is a stale artifact nothing else catches. |
| 317 | assert_eq!( |
| 318 | golden.runtime_version, GOLDEN_RUNTIME_VERSION, |
| 319 | "providers-export.golden.json is stamped {}; regenerate with: \ |
| 320 | WRITE_GOLDEN=1 cargo test -p codewhale-config --lib -- --ignored \ |
| 321 | write_golden_providers_export_when_requested", |
| 322 | golden.runtime_version |
| 323 | ); |
| 324 | assert!( |
| 325 | !serde_json::to_string(&export) |
| 326 | .unwrap() |
| 327 | .to_ascii_lowercase() |
| 328 | .contains("artificialanalysis"), |
| 329 | "export must never carry Artificial Analysis fields" |
| 330 | ); |
| 331 | } |
| 332 | } |
| 333 |