1use std::collections::{BTreeMap, BTreeSet};
9
10use serde::Serialize;
11use serde_json::{json, Value};
12
13use crate::llm;
14use crate::llm_config::{self, AliasDef, AliasToolCallingDef, ModelDef, ModelPricing, ProviderDef};
15
16pub const PROVIDER_CATALOG_SCHEMA_VERSION: u32 = 2;
17pub const PROVIDER_CATALOG_SCHEMA_ID: &str =
18 "https://harnlang.com/schemas/provider-catalog.v2.json";
19pub const PROVIDER_CATALOG_GENERATOR: &str = "harn providers export";
20
21#[derive(Debug, Clone, Serialize)]
22pub struct ProviderCatalogArtifact {
23 pub schema_version: u32,
24 pub schema: String,
25 pub generated_by: String,
26 pub providers: Vec<CatalogProvider>,
27 pub models: Vec<CatalogModel>,
28 pub aliases: Vec<CatalogAlias>,
29 pub variants: Vec<CatalogVariant>,
30 pub qc_defaults: BTreeMap<String, String>,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct CatalogProvider {
35 pub id: String,
36 pub display_name: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub icon: Option<String>,
39 pub classification: ProviderClassification,
40 pub endpoint: ProviderEndpoint,
41 pub auth: ProviderAuth,
42 pub protocols: Vec<String>,
43 pub features: Vec<String>,
44 pub caveats: Vec<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub rpm: Option<u32>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub latency_p50_ms: Option<u64>,
49}
50
51#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ProviderClassification {
54 Hosted,
55 Local,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct ProviderEndpoint {
60 pub base_url: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub base_url_env: Option<String>,
63 pub chat_endpoint: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub completion_endpoint: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize)]
69pub struct ProviderAuth {
70 pub style: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub header: Option<String>,
73 pub env: Vec<String>,
74 pub required: bool,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct CatalogAlias {
79 pub name: String,
80 pub model_id: String,
81 pub provider: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub tool_format: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub tool_calling: Option<AliasToolCallingDef>,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct CatalogModel {
90 pub id: String,
91 pub name: String,
92 pub provider: String,
93 pub aliases: Vec<String>,
94 pub context_window: u64,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub runtime_context_window: Option<u64>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub stream_timeout: Option<f64>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub prefer_prefill_done: Option<bool>,
101 pub modalities: ModelModalities,
102 pub tool_support: ModelToolSupport,
103 pub structured_output: String,
104 pub format_preferences: ModelFormatPreferences,
105 pub reasoning: ModelReasoning,
106 pub prompt_cache: bool,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub pricing: Option<ModelPricing>,
109 pub deprecation: ModelDeprecation,
110 pub quality_tags: Vec<String>,
111 pub capability_tags: Vec<String>,
112}
113
114#[derive(Debug, Clone, Serialize)]
115pub struct ModelModalities {
116 pub input: Vec<String>,
117 pub output: Vec<String>,
118}
119
120#[derive(Debug, Clone, Serialize)]
121pub struct ModelToolSupport {
122 pub native: bool,
123 pub text: bool,
124 pub tool_search: Vec<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub max_tools: Option<u32>,
127}
128
129#[derive(Debug, Clone, Serialize)]
130pub struct ModelFormatPreferences {
131 pub prefers_xml_scaffolding: bool,
132 pub prefers_markdown_scaffolding: bool,
133 pub structured_output_mode: String,
134 pub supports_assistant_prefill: bool,
135 pub prefers_role_developer: bool,
136 pub prefers_xml_tools: bool,
137 pub thinking_block_style: String,
138}
139
140#[derive(Debug, Clone, Serialize)]
141pub struct ModelReasoning {
142 pub modes: Vec<String>,
143 pub effort_supported: bool,
144 pub none_supported: bool,
145 pub interleaved_supported: bool,
146 pub preserve_thinking: bool,
147}
148
149#[derive(Debug, Clone, Serialize)]
150pub struct ModelDeprecation {
151 pub status: DeprecationStatus,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub note: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
157#[serde(rename_all = "snake_case")]
158pub enum DeprecationStatus {
159 Active,
160 Deprecated,
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct CatalogVariant {
165 pub id: String,
166 pub label: String,
167 pub description: String,
168 pub model_id: String,
169 pub provider: String,
170 pub source: String,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct ProviderCatalogValidation {
175 pub errors: Vec<String>,
176 pub warnings: Vec<String>,
177}
178
179impl ProviderCatalogValidation {
180 pub fn is_ok(&self) -> bool {
181 self.errors.is_empty()
182 }
183}
184
185pub fn artifact() -> ProviderCatalogArtifact {
186 let alias_entries = llm_config::alias_entries();
187 let aliases_by_model = aliases_by_model(&alias_entries);
188 let providers = llm_config::provider_names()
189 .into_iter()
190 .filter_map(|id| {
191 llm_config::provider_config(&id).map(|provider| catalog_provider(id, provider))
192 })
193 .collect();
194 let models = llm_config::model_catalog_entries()
195 .into_iter()
196 .map(|(id, model)| catalog_model(id, model, &aliases_by_model))
197 .collect::<Vec<_>>();
198 let aliases = alias_entries
199 .iter()
200 .map(|(name, alias)| catalog_alias(name, alias))
201 .collect::<Vec<_>>();
202 let variants = catalog_variants(&models, &aliases);
203
204 ProviderCatalogArtifact {
205 schema_version: PROVIDER_CATALOG_SCHEMA_VERSION,
206 schema: PROVIDER_CATALOG_SCHEMA_ID.to_string(),
207 generated_by: PROVIDER_CATALOG_GENERATOR.to_string(),
208 providers,
209 models,
210 aliases,
211 variants,
212 qc_defaults: llm_config::qc_defaults(),
213 }
214}
215
216pub fn artifact_json() -> Result<String, serde_json::Error> {
217 serde_json::to_string_pretty(&artifact()).map(|mut text| {
218 text.push('\n');
219 text
220 })
221}
222
223pub fn schema_json() -> Result<String, serde_json::Error> {
224 serde_json::to_string_pretty(&schema_value()).map(|mut text| {
225 text.push('\n');
226 text
227 })
228}
229
230pub fn typescript_binding() -> Result<String, serde_json::Error> {
231 let json = artifact_json()?;
232 Ok(format!(
233 "{}{}{}{}{}",
234 generated_header("//", "typescript"),
235 TYPESCRIPT_TYPES,
236 "\nexport const harnProviderCatalog: HarnProviderCatalog = ",
237 json.trim_end(),
238 ";\n",
239 ) + TYPESCRIPT_COMPAT_EXPORTS)
240}
241
242pub fn swift_binding() -> Result<String, serde_json::Error> {
243 let json = artifact_json()?;
244 Ok(format!(
245 "{}{}\npublic let harnProviderCatalogJSON = #\"\"\"\n{}\"\"\"#\n",
246 generated_header("//", "swift"),
247 SWIFT_TYPES,
248 json
249 ))
250}
251
252pub fn validate_artifact(artifact: &ProviderCatalogArtifact) -> ProviderCatalogValidation {
253 let mut result = ProviderCatalogValidation::default();
254 if artifact.schema_version != PROVIDER_CATALOG_SCHEMA_VERSION {
255 result.errors.push(format!(
256 "schema_version must be {}, got {}",
257 PROVIDER_CATALOG_SCHEMA_VERSION, artifact.schema_version
258 ));
259 }
260 if artifact.providers.is_empty() {
261 result.errors.push("catalog has no providers".to_string());
262 }
263 if artifact.models.is_empty() {
264 result.errors.push("catalog has no models".to_string());
265 }
266
267 let provider_ids: BTreeSet<_> = artifact.providers.iter().map(|p| p.id.as_str()).collect();
268 for provider in &artifact.providers {
269 if provider.id.trim().is_empty() {
270 result
271 .errors
272 .push("provider id cannot be empty".to_string());
273 }
274 if provider.display_name.trim().is_empty() {
275 result.errors.push(format!(
276 "provider {} display_name cannot be empty",
277 provider.id
278 ));
279 }
280 if provider.endpoint.chat_endpoint.trim().is_empty() {
281 result.errors.push(format!(
282 "provider {} chat_endpoint cannot be empty",
283 provider.id
284 ));
285 }
286 if provider.auth.required
287 && provider.auth.env.is_empty()
288 && provider.auth.style != "aws_sigv4"
289 {
290 result.errors.push(format!(
291 "provider {} requires auth but declares no auth env keys",
292 provider.id
293 ));
294 }
295 }
296
297 let mut alias_names = BTreeSet::new();
298 for alias in &artifact.aliases {
299 if alias.name.trim().is_empty() {
300 result.errors.push("alias name cannot be empty".to_string());
301 }
302 if !alias_names.insert(alias.name.as_str()) {
303 result
304 .errors
305 .push(format!("duplicate alias name {}", alias.name));
306 }
307 if !provider_ids.contains(alias.provider.as_str()) {
308 result.errors.push(format!(
309 "alias {} references unknown provider {}",
310 alias.name, alias.provider
311 ));
312 }
313 }
314
315 let mut model_ids = BTreeSet::new();
316 let mut model_pairs = BTreeSet::new();
317 for model in &artifact.models {
318 if !model_ids.insert(model.id.as_str()) {
319 result
320 .errors
321 .push(format!("duplicate model id {}", model.id));
322 }
323 model_pairs.insert((model.provider.as_str(), model.id.as_str()));
324 if model.name.trim().is_empty() {
325 result
326 .errors
327 .push(format!("model {} name cannot be empty", model.id));
328 }
329 if !provider_ids.contains(model.provider.as_str()) {
330 result.errors.push(format!(
331 "model {} references unknown provider {}",
332 model.id, model.provider
333 ));
334 }
335 if model.context_window == 0 {
336 result.errors.push(format!(
337 "model {} context_window must be positive",
338 model.id
339 ));
340 }
341 if let Some(pricing) = &model.pricing {
342 validate_pricing(model, pricing, &mut result);
343 }
344 if model.deprecation.status == DeprecationStatus::Deprecated
345 && model
346 .deprecation
347 .note
348 .as_deref()
349 .unwrap_or("")
350 .trim()
351 .is_empty()
352 {
353 result.errors.push(format!(
354 "deprecated model {} must include deprecation.note",
355 model.id
356 ));
357 }
358 }
359
360 for alias in &artifact.aliases {
361 if !model_pairs.contains(&(alias.provider.as_str(), alias.model_id.as_str())) {
362 result.errors.push(format!(
363 "alias {} targets {}/{} without a catalog row",
364 alias.name, alias.provider, alias.model_id
365 ));
366 }
367 }
368
369 for variant in &artifact.variants {
370 if variant.id.trim().is_empty() {
371 result.errors.push("variant id cannot be empty".to_string());
372 }
373 if !provider_ids.contains(variant.provider.as_str()) {
374 result.errors.push(format!(
375 "variant {} references unknown provider {}",
376 variant.id, variant.provider
377 ));
378 }
379 if !model_pairs.contains(&(variant.provider.as_str(), variant.model_id.as_str())) {
380 result.errors.push(format!(
381 "variant {} targets {}/{} without a catalog row",
382 variant.id, variant.provider, variant.model_id
383 ));
384 }
385 }
386
387 result
388}
389
390pub fn validate_current() -> ProviderCatalogValidation {
391 validate_artifact(&artifact())
392}
393
394pub fn schema_value() -> Value {
395 json!({
396 "$schema": "https://json-schema.org/draft/2020-12/schema",
397 "$id": PROVIDER_CATALOG_SCHEMA_ID,
398 "title": "Harn provider catalog",
399 "type": "object",
400 "required": ["schema_version", "schema", "generated_by", "providers", "models", "aliases", "variants", "qc_defaults"],
401 "properties": {
402 "schema_version": {"const": PROVIDER_CATALOG_SCHEMA_VERSION},
403 "schema": {"const": PROVIDER_CATALOG_SCHEMA_ID},
404 "generated_by": {"type": "string"},
405 "providers": {"type": "array", "items": {"$ref": "#/$defs/provider"}},
406 "models": {"type": "array", "items": {"$ref": "#/$defs/model"}},
407 "aliases": {"type": "array", "items": {"$ref": "#/$defs/alias"}},
408 "variants": {"type": "array", "items": {"$ref": "#/$defs/variant"}},
409 "qc_defaults": {"type": "object", "additionalProperties": {"type": "string"}}
410 },
411 "additionalProperties": false,
412 "$defs": {
413 "provider": {
414 "type": "object",
415 "required": ["id", "display_name", "classification", "endpoint", "auth", "protocols", "features", "caveats"],
416 "properties": {
417 "id": {"type": "string", "minLength": 1},
418 "display_name": {"type": "string", "minLength": 1},
419 "icon": {"type": "string"},
420 "classification": {"enum": ["hosted", "local"]},
421 "endpoint": {"$ref": "#/$defs/endpoint"},
422 "auth": {"$ref": "#/$defs/auth"},
423 "protocols": {"type": "array", "items": {"type": "string"}},
424 "features": {"type": "array", "items": {"type": "string"}},
425 "caveats": {"type": "array", "items": {"type": "string"}},
426 "rpm": {"type": "integer", "minimum": 1},
427 "latency_p50_ms": {"type": "integer", "minimum": 0}
428 },
429 "additionalProperties": false
430 },
431 "endpoint": {
432 "type": "object",
433 "required": ["base_url", "chat_endpoint"],
434 "properties": {
435 "base_url": {"type": "string"},
436 "base_url_env": {"type": "string"},
437 "chat_endpoint": {"type": "string", "minLength": 1},
438 "completion_endpoint": {"type": "string"}
439 },
440 "additionalProperties": false
441 },
442 "auth": {
443 "type": "object",
444 "required": ["style", "env", "required"],
445 "properties": {
446 "style": {"type": "string"},
447 "header": {"type": "string"},
448 "env": {"type": "array", "items": {"type": "string"}},
449 "required": {"type": "boolean"}
450 },
451 "additionalProperties": false
452 },
453 "alias": {
454 "type": "object",
455 "required": ["name", "model_id", "provider"],
456 "properties": {
457 "name": {"type": "string", "minLength": 1},
458 "model_id": {"type": "string", "minLength": 1},
459 "provider": {"type": "string", "minLength": 1},
460 "tool_format": {"type": "string"},
461 "tool_calling": {
462 "type": "object",
463 "properties": {
464 "native": {"type": "string"},
465 "text": {"type": "string"},
466 "streaming_native": {"type": "string"},
467 "fallback_mode": {"type": "string"},
468 "failure_reason": {"type": "string"},
469 "last_probe_at": {"type": "string"}
470 },
471 "additionalProperties": false
472 }
473 },
474 "additionalProperties": false
475 },
476 "model": {
477 "type": "object",
478 "required": [
479 "id",
480 "name",
481 "provider",
482 "aliases",
483 "context_window",
484 "modalities",
485 "tool_support",
486 "structured_output",
487 "format_preferences",
488 "reasoning",
489 "prompt_cache",
490 "deprecation",
491 "quality_tags",
492 "capability_tags"
493 ],
494 "properties": {
495 "id": {"type": "string", "minLength": 1},
496 "name": {"type": "string", "minLength": 1},
497 "provider": {"type": "string", "minLength": 1},
498 "aliases": {"type": "array", "items": {"type": "string"}},
499 "context_window": {"type": "integer", "minimum": 1},
500 "runtime_context_window": {"type": "integer", "minimum": 1},
501 "stream_timeout": {"type": "number", "exclusiveMinimum": 0},
502 "prefer_prefill_done": {"type": "boolean"},
503 "modalities": {"$ref": "#/$defs/modalities"},
504 "tool_support": {"$ref": "#/$defs/tool_support"},
505 "structured_output": {"type": "string"},
506 "format_preferences": {"$ref": "#/$defs/format_preferences"},
507 "reasoning": {"$ref": "#/$defs/reasoning"},
508 "prompt_cache": {"type": "boolean"},
509 "pricing": {"$ref": "#/$defs/pricing"},
510 "deprecation": {"$ref": "#/$defs/deprecation"},
511 "quality_tags": {"type": "array", "items": {"type": "string"}},
512 "capability_tags": {"type": "array", "items": {"type": "string"}}
513 },
514 "additionalProperties": false
515 },
516 "modalities": {
517 "type": "object",
518 "required": ["input", "output"],
519 "properties": {
520 "input": {"type": "array", "items": {"type": "string"}, "minItems": 1},
521 "output": {"type": "array", "items": {"type": "string"}, "minItems": 1}
522 },
523 "additionalProperties": false
524 },
525 "tool_support": {
526 "type": "object",
527 "required": ["native", "text", "tool_search"],
528 "properties": {
529 "native": {"type": "boolean"},
530 "text": {"type": "boolean"},
531 "tool_search": {"type": "array", "items": {"type": "string"}},
532 "max_tools": {"type": "integer", "minimum": 1}
533 },
534 "additionalProperties": false
535 },
536 "format_preferences": {
537 "type": "object",
538 "required": [
539 "prefers_xml_scaffolding",
540 "prefers_markdown_scaffolding",
541 "structured_output_mode",
542 "supports_assistant_prefill",
543 "prefers_role_developer",
544 "prefers_xml_tools",
545 "thinking_block_style"
546 ],
547 "properties": {
548 "prefers_xml_scaffolding": {"type": "boolean"},
549 "prefers_markdown_scaffolding": {"type": "boolean"},
550 "structured_output_mode": {"enum": ["native_json", "delimited", "xml_tagged", "none"]},
551 "supports_assistant_prefill": {"type": "boolean"},
552 "prefers_role_developer": {"type": "boolean"},
553 "prefers_xml_tools": {"type": "boolean"},
554 "thinking_block_style": {"enum": ["none", "thinking_blocks", "reasoning_summary", "inline"]}
555 },
556 "additionalProperties": false
557 },
558 "reasoning": {
559 "type": "object",
560 "required": ["modes", "effort_supported", "none_supported", "interleaved_supported", "preserve_thinking"],
561 "properties": {
562 "modes": {"type": "array", "items": {"type": "string"}},
563 "effort_supported": {"type": "boolean"},
564 "none_supported": {"type": "boolean"},
565 "interleaved_supported": {"type": "boolean"},
566 "preserve_thinking": {"type": "boolean"}
567 },
568 "additionalProperties": false
569 },
570 "pricing": {
571 "type": "object",
572 "required": ["input_per_mtok", "output_per_mtok"],
573 "properties": {
574 "input_per_mtok": {"type": "number", "minimum": 0},
575 "output_per_mtok": {"type": "number", "minimum": 0},
576 "cache_read_per_mtok": {"type": ["number", "null"], "minimum": 0},
577 "cache_write_per_mtok": {"type": ["number", "null"], "minimum": 0}
578 },
579 "additionalProperties": false
580 },
581 "deprecation": {
582 "type": "object",
583 "required": ["status"],
584 "properties": {
585 "status": {"enum": ["active", "deprecated"]},
586 "note": {"type": "string"}
587 },
588 "additionalProperties": false
589 },
590 "variant": {
591 "type": "object",
592 "required": ["id", "label", "description", "model_id", "provider", "source"],
593 "properties": {
594 "id": {"type": "string", "minLength": 1},
595 "label": {"type": "string", "minLength": 1},
596 "description": {"type": "string"},
597 "model_id": {"type": "string", "minLength": 1},
598 "provider": {"type": "string", "minLength": 1},
599 "source": {"type": "string", "minLength": 1}
600 },
601 "additionalProperties": false
602 }
603 }
604 })
605}
606
607fn catalog_provider(id: String, provider: ProviderDef) -> CatalogProvider {
608 CatalogProvider {
609 display_name: provider
610 .display_name
611 .clone()
612 .unwrap_or_else(|| title_case(&id)),
613 icon: provider.icon.clone(),
614 classification: provider_classification(&provider),
615 endpoint: ProviderEndpoint {
616 base_url: provider.base_url.clone(),
617 base_url_env: provider.base_url_env.clone(),
618 chat_endpoint: provider.chat_endpoint.clone(),
619 completion_endpoint: provider.completion_endpoint.clone(),
620 },
621 auth: ProviderAuth {
622 style: provider.auth_style.clone(),
623 header: provider.auth_header.clone(),
624 env: llm_config::auth_env_names(&provider.auth_env),
625 required: provider.auth_style != "none",
626 },
627 protocols: provider_protocols(&id, &provider),
628 features: provider.features.clone(),
629 caveats: provider_caveats(&id, &provider),
630 rpm: provider.rpm,
631 latency_p50_ms: provider.latency_p50_ms,
632 id,
633 }
634}
635
636fn catalog_alias(name: &str, alias: &AliasDef) -> CatalogAlias {
637 CatalogAlias {
638 name: name.to_string(),
639 model_id: alias.id.clone(),
640 provider: alias.provider.clone(),
641 tool_format: alias.tool_format.clone(),
642 tool_calling: llm_config::alias_tool_calling_entry(name),
643 }
644}
645
646fn catalog_model(
647 id: String,
648 model: ModelDef,
649 aliases_by_model: &BTreeMap<(String, String), Vec<String>>,
650) -> CatalogModel {
651 let caps = llm::capabilities::lookup(&model.provider, &id);
652 let structured_output = caps
653 .structured_output
654 .clone()
655 .or_else(|| caps.json_schema.clone())
656 .unwrap_or_else(|| "none".to_string());
657 let aliases = aliases_by_model
658 .get(&(model.provider.clone(), id.clone()))
659 .cloned()
660 .unwrap_or_default();
661 let quality_tags = model_quality_tags(&model, &aliases);
662 CatalogModel {
663 aliases,
664 modalities: modalities_from_caps(&caps),
665 tool_support: ModelToolSupport {
666 native: caps.native_tools,
667 text: caps.text_tool_wire_format_supported,
668 tool_search: caps.tool_search.clone(),
669 max_tools: caps.max_tools,
670 },
671 structured_output,
672 format_preferences: ModelFormatPreferences {
673 prefers_xml_scaffolding: caps.prefers_xml_scaffolding,
674 prefers_markdown_scaffolding: caps.prefers_markdown_scaffolding,
675 structured_output_mode: caps.structured_output_mode.clone(),
676 supports_assistant_prefill: caps.supports_assistant_prefill,
677 prefers_role_developer: caps.prefers_role_developer,
678 prefers_xml_tools: caps.prefers_xml_tools,
679 thinking_block_style: caps.thinking_block_style.clone(),
680 },
681 reasoning: ModelReasoning {
682 modes: caps.thinking_modes.clone(),
683 effort_supported: caps.reasoning_effort_supported,
684 none_supported: caps.reasoning_none_supported,
685 interleaved_supported: caps.interleaved_thinking_supported,
686 preserve_thinking: caps.preserve_thinking,
687 },
688 prompt_cache: caps.prompt_caching,
689 pricing: model.pricing.clone(),
690 deprecation: ModelDeprecation {
691 status: if model.deprecated {
692 DeprecationStatus::Deprecated
693 } else {
694 DeprecationStatus::Active
695 },
696 note: model.deprecation_note.clone(),
697 },
698 quality_tags,
699 capability_tags: model.capabilities.clone(),
700 id,
701 name: model.name,
702 provider: model.provider,
703 context_window: model.context_window,
704 runtime_context_window: model.runtime_context_window,
705 stream_timeout: model.stream_timeout,
706 prefer_prefill_done: model.prefer_prefill_done,
707 }
708}
709
710fn model_quality_tags(model: &ModelDef, aliases: &[String]) -> Vec<String> {
711 let mut tags: BTreeSet<String> = model.quality_tags.iter().cloned().collect();
712 for alias in aliases {
713 match alias.as_str() {
714 "frontier" | "tier/frontier" => {
715 tags.insert("frontier".to_string());
716 }
717 "mid" | "tier/mid" => {
718 tags.insert("balanced".to_string());
719 }
720 "small" | "tier/small" => {
721 tags.insert("small".to_string());
722 }
723 _ => {}
724 }
725 }
726 if is_local_provider(&model.provider) {
727 tags.insert("local".to_string());
728 }
729 tags.into_iter().collect()
730}
731
732fn aliases_by_model(aliases: &[(String, AliasDef)]) -> BTreeMap<(String, String), Vec<String>> {
733 let mut by_model: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
734 for (name, alias) in aliases {
735 by_model
736 .entry((alias.provider.clone(), alias.id.clone()))
737 .or_default()
738 .push(name.clone());
739 }
740 for names in by_model.values_mut() {
741 names.sort();
742 }
743 by_model
744}
745
746fn modalities_from_caps(caps: &llm::capabilities::Capabilities) -> ModelModalities {
747 let mut input = vec!["text".to_string()];
748 if caps.vision || caps.vision_supported {
749 input.push("image".to_string());
750 }
751 if caps.audio {
752 input.push("audio".to_string());
753 }
754 if caps.pdf {
755 input.push("pdf".to_string());
756 }
757 ModelModalities {
758 input,
759 output: vec!["text".to_string()],
760 }
761}
762
763fn catalog_variants(models: &[CatalogModel], aliases: &[CatalogAlias]) -> Vec<CatalogVariant> {
764 let mut variants = Vec::new();
765 for (id, label, description, alias_name) in [
766 (
767 "fast",
768 "Fast",
769 "Lowest-latency general coding-agent route.",
770 "small",
771 ),
772 (
773 "balanced",
774 "Balanced",
775 "Default cost/quality tradeoff for routine coding-agent work.",
776 "mid",
777 ),
778 (
779 "high-reasoning",
780 "High reasoning",
781 "Frontier route for hard planning, repair, and review tasks.",
782 "frontier",
783 ),
784 ] {
785 if let Some(alias) = aliases.iter().find(|alias| alias.name == alias_name) {
786 variants.push(CatalogVariant {
787 id: id.to_string(),
788 label: label.to_string(),
789 description: description.to_string(),
790 model_id: alias.model_id.clone(),
791 provider: alias.provider.clone(),
792 source: format!("alias:{alias_name}"),
793 });
794 }
795 }
796 push_variant_from_model(
797 &mut variants,
798 "local",
799 "Local",
800 "Best local/offline model route in the checked-in catalog.",
801 models
802 .iter()
803 .filter(|model| is_local_provider(&model.provider))
804 .max_by_key(|model| model.context_window),
805 );
806 push_variant_from_model(
807 &mut variants,
808 "cheap",
809 "Cheap",
810 "Lowest known hosted input+output token price.",
811 models
812 .iter()
813 .filter(|model| !is_local_provider(&model.provider))
814 .min_by(|left, right| {
815 pricing_total(left)
816 .partial_cmp(&pricing_total(right))
817 .unwrap_or(std::cmp::Ordering::Equal)
818 }),
819 );
820 push_variant_from_model(
821 &mut variants,
822 "vision-capable",
823 "Vision capable",
824 "A model route that accepts image input.",
825 models
826 .iter()
827 .filter(|model| model.modalities.input.iter().any(|mode| mode == "image"))
828 .max_by_key(|model| model.context_window),
829 );
830 push_variant_from_model(
831 &mut variants,
832 "long-context",
833 "Long context",
834 "Largest context-window route in the checked-in catalog.",
835 models.iter().max_by_key(|model| model.context_window),
836 );
837 variants
838}
839
840fn push_variant_from_model(
841 variants: &mut Vec<CatalogVariant>,
842 id: &str,
843 label: &str,
844 description: &str,
845 model: Option<&CatalogModel>,
846) {
847 if let Some(model) = model {
848 variants.push(CatalogVariant {
849 id: id.to_string(),
850 label: label.to_string(),
851 description: description.to_string(),
852 model_id: model.id.clone(),
853 provider: model.provider.clone(),
854 source: "catalog".to_string(),
855 });
856 }
857}
858
859fn pricing_total(model: &CatalogModel) -> f64 {
860 model
861 .pricing
862 .as_ref()
863 .map(|pricing| pricing.input_per_mtok + pricing.output_per_mtok)
864 .unwrap_or(f64::MAX)
865}
866
867fn validate_pricing(
868 model: &CatalogModel,
869 pricing: &ModelPricing,
870 result: &mut ProviderCatalogValidation,
871) {
872 for (field, value) in [
873 ("input_per_mtok", Some(pricing.input_per_mtok)),
874 ("output_per_mtok", Some(pricing.output_per_mtok)),
875 ("cache_read_per_mtok", pricing.cache_read_per_mtok),
876 ("cache_write_per_mtok", pricing.cache_write_per_mtok),
877 ] {
878 if value.is_some_and(|value| value < 0.0) {
879 result.errors.push(format!(
880 "model {} pricing.{} must be non-negative",
881 model.id, field
882 ));
883 }
884 }
885}
886
887fn provider_classification(provider: &ProviderDef) -> ProviderClassification {
888 if provider.auth_style == "none"
889 || provider.base_url.contains("localhost")
890 || provider.base_url.contains("127.0.0.1")
891 {
892 ProviderClassification::Local
893 } else {
894 ProviderClassification::Hosted
895 }
896}
897
898fn provider_protocols(id: &str, provider: &ProviderDef) -> Vec<String> {
899 match id {
900 "anthropic" => vec!["anthropic_messages".to_string()],
901 "gemini" => vec!["gemini_generate_content".to_string()],
902 "vertex" => vec!["vertex_generate_content".to_string()],
903 "bedrock" => vec!["bedrock_converse".to_string()],
904 "azure_openai" => vec!["azure_openai_chat_completions".to_string()],
905 "ollama" if provider.chat_endpoint.starts_with("/api/") => {
906 vec!["ollama_native".to_string()]
907 }
908 _ => vec!["openai_chat_completions".to_string()],
909 }
910}
911
912fn provider_caveats(id: &str, provider: &ProviderDef) -> Vec<String> {
913 let mut caveats = Vec::new();
914 if provider.auth_style == "aws_sigv4" {
915 caveats.push("Credentials are resolved through the AWS SDK chain.".to_string());
916 }
917 if id == "azure_openai" {
918 caveats.push("The Harn model field names the Azure deployment.".to_string());
919 }
920 if id == "ollama" && provider.chat_endpoint == "/api/chat" {
921 caveats.push(
922 "Native Ollama chat returns NDJSON and can apply model-family parsers.".to_string(),
923 );
924 }
925 caveats
926}
927
928fn is_local_provider(provider: &str) -> bool {
929 matches!(
930 provider,
931 "ollama" | "local" | "llamacpp" | "mlx" | "vllm" | "tgi"
932 )
933}
934
935fn title_case(id: &str) -> String {
936 id.split('_')
937 .map(|part| {
938 let mut chars = part.chars();
939 match chars.next() {
940 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
941 None => String::new(),
942 }
943 })
944 .collect::<Vec<_>>()
945 .join(" ")
946}
947
948fn generated_header(comment: &str, language: &str) -> String {
949 format!(
950 "{comment} GENERATED by `{}` - do not edit by hand.\n{comment} Source: Harn runtime provider catalog schema v{}.\n{comment} Language: {language}.\n\n",
951 PROVIDER_CATALOG_GENERATOR, PROVIDER_CATALOG_SCHEMA_VERSION
952 )
953}
954
955const TYPESCRIPT_TYPES: &str = r#"export interface HarnProviderCatalog {
956 schema_version: 2
957 schema: string
958 generated_by: string
959 providers: HarnCatalogProvider[]
960 models: HarnCatalogModel[]
961 aliases: HarnCatalogAlias[]
962 variants: HarnCatalogVariant[]
963 qc_defaults: Record<string, string>
964}
965
966export interface HarnCatalogProvider {
967 id: string
968 display_name: string
969 icon?: string
970 classification: "hosted" | "local"
971 endpoint: HarnProviderEndpoint
972 auth: HarnProviderAuth
973 protocols: string[]
974 features: string[]
975 caveats: string[]
976 rpm?: number
977 latency_p50_ms?: number
978}
979
980export interface HarnProviderEndpoint {
981 base_url: string
982 base_url_env?: string
983 chat_endpoint: string
984 completion_endpoint?: string
985}
986
987export interface HarnProviderAuth {
988 style: string
989 header?: string
990 env: string[]
991 required: boolean
992}
993
994export interface HarnCatalogAlias {
995 name: string
996 model_id: string
997 provider: string
998 tool_format?: string
999 tool_calling?: HarnAliasToolCalling
1000}
1001
1002export interface HarnAliasToolCalling {
1003 native?: string
1004 text?: string
1005 streaming_native?: string
1006 fallback_mode?: string
1007 failure_reason?: string
1008 last_probe_at?: string
1009}
1010
1011export interface HarnCatalogModel {
1012 id: string
1013 name: string
1014 provider: string
1015 aliases: string[]
1016 context_window: number
1017 runtime_context_window?: number
1018 stream_timeout?: number
1019 prefer_prefill_done?: boolean
1020 modalities: { input: string[]; output: string[] }
1021 tool_support: {
1022 native: boolean
1023 text: boolean
1024 tool_search: string[]
1025 max_tools?: number
1026 }
1027 structured_output: string
1028 format_preferences: {
1029 prefers_xml_scaffolding: boolean
1030 prefers_markdown_scaffolding: boolean
1031 structured_output_mode: "native_json" | "delimited" | "xml_tagged" | "none"
1032 supports_assistant_prefill: boolean
1033 prefers_role_developer: boolean
1034 prefers_xml_tools: boolean
1035 thinking_block_style: "none" | "thinking_blocks" | "reasoning_summary" | "inline"
1036 }
1037 reasoning: {
1038 modes: string[]
1039 effort_supported: boolean
1040 none_supported: boolean
1041 interleaved_supported: boolean
1042 preserve_thinking: boolean
1043 }
1044 prompt_cache: boolean
1045 pricing?: HarnModelPricing
1046 deprecation: { status: "active" | "deprecated"; note?: string }
1047 quality_tags: string[]
1048 capability_tags: string[]
1049}
1050
1051export interface HarnModelPricing {
1052 input_per_mtok: number
1053 output_per_mtok: number
1054 cache_read_per_mtok?: number | null
1055 cache_write_per_mtok?: number | null
1056}
1057
1058export interface HarnCatalogVariant {
1059 id: string
1060 label: string
1061 description: string
1062 model_id: string
1063 provider: string
1064 source: string
1065}
1066
1067export interface CatalogEntry {
1068 id: string
1069 name: string
1070 provider: string
1071 contextWindow: number
1072 runtimeContextWindow?: number
1073 capabilities: string[]
1074 pricing?: {
1075 inputPerMTok: number
1076 outputPerMTok: number
1077 cacheReadPerMTok?: number | null
1078 cacheWritePerMTok?: number | null
1079 }
1080 streamTimeout?: number
1081 preferPrefillDone?: boolean
1082}
1083
1084export interface CatalogAlias {
1085 alias: string
1086 id: string
1087 provider: string
1088 toolFormat?: string
1089 toolCalling?: HarnAliasToolCalling
1090}
1091
1092"#;
1093
1094const TYPESCRIPT_COMPAT_EXPORTS: &str = r#"
1095export const MODEL_CATALOG: readonly CatalogEntry[] = harnProviderCatalog.models.map((model) => ({
1096 id: model.id,
1097 name: model.name,
1098 provider: model.provider,
1099 contextWindow: model.context_window,
1100 runtimeContextWindow: model.runtime_context_window,
1101 capabilities: model.capability_tags,
1102 pricing: model.pricing
1103 ? {
1104 inputPerMTok: model.pricing.input_per_mtok,
1105 outputPerMTok: model.pricing.output_per_mtok,
1106 cacheReadPerMTok: model.pricing.cache_read_per_mtok,
1107 cacheWritePerMTok: model.pricing.cache_write_per_mtok,
1108 }
1109 : undefined,
1110 streamTimeout: model.stream_timeout,
1111 preferPrefillDone: model.prefer_prefill_done,
1112}))
1113
1114export const ALIASES: readonly CatalogAlias[] = harnProviderCatalog.aliases.map((alias) => ({
1115 alias: alias.name,
1116 id: alias.model_id,
1117 provider: alias.provider,
1118 toolFormat: alias.tool_format,
1119 toolCalling: alias.tool_calling,
1120}))
1121
1122export const QC_DEFAULTS: Readonly<Record<string, string>> = harnProviderCatalog.qc_defaults
1123
1124export function pricingFor(modelId: string): CatalogEntry["pricing"] | undefined {
1125 return entryFor(modelId)?.pricing
1126}
1127
1128export function entryFor(modelId: string): CatalogEntry | undefined {
1129 return MODEL_CATALOG.find((entry) => entry.id === modelId)
1130}
1131
1132export function aliasesByProvider(provider: string): readonly CatalogAlias[] {
1133 return ALIASES.filter((alias) => alias.provider === provider)
1134}
1135
1136export function qcDefaultModel(provider: string): string | undefined {
1137 return QC_DEFAULTS[provider]
1138}
1139"#;
1140
1141const SWIFT_TYPES: &str = r#"public struct HarnProviderCatalog: Codable, Sendable, Equatable {
1142 public let schemaVersion: Int
1143 public let schema: String
1144 public let generatedBy: String
1145 public let providers: [HarnCatalogProvider]
1146 public let models: [HarnCatalogModel]
1147 public let aliases: [HarnCatalogAlias]
1148 public let variants: [HarnCatalogVariant]
1149 public let qcDefaults: [String: String]
1150
1151 enum CodingKeys: String, CodingKey {
1152 case schemaVersion = "schema_version"
1153 case schema
1154 case generatedBy = "generated_by"
1155 case providers
1156 case models
1157 case aliases
1158 case variants
1159 case qcDefaults = "qc_defaults"
1160 }
1161}
1162
1163public struct HarnCatalogProvider: Codable, Sendable, Equatable {
1164 public let id: String
1165 public let displayName: String
1166 public let icon: String?
1167 public let classification: String
1168 public let endpoint: HarnProviderEndpoint
1169 public let auth: HarnProviderAuth
1170 public let protocols: [String]
1171 public let features: [String]
1172 public let caveats: [String]
1173 public let rpm: Int?
1174 public let latencyP50Ms: Int?
1175
1176 enum CodingKeys: String, CodingKey {
1177 case id
1178 case displayName = "display_name"
1179 case icon
1180 case classification
1181 case endpoint
1182 case auth
1183 case protocols
1184 case features
1185 case caveats
1186 case rpm
1187 case latencyP50Ms = "latency_p50_ms"
1188 }
1189}
1190
1191public struct HarnProviderEndpoint: Codable, Sendable, Equatable {
1192 public let baseURL: String
1193 public let baseURLEnv: String?
1194 public let chatEndpoint: String
1195 public let completionEndpoint: String?
1196
1197 enum CodingKeys: String, CodingKey {
1198 case baseURL = "base_url"
1199 case baseURLEnv = "base_url_env"
1200 case chatEndpoint = "chat_endpoint"
1201 case completionEndpoint = "completion_endpoint"
1202 }
1203}
1204
1205public struct HarnProviderAuth: Codable, Sendable, Equatable {
1206 public let style: String
1207 public let header: String?
1208 public let env: [String]
1209 public let required: Bool
1210}
1211
1212public struct HarnCatalogAlias: Codable, Sendable, Equatable {
1213 public let name: String
1214 public let modelID: String
1215 public let provider: String
1216 public let toolFormat: String?
1217 public let toolCalling: HarnAliasToolCalling?
1218
1219 enum CodingKeys: String, CodingKey {
1220 case name
1221 case modelID = "model_id"
1222 case provider
1223 case toolFormat = "tool_format"
1224 case toolCalling = "tool_calling"
1225 }
1226}
1227
1228public struct HarnAliasToolCalling: Codable, Sendable, Equatable {
1229 public let native: String?
1230 public let text: String?
1231 public let streamingNative: String?
1232 public let fallbackMode: String?
1233 public let failureReason: String?
1234 public let lastProbeAt: String?
1235
1236 enum CodingKeys: String, CodingKey {
1237 case native
1238 case text
1239 case streamingNative = "streaming_native"
1240 case fallbackMode = "fallback_mode"
1241 case failureReason = "failure_reason"
1242 case lastProbeAt = "last_probe_at"
1243 }
1244}
1245
1246public struct HarnCatalogModel: Codable, Sendable, Equatable {
1247 public let id: String
1248 public let name: String
1249 public let provider: String
1250 public let aliases: [String]
1251 public let contextWindow: Int
1252 public let runtimeContextWindow: Int?
1253 public let streamTimeout: Double?
1254 public let preferPrefillDone: Bool?
1255 public let modalities: HarnModelModalities
1256 public let toolSupport: HarnModelToolSupport
1257 public let structuredOutput: String
1258 public let formatPreferences: HarnModelFormatPreferences
1259 public let reasoning: HarnModelReasoning
1260 public let promptCache: Bool
1261 public let pricing: HarnModelPricing?
1262 public let deprecation: HarnModelDeprecation
1263 public let qualityTags: [String]
1264 public let capabilityTags: [String]
1265
1266 enum CodingKeys: String, CodingKey {
1267 case id
1268 case name
1269 case provider
1270 case aliases
1271 case contextWindow = "context_window"
1272 case runtimeContextWindow = "runtime_context_window"
1273 case streamTimeout = "stream_timeout"
1274 case preferPrefillDone = "prefer_prefill_done"
1275 case modalities
1276 case toolSupport = "tool_support"
1277 case structuredOutput = "structured_output"
1278 case formatPreferences = "format_preferences"
1279 case reasoning
1280 case promptCache = "prompt_cache"
1281 case pricing
1282 case deprecation
1283 case qualityTags = "quality_tags"
1284 case capabilityTags = "capability_tags"
1285 }
1286}
1287
1288public struct HarnModelModalities: Codable, Sendable, Equatable {
1289 public let input: [String]
1290 public let output: [String]
1291}
1292
1293public struct HarnModelToolSupport: Codable, Sendable, Equatable {
1294 public let native: Bool
1295 public let text: Bool
1296 public let toolSearch: [String]
1297 public let maxTools: Int?
1298
1299 enum CodingKeys: String, CodingKey {
1300 case native
1301 case text
1302 case toolSearch = "tool_search"
1303 case maxTools = "max_tools"
1304 }
1305}
1306
1307public struct HarnModelFormatPreferences: Codable, Sendable, Equatable {
1308 public let prefersXMLScaffolding: Bool
1309 public let prefersMarkdownScaffolding: Bool
1310 public let structuredOutputMode: String
1311 public let supportsAssistantPrefill: Bool
1312 public let prefersRoleDeveloper: Bool
1313 public let prefersXMLTools: Bool
1314 public let thinkingBlockStyle: String
1315
1316 enum CodingKeys: String, CodingKey {
1317 case prefersXMLScaffolding = "prefers_xml_scaffolding"
1318 case prefersMarkdownScaffolding = "prefers_markdown_scaffolding"
1319 case structuredOutputMode = "structured_output_mode"
1320 case supportsAssistantPrefill = "supports_assistant_prefill"
1321 case prefersRoleDeveloper = "prefers_role_developer"
1322 case prefersXMLTools = "prefers_xml_tools"
1323 case thinkingBlockStyle = "thinking_block_style"
1324 }
1325}
1326
1327public struct HarnModelReasoning: Codable, Sendable, Equatable {
1328 public let modes: [String]
1329 public let effortSupported: Bool
1330 public let noneSupported: Bool
1331 public let interleavedSupported: Bool
1332 public let preserveThinking: Bool
1333
1334 enum CodingKeys: String, CodingKey {
1335 case modes
1336 case effortSupported = "effort_supported"
1337 case noneSupported = "none_supported"
1338 case interleavedSupported = "interleaved_supported"
1339 case preserveThinking = "preserve_thinking"
1340 }
1341}
1342
1343public struct HarnModelPricing: Codable, Sendable, Equatable {
1344 public let inputPerMTok: Double
1345 public let outputPerMTok: Double
1346 public let cacheReadPerMTok: Double?
1347 public let cacheWritePerMTok: Double?
1348
1349 enum CodingKeys: String, CodingKey {
1350 case inputPerMTok = "input_per_mtok"
1351 case outputPerMTok = "output_per_mtok"
1352 case cacheReadPerMTok = "cache_read_per_mtok"
1353 case cacheWritePerMTok = "cache_write_per_mtok"
1354 }
1355}
1356
1357public struct HarnModelDeprecation: Codable, Sendable, Equatable {
1358 public let status: String
1359 public let note: String?
1360}
1361
1362public struct HarnCatalogVariant: Codable, Sendable, Equatable {
1363 public let id: String
1364 public let label: String
1365 public let description: String
1366 public let modelID: String
1367 public let provider: String
1368 public let source: String
1369
1370 enum CodingKeys: String, CodingKey {
1371 case id
1372 case label
1373 case description
1374 case modelID = "model_id"
1375 case provider
1376 case source
1377 }
1378}
1379"#;
1380
1381#[cfg(test)]
1382mod tests {
1383 use super::*;
1384
1385 struct OverrideGuard;
1386
1387 impl Drop for OverrideGuard {
1388 fn drop(&mut self) {
1389 llm_config::clear_user_overrides();
1390 }
1391 }
1392
1393 fn install_overlay(toml_src: &str) -> OverrideGuard {
1394 let overlay = llm_config::parse_config_toml(toml_src).expect("overlay parses");
1395 llm_config::set_user_overrides(Some(overlay));
1396 OverrideGuard
1397 }
1398
1399 #[test]
1400 fn generated_catalog_validates() {
1401 llm_config::clear_user_overrides();
1402 let report = validate_current();
1403 assert!(
1404 report.errors.is_empty(),
1405 "catalog validation errors: {:?}",
1406 report.errors
1407 );
1408 }
1409
1410 #[test]
1411 fn generated_catalog_derives_quality_tags_from_routes() {
1412 let catalog = artifact();
1413 let frontier = catalog
1414 .models
1415 .iter()
1416 .find(|model| model.aliases.iter().any(|alias| alias == "frontier"))
1417 .expect("frontier alias target is exported");
1418 assert!(frontier.quality_tags.iter().any(|tag| tag == "frontier"));
1419
1420 let local = catalog
1421 .models
1422 .iter()
1423 .find(|model| model.aliases.iter().any(|alias| alias == "local-gemma4"))
1424 .expect("local alias target is exported");
1425 assert!(local.quality_tags.iter().any(|tag| tag == "local"));
1426 }
1427
1428 #[test]
1429 fn validation_rejects_missing_required_metadata() {
1430 let mut catalog = artifact();
1431 catalog.providers[0].display_name.clear();
1432 let report = validate_artifact(&catalog);
1433 assert!(
1434 report
1435 .errors
1436 .iter()
1437 .any(|message| message.contains("display_name cannot be empty")),
1438 "expected provider metadata validation error, got {:?}",
1439 report.errors
1440 );
1441 }
1442
1443 #[test]
1444 fn validation_rejects_duplicate_and_dangling_aliases() {
1445 let mut duplicated = artifact();
1446 duplicated.aliases.push(duplicated.aliases[0].clone());
1447 let duplicate_report = validate_artifact(&duplicated);
1448 assert!(
1449 duplicate_report
1450 .errors
1451 .iter()
1452 .any(|message| message.contains("duplicate alias name")),
1453 "expected duplicate alias validation error, got {:?}",
1454 duplicate_report.errors
1455 );
1456
1457 let mut dangling = artifact();
1458 dangling.aliases[0].model_id = "missing-model".to_string();
1459 let dangling_report = validate_artifact(&dangling);
1460 assert!(
1461 dangling_report
1462 .errors
1463 .iter()
1464 .any(|message| message.contains("without a catalog row")),
1465 "expected dangling alias validation error, got {:?}",
1466 dangling_report.errors
1467 );
1468 }
1469
1470 #[test]
1471 fn overlay_merge_surfaces_private_model() {
1472 let _guard = install_overlay(
1473 r#"
1474[providers.private]
1475display_name = "Private"
1476base_url = "http://127.0.0.1:9000"
1477auth_style = "none"
1478chat_endpoint = "/v1/chat/completions"
1479
1480[aliases]
1481private-fast = { id = "private/fast", provider = "private" }
1482
1483[models."private/fast"]
1484name = "Private Fast"
1485provider = "private"
1486context_window = 8192
1487quality_tags = ["experiment"]
1488"#,
1489 );
1490 let catalog = artifact();
1491 assert!(catalog.providers.iter().any(|p| p.id == "private"));
1492 let model = catalog
1493 .models
1494 .iter()
1495 .find(|model| model.id == "private/fast")
1496 .expect("private model is exported");
1497 assert_eq!(model.aliases, vec!["private-fast"]);
1498 assert_eq!(model.quality_tags, vec!["experiment"]);
1499 }
1500
1501 #[test]
1502 fn deprecated_models_require_notes() {
1503 let _guard = install_overlay(
1504 r#"
1505[models."old-model"]
1506name = "Old Model"
1507provider = "openai"
1508context_window = 4096
1509deprecated = true
1510"#,
1511 );
1512 let report = validate_current();
1513 assert!(
1514 report
1515 .errors
1516 .iter()
1517 .any(|message| message.contains("deprecated model old-model")),
1518 "expected deprecation validation error, got {:?}",
1519 report.errors
1520 );
1521 }
1522
1523 #[test]
1524 fn generated_schema_accepts_generated_artifact_shape() {
1525 let schema = schema_value();
1526 assert_eq!(schema["$id"], PROVIDER_CATALOG_SCHEMA_ID);
1527 let artifact_value = serde_json::to_value(artifact()).expect("artifact serializes");
1528 assert_eq!(
1529 artifact_value["schema_version"],
1530 PROVIDER_CATALOG_SCHEMA_VERSION
1531 );
1532 assert!(artifact_value["providers"]
1533 .as_array()
1534 .is_some_and(|v| !v.is_empty()));
1535 assert!(artifact_value["models"]
1536 .as_array()
1537 .is_some_and(|v| !v.is_empty()));
1538 }
1539}