Skip to main content

harn_vm/
provider_catalog.rs

1//! Generated provider/model catalog artifact support.
2//!
3//! `llm_config` is the runtime source of truth. This module projects that
4//! configuration plus the capability matrix into a stable JSON contract that
5//! downstream products can vendor without re-parsing Harn's internal TOML or
6//! Rust literals.
7
8use 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}