返回 CodeWhale
descriptors.rs
根目录 / crates / config / src / descriptors.rs
1 //! OMP-style provider descriptors: how to talk to a host.
2 //!
3 //! One committed data file owns built-in/legacy defaults and compatible hosts.
4 //! Model rosters are **not** compiled here. A descriptor names the wire, URL, env
5 //! var, and whether authenticated `GET /v1/models` is the catalog authority
6 //! for that host. Offerings come from the Codewhale catalog layers and live
7 //! provider `/models` refreshes.
8
9 use std::sync::OnceLock;
10
11 use serde::Deserialize;
12
13 const DESCRIPTORS_JSON: &str = include_str!("../assets/provider_descriptors.json");
14
15 /// Immutable built-in view compiled from the same descriptor data file.
16 #[derive(Debug, Clone, Copy)]
17 pub(crate) struct BuiltinProviderDescriptor {
18 pub kind: crate::ProviderKind,
19 pub id: &'static str,
20 pub label: &'static str,
21 pub base_url: &'static str,
22 pub default_model: &'static str,
23 pub env_vars: &'static [&'static str],
24 pub aliases: &'static [&'static str],
25 pub config_key: &'static str,
26 pub secret_store_slot: &'static str,
27 pub family: &'static str,
28 pub selectable: bool,
29 pub retired: bool,
30 pub wire_policy: crate::provider::WirePolicy,
31 pub credential_help: crate::provider::CredentialHelp,
32 }
33
34 /// Exact compatibility identity for the TUI-only legacy DeepSeek China table.
35 #[derive(Debug, Clone, Copy)]
36 pub struct LegacyProviderDescriptor {
37 /// Canonical historical identity.
38 pub id: &'static str,
39 /// Legacy display label.
40 pub label: &'static str,
41 /// Historical route seed.
42 pub base_url: &'static str,
43 /// Historical model seed.
44 pub default_model: &'static str,
45 /// Config table key; never collapsed into an alias during lookup.
46 pub config_key: &'static str,
47 /// Existing shared durable credential slot.
48 pub secret_store_slot: &'static str,
49 }
50
51 /// Pure identity/presentation compatibility from the existing descriptor owner.
52 /// This is a name projection, never credential or route admission authority.
53 #[derive(Debug, Clone, Copy)]
54 pub struct ProviderCompatibility {
55 pub kind: crate::ProviderKind,
56 pub id: &'static str,
57 pub tui_wire_tag: &'static str,
58 pub config_key: &'static str,
59 pub base_url_config_key: &'static str,
60 pub catalog_id: &'static str,
61 pub catalog_source_id: &'static str,
62 pub subagent_aliases: &'static [&'static str],
63 pub selector_aliases: &'static [&'static str],
64 pub label: &'static str,
65 pub base_url: &'static str,
66 pub default_model: &'static str,
67 }
68
69 /// Released presentation rows in the descriptor owner's stable order.
70 #[must_use]
71 pub fn provider_compatibility() -> &'static [ProviderCompatibility] {
72 PROVIDER_COMPATIBILITY
73 }
74
75 /// Exact canonical configured name; custom table names are not classified here.
76 #[must_use]
77 pub fn compatibility_for_id(id: &str) -> Option<&'static ProviderCompatibility> {
78 PROVIDER_COMPATIBILITY.iter().find(|row| row.id == id)
79 }
80
81 /// Intrinsic built-in's canonical metadata. No active config is consulted.
82 #[must_use]
83 pub fn compatibility_for_kind(kind: crate::ProviderKind) -> &'static ProviderCompatibility {
84 PROVIDER_COMPATIBILITY
85 .iter()
86 .find(|row| row.id == kind.as_str())
87 .expect("generated compatibility covers every intrinsic kind")
88 }
89
90 /// Decode a released presentation tag; callers still admit the resulting name.
91 #[must_use]
92 pub fn compatibility_from_wire_tag(tag: &str) -> Option<&'static ProviderCompatibility> {
93 PROVIDER_COMPATIBILITY
94 .iter()
95 .find(|row| row.tui_wire_tag == tag)
96 }
97
98 /// Explicit legacy aliases are checked before ordinary kind alias grouping.
99 #[must_use]
100 pub fn compatibility_for_selector(value: &str) -> Option<&'static ProviderCompatibility> {
101 let name = value.trim();
102 PROVIDER_COMPATIBILITY
103 .iter()
104 .find(|row| {
105 row.selector_aliases
106 .iter()
107 .any(|alias| alias.eq_ignore_ascii_case(name))
108 })
109 .or_else(|| crate::ProviderKind::parse_config_identity(name).map(compatibility_for_kind))
110 }
111
112 /// Released TUI wire spelling, paired with an exact non-secret route key.
113 /// This projection does not admit a route or infer a custom table's kind.
114 #[must_use]
115 pub fn tui_wire_tag_for_route(kind: crate::ProviderKind, id: &str) -> Option<&'static str> {
116 if id.trim().is_empty() || id.trim() != id {
117 return None;
118 }
119 if kind == crate::ProviderKind::Custom {
120 return Some(compatibility_for_kind(kind).tui_wire_tag);
121 }
122 let row = compatibility_for_id(id)?;
123 (row.kind == kind).then_some(row.tui_wire_tag)
124 }
125
126 /// Decode a released tag while retaining its exact identity provenance.
127 #[must_use]
128 pub fn kind_from_tui_wire_tag(tag: &str, id: &str) -> Option<crate::ProviderKind> {
129 let row = compatibility_from_wire_tag(tag)?;
130 (tui_wire_tag_for_route(row.kind, id) == Some(tag)).then_some(row.kind)
131 }
132
133 /// Compile-time compatibility constants, generated from descriptor data.
134 pub mod defaults {
135 include!(concat!(env!("OUT_DIR"), "/provider_defaults.rs"));
136 }
137
138 include!(concat!(env!("OUT_DIR"), "/provider_descriptors.rs"));
139
140 /// How this host's model list is discovered.
141 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
142 #[serde(rename_all = "snake_case")]
143 pub enum DescriptorDiscovery {
144 /// Authenticated `GET {base_url}/models` is authoritative for this credential.
145 ModelsEndpoint,
146 /// No live discovery; only catalog/config rows.
147 None,
148 }
149
150 /// Transport used to send turns. Not a brand enum.
151 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
152 #[serde(rename_all = "kebab-case")]
153 pub enum DescriptorWire {
154 OpenaiCompatible,
155 AnthropicMessages,
156 }
157
158 #[derive(Debug, Deserialize)]
159 struct DescriptorFile {
160 descriptors: Vec<ProviderDescriptor>,
161 }
162
163 /// Data row describing a hosted OpenAI-compatible (or Anthropic Messages) gateway.
164 #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
165 pub struct ProviderDescriptor {
166 pub id: String,
167 pub label: String,
168 pub wire: DescriptorWire,
169 pub base_url: String,
170 pub api_key_env: String,
171 pub default_model: String,
172 pub discovery: DescriptorDiscovery,
173 #[serde(default)]
174 pub docs_url: Option<String>,
175 #[serde(default)]
176 pub credential_url: Option<String>,
177 #[serde(default)]
178 pub guidance: Option<String>,
179 #[serde(default)]
180 pub aliases: Vec<String>,
181 }
182
183 impl ProviderDescriptor {
184 #[must_use]
185 pub fn matches(&self, needle: &str) -> bool {
186 let needle = needle.trim().to_ascii_lowercase().replace('_', "-");
187 if needle.is_empty() {
188 return false;
189 }
190 self.id == needle
191 || self
192 .aliases
193 .iter()
194 .any(|alias| alias.eq_ignore_ascii_case(&needle))
195 }
196 }
197
198 static DESCRIPTORS: OnceLock<Vec<ProviderDescriptor>> = OnceLock::new();
199
200 /// Bundled compatible-host descriptors. Panics only if the committed JSON is invalid.
201 #[must_use]
202 pub fn bundled_provider_descriptors() -> &'static [ProviderDescriptor] {
203 DESCRIPTORS
204 .get_or_init(|| {
205 let file: DescriptorFile = serde_json::from_str(DESCRIPTORS_JSON)
206 .expect("committed provider_descriptors.json must parse");
207 file.descriptors
208 })
209 .as_slice()
210 }
211
212 #[must_use]
213 pub fn provider_descriptor(id: &str) -> Option<&'static ProviderDescriptor> {
214 bundled_provider_descriptors()
215 .iter()
216 .find(|descriptor| descriptor.matches(id))
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222
223 #[test]
224 fn descriptors_parse_and_command_code_is_a_row_not_a_kind() {
225 let rows = bundled_provider_descriptors();
226 assert!(
227 rows.len() >= 6,
228 "expected compatible hosts plus command-code and dashscope"
229 );
230 for row in rows {
231 assert!(row.base_url.starts_with("https://"), "{}", row.id);
232 assert!(!row.api_key_env.is_empty(), "{}", row.id);
233 assert!(!row.default_model.is_empty(), "{}", row.id);
234 assert_eq!(row.discovery, DescriptorDiscovery::ModelsEndpoint);
235 assert_eq!(row.wire, DescriptorWire::OpenaiCompatible);
236 }
237 let cmd = provider_descriptor("command-code").expect("command-code");
238 assert_eq!(cmd.base_url, "https://api.commandcode.ai/provider/v1");
239 assert_eq!(cmd.api_key_env, "COMMAND_CODE_API_KEY");
240 assert_eq!(
241 provider_descriptor("cmd-code").map(|row| row.id.as_str()),
242 Some("command-code")
243 );
244 // Alibaba Model Studio is a data-driven row: live /v1/models is the
245 // Qwen model authority, never a compiled roster.
246 let dashscope = provider_descriptor("dashscope").expect("dashscope");
247 assert_eq!(
248 dashscope.base_url,
249 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
250 );
251 assert_eq!(dashscope.api_key_env, "DASHSCOPE_API_KEY");
252 assert_eq!(
253 provider_descriptor("qwen").map(|row| row.id.as_str()),
254 Some("dashscope"),
255 "the founder's `qwen` name resolves to the DashScope row"
256 );
257 }
258
259 /// #6616: AICraft carries the console, docs and guidance its neighbours
260 /// do, and every link a descriptor publishes is HTTPS.
261 #[test]
262 fn aicraft_carries_console_docs_and_guidance() {
263 let aicraft = provider_descriptor("ai-craft").expect("aicraft");
264 assert_eq!(aicraft.id, "aicraft");
265 assert_eq!(
266 aicraft.docs_url.as_deref(),
267 Some("https://aicraftapi.com/docs.html#codewhale")
268 );
269 assert_eq!(
270 aicraft.credential_url.as_deref(),
271 Some("https://aicraftapi.com/dashboard.html")
272 );
273 let guidance = aicraft.guidance.as_deref().expect("aicraft guidance");
274 assert!(guidance.contains("Store AICRAFT_API_KEY"), "{guidance}");
275 for row in bundled_provider_descriptors() {
276 for url in [&row.docs_url, &row.credential_url].into_iter().flatten() {
277 assert!(url.starts_with("https://"), "{}: {url}", row.id);
278 }
279 }
280 }
281
282 /// #6695: Tsubasa is a data row on the existing compatible transport with
283 /// its own key env. The row carries no context field, so the guidance is
284 /// where the 32K window and the second public model id reach the user.
285 #[test]
286 fn tsubasa_is_a_compatible_row_with_its_own_key() {
287 let tsubasa = provider_descriptor("tsubasa").expect("tsubasa");
288 assert_eq!(tsubasa.wire, DescriptorWire::OpenaiCompatible);
289 assert_eq!(tsubasa.base_url, "https://api.tsubasa.sh/v1");
290 assert_eq!(tsubasa.api_key_env, "TSUBASA_API_KEY");
291 assert_eq!(tsubasa.default_model, "tsubasa-pro");
292 let guidance = tsubasa.guidance.as_deref().expect("tsubasa guidance");
293 for needle in [
294 "tsubasa-fast",
295 "context_window = 32768",
296 "Store TSUBASA_API_KEY",
297 ] {
298 assert!(guidance.contains(needle), "{needle}: {guidance}");
299 }
300 }
301
302 #[test]
303 fn cheaper_inference_is_a_descriptor_row() {
304 let row = provider_descriptor("cheaper-inference").expect("cheaperinference");
305 assert_eq!(row.id, "cheaperinference");
306 assert_eq!(row.base_url, "https://api.cheaperinference.com/v1");
307 assert_eq!(row.api_key_env, "CHEAPER_INFERENCE_API_KEY");
308 assert_eq!(row.default_model, "gpt-5.4-mini");
309 assert_eq!(
310 provider_descriptor("cheaper_inference").map(|row| row.id.as_str()),
311 Some("cheaperinference")
312 );
313 }
314
315 #[test]
316 fn descriptors_do_not_embed_model_rosters() {
317 let raw = DESCRIPTORS_JSON;
318 assert!(
319 !raw.contains("moonshotai/Kimi-K2.7-Code"),
320 "do not compile a Baseten/Kimi roster into descriptors"
321 );
322 assert!(
323 !raw.contains("openai/gpt-oss-120b"),
324 "do not compile a Groq roster into descriptors"
325 );
326 }
327 #[test]
328 fn metadata_preserves_distinct_registry_and_selector_orders() {
329 use crate::ProviderKind;
330 let registry: Vec<_> = crate::provider::all_providers()
331 .iter()
332 .map(|row| row.kind())
333 .collect();
334 let index = |rows: &[ProviderKind], kind| rows.iter().position(|row| *row == kind).unwrap();
335 assert_eq!(registry.len(), 52);
336 assert_eq!(ProviderKind::ALL.len(), 46);
337 assert!(
338 index(&registry, ProviderKind::Modelscope) < index(&registry, ProviderKind::Together)
339 );
340 assert!(
341 index(&ProviderKind::ALL, ProviderKind::ModelstudioTokenPlan)
342 < index(&ProviderKind::ALL, ProviderKind::Modelscope)
343 );
344 assert_eq!(ProviderKind::ALL.last(), Some(&ProviderKind::Custom));
345 assert_eq!(ProviderKind::parse("agy"), None);
346 assert_eq!(
347 ProviderKind::parse_config_identity("agy"),
348 Some(ProviderKind::Antigravity)
349 );
350 assert!(
351 crate::provider::providers_sorted_for_display()
352 .iter()
353 .all(|row| row.kind() != ProviderKind::Antigravity)
354 );
355 }
356
357 #[test]
358 fn grouped_secret_slots_do_not_collapse_config_or_wire_identity() {
359 use crate::ProviderKind;
360 use crate::provider::{CredentialAcquisition, WireFormat, WirePolicy};
361 let token = ProviderKind::ModelstudioTokenPlan.provider();
362 let coding = ProviderKind::ModelstudioCodingPlanAnthropic.provider();
363 assert_ne!(token.provider_config_key(), coding.provider_config_key());
364 assert_ne!(token.default_base_url(), coding.default_base_url());
365 assert_eq!(
366 ProviderKind::ModelstudioCodingPlanAnthropic.secret_store_slot(),
367 "modelstudio-token-plan"
368 );
369 assert_eq!(
370 ProviderKind::SiliconflowCN.secret_store_slot(),
371 "siliconflow"
372 );
373 assert_eq!(
374 ProviderKind::parse_config_identity(coding.id()),
375 Some(ProviderKind::ModelstudioCodingPlanAnthropic)
376 );
377 assert_eq!(
378 token.wire_policy(),
379 WirePolicy::Fixed(WireFormat::ChatCompletions)
380 );
381 assert_eq!(
382 coding.wire_policy(),
383 WirePolicy::Fixed(WireFormat::AnthropicMessages)
384 );
385 assert_eq!(
386 ProviderKind::Codewhale.provider().wire_policy(),
387 WirePolicy::ModelAware
388 );
389 // Preserve the existing public const metadata accessor as well as
390 // the ordinary trait facade used by runtime consumers.
391 const CODEX_HELP: crate::provider::CredentialHelp =
392 crate::provider::credential_help(ProviderKind::OpenaiCodex);
393 assert_eq!(CODEX_HELP.acquisition, CredentialAcquisition::OAuth);
394 assert_eq!(
395 ProviderKind::Sglang
396 .provider()
397 .credential_help()
398 .acquisition,
399 CredentialAcquisition::LocalOptional
400 );
401 assert_eq!(LEGACY_DEEPSEEK_CN.secret_store_slot, "deepseek");
402 assert_eq!(LEGACY_DEEPSEEK_CN.config_key, "deepseek_cn");
403 // A named custom table must never obtain built-in authority just by
404 // reusing its name; compatible-host lookup remains separately scoped.
405 assert!(provider_descriptor("openai").is_none());
406 assert!(provider_descriptor("deepseek").is_none());
407 assert_eq!(bundled_provider_descriptors().len(), 10);
408 }
409 }
410
410 lines RUST