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::{
15    self, AliasDef, AliasToolCallingDef, ModelAvailability, ModelDef, ModelPricing, ProviderDef,
16};
17
18pub const PROVIDER_CATALOG_SCHEMA_VERSION: u32 = 2;
19pub const PROVIDER_CATALOG_SCHEMA_ID: &str =
20    "https://harnlang.com/schemas/provider-catalog.v2.json";
21pub const PROVIDER_CATALOG_GENERATOR: &str = "harn providers export";
22
23#[derive(Debug, Clone, Serialize)]
24pub struct ProviderCatalogArtifact {
25    pub schema_version: u32,
26    pub schema: String,
27    pub generated_by: String,
28    pub providers: Vec<CatalogProvider>,
29    pub models: Vec<CatalogModel>,
30    pub aliases: Vec<CatalogAlias>,
31    pub variants: Vec<CatalogVariant>,
32    pub qc_defaults: BTreeMap<String, String>,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct CatalogProvider {
37    pub id: String,
38    pub display_name: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub icon: Option<String>,
41    pub classification: ProviderClassification,
42    pub endpoint: ProviderEndpoint,
43    pub auth: ProviderAuth,
44    pub protocols: Vec<String>,
45    pub features: Vec<String>,
46    pub caveats: Vec<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub rpm: Option<u32>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub latency_p50_ms: Option<u64>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum ProviderClassification {
56    Hosted,
57    Local,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct ProviderEndpoint {
62    pub base_url: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub base_url_env: Option<String>,
65    pub chat_endpoint: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub completion_endpoint: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct ProviderAuth {
72    pub style: String,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub header: Option<String>,
75    pub env: Vec<String>,
76    pub required: bool,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct CatalogAlias {
81    pub name: String,
82    pub model_id: String,
83    pub provider: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub tool_format: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub tool_calling: Option<AliasToolCallingDef>,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct CatalogModel {
92    pub id: String,
93    pub name: String,
94    pub provider: String,
95    pub aliases: Vec<String>,
96    pub context_window: u64,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub runtime_context_window: Option<u64>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub stream_timeout: Option<f64>,
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 availability: ModelAvailabilityStatus,
111    pub quality_tags: Vec<String>,
112    pub capability_tags: Vec<String>,
113    /// Popular-consensus tier label: "small" | "mid" | "frontier" |
114    /// "reasoning". Self-declared on the model row; the rule-based path
115    /// is a fallback only.
116    pub tier: String,
117    /// True when weights are downloadable / self-hostable.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub open_weight: Option<bool>,
120    /// Workload-shaped strength tags (coding, summarization, vision, ...).
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub strengths: Vec<String>,
123    /// Public benchmark numbers, snake_case identifier -> score.
124    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
125    pub benchmarks: BTreeMap<String, f64>,
126    /// Accelerated-serving ("fast mode") tier metadata, when offered.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub fast_mode: Option<ModelFastMode>,
129}
130
131#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
132#[serde(rename_all = "snake_case")]
133pub enum ModelAvailabilityStatus {
134    Serverless,
135    Dedicated,
136    Unknown,
137}
138
139impl From<ModelAvailability> for ModelAvailabilityStatus {
140    fn from(value: ModelAvailability) -> Self {
141        match value {
142            ModelAvailability::Serverless => Self::Serverless,
143            ModelAvailability::Dedicated => Self::Dedicated,
144            ModelAvailability::Unknown => Self::Unknown,
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize)]
150pub struct ModelModalities {
151    pub input: Vec<String>,
152    pub output: Vec<String>,
153}
154
155#[derive(Debug, Clone, Serialize)]
156pub struct ModelToolSupport {
157    pub native: bool,
158    pub text: bool,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub preferred_format: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub parity: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub parity_notes: Option<String>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub empirical_parity: Option<ModelToolEmpiricalParity>,
167    pub tool_search: Vec<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub max_tools: Option<u32>,
170}
171
172#[derive(Debug, Clone, Serialize)]
173pub struct ModelToolEmpiricalParity {
174    pub verdict: String,
175    pub preferred_format: String,
176    pub confidence: String,
177    pub sample_size: u32,
178    pub last_evaluated: String,
179    pub native_pass_rate: f64,
180    pub text_pass_rate: f64,
181    pub verifier_divergence_rate: f64,
182}
183
184#[derive(Debug, Clone, Serialize)]
185pub struct ModelFormatPreferences {
186    pub prefers_xml_scaffolding: bool,
187    pub prefers_markdown_scaffolding: bool,
188    pub structured_output_mode: String,
189    pub supports_assistant_prefill: bool,
190    pub prefers_role_developer: bool,
191    pub prefers_xml_tools: bool,
192    pub thinking_block_style: String,
193}
194
195#[derive(Debug, Clone, Serialize)]
196pub struct ModelReasoning {
197    pub modes: Vec<String>,
198    pub effort_supported: bool,
199    pub none_supported: bool,
200    pub interleaved_supported: bool,
201    pub preserve_thinking: bool,
202}
203
204#[derive(Debug, Clone, Serialize)]
205pub struct ModelDeprecation {
206    pub status: DeprecationStatus,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub note: Option<String>,
209    /// Catalog id of the model that supersedes this one, when declared.
210    /// Surfaces `ModelDef::superseded_by` as a machine-readable migration
211    /// target so downstream consumers don't have to parse `note` prose.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub superseded_by: Option<String>,
214}
215
216#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
217#[serde(rename_all = "snake_case")]
218pub enum DeprecationStatus {
219    Active,
220    Deprecated,
221}
222
223/// Catalog projection of an accelerated-serving ("fast mode") tier.
224/// Surfaces `ModelDef::fast_mode` so downstream consumers can show the
225/// opt-in knob, premium pricing, and lifecycle without re-parsing the
226/// source TOML. Absent on models with no faster serving path.
227#[derive(Debug, Clone, Serialize)]
228pub struct ModelFastMode {
229    pub param: String,
230    pub value: String,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub beta_header: Option<String>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub otps_speedup: Option<f64>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub status: Option<String>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub pricing: Option<ModelPricing>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub note: Option<String>,
241}
242
243#[derive(Debug, Clone, Serialize)]
244pub struct CatalogVariant {
245    pub id: String,
246    pub label: String,
247    pub description: String,
248    pub model_id: String,
249    pub provider: String,
250    pub source: String,
251}
252
253#[derive(Debug, Clone, Default, PartialEq, Eq)]
254pub struct ProviderCatalogValidation {
255    pub errors: Vec<String>,
256    pub warnings: Vec<String>,
257}
258
259impl ProviderCatalogValidation {
260    pub fn is_ok(&self) -> bool {
261        self.errors.is_empty()
262    }
263}
264
265pub fn artifact() -> ProviderCatalogArtifact {
266    let alias_entries = llm_config::alias_entries();
267    let aliases_by_model = aliases_by_model(&alias_entries);
268    let providers = llm_config::provider_names()
269        .into_iter()
270        .filter_map(|id| {
271            llm_config::provider_config(&id).map(|provider| catalog_provider(id, provider))
272        })
273        .collect();
274    let models = llm_config::model_catalog_entries()
275        .into_iter()
276        .map(|(id, model)| catalog_model(id, model, &aliases_by_model))
277        .collect::<Vec<_>>();
278    let aliases = alias_entries
279        .iter()
280        .map(|(name, alias)| catalog_alias(name, alias))
281        .collect::<Vec<_>>();
282    let variants = catalog_variants(&models, &aliases);
283
284    ProviderCatalogArtifact {
285        schema_version: PROVIDER_CATALOG_SCHEMA_VERSION,
286        schema: PROVIDER_CATALOG_SCHEMA_ID.to_string(),
287        generated_by: PROVIDER_CATALOG_GENERATOR.to_string(),
288        providers,
289        models,
290        aliases,
291        variants,
292        qc_defaults: llm_config::qc_defaults(),
293    }
294}
295
296pub fn artifact_json() -> Result<String, serde_json::Error> {
297    serde_json::to_string_pretty(&artifact()).map(|mut text| {
298        text.push('\n');
299        text
300    })
301}
302
303pub fn schema_json() -> Result<String, serde_json::Error> {
304    serde_json::to_string_pretty(&schema_value()).map(|mut text| {
305        text.push('\n');
306        text
307    })
308}
309
310pub fn typescript_binding() -> Result<String, serde_json::Error> {
311    let json = artifact_json()?;
312    Ok(format!(
313        "{}{}{}{}{}",
314        generated_header("//", "typescript"),
315        TYPESCRIPT_TYPES,
316        "\nexport const harnProviderCatalog: HarnProviderCatalog = ",
317        json.trim_end(),
318        ";\n",
319    ) + TYPESCRIPT_COMPAT_EXPORTS)
320}
321
322pub fn swift_binding() -> Result<String, serde_json::Error> {
323    let json = artifact_json()?;
324    Ok(format!(
325        "{}{}\npublic let harnProviderCatalogJSON = #\"\"\"\n{}\"\"\"#\n",
326        generated_header("//", "swift"),
327        SWIFT_TYPES,
328        json
329    ))
330}
331
332pub fn validate_artifact(artifact: &ProviderCatalogArtifact) -> ProviderCatalogValidation {
333    let mut result = ProviderCatalogValidation::default();
334    if artifact.schema_version != PROVIDER_CATALOG_SCHEMA_VERSION {
335        result.errors.push(format!(
336            "schema_version must be {}, got {}",
337            PROVIDER_CATALOG_SCHEMA_VERSION, artifact.schema_version
338        ));
339    }
340    if artifact.providers.is_empty() {
341        result.errors.push("catalog has no providers".to_string());
342    }
343    if artifact.models.is_empty() {
344        result.errors.push("catalog has no models".to_string());
345    }
346
347    let provider_ids: BTreeSet<_> = artifact.providers.iter().map(|p| p.id.as_str()).collect();
348    for provider in &artifact.providers {
349        if provider.id.trim().is_empty() {
350            result
351                .errors
352                .push("provider id cannot be empty".to_string());
353        }
354        if provider.display_name.trim().is_empty() {
355            result.errors.push(format!(
356                "provider {} display_name cannot be empty",
357                provider.id
358            ));
359        }
360        if provider.endpoint.chat_endpoint.trim().is_empty() {
361            result.errors.push(format!(
362                "provider {} chat_endpoint cannot be empty",
363                provider.id
364            ));
365        }
366        if provider.auth.required
367            && provider.auth.env.is_empty()
368            && provider.auth.style != "aws_sigv4"
369        {
370            result.errors.push(format!(
371                "provider {} requires auth but declares no auth env keys",
372                provider.id
373            ));
374        }
375    }
376
377    let mut alias_names = BTreeSet::new();
378    for alias in &artifact.aliases {
379        if alias.name.trim().is_empty() {
380            result.errors.push("alias name cannot be empty".to_string());
381        }
382        if !alias_names.insert(alias.name.as_str()) {
383            result
384                .errors
385                .push(format!("duplicate alias name {}", alias.name));
386        }
387        if !provider_ids.contains(alias.provider.as_str()) {
388            result.errors.push(format!(
389                "alias {} references unknown provider {}",
390                alias.name, alias.provider
391            ));
392        }
393    }
394
395    let mut model_ids = BTreeSet::new();
396    let mut model_pairs = BTreeSet::new();
397    for model in &artifact.models {
398        if !model_ids.insert(model.id.as_str()) {
399            result
400                .errors
401                .push(format!("duplicate model id {}", model.id));
402        }
403        model_pairs.insert((model.provider.as_str(), model.id.as_str()));
404        if model.name.trim().is_empty() {
405            result
406                .errors
407                .push(format!("model {} name cannot be empty", model.id));
408        }
409        if !provider_ids.contains(model.provider.as_str()) {
410            result.errors.push(format!(
411                "model {} references unknown provider {}",
412                model.id, model.provider
413            ));
414        }
415        if model.context_window == 0 {
416            result.errors.push(format!(
417                "model {} context_window must be positive",
418                model.id
419            ));
420        }
421        if let Some(pricing) = &model.pricing {
422            validate_pricing(model, pricing, &mut result);
423        }
424        if model.deprecation.status == DeprecationStatus::Deprecated
425            && model
426                .deprecation
427                .note
428                .as_deref()
429                .unwrap_or("")
430                .trim()
431                .is_empty()
432        {
433            result.errors.push(format!(
434                "deprecated model {} must include deprecation.note",
435                model.id
436            ));
437        }
438        if let Some(fast) = &model.fast_mode {
439            if let Some(pricing) = &fast.pricing {
440                validate_pricing(model, pricing, &mut result);
441            }
442            if let Some(status) = fast.status.as_deref() {
443                if !matches!(status, "ga" | "research_preview" | "deprecated") {
444                    result.warnings.push(format!(
445                        "model {} fast_mode.status {:?} is not one of ga|research_preview|deprecated",
446                        model.id, status
447                    ));
448                }
449            }
450        }
451    }
452
453    // Structured supersession pointers must reference a real catalog row so
454    // `superseded_by` can be trusted as a migration target by downstream
455    // tooling. A dangling pointer is a soft warning (the row is still
456    // usable) rather than a hard error, mirroring how `note` is advisory.
457    for model in &artifact.models {
458        if let Some(target) = model.deprecation.superseded_by.as_deref() {
459            if !model_ids.contains(target) {
460                result.warnings.push(format!(
461                    "model {} declares superseded_by {} with no matching catalog row",
462                    model.id, target
463                ));
464            }
465        }
466    }
467
468    let dedicated_pairs: BTreeSet<(&str, &str)> = artifact
469        .models
470        .iter()
471        .filter(|model| model.availability == ModelAvailabilityStatus::Dedicated)
472        .map(|model| (model.provider.as_str(), model.id.as_str()))
473        .collect();
474    for alias in &artifact.aliases {
475        if !model_pairs.contains(&(alias.provider.as_str(), alias.model_id.as_str())) {
476            result.errors.push(format!(
477                "alias {} targets {}/{} without a catalog row",
478                alias.name, alias.provider, alias.model_id
479            ));
480        }
481        if is_tier_alias(&alias.name)
482            && dedicated_pairs.contains(&(alias.provider.as_str(), alias.model_id.as_str()))
483        {
484            result.warnings.push(format!(
485                "tier alias {} targets dedicated-only model {}/{}; serverless callers will fail until the dedicated endpoint is provisioned",
486                alias.name, alias.provider, alias.model_id
487            ));
488        }
489    }
490
491    for variant in &artifact.variants {
492        if variant.id.trim().is_empty() {
493            result.errors.push("variant id cannot be empty".to_string());
494        }
495        if !provider_ids.contains(variant.provider.as_str()) {
496            result.errors.push(format!(
497                "variant {} references unknown provider {}",
498                variant.id, variant.provider
499            ));
500        }
501        if !model_pairs.contains(&(variant.provider.as_str(), variant.model_id.as_str())) {
502            result.errors.push(format!(
503                "variant {} targets {}/{} without a catalog row",
504                variant.id, variant.provider, variant.model_id
505            ));
506        }
507    }
508
509    result
510}
511
512pub fn validate_current() -> ProviderCatalogValidation {
513    validate_artifact(&artifact())
514}
515
516pub fn schema_value() -> Value {
517    json!({
518        "$schema": "https://json-schema.org/draft/2020-12/schema",
519        "$id": PROVIDER_CATALOG_SCHEMA_ID,
520        "title": "Harn provider catalog",
521        "type": "object",
522        "required": ["schema_version", "schema", "generated_by", "providers", "models", "aliases", "variants", "qc_defaults"],
523        "properties": {
524            "schema_version": {"const": PROVIDER_CATALOG_SCHEMA_VERSION},
525            "schema": {"const": PROVIDER_CATALOG_SCHEMA_ID},
526            "generated_by": {"type": "string"},
527            "providers": {"type": "array", "items": {"$ref": "#/$defs/provider"}},
528            "models": {"type": "array", "items": {"$ref": "#/$defs/model"}},
529            "aliases": {"type": "array", "items": {"$ref": "#/$defs/alias"}},
530            "variants": {"type": "array", "items": {"$ref": "#/$defs/variant"}},
531            "qc_defaults": {"type": "object", "additionalProperties": {"type": "string"}}
532        },
533        "additionalProperties": false,
534        "$defs": {
535            "provider": {
536                "type": "object",
537                "required": ["id", "display_name", "classification", "endpoint", "auth", "protocols", "features", "caveats"],
538                "properties": {
539                    "id": {"type": "string", "minLength": 1},
540                    "display_name": {"type": "string", "minLength": 1},
541                    "icon": {"type": "string"},
542                    "classification": {"enum": ["hosted", "local"]},
543                    "endpoint": {"$ref": "#/$defs/endpoint"},
544                    "auth": {"$ref": "#/$defs/auth"},
545                    "protocols": {"type": "array", "items": {"type": "string"}},
546                    "features": {"type": "array", "items": {"type": "string"}},
547                    "caveats": {"type": "array", "items": {"type": "string"}},
548                    "rpm": {"type": "integer", "minimum": 1},
549                    "latency_p50_ms": {"type": "integer", "minimum": 0}
550                },
551                "additionalProperties": false
552            },
553            "endpoint": {
554                "type": "object",
555                "required": ["base_url", "chat_endpoint"],
556                "properties": {
557                    "base_url": {"type": "string"},
558                    "base_url_env": {"type": "string"},
559                    "chat_endpoint": {"type": "string", "minLength": 1},
560                    "completion_endpoint": {"type": "string"}
561                },
562                "additionalProperties": false
563            },
564            "auth": {
565                "type": "object",
566                "required": ["style", "env", "required"],
567                "properties": {
568                    "style": {"type": "string"},
569                    "header": {"type": "string"},
570                    "env": {"type": "array", "items": {"type": "string"}},
571                    "required": {"type": "boolean"}
572                },
573                "additionalProperties": false
574            },
575            "alias": {
576                "type": "object",
577                "required": ["name", "model_id", "provider"],
578                "properties": {
579                    "name": {"type": "string", "minLength": 1},
580                    "model_id": {"type": "string", "minLength": 1},
581                    "provider": {"type": "string", "minLength": 1},
582                    "tool_format": {"type": "string"},
583                    "tool_calling": {
584                        "type": "object",
585                        "properties": {
586                            "native": {"type": "string"},
587                            "text": {"type": "string"},
588                            "streaming_native": {"type": "string"},
589                            "fallback_mode": {"type": "string"},
590                            "failure_reason": {"type": "string"},
591                            "last_probe_at": {"type": "string"}
592                        },
593                        "additionalProperties": false
594                    }
595                },
596                "additionalProperties": false
597            },
598            "model": {
599                "type": "object",
600                "required": [
601                    "id",
602                    "name",
603                    "provider",
604                    "aliases",
605                    "context_window",
606                    "modalities",
607                    "tool_support",
608                    "structured_output",
609                    "format_preferences",
610                    "reasoning",
611                    "prompt_cache",
612                    "deprecation",
613                    "availability",
614                    "quality_tags",
615                    "capability_tags",
616                    "tier"
617                ],
618                "properties": {
619                    "id": {"type": "string", "minLength": 1},
620                    "name": {"type": "string", "minLength": 1},
621                    "provider": {"type": "string", "minLength": 1},
622                    "aliases": {"type": "array", "items": {"type": "string"}},
623                    "context_window": {"type": "integer", "minimum": 1},
624                    "runtime_context_window": {"type": "integer", "minimum": 1},
625                    "stream_timeout": {"type": "number", "exclusiveMinimum": 0},
626                    "modalities": {"$ref": "#/$defs/modalities"},
627                    "tool_support": {"$ref": "#/$defs/tool_support"},
628                    "structured_output": {"type": "string"},
629                    "format_preferences": {"$ref": "#/$defs/format_preferences"},
630                    "reasoning": {"$ref": "#/$defs/reasoning"},
631                    "prompt_cache": {"type": "boolean"},
632                    "pricing": {"$ref": "#/$defs/pricing"},
633                    "deprecation": {"$ref": "#/$defs/deprecation"},
634                    "availability": {"enum": ["serverless", "dedicated", "unknown"]},
635                    "quality_tags": {"type": "array", "items": {"type": "string"}},
636                    "capability_tags": {"type": "array", "items": {"type": "string"}},
637                    "tier": {"enum": ["small", "mid", "frontier", "reasoning"]},
638                    "open_weight": {"type": "boolean"},
639                    "strengths": {"type": "array", "items": {"type": "string"}},
640                    "benchmarks": {"type": "object", "additionalProperties": {"type": "number"}},
641                    "fast_mode": {"$ref": "#/$defs/fast_mode"}
642                },
643                "additionalProperties": false
644            },
645            "modalities": {
646                "type": "object",
647                "required": ["input", "output"],
648                "properties": {
649                    "input": {"type": "array", "items": {"type": "string"}, "minItems": 1},
650                    "output": {"type": "array", "items": {"type": "string"}, "minItems": 1}
651                },
652                "additionalProperties": false
653            },
654            "tool_support": {
655                "type": "object",
656                "required": ["native", "text", "tool_search"],
657                "properties": {
658                    "native": {"type": "boolean"},
659                    "text": {"type": "boolean"},
660                    "preferred_format": {"type": "string"},
661                    "parity": {"type": "string"},
662                    "parity_notes": {"type": "string"},
663                    "empirical_parity": {"$ref": "#/$defs/tool_empirical_parity"},
664                    "tool_search": {"type": "array", "items": {"type": "string"}},
665                    "max_tools": {"type": "integer", "minimum": 1}
666                },
667                "additionalProperties": false
668            },
669            "tool_empirical_parity": {
670                "type": "object",
671                "required": [
672                    "verdict",
673                    "preferred_format",
674                    "confidence",
675                    "sample_size",
676                    "last_evaluated",
677                    "native_pass_rate",
678                    "text_pass_rate",
679                    "verifier_divergence_rate"
680                ],
681                "properties": {
682                    "verdict": {"type": "string"},
683                    "preferred_format": {"type": "string"},
684                    "confidence": {"type": "string"},
685                    "sample_size": {"type": "integer", "minimum": 1},
686                    "last_evaluated": {"type": "string", "minLength": 1},
687                    "native_pass_rate": {"type": "number", "minimum": 0, "maximum": 1},
688                    "text_pass_rate": {"type": "number", "minimum": 0, "maximum": 1},
689                    "verifier_divergence_rate": {"type": "number", "minimum": 0, "maximum": 1}
690                },
691                "additionalProperties": false
692            },
693            "format_preferences": {
694                "type": "object",
695                "required": [
696                    "prefers_xml_scaffolding",
697                    "prefers_markdown_scaffolding",
698                    "structured_output_mode",
699                    "supports_assistant_prefill",
700                    "prefers_role_developer",
701                    "prefers_xml_tools",
702                    "thinking_block_style"
703                ],
704                "properties": {
705                    "prefers_xml_scaffolding": {"type": "boolean"},
706                    "prefers_markdown_scaffolding": {"type": "boolean"},
707                    "structured_output_mode": {"enum": ["native_json", "delimited", "xml_tagged", "none"]},
708                    "supports_assistant_prefill": {"type": "boolean"},
709                    "prefers_role_developer": {"type": "boolean"},
710                    "prefers_xml_tools": {"type": "boolean"},
711                    "thinking_block_style": {"enum": ["none", "thinking_blocks", "reasoning_summary", "inline"]}
712                },
713                "additionalProperties": false
714            },
715            "reasoning": {
716                "type": "object",
717                "required": ["modes", "effort_supported", "none_supported", "interleaved_supported", "preserve_thinking"],
718                "properties": {
719                    "modes": {"type": "array", "items": {"type": "string"}},
720                    "effort_supported": {"type": "boolean"},
721                    "none_supported": {"type": "boolean"},
722                    "interleaved_supported": {"type": "boolean"},
723                    "preserve_thinking": {"type": "boolean"}
724                },
725                "additionalProperties": false
726            },
727            "pricing": {
728                "type": "object",
729                "required": ["input_per_mtok", "output_per_mtok"],
730                "properties": {
731                    "input_per_mtok": {"type": "number", "minimum": 0},
732                    "output_per_mtok": {"type": "number", "minimum": 0},
733                    "cache_read_per_mtok": {"type": ["number", "null"], "minimum": 0},
734                    "cache_write_per_mtok": {"type": ["number", "null"], "minimum": 0}
735                },
736                "additionalProperties": false
737            },
738            "fast_mode": {
739                "type": "object",
740                "required": ["param", "value"],
741                "properties": {
742                    "param": {"type": "string", "minLength": 1},
743                    "value": {"type": "string", "minLength": 1},
744                    "beta_header": {"type": "string"},
745                    "otps_speedup": {"type": "number", "exclusiveMinimum": 0},
746                    "status": {"type": "string"},
747                    "pricing": {"$ref": "#/$defs/pricing"},
748                    "note": {"type": "string"}
749                },
750                "additionalProperties": false
751            },
752            "deprecation": {
753                "type": "object",
754                "required": ["status"],
755                "properties": {
756                    "status": {"enum": ["active", "deprecated"]},
757                    "note": {"type": "string"},
758                    "superseded_by": {"type": "string"}
759                },
760                "additionalProperties": false
761            },
762            "variant": {
763                "type": "object",
764                "required": ["id", "label", "description", "model_id", "provider", "source"],
765                "properties": {
766                    "id": {"type": "string", "minLength": 1},
767                    "label": {"type": "string", "minLength": 1},
768                    "description": {"type": "string"},
769                    "model_id": {"type": "string", "minLength": 1},
770                    "provider": {"type": "string", "minLength": 1},
771                    "source": {"type": "string", "minLength": 1}
772                },
773                "additionalProperties": false
774            }
775        }
776    })
777}
778
779fn catalog_provider(id: String, provider: ProviderDef) -> CatalogProvider {
780    CatalogProvider {
781        display_name: provider
782            .display_name
783            .clone()
784            .unwrap_or_else(|| title_case(&id)),
785        icon: provider.icon.clone(),
786        classification: provider_classification(&provider),
787        endpoint: ProviderEndpoint {
788            base_url: provider.base_url.clone(),
789            base_url_env: provider.base_url_env.clone(),
790            chat_endpoint: provider.chat_endpoint.clone(),
791            completion_endpoint: provider.completion_endpoint.clone(),
792        },
793        auth: ProviderAuth {
794            style: provider.auth_style.clone(),
795            header: provider.auth_header.clone(),
796            env: llm_config::auth_env_names(&provider.auth_env),
797            required: provider.auth_style != "none",
798        },
799        protocols: provider_protocols(&id, &provider),
800        features: provider.features.clone(),
801        caveats: provider_caveats(&id, &provider),
802        rpm: provider.rpm,
803        latency_p50_ms: provider.latency_p50_ms,
804        id,
805    }
806}
807
808fn catalog_alias(name: &str, alias: &AliasDef) -> CatalogAlias {
809    CatalogAlias {
810        name: name.to_string(),
811        model_id: alias.id.clone(),
812        provider: alias.provider.clone(),
813        tool_format: alias.tool_format.clone(),
814        tool_calling: llm_config::alias_tool_calling_entry(name),
815    }
816}
817
818fn catalog_model(
819    id: String,
820    model: ModelDef,
821    aliases_by_model: &BTreeMap<(String, String), Vec<String>>,
822) -> CatalogModel {
823    let caps = llm::capabilities::lookup(&model.provider, &id);
824    let structured_output = caps
825        .structured_output
826        .clone()
827        .or_else(|| caps.json_schema.clone())
828        .unwrap_or_else(|| "none".to_string());
829    let aliases = aliases_by_model
830        .get(&(model.provider.clone(), id.clone()))
831        .cloned()
832        .unwrap_or_default();
833    let quality_tags = model_quality_tags(&model, &aliases);
834    CatalogModel {
835        aliases,
836        modalities: modalities_from_caps(&caps),
837        tool_support: ModelToolSupport {
838            native: caps.native_tools,
839            text: caps.text_tool_wire_format_supported,
840            preferred_format: caps.preferred_tool_format.clone(),
841            parity: caps.tool_mode_parity.clone(),
842            parity_notes: caps.tool_mode_parity_notes.clone(),
843            empirical_parity: None,
844            tool_search: caps.tool_search.clone(),
845            max_tools: caps.max_tools,
846        },
847        structured_output,
848        format_preferences: ModelFormatPreferences {
849            prefers_xml_scaffolding: caps.prefers_xml_scaffolding,
850            prefers_markdown_scaffolding: caps.prefers_markdown_scaffolding,
851            structured_output_mode: caps.structured_output_mode.clone(),
852            supports_assistant_prefill: caps.supports_assistant_prefill,
853            prefers_role_developer: caps.prefers_role_developer,
854            prefers_xml_tools: caps.prefers_xml_tools,
855            thinking_block_style: caps.thinking_block_style.clone(),
856        },
857        reasoning: ModelReasoning {
858            modes: caps.thinking_modes.clone(),
859            effort_supported: caps.reasoning_effort_supported,
860            none_supported: caps.reasoning_none_supported,
861            interleaved_supported: caps.interleaved_thinking_supported,
862            preserve_thinking: caps.preserve_thinking,
863        },
864        prompt_cache: caps.prompt_caching,
865        pricing: model.pricing.clone(),
866        deprecation: ModelDeprecation {
867            status: if model.deprecated {
868                DeprecationStatus::Deprecated
869            } else {
870                DeprecationStatus::Active
871            },
872            note: model.deprecation_note.clone(),
873            superseded_by: model.superseded_by.clone(),
874        },
875        availability: ModelAvailabilityStatus::from(model.availability),
876        quality_tags,
877        capability_tags: model.capabilities.clone(),
878        tier: llm_config::model_tier(&id),
879        open_weight: model.open_weight,
880        strengths: model.strengths.clone(),
881        benchmarks: model.benchmarks.clone(),
882        fast_mode: model.fast_mode.as_ref().map(|fm| ModelFastMode {
883            param: fm.param.clone(),
884            value: fm.value.clone(),
885            beta_header: fm.beta_header.clone(),
886            otps_speedup: fm.otps_speedup,
887            status: fm.status.clone(),
888            pricing: fm.pricing.clone(),
889            note: fm.note.clone(),
890        }),
891        id,
892        name: model.name,
893        provider: model.provider,
894        context_window: model.context_window,
895        runtime_context_window: model.runtime_context_window,
896        stream_timeout: model.stream_timeout,
897    }
898}
899
900fn model_quality_tags(model: &ModelDef, aliases: &[String]) -> Vec<String> {
901    let mut tags: BTreeSet<String> = model.quality_tags.iter().cloned().collect();
902    for alias in aliases {
903        match alias.as_str() {
904            "frontier" | "tier/frontier" => {
905                tags.insert("frontier".to_string());
906            }
907            "mid" | "tier/mid" => {
908                tags.insert("balanced".to_string());
909            }
910            "small" | "tier/small" => {
911                tags.insert("small".to_string());
912            }
913            _ => {}
914        }
915    }
916    if is_local_provider(&model.provider) {
917        tags.insert("local".to_string());
918    }
919    tags.into_iter().collect()
920}
921
922fn aliases_by_model(aliases: &[(String, AliasDef)]) -> BTreeMap<(String, String), Vec<String>> {
923    let mut by_model: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
924    for (name, alias) in aliases {
925        by_model
926            .entry((alias.provider.clone(), alias.id.clone()))
927            .or_default()
928            .push(name.clone());
929    }
930    for names in by_model.values_mut() {
931        names.sort();
932    }
933    by_model
934}
935
936fn modalities_from_caps(caps: &llm::capabilities::Capabilities) -> ModelModalities {
937    let mut input = vec!["text".to_string()];
938    if caps.vision || caps.vision_supported {
939        input.push("image".to_string());
940    }
941    if caps.audio {
942        input.push("audio".to_string());
943    }
944    if caps.pdf {
945        input.push("pdf".to_string());
946    }
947    ModelModalities {
948        input,
949        output: vec!["text".to_string()],
950    }
951}
952
953fn catalog_variants(models: &[CatalogModel], aliases: &[CatalogAlias]) -> Vec<CatalogVariant> {
954    let mut variants = Vec::new();
955    for (id, label, description, alias_name) in [
956        (
957            "fast",
958            "Fast",
959            "Lowest-latency general coding-agent route.",
960            "small",
961        ),
962        (
963            "balanced",
964            "Balanced",
965            "Default cost/quality tradeoff for routine coding-agent work.",
966            "mid",
967        ),
968        (
969            "high-reasoning",
970            "High reasoning",
971            "Frontier route for hard planning, repair, and review tasks.",
972            "frontier",
973        ),
974    ] {
975        if let Some(alias) = aliases.iter().find(|alias| alias.name == alias_name) {
976            variants.push(CatalogVariant {
977                id: id.to_string(),
978                label: label.to_string(),
979                description: description.to_string(),
980                model_id: alias.model_id.clone(),
981                provider: alias.provider.clone(),
982                source: format!("alias:{alias_name}"),
983            });
984        }
985    }
986    push_variant_from_model(
987        &mut variants,
988        "local",
989        "Local",
990        "Best local/offline model route in the checked-in catalog.",
991        models
992            .iter()
993            .filter(|model| is_local_provider(&model.provider))
994            .max_by_key(|model| model.context_window),
995    );
996    push_variant_from_model(
997        &mut variants,
998        "cheap",
999        "Cheap",
1000        "Lowest known hosted input+output token price.",
1001        models
1002            .iter()
1003            .filter(|model| !is_local_provider(&model.provider))
1004            .min_by(|left, right| {
1005                pricing_total(left)
1006                    .partial_cmp(&pricing_total(right))
1007                    .unwrap_or(std::cmp::Ordering::Equal)
1008            }),
1009    );
1010    push_variant_from_model(
1011        &mut variants,
1012        "vision-capable",
1013        "Vision capable",
1014        "A model route that accepts image input.",
1015        models
1016            .iter()
1017            .filter(|model| model.modalities.input.iter().any(|mode| mode == "image"))
1018            .max_by_key(|model| model.context_window),
1019    );
1020    push_variant_from_model(
1021        &mut variants,
1022        "long-context",
1023        "Long context",
1024        "Largest context-window route in the checked-in catalog.",
1025        models.iter().max_by_key(|model| model.context_window),
1026    );
1027    variants
1028}
1029
1030fn push_variant_from_model(
1031    variants: &mut Vec<CatalogVariant>,
1032    id: &str,
1033    label: &str,
1034    description: &str,
1035    model: Option<&CatalogModel>,
1036) {
1037    if let Some(model) = model {
1038        variants.push(CatalogVariant {
1039            id: id.to_string(),
1040            label: label.to_string(),
1041            description: description.to_string(),
1042            model_id: model.id.clone(),
1043            provider: model.provider.clone(),
1044            source: "catalog".to_string(),
1045        });
1046    }
1047}
1048
1049fn pricing_total(model: &CatalogModel) -> f64 {
1050    model
1051        .pricing
1052        .as_ref()
1053        .map(|pricing| pricing.input_per_mtok + pricing.output_per_mtok)
1054        .unwrap_or(f64::MAX)
1055}
1056
1057fn validate_pricing(
1058    model: &CatalogModel,
1059    pricing: &ModelPricing,
1060    result: &mut ProviderCatalogValidation,
1061) {
1062    for (field, value) in [
1063        ("input_per_mtok", Some(pricing.input_per_mtok)),
1064        ("output_per_mtok", Some(pricing.output_per_mtok)),
1065        ("cache_read_per_mtok", pricing.cache_read_per_mtok),
1066        ("cache_write_per_mtok", pricing.cache_write_per_mtok),
1067    ] {
1068        if value.is_some_and(|value| value < 0.0) {
1069            result.errors.push(format!(
1070                "model {} pricing.{} must be non-negative",
1071                model.id, field
1072            ));
1073        }
1074    }
1075}
1076
1077fn provider_classification(provider: &ProviderDef) -> ProviderClassification {
1078    if provider.auth_style == "none"
1079        || provider.base_url.contains("localhost")
1080        || provider.base_url.contains("127.0.0.1")
1081    {
1082        ProviderClassification::Local
1083    } else {
1084        ProviderClassification::Hosted
1085    }
1086}
1087
1088fn provider_protocols(id: &str, provider: &ProviderDef) -> Vec<String> {
1089    match id {
1090        "anthropic" => vec!["anthropic_messages".to_string()],
1091        "gemini" => vec!["gemini_generate_content".to_string()],
1092        "vertex" => vec!["vertex_generate_content".to_string()],
1093        "bedrock" => vec!["bedrock_converse".to_string()],
1094        "azure_openai" => vec!["azure_openai_chat_completions".to_string()],
1095        "ollama" if provider.chat_endpoint.starts_with("/api/") => {
1096            vec!["ollama_native".to_string()]
1097        }
1098        _ => vec!["openai_chat_completions".to_string()],
1099    }
1100}
1101
1102fn provider_caveats(id: &str, provider: &ProviderDef) -> Vec<String> {
1103    let mut caveats = Vec::new();
1104    if provider.auth_style == "aws_sigv4" {
1105        caveats.push("Credentials are resolved through the AWS SDK chain.".to_string());
1106    }
1107    if id == "azure_openai" {
1108        caveats.push("The Harn model field names the Azure deployment.".to_string());
1109    }
1110    if id == "ollama" && provider.chat_endpoint == "/api/chat" {
1111        caveats.push(
1112            "Native Ollama chat returns NDJSON and can apply model-family parsers.".to_string(),
1113        );
1114    }
1115    caveats
1116}
1117
1118fn is_local_provider(provider: &str) -> bool {
1119    matches!(
1120        provider,
1121        "ollama" | "local" | "llamacpp" | "mlx" | "vllm" | "tgi"
1122    )
1123}
1124
1125fn is_tier_alias(name: &str) -> bool {
1126    matches!(
1127        name,
1128        "frontier"
1129            | "mid"
1130            | "small"
1131            | "tier/frontier"
1132            | "tier/mid"
1133            | "tier/small"
1134            | "sonnet"
1135            | "opus"
1136            | "haiku"
1137    )
1138}
1139
1140fn title_case(id: &str) -> String {
1141    id.split('_')
1142        .map(|part| {
1143            let mut chars = part.chars();
1144            match chars.next() {
1145                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1146                None => String::new(),
1147            }
1148        })
1149        .collect::<Vec<_>>()
1150        .join(" ")
1151}
1152
1153fn generated_header(comment: &str, language: &str) -> String {
1154    format!(
1155        "{comment} GENERATED by `{PROVIDER_CATALOG_GENERATOR}` - do not edit by hand.\n{comment} Source: Harn runtime provider catalog schema v{PROVIDER_CATALOG_SCHEMA_VERSION}.\n{comment} Language: {language}.\n\n"
1156    )
1157}
1158
1159const TYPESCRIPT_TYPES: &str = r#"export interface HarnProviderCatalog {
1160  schema_version: 2
1161  schema: string
1162  generated_by: string
1163  providers: HarnCatalogProvider[]
1164  models: HarnCatalogModel[]
1165  aliases: HarnCatalogAlias[]
1166  variants: HarnCatalogVariant[]
1167  qc_defaults: Record<string, string>
1168}
1169
1170export interface HarnCatalogProvider {
1171  id: string
1172  display_name: string
1173  icon?: string
1174  classification: "hosted" | "local"
1175  endpoint: HarnProviderEndpoint
1176  auth: HarnProviderAuth
1177  protocols: string[]
1178  features: string[]
1179  caveats: string[]
1180  rpm?: number
1181  latency_p50_ms?: number
1182}
1183
1184export interface HarnProviderEndpoint {
1185  base_url: string
1186  base_url_env?: string
1187  chat_endpoint: string
1188  completion_endpoint?: string
1189}
1190
1191export interface HarnProviderAuth {
1192  style: string
1193  header?: string
1194  env: string[]
1195  required: boolean
1196}
1197
1198export interface HarnCatalogAlias {
1199  name: string
1200  model_id: string
1201  provider: string
1202  tool_format?: string
1203  tool_calling?: HarnAliasToolCalling
1204}
1205
1206export interface HarnAliasToolCalling {
1207  native?: string
1208  text?: string
1209  streaming_native?: string
1210  fallback_mode?: string
1211  failure_reason?: string
1212  last_probe_at?: string
1213}
1214
1215export interface HarnCatalogModel {
1216  id: string
1217  name: string
1218  provider: string
1219  aliases: string[]
1220  context_window: number
1221  runtime_context_window?: number
1222  stream_timeout?: number
1223  modalities: { input: string[]; output: string[] }
1224  tool_support: {
1225    native: boolean
1226    text: boolean
1227    preferred_format?: string
1228    parity?: string
1229    parity_notes?: string
1230    empirical_parity?: HarnToolEmpiricalParity
1231    tool_search: string[]
1232    max_tools?: number
1233  }
1234  structured_output: string
1235  format_preferences: {
1236    prefers_xml_scaffolding: boolean
1237    prefers_markdown_scaffolding: boolean
1238    structured_output_mode: "native_json" | "delimited" | "xml_tagged" | "none"
1239    supports_assistant_prefill: boolean
1240    prefers_role_developer: boolean
1241    prefers_xml_tools: boolean
1242    thinking_block_style: "none" | "thinking_blocks" | "reasoning_summary" | "inline"
1243  }
1244  reasoning: {
1245    modes: string[]
1246    effort_supported: boolean
1247    none_supported: boolean
1248    interleaved_supported: boolean
1249    preserve_thinking: boolean
1250  }
1251  prompt_cache: boolean
1252  pricing?: HarnModelPricing
1253  deprecation: { status: "active" | "deprecated"; note?: string; superseded_by?: string }
1254  availability: "serverless" | "dedicated" | "unknown"
1255  quality_tags: string[]
1256  capability_tags: string[]
1257  tier: "small" | "mid" | "frontier" | "reasoning"
1258  open_weight?: boolean
1259  strengths?: string[]
1260  benchmarks?: Record<string, number>
1261  fast_mode?: HarnModelFastMode
1262}
1263
1264export interface HarnToolEmpiricalParity {
1265  verdict: string
1266  preferred_format: string
1267  confidence: string
1268  sample_size: number
1269  last_evaluated: string
1270  native_pass_rate: number
1271  text_pass_rate: number
1272  verifier_divergence_rate: number
1273}
1274
1275export interface HarnModelPricing {
1276  input_per_mtok: number
1277  output_per_mtok: number
1278  cache_read_per_mtok?: number | null
1279  cache_write_per_mtok?: number | null
1280}
1281
1282export interface HarnModelFastMode {
1283  param: string
1284  value: string
1285  beta_header?: string
1286  otps_speedup?: number
1287  status?: string
1288  pricing?: HarnModelPricing
1289  note?: string
1290}
1291
1292export interface HarnCatalogVariant {
1293  id: string
1294  label: string
1295  description: string
1296  model_id: string
1297  provider: string
1298  source: string
1299}
1300
1301export interface CatalogEntry {
1302  id: string
1303  name: string
1304  provider: string
1305  contextWindow: number
1306  runtimeContextWindow?: number
1307  capabilities: string[]
1308  pricing?: {
1309    inputPerMTok: number
1310    outputPerMTok: number
1311    cacheReadPerMTok?: number | null
1312    cacheWritePerMTok?: number | null
1313  }
1314  streamTimeout?: number
1315}
1316
1317export interface CatalogAlias {
1318  alias: string
1319  id: string
1320  provider: string
1321  toolFormat?: string
1322  toolCalling?: HarnAliasToolCalling
1323}
1324
1325"#;
1326
1327const TYPESCRIPT_COMPAT_EXPORTS: &str = r#"
1328export const MODEL_CATALOG: readonly CatalogEntry[] = harnProviderCatalog.models.map((model) => ({
1329  id: model.id,
1330  name: model.name,
1331  provider: model.provider,
1332  contextWindow: model.context_window,
1333  runtimeContextWindow: model.runtime_context_window,
1334  capabilities: model.capability_tags,
1335  pricing: model.pricing
1336    ? {
1337        inputPerMTok: model.pricing.input_per_mtok,
1338        outputPerMTok: model.pricing.output_per_mtok,
1339        cacheReadPerMTok: model.pricing.cache_read_per_mtok,
1340        cacheWritePerMTok: model.pricing.cache_write_per_mtok,
1341      }
1342    : undefined,
1343  streamTimeout: model.stream_timeout,
1344}))
1345
1346export const ALIASES: readonly CatalogAlias[] = harnProviderCatalog.aliases.map((alias) => ({
1347  alias: alias.name,
1348  id: alias.model_id,
1349  provider: alias.provider,
1350  toolFormat: alias.tool_format,
1351  toolCalling: alias.tool_calling,
1352}))
1353
1354export const QC_DEFAULTS: Readonly<Record<string, string>> = harnProviderCatalog.qc_defaults
1355
1356export function pricingFor(modelId: string): CatalogEntry["pricing"] | undefined {
1357  return entryFor(modelId)?.pricing
1358}
1359
1360export function entryFor(modelId: string): CatalogEntry | undefined {
1361  return MODEL_CATALOG.find((entry) => entry.id === modelId)
1362}
1363
1364export function aliasesByProvider(provider: string): readonly CatalogAlias[] {
1365  return ALIASES.filter((alias) => alias.provider === provider)
1366}
1367
1368export function qcDefaultModel(provider: string): string | undefined {
1369  return QC_DEFAULTS[provider]
1370}
1371"#;
1372
1373const SWIFT_TYPES: &str = r#"public struct HarnProviderCatalog: Codable, Sendable, Equatable {
1374    public let schemaVersion: Int
1375    public let schema: String
1376    public let generatedBy: String
1377    public let providers: [HarnCatalogProvider]
1378    public let models: [HarnCatalogModel]
1379    public let aliases: [HarnCatalogAlias]
1380    public let variants: [HarnCatalogVariant]
1381    public let qcDefaults: [String: String]
1382
1383    enum CodingKeys: String, CodingKey {
1384        case schemaVersion = "schema_version"
1385        case schema
1386        case generatedBy = "generated_by"
1387        case providers
1388        case models
1389        case aliases
1390        case variants
1391        case qcDefaults = "qc_defaults"
1392    }
1393}
1394
1395public struct HarnCatalogProvider: Codable, Sendable, Equatable {
1396    public let id: String
1397    public let displayName: String
1398    public let icon: String?
1399    public let classification: String
1400    public let endpoint: HarnProviderEndpoint
1401    public let auth: HarnProviderAuth
1402    public let protocols: [String]
1403    public let features: [String]
1404    public let caveats: [String]
1405    public let rpm: Int?
1406    public let latencyP50Ms: Int?
1407
1408    enum CodingKeys: String, CodingKey {
1409        case id
1410        case displayName = "display_name"
1411        case icon
1412        case classification
1413        case endpoint
1414        case auth
1415        case protocols
1416        case features
1417        case caveats
1418        case rpm
1419        case latencyP50Ms = "latency_p50_ms"
1420    }
1421}
1422
1423public struct HarnProviderEndpoint: Codable, Sendable, Equatable {
1424    public let baseURL: String
1425    public let baseURLEnv: String?
1426    public let chatEndpoint: String
1427    public let completionEndpoint: String?
1428
1429    enum CodingKeys: String, CodingKey {
1430        case baseURL = "base_url"
1431        case baseURLEnv = "base_url_env"
1432        case chatEndpoint = "chat_endpoint"
1433        case completionEndpoint = "completion_endpoint"
1434    }
1435}
1436
1437public struct HarnProviderAuth: Codable, Sendable, Equatable {
1438    public let style: String
1439    public let header: String?
1440    public let env: [String]
1441    public let required: Bool
1442}
1443
1444public struct HarnCatalogAlias: Codable, Sendable, Equatable {
1445    public let name: String
1446    public let modelID: String
1447    public let provider: String
1448    public let toolFormat: String?
1449    public let toolCalling: HarnAliasToolCalling?
1450
1451    enum CodingKeys: String, CodingKey {
1452        case name
1453        case modelID = "model_id"
1454        case provider
1455        case toolFormat = "tool_format"
1456        case toolCalling = "tool_calling"
1457    }
1458}
1459
1460public struct HarnAliasToolCalling: Codable, Sendable, Equatable {
1461    public let native: String?
1462    public let text: String?
1463    public let streamingNative: String?
1464    public let fallbackMode: String?
1465    public let failureReason: String?
1466    public let lastProbeAt: String?
1467
1468    enum CodingKeys: String, CodingKey {
1469        case native
1470        case text
1471        case streamingNative = "streaming_native"
1472        case fallbackMode = "fallback_mode"
1473        case failureReason = "failure_reason"
1474        case lastProbeAt = "last_probe_at"
1475    }
1476}
1477
1478public struct HarnCatalogModel: Codable, Sendable, Equatable {
1479    public let id: String
1480    public let name: String
1481    public let provider: String
1482    public let aliases: [String]
1483    public let contextWindow: Int
1484    public let runtimeContextWindow: Int?
1485    public let streamTimeout: Double?
1486    public let modalities: HarnModelModalities
1487    public let toolSupport: HarnModelToolSupport
1488    public let structuredOutput: String
1489    public let formatPreferences: HarnModelFormatPreferences
1490    public let reasoning: HarnModelReasoning
1491    public let promptCache: Bool
1492    public let pricing: HarnModelPricing?
1493    public let deprecation: HarnModelDeprecation
1494    public let availability: String
1495    public let qualityTags: [String]
1496    public let capabilityTags: [String]
1497    /// Popular-consensus tier label: "small" | "mid" | "frontier" | "reasoning".
1498    public let tier: String
1499    /// True when weights are downloadable / self-hostable; nil when the
1500    /// catalog row predates the field.
1501    public let openWeight: Bool?
1502    /// Workload-shaped strength tags (`coding`, `summarization`, `vision`, ...).
1503    public let strengths: [String]
1504    /// Public benchmark numbers keyed by `snake_case` identifier.
1505    public let benchmarks: [String: Double]
1506    /// Accelerated-serving ("fast mode") tier metadata, when offered.
1507    public let fastMode: HarnModelFastMode?
1508
1509    enum CodingKeys: String, CodingKey {
1510        case id
1511        case name
1512        case provider
1513        case aliases
1514        case contextWindow = "context_window"
1515        case runtimeContextWindow = "runtime_context_window"
1516        case streamTimeout = "stream_timeout"
1517        case modalities
1518        case toolSupport = "tool_support"
1519        case structuredOutput = "structured_output"
1520        case formatPreferences = "format_preferences"
1521        case reasoning
1522        case promptCache = "prompt_cache"
1523        case pricing
1524        case deprecation
1525        case availability
1526        case qualityTags = "quality_tags"
1527        case capabilityTags = "capability_tags"
1528        case tier
1529        case openWeight = "open_weight"
1530        case strengths
1531        case benchmarks
1532        case fastMode = "fast_mode"
1533    }
1534
1535    public init(from decoder: Decoder) throws {
1536        let container = try decoder.container(keyedBy: CodingKeys.self)
1537        id = try container.decode(String.self, forKey: .id)
1538        name = try container.decode(String.self, forKey: .name)
1539        provider = try container.decode(String.self, forKey: .provider)
1540        aliases = try container.decode([String].self, forKey: .aliases)
1541        contextWindow = try container.decode(Int.self, forKey: .contextWindow)
1542        runtimeContextWindow = try container.decodeIfPresent(Int.self, forKey: .runtimeContextWindow)
1543        streamTimeout = try container.decodeIfPresent(Double.self, forKey: .streamTimeout)
1544        modalities = try container.decode(HarnModelModalities.self, forKey: .modalities)
1545        toolSupport = try container.decode(HarnModelToolSupport.self, forKey: .toolSupport)
1546        structuredOutput = try container.decode(String.self, forKey: .structuredOutput)
1547        formatPreferences = try container.decode(HarnModelFormatPreferences.self, forKey: .formatPreferences)
1548        reasoning = try container.decode(HarnModelReasoning.self, forKey: .reasoning)
1549        promptCache = try container.decode(Bool.self, forKey: .promptCache)
1550        pricing = try container.decodeIfPresent(HarnModelPricing.self, forKey: .pricing)
1551        deprecation = try container.decode(HarnModelDeprecation.self, forKey: .deprecation)
1552        availability = try container.decode(String.self, forKey: .availability)
1553        qualityTags = try container.decode([String].self, forKey: .qualityTags)
1554        capabilityTags = try container.decode([String].self, forKey: .capabilityTags)
1555        tier = try container.decode(String.self, forKey: .tier)
1556        openWeight = try container.decodeIfPresent(Bool.self, forKey: .openWeight)
1557        strengths = try container.decodeIfPresent([String].self, forKey: .strengths) ?? []
1558        benchmarks = try container.decodeIfPresent([String: Double].self, forKey: .benchmarks) ?? [:]
1559        fastMode = try container.decodeIfPresent(HarnModelFastMode.self, forKey: .fastMode)
1560    }
1561}
1562
1563public struct HarnModelModalities: Codable, Sendable, Equatable {
1564    public let input: [String]
1565    public let output: [String]
1566}
1567
1568public struct HarnModelToolSupport: Codable, Sendable, Equatable {
1569    public let native: Bool
1570    public let text: Bool
1571    public let preferredFormat: String?
1572    public let parity: String?
1573    public let parityNotes: String?
1574    public let empiricalParity: HarnToolEmpiricalParity?
1575    public let toolSearch: [String]
1576    public let maxTools: Int?
1577
1578    enum CodingKeys: String, CodingKey {
1579        case native
1580        case text
1581        case preferredFormat = "preferred_format"
1582        case parity
1583        case parityNotes = "parity_notes"
1584        case empiricalParity = "empirical_parity"
1585        case toolSearch = "tool_search"
1586        case maxTools = "max_tools"
1587    }
1588}
1589
1590public struct HarnToolEmpiricalParity: Codable, Sendable, Equatable {
1591    public let verdict: String
1592    public let preferredFormat: String
1593    public let confidence: String
1594    public let sampleSize: Int
1595    public let lastEvaluated: String
1596    public let nativePassRate: Double
1597    public let textPassRate: Double
1598    public let verifierDivergenceRate: Double
1599
1600    enum CodingKeys: String, CodingKey {
1601        case verdict
1602        case preferredFormat = "preferred_format"
1603        case confidence
1604        case sampleSize = "sample_size"
1605        case lastEvaluated = "last_evaluated"
1606        case nativePassRate = "native_pass_rate"
1607        case textPassRate = "text_pass_rate"
1608        case verifierDivergenceRate = "verifier_divergence_rate"
1609    }
1610}
1611
1612public struct HarnModelFormatPreferences: Codable, Sendable, Equatable {
1613    public let prefersXMLScaffolding: Bool
1614    public let prefersMarkdownScaffolding: Bool
1615    public let structuredOutputMode: String
1616    public let supportsAssistantPrefill: Bool
1617    public let prefersRoleDeveloper: Bool
1618    public let prefersXMLTools: Bool
1619    public let thinkingBlockStyle: String
1620
1621    enum CodingKeys: String, CodingKey {
1622        case prefersXMLScaffolding = "prefers_xml_scaffolding"
1623        case prefersMarkdownScaffolding = "prefers_markdown_scaffolding"
1624        case structuredOutputMode = "structured_output_mode"
1625        case supportsAssistantPrefill = "supports_assistant_prefill"
1626        case prefersRoleDeveloper = "prefers_role_developer"
1627        case prefersXMLTools = "prefers_xml_tools"
1628        case thinkingBlockStyle = "thinking_block_style"
1629    }
1630}
1631
1632public struct HarnModelReasoning: Codable, Sendable, Equatable {
1633    public let modes: [String]
1634    public let effortSupported: Bool
1635    public let noneSupported: Bool
1636    public let interleavedSupported: Bool
1637    public let preserveThinking: Bool
1638
1639    enum CodingKeys: String, CodingKey {
1640        case modes
1641        case effortSupported = "effort_supported"
1642        case noneSupported = "none_supported"
1643        case interleavedSupported = "interleaved_supported"
1644        case preserveThinking = "preserve_thinking"
1645    }
1646}
1647
1648public struct HarnModelPricing: Codable, Sendable, Equatable {
1649    public let inputPerMTok: Double
1650    public let outputPerMTok: Double
1651    public let cacheReadPerMTok: Double?
1652    public let cacheWritePerMTok: Double?
1653
1654    enum CodingKeys: String, CodingKey {
1655        case inputPerMTok = "input_per_mtok"
1656        case outputPerMTok = "output_per_mtok"
1657        case cacheReadPerMTok = "cache_read_per_mtok"
1658        case cacheWritePerMTok = "cache_write_per_mtok"
1659    }
1660}
1661
1662public struct HarnModelDeprecation: Codable, Sendable, Equatable {
1663    public let status: String
1664    public let note: String?
1665    public let supersededBy: String?
1666
1667    enum CodingKeys: String, CodingKey {
1668        case status
1669        case note
1670        case supersededBy = "superseded_by"
1671    }
1672}
1673
1674public struct HarnModelFastMode: Codable, Sendable, Equatable {
1675    public let param: String
1676    public let value: String
1677    public let betaHeader: String?
1678    public let otpsSpeedup: Double?
1679    public let status: String?
1680    public let pricing: HarnModelPricing?
1681    public let note: String?
1682
1683    enum CodingKeys: String, CodingKey {
1684        case param
1685        case value
1686        case betaHeader = "beta_header"
1687        case otpsSpeedup = "otps_speedup"
1688        case status
1689        case pricing
1690        case note
1691    }
1692}
1693
1694public struct HarnCatalogVariant: Codable, Sendable, Equatable {
1695    public let id: String
1696    public let label: String
1697    public let description: String
1698    public let modelID: String
1699    public let provider: String
1700    public let source: String
1701
1702    enum CodingKeys: String, CodingKey {
1703        case id
1704        case label
1705        case description
1706        case modelID = "model_id"
1707        case provider
1708        case source
1709    }
1710}
1711"#;
1712
1713#[cfg(test)]
1714mod tests {
1715    use super::*;
1716
1717    struct OverrideGuard;
1718
1719    impl Drop for OverrideGuard {
1720        fn drop(&mut self) {
1721            llm_config::clear_user_overrides();
1722        }
1723    }
1724
1725    fn install_overlay(toml_src: &str) -> OverrideGuard {
1726        let overlay = llm_config::parse_config_toml(toml_src).expect("overlay parses");
1727        llm_config::set_user_overrides(Some(overlay));
1728        OverrideGuard
1729    }
1730
1731    #[test]
1732    fn generated_catalog_validates() {
1733        llm_config::clear_user_overrides();
1734        let report = validate_current();
1735        assert!(
1736            report.errors.is_empty(),
1737            "catalog validation errors: {:?}",
1738            report.errors
1739        );
1740    }
1741
1742    #[test]
1743    fn generated_catalog_derives_quality_tags_from_routes() {
1744        let catalog = artifact();
1745        let frontier = catalog
1746            .models
1747            .iter()
1748            .find(|model| model.aliases.iter().any(|alias| alias == "frontier"))
1749            .expect("frontier alias target is exported");
1750        assert!(frontier.quality_tags.iter().any(|tag| tag == "frontier"));
1751
1752        let local = catalog
1753            .models
1754            .iter()
1755            .find(|model| model.aliases.iter().any(|alias| alias == "local-gemma4"))
1756            .expect("local alias target is exported");
1757        assert!(local.quality_tags.iter().any(|tag| tag == "local"));
1758    }
1759
1760    #[test]
1761    fn validation_rejects_missing_required_metadata() {
1762        let mut catalog = artifact();
1763        catalog.providers[0].display_name.clear();
1764        let report = validate_artifact(&catalog);
1765        assert!(
1766            report
1767                .errors
1768                .iter()
1769                .any(|message| message.contains("display_name cannot be empty")),
1770            "expected provider metadata validation error, got {:?}",
1771            report.errors
1772        );
1773    }
1774
1775    #[test]
1776    fn validation_rejects_duplicate_and_dangling_aliases() {
1777        let mut duplicated = artifact();
1778        duplicated.aliases.push(duplicated.aliases[0].clone());
1779        let duplicate_report = validate_artifact(&duplicated);
1780        assert!(
1781            duplicate_report
1782                .errors
1783                .iter()
1784                .any(|message| message.contains("duplicate alias name")),
1785            "expected duplicate alias validation error, got {:?}",
1786            duplicate_report.errors
1787        );
1788
1789        let mut dangling = artifact();
1790        dangling.aliases[0].model_id = "missing-model".to_string();
1791        let dangling_report = validate_artifact(&dangling);
1792        assert!(
1793            dangling_report
1794                .errors
1795                .iter()
1796                .any(|message| message.contains("without a catalog row")),
1797            "expected dangling alias validation error, got {:?}",
1798            dangling_report.errors
1799        );
1800    }
1801
1802    #[test]
1803    fn overlay_merge_surfaces_private_model() {
1804        let _guard = install_overlay(
1805            r#"
1806[providers.private]
1807display_name = "Private"
1808base_url = "http://127.0.0.1:9000"
1809auth_style = "none"
1810chat_endpoint = "/v1/chat/completions"
1811
1812[aliases]
1813private-fast = { id = "private/fast", provider = "private" }
1814
1815[models."private/fast"]
1816name = "Private Fast"
1817provider = "private"
1818context_window = 8192
1819quality_tags = ["experiment"]
1820"#,
1821        );
1822        let catalog = artifact();
1823        assert!(catalog.providers.iter().any(|p| p.id == "private"));
1824        let model = catalog
1825            .models
1826            .iter()
1827            .find(|model| model.id == "private/fast")
1828            .expect("private model is exported");
1829        assert_eq!(model.aliases, vec!["private-fast"]);
1830        assert_eq!(model.quality_tags, vec!["experiment"]);
1831    }
1832
1833    #[test]
1834    fn cataloged_models_default_to_serverless_availability() {
1835        llm_config::clear_user_overrides();
1836        let catalog = artifact();
1837        let qwen_dedicated = catalog
1838            .models
1839            .iter()
1840            .find(|model| model.id == "Qwen/Qwen3-Coder-Next-FP8")
1841            .expect("Together dedicated route is exported");
1842        assert_eq!(
1843            qwen_dedicated.availability,
1844            ModelAvailabilityStatus::Dedicated
1845        );
1846
1847        let bundled_serverless = catalog
1848            .models
1849            .iter()
1850            .find(|model| model.id == "qwen/qwen3-coder")
1851            .expect("OpenRouter Qwen3 Coder is exported");
1852        assert_eq!(
1853            bundled_serverless.availability,
1854            ModelAvailabilityStatus::Serverless
1855        );
1856    }
1857
1858    #[test]
1859    fn tier_alias_targeting_dedicated_model_emits_warning() {
1860        let _guard = install_overlay(
1861            r#"
1862[providers.together_test]
1863display_name = "Together (test)"
1864base_url = "https://api.together.xyz/v1"
1865auth_style = "bearer"
1866auth_env = "TOGETHER_AI_API_KEY"
1867chat_endpoint = "/chat/completions"
1868
1869[aliases.frontier]
1870id = "Qwen/Test-Dedicated-Only"
1871provider = "together_test"
1872
1873[models."Qwen/Test-Dedicated-Only"]
1874name = "Qwen Dedicated Only"
1875provider = "together_test"
1876context_window = 8192
1877availability = "dedicated"
1878"#,
1879        );
1880        let report = validate_current();
1881        assert!(
1882            report.warnings.iter().any(|message| {
1883                message.contains("tier alias frontier") && message.contains("dedicated-only model")
1884            }),
1885            "expected dedicated-alias warning, got {:?}",
1886            report.warnings
1887        );
1888    }
1889
1890    #[test]
1891    fn overlay_parses_availability_strings() {
1892        let _guard = install_overlay(
1893            r#"
1894[providers.experiment_co]
1895display_name = "Experiment Co"
1896base_url = "https://example.test/v1"
1897auth_style = "bearer"
1898auth_env = "EXPERIMENT_API_KEY"
1899chat_endpoint = "/chat/completions"
1900
1901[models."exp/discovered"]
1902name = "Discovered Route"
1903provider = "experiment_co"
1904context_window = 4096
1905availability = "unknown"
1906"#,
1907        );
1908        let catalog = artifact();
1909        let model = catalog
1910            .models
1911            .iter()
1912            .find(|model| model.id == "exp/discovered")
1913            .expect("overlay model is exported");
1914        assert_eq!(model.availability, ModelAvailabilityStatus::Unknown);
1915    }
1916
1917    #[test]
1918    fn deprecated_models_require_notes() {
1919        let _guard = install_overlay(
1920            r#"
1921[models."old-model"]
1922name = "Old Model"
1923provider = "openai"
1924context_window = 4096
1925deprecated = true
1926"#,
1927        );
1928        let report = validate_current();
1929        assert!(
1930            report
1931                .errors
1932                .iter()
1933                .any(|message| message.contains("deprecated model old-model")),
1934            "expected deprecation validation error, got {:?}",
1935            report.errors
1936        );
1937    }
1938
1939    #[test]
1940    fn generated_schema_accepts_generated_artifact_shape() {
1941        let schema = schema_value();
1942        assert_eq!(schema["$id"], PROVIDER_CATALOG_SCHEMA_ID);
1943        assert_eq!(
1944            schema["$defs"]["tool_support"]["properties"]["empirical_parity"]["$ref"],
1945            "#/$defs/tool_empirical_parity"
1946        );
1947        let artifact_value = serde_json::to_value(artifact()).expect("artifact serializes");
1948        assert_eq!(
1949            artifact_value["schema_version"],
1950            PROVIDER_CATALOG_SCHEMA_VERSION
1951        );
1952        assert!(artifact_value["providers"]
1953            .as_array()
1954            .is_some_and(|v| !v.is_empty()));
1955        assert!(artifact_value["models"]
1956            .as_array()
1957            .is_some_and(|v| !v.is_empty()));
1958    }
1959
1960    #[test]
1961    fn downstream_bindings_include_empirical_tool_parity_shape() {
1962        let typescript = typescript_binding().expect("typescript binding renders");
1963        assert!(typescript.contains("empirical_parity?: HarnToolEmpiricalParity"));
1964        assert!(typescript.contains("export interface HarnToolEmpiricalParity"));
1965
1966        let swift = swift_binding().expect("swift binding renders");
1967        assert!(swift.contains("public let empiricalParity: HarnToolEmpiricalParity?"));
1968        assert!(swift.contains("public struct HarnToolEmpiricalParity"));
1969    }
1970
1971    #[test]
1972    fn fast_mode_and_supersession_surface_in_contract() {
1973        let schema = schema_value();
1974        assert_eq!(
1975            schema["$defs"]["model"]["properties"]["fast_mode"]["$ref"],
1976            "#/$defs/fast_mode"
1977        );
1978        assert_eq!(
1979            schema["$defs"]["fast_mode"]["properties"]["pricing"]["$ref"],
1980            "#/$defs/pricing"
1981        );
1982        assert!(schema["$defs"]["deprecation"]["properties"]["superseded_by"].is_object());
1983
1984        let typescript = typescript_binding().expect("typescript binding renders");
1985        assert!(typescript.contains("export interface HarnModelFastMode"));
1986        assert!(typescript.contains("fast_mode?: HarnModelFastMode"));
1987        assert!(typescript.contains("superseded_by?: string"));
1988
1989        let swift = swift_binding().expect("swift binding renders");
1990        assert!(swift.contains("public struct HarnModelFastMode"));
1991        assert!(swift.contains("public let fastMode: HarnModelFastMode?"));
1992        assert!(swift.contains("case supersededBy = \"superseded_by\""));
1993    }
1994
1995    #[test]
1996    fn dangling_superseded_by_and_unknown_fast_status_warn() {
1997        let _guard = install_overlay(
1998            r#"
1999[providers.warn_co]
2000display_name = "Warn Co"
2001base_url = "https://example.test/v1"
2002auth_style = "bearer"
2003auth_env = "WARN_API_KEY"
2004chat_endpoint = "/chat/completions"
2005
2006[models."warn/old"]
2007name = "Warn Old"
2008provider = "warn_co"
2009context_window = 4096
2010deprecated = true
2011deprecation_note = "Retiring soon."
2012superseded_by = "warn/does-not-exist"
2013fast_mode = { param = "speed", value = "fast", status = "turbo", pricing = { input_per_mtok = 1.0, output_per_mtok = 2.0 } }
2014"#,
2015        );
2016        let report = validate_current();
2017        assert!(
2018            report
2019                .warnings
2020                .iter()
2021                .any(|message| message.contains("superseded_by warn/does-not-exist")),
2022            "expected dangling superseded_by warning, got {:?}",
2023            report.warnings
2024        );
2025        assert!(
2026            report
2027                .warnings
2028                .iter()
2029                .any(|message| message.contains("fast_mode.status") && message.contains("turbo")),
2030            "expected fast_mode.status warning, got {:?}",
2031            report.warnings
2032        );
2033    }
2034}