返回 CodeWhale
export.rs
根目录 / crates / config / src / route / export.rs
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
333 lines RUST