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};
9use std::path::PathBuf;
10use std::time::Duration;
11
12use base64::Engine as _;
13use ed25519_dalek::Verifier as _;
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16
17use crate::llm;
18use crate::llm_config::{
19    self, AliasDef, AliasToolCallingDef, ModelAvailability, ModelDef, ModelPricing, ProviderDef,
20};
21
22pub const PROVIDER_CATALOG_SCHEMA_VERSION: u32 = 2;
23pub const PROVIDER_CATALOG_SCHEMA_ID: &str =
24    "https://harnlang.com/schemas/provider-catalog.v2.json";
25pub const PROVIDER_CATALOG_GENERATOR: &str = "harn providers export";
26pub const HARN_DISABLE_CATALOG_REFRESH_ENV: &str = "HARN_DISABLE_CATALOG_REFRESH";
27pub const HARN_PROVIDER_CATALOG_URL_ENV: &str = "HARN_PROVIDER_CATALOG_URL";
28pub const HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV: &str = "HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED";
29pub const HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV: &str = "HARN_PROVIDER_CATALOG_TRUSTED_KEYS";
30pub const DEFAULT_PROVIDER_CATALOG_URL: &str =
31    "https://burin-labs.github.io/harn-cloud/provider-catalog/provider-catalog.json";
32
33const DEFAULT_REMOTE_TTL_MS: u64 = 24 * 60 * 60 * 1000;
34const REMOTE_CACHE_DIR: &str = "provider-catalog";
35const REMOTE_CACHE_BODY_FILE: &str = "catalog.json";
36const REMOTE_CACHE_META_FILE: &str = "catalog.meta.json";
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProviderCatalogArtifact {
40    pub schema_version: u32,
41    pub schema: String,
42    pub generated_by: String,
43    pub providers: Vec<CatalogProvider>,
44    pub models: Vec<CatalogModel>,
45    pub aliases: Vec<CatalogAlias>,
46    pub variants: Vec<CatalogVariant>,
47    pub qc_defaults: BTreeMap<String, String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CatalogProvider {
52    pub id: String,
53    pub display_name: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub icon: Option<String>,
56    pub classification: ProviderClassification,
57    pub endpoint: ProviderEndpoint,
58    pub auth: ProviderAuth,
59    pub protocols: Vec<String>,
60    pub features: Vec<String>,
61    pub caveats: Vec<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub rpm: Option<u32>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub latency_p50_ms: Option<u64>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ProviderClassification {
71    Hosted,
72    Local,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ProviderEndpoint {
77    pub base_url: String,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub base_url_env: Option<String>,
80    pub chat_endpoint: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub completion_endpoint: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ProviderAuth {
87    pub style: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub header: Option<String>,
90    pub env: Vec<String>,
91    pub required: bool,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CatalogAlias {
96    pub name: String,
97    pub model_id: String,
98    pub provider: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub tool_format: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub tool_calling: Option<AliasToolCallingDef>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct CatalogModel {
107    pub id: String,
108    pub name: String,
109    pub provider: String,
110    pub aliases: Vec<String>,
111    pub context_window: u64,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub runtime_context_window: Option<u64>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub stream_timeout: Option<f64>,
116    pub modalities: ModelModalities,
117    pub tool_support: ModelToolSupport,
118    pub structured_output: String,
119    pub format_preferences: ModelFormatPreferences,
120    pub reasoning: ModelReasoning,
121    pub prompt_cache: bool,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub pricing: Option<ModelPricing>,
124    pub deprecation: ModelDeprecation,
125    pub availability: ModelAvailabilityStatus,
126    pub quality_tags: Vec<String>,
127    pub capability_tags: Vec<String>,
128    pub family: String,
129    pub lineage: String,
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub complementary_with: Vec<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub avoid_as_reviewer_for: Vec<String>,
134    /// Popular-consensus tier label: "small" | "mid" | "frontier" |
135    /// "reasoning". Self-declared on the model row; the rule-based path
136    /// is a fallback only.
137    pub tier: String,
138    /// True when weights are downloadable / self-hostable.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub open_weight: Option<bool>,
141    /// Workload-shaped strength tags (coding, summarization, vision, ...).
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub strengths: Vec<String>,
144    /// Public benchmark numbers, snake_case identifier -> score.
145    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146    pub benchmarks: BTreeMap<String, f64>,
147    /// Accelerated-serving ("fast mode") tier metadata, when offered.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub fast_mode: Option<ModelFastMode>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum ModelAvailabilityStatus {
155    Serverless,
156    Dedicated,
157    Unknown,
158}
159
160impl From<ModelAvailability> for ModelAvailabilityStatus {
161    fn from(value: ModelAvailability) -> Self {
162        match value {
163            ModelAvailability::Serverless => Self::Serverless,
164            ModelAvailability::Dedicated => Self::Dedicated,
165            ModelAvailability::Unknown => Self::Unknown,
166        }
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ModelModalities {
172    pub input: Vec<String>,
173    pub output: Vec<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct ModelToolSupport {
178    pub native: bool,
179    pub text: bool,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub preferred_format: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub parity: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub parity_notes: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub empirical_parity: Option<ModelToolEmpiricalParity>,
188    pub tool_search: Vec<String>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub max_tools: Option<u32>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct ModelToolEmpiricalParity {
195    pub verdict: String,
196    pub preferred_format: String,
197    pub confidence: String,
198    pub sample_size: u32,
199    pub last_evaluated: String,
200    pub native_pass_rate: f64,
201    pub text_pass_rate: f64,
202    pub verifier_divergence_rate: f64,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ModelFormatPreferences {
207    pub prefers_xml_scaffolding: bool,
208    pub prefers_markdown_scaffolding: bool,
209    pub structured_output_mode: String,
210    pub supports_assistant_prefill: bool,
211    pub prefers_role_developer: bool,
212    pub prefers_xml_tools: bool,
213    pub thinking_block_style: String,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ModelReasoning {
218    pub modes: Vec<String>,
219    pub effort_supported: bool,
220    pub none_supported: bool,
221    pub interleaved_supported: bool,
222    pub preserve_thinking: bool,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ModelDeprecation {
227    pub status: DeprecationStatus,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub note: Option<String>,
230    /// Catalog id of the model that supersedes this one, when declared.
231    /// Surfaces `ModelDef::superseded_by` as a machine-readable migration
232    /// target so downstream consumers don't have to parse `note` prose.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub superseded_by: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum DeprecationStatus {
240    Active,
241    Deprecated,
242}
243
244/// Catalog projection of an accelerated-serving ("fast mode") tier.
245/// Surfaces `ModelDef::fast_mode` so downstream consumers can show the
246/// opt-in knob, premium pricing, and lifecycle without re-parsing the
247/// source TOML. Absent on models with no faster serving path.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ModelFastMode {
250    pub param: String,
251    pub value: String,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub beta_header: Option<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub otps_speedup: Option<f64>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub status: Option<String>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub pricing: Option<ModelPricing>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub note: Option<String>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct CatalogVariant {
266    pub id: String,
267    pub label: String,
268    pub description: String,
269    pub model_id: String,
270    pub provider: String,
271    pub source: String,
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub struct ProviderCatalogValidation {
276    pub errors: Vec<String>,
277    pub warnings: Vec<String>,
278}
279
280impl ProviderCatalogValidation {
281    pub fn is_ok(&self) -> bool {
282        self.errors.is_empty()
283    }
284}
285
286#[derive(Debug, Clone, Default)]
287pub struct CatalogRefreshOptions {
288    pub url: Option<String>,
289    pub force: bool,
290}
291
292#[derive(Debug, Clone, Serialize)]
293pub struct CatalogRefreshReport {
294    pub status: String,
295    pub refreshed: bool,
296    pub source_url: String,
297    pub cache_path: String,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub etag: Option<String>,
300    pub ttl_ms: u64,
301    pub provider_count: usize,
302    pub model_count: usize,
303    pub alias_count: usize,
304    pub warning: Option<String>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308struct CatalogCacheMetadata {
309    source_url: String,
310    fetched_at_ms: u64,
311    ttl_ms: u64,
312    etag: Option<String>,
313}
314
315#[derive(Debug, Deserialize)]
316struct CatalogDocument {
317    #[serde(default, alias = "ttl_ms", alias = "ttlMS")]
318    ttl_ms: Option<u64>,
319    catalog: ProviderCatalogArtifact,
320    #[serde(default)]
321    signature: Option<CatalogDocumentSignature>,
322}
323
324#[derive(Debug, Deserialize)]
325struct CatalogDocumentSignature {
326    #[serde(default)]
327    algorithm: String,
328    key_id: String,
329    signature: String,
330}
331
332struct DecodedCatalogDocument {
333    artifact: ProviderCatalogArtifact,
334    ttl_ms: u64,
335}
336
337pub async fn refresh_runtime_catalog(options: CatalogRefreshOptions) -> CatalogRefreshReport {
338    let source_url = options
339        .url
340        .clone()
341        .or_else(|| env_nonempty(HARN_PROVIDER_CATALOG_URL_ENV))
342        .unwrap_or_else(|| DEFAULT_PROVIDER_CATALOG_URL.to_string());
343    let cache_dir = default_refresh_cache_dir();
344    let cache_path = cache_dir.join(REMOTE_CACHE_BODY_FILE);
345    if refresh_disabled() {
346        return refresh_report(
347            "disabled",
348            false,
349            source_url,
350            cache_path,
351            None,
352            DEFAULT_REMOTE_TTL_MS,
353            None,
354        );
355    }
356    if crate::llm::current_agent_session_id().is_some() {
357        return refresh_report(
358            "skipped_agent_loop",
359            false,
360            source_url,
361            cache_path,
362            None,
363            DEFAULT_REMOTE_TTL_MS,
364            Some("catalog refresh is disabled inside a live agent loop".to_string()),
365        );
366    }
367
368    if !options.force {
369        if let Some((metadata, body)) = load_fresh_cached_catalog(&source_url, &cache_dir) {
370            return install_remote_catalog_from_body(
371                "cache_hit",
372                false,
373                &source_url,
374                &cache_path,
375                metadata.etag,
376                &body,
377                metadata.ttl_ms,
378                allow_unsigned_for_url(&source_url),
379            );
380        }
381    }
382
383    let metadata = read_cache_metadata(&cache_dir).filter(|meta| meta.source_url == source_url);
384    match fetch_remote_catalog(&source_url, metadata.as_ref()).await {
385        Ok(FetchedCatalog::NotModified) => {
386            if let Some((metadata, body)) = load_any_cached_catalog(&source_url, &cache_dir) {
387                let _ = write_cache_metadata(
388                    &cache_dir,
389                    &CatalogCacheMetadata {
390                        fetched_at_ms: now_ms(),
391                        ..metadata.clone()
392                    },
393                );
394                return install_remote_catalog_from_body(
395                    "not_modified",
396                    false,
397                    &source_url,
398                    &cache_path,
399                    metadata.etag,
400                    &body,
401                    metadata.ttl_ms,
402                    allow_unsigned_for_url(&source_url),
403                );
404            }
405            refresh_report(
406                "fallback",
407                false,
408                source_url,
409                cache_path,
410                None,
411                DEFAULT_REMOTE_TTL_MS,
412                Some("remote returned 304 but no cached catalog was available".to_string()),
413            )
414        }
415        Ok(FetchedCatalog::Body { body, etag }) => {
416            match decode_and_validate_document(&body, allow_unsigned_for_url(&source_url)) {
417                Ok(decoded) => {
418                    if let Err(error) = write_catalog_cache(
419                        &cache_dir,
420                        &body,
421                        &CatalogCacheMetadata {
422                            source_url: source_url.clone(),
423                            fetched_at_ms: now_ms(),
424                            ttl_ms: decoded.ttl_ms,
425                            etag: etag.clone(),
426                        },
427                    ) {
428                        eprintln!(
429                            "[provider_catalog] warning: failed to write runtime catalog cache: {error}"
430                        );
431                    }
432                    install_decoded_catalog(
433                        "refreshed",
434                        true,
435                        source_url,
436                        cache_path,
437                        etag,
438                        decoded,
439                        None,
440                    )
441                }
442                Err(error) => install_stale_or_fallback(
443                    source_url,
444                    cache_dir,
445                    cache_path,
446                    format!("remote catalog rejected: {error}"),
447                ),
448            }
449        }
450        Err(error) => install_stale_or_fallback(source_url, cache_dir, cache_path, error),
451    }
452}
453
454fn install_stale_or_fallback(
455    source_url: String,
456    cache_dir: PathBuf,
457    cache_path: PathBuf,
458    warning: String,
459) -> CatalogRefreshReport {
460    eprintln!("[provider_catalog] warning: {warning}");
461    if let Some((metadata, body)) = load_any_cached_catalog(&source_url, &cache_dir) {
462        return install_remote_catalog_from_body(
463            "stale_cache",
464            false,
465            &source_url,
466            &cache_path,
467            metadata.etag,
468            &body,
469            metadata.ttl_ms,
470            allow_unsigned_for_url(&source_url),
471        );
472    }
473    refresh_report(
474        "fallback",
475        false,
476        source_url,
477        cache_path,
478        None,
479        DEFAULT_REMOTE_TTL_MS,
480        Some(warning),
481    )
482}
483
484fn install_remote_catalog_from_body(
485    status: &str,
486    refreshed: bool,
487    source_url: &str,
488    cache_path: &std::path::Path,
489    etag: Option<String>,
490    body: &str,
491    fallback_ttl_ms: u64,
492    allow_unsigned: bool,
493) -> CatalogRefreshReport {
494    match decode_and_validate_document(body, allow_unsigned) {
495        Ok(mut decoded) => {
496            if decoded.ttl_ms == DEFAULT_REMOTE_TTL_MS {
497                decoded.ttl_ms = fallback_ttl_ms;
498            }
499            install_decoded_catalog(
500                status,
501                refreshed,
502                source_url.to_string(),
503                cache_path.to_path_buf(),
504                etag,
505                decoded,
506                None,
507            )
508        }
509        Err(error) => refresh_report(
510            "fallback",
511            false,
512            source_url.to_string(),
513            cache_path.to_path_buf(),
514            etag,
515            fallback_ttl_ms,
516            Some(format!("cached catalog rejected: {error}")),
517        ),
518    }
519}
520
521fn install_decoded_catalog(
522    status: &str,
523    refreshed: bool,
524    source_url: String,
525    cache_path: PathBuf,
526    etag: Option<String>,
527    decoded: DecodedCatalogDocument,
528    warning: Option<String>,
529) -> CatalogRefreshReport {
530    let provider_count = decoded.artifact.providers.len();
531    let model_count = decoded.artifact.models.len();
532    let alias_count = decoded.artifact.aliases.len();
533    crate::llm_config::set_runtime_catalog_overlay(Some(config_from_artifact(&decoded.artifact)));
534    CatalogRefreshReport {
535        status: status.to_string(),
536        refreshed,
537        source_url,
538        cache_path: cache_path.display().to_string(),
539        etag,
540        ttl_ms: decoded.ttl_ms,
541        provider_count,
542        model_count,
543        alias_count,
544        warning,
545    }
546}
547
548fn refresh_report(
549    status: &str,
550    refreshed: bool,
551    source_url: String,
552    cache_path: PathBuf,
553    etag: Option<String>,
554    ttl_ms: u64,
555    warning: Option<String>,
556) -> CatalogRefreshReport {
557    let current = artifact();
558    CatalogRefreshReport {
559        status: status.to_string(),
560        refreshed,
561        source_url,
562        cache_path: cache_path.display().to_string(),
563        etag,
564        ttl_ms,
565        provider_count: current.providers.len(),
566        model_count: current.models.len(),
567        alias_count: current.aliases.len(),
568        warning,
569    }
570}
571
572enum FetchedCatalog {
573    NotModified,
574    Body { body: String, etag: Option<String> },
575}
576
577async fn fetch_remote_catalog(
578    url: &str,
579    metadata: Option<&CatalogCacheMetadata>,
580) -> Result<FetchedCatalog, String> {
581    let client = reqwest::Client::builder()
582        .timeout(Duration::from_secs(5))
583        .build()
584        .map_err(|error| format!("failed to build HTTP client: {error}"))?;
585    let mut request = client.get(url);
586    if let Some(etag) = metadata.and_then(|meta| meta.etag.as_deref()) {
587        request = request.header(reqwest::header::IF_NONE_MATCH, etag);
588    }
589    let response = request
590        .send()
591        .await
592        .map_err(|error| format!("failed to fetch runtime provider catalog: {error}"))?;
593    if response.status() == reqwest::StatusCode::NOT_MODIFIED {
594        return Ok(FetchedCatalog::NotModified);
595    }
596    if !response.status().is_success() {
597        return Err(format!(
598            "runtime provider catalog fetch returned HTTP {}",
599            response.status()
600        ));
601    }
602    let etag = response
603        .headers()
604        .get(reqwest::header::ETAG)
605        .and_then(|value| value.to_str().ok())
606        .map(str::to_string);
607    let body = response
608        .text()
609        .await
610        .map_err(|error| format!("failed to read runtime provider catalog body: {error}"))?;
611    Ok(FetchedCatalog::Body { body, etag })
612}
613
614fn decode_and_validate_document(
615    body: &str,
616    allow_unsigned: bool,
617) -> Result<DecodedCatalogDocument, String> {
618    if let Ok(artifact) = serde_json::from_str::<ProviderCatalogArtifact>(body) {
619        if !allow_unsigned {
620            return Err(format!(
621                "unsigned provider catalog rejected; set {HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV}=1 only for trusted development sources"
622            ));
623        }
624        validate_remote_artifact(artifact, DEFAULT_REMOTE_TTL_MS)
625    } else {
626        let document: CatalogDocument = serde_json::from_str(body)
627            .map_err(|error| format!("catalog JSON does not match the runtime schema: {error}"))?;
628        verify_document_signature(&document)?;
629        validate_remote_artifact(
630            document.catalog,
631            document.ttl_ms.unwrap_or(DEFAULT_REMOTE_TTL_MS),
632        )
633    }
634}
635
636fn validate_remote_artifact(
637    artifact: ProviderCatalogArtifact,
638    ttl_ms: u64,
639) -> Result<DecodedCatalogDocument, String> {
640    let report = validate_artifact(&artifact);
641    if !report.errors.is_empty() {
642        return Err(report.errors.join("; "));
643    }
644    Ok(DecodedCatalogDocument {
645        artifact,
646        ttl_ms: ttl_ms.max(1),
647    })
648}
649
650fn verify_document_signature(document: &CatalogDocument) -> Result<(), String> {
651    let signature = document
652        .signature
653        .as_ref()
654        .ok_or_else(|| "signed catalog envelope is missing signature metadata".to_string())?;
655    if signature.algorithm != "ed25519" {
656        return Err(format!(
657            "unsupported catalog signature algorithm {}",
658            signature.algorithm
659        ));
660    }
661    let trusted_keys = trusted_catalog_keys()?;
662    let public_key = trusted_keys.get(&signature.key_id).ok_or_else(|| {
663        format!(
664            "catalog signature key {} is not trusted; configure {HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV}",
665            signature.key_id
666        )
667    })?;
668    let canonical = serde_json::to_vec(&document.catalog)
669        .map_err(|error| format!("failed to canonicalize signed catalog: {error}"))?;
670    let signature_bytes = base64::engine::general_purpose::STANDARD
671        .decode(&signature.signature)
672        .map_err(|error| format!("catalog signature is not valid base64: {error}"))?;
673    let signature = ed25519_dalek::Signature::from_slice(&signature_bytes)
674        .map_err(|error| format!("catalog signature has invalid length: {error}"))?;
675    public_key
676        .verify(&canonical, &signature)
677        .map_err(|error| format!("catalog signature did not verify: {error}"))
678}
679
680fn trusted_catalog_keys() -> Result<BTreeMap<String, ed25519_dalek::VerifyingKey>, String> {
681    let mut keys = BTreeMap::new();
682    let Some(raw) = env_nonempty(HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV) else {
683        return Ok(keys);
684    };
685    for entry in raw
686        .split(',')
687        .map(str::trim)
688        .filter(|entry| !entry.is_empty())
689    {
690        let (key_id, encoded) = entry
691            .split_once('=')
692            .or_else(|| entry.split_once(':'))
693            .ok_or_else(|| {
694                format!(
695                    "{HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV} entries must use key_id=base64_public_key"
696                )
697            })?;
698        let bytes = base64::engine::general_purpose::STANDARD
699            .decode(encoded.trim())
700            .map_err(|error| format!("catalog public key {key_id} is not valid base64: {error}"))?;
701        let public_key = ed25519_dalek::VerifyingKey::from_bytes(
702            bytes
703                .as_slice()
704                .try_into()
705                .map_err(|_| format!("catalog public key {key_id} must be 32 bytes"))?,
706        )
707        .map_err(|error| format!("catalog public key {key_id} is invalid: {error}"))?;
708        keys.insert(key_id.trim().to_string(), public_key);
709    }
710    Ok(keys)
711}
712
713fn config_from_artifact(artifact: &ProviderCatalogArtifact) -> llm_config::ProvidersConfig {
714    llm_config::ProvidersConfig {
715        providers: artifact
716            .providers
717            .iter()
718            .map(|provider| (provider.id.clone(), provider_def_from_catalog(provider)))
719            .collect(),
720        aliases: artifact
721            .aliases
722            .iter()
723            .map(|alias| {
724                (
725                    alias.name.clone(),
726                    llm_config::AliasDef {
727                        id: alias.model_id.clone(),
728                        provider: alias.provider.clone(),
729                        tool_format: alias.tool_format.clone(),
730                    },
731                )
732            })
733            .collect(),
734        alias_tool_calling: artifact
735            .aliases
736            .iter()
737            .filter_map(|alias| {
738                alias
739                    .tool_calling
740                    .clone()
741                    .map(|tool_calling| (alias.name.clone(), tool_calling))
742            })
743            .collect(),
744        models: artifact
745            .models
746            .iter()
747            .map(|model| (model.id.clone(), model_def_from_catalog(model)))
748            .collect(),
749        qc_defaults: artifact.qc_defaults.clone(),
750        ..llm_config::ProvidersConfig::default()
751    }
752}
753
754fn provider_def_from_catalog(provider: &CatalogProvider) -> llm_config::ProviderDef {
755    llm_config::ProviderDef {
756        display_name: Some(provider.display_name.clone()),
757        icon: provider.icon.clone(),
758        base_url: provider.endpoint.base_url.clone(),
759        base_url_env: provider.endpoint.base_url_env.clone(),
760        auth_style: provider.auth.style.clone(),
761        auth_style_explicit: true,
762        auth_header: provider.auth.header.clone(),
763        auth_env: match provider.auth.env.as_slice() {
764            [] => llm_config::AuthEnv::None,
765            [one] => llm_config::AuthEnv::Single(one.clone()),
766            many => llm_config::AuthEnv::Multiple(many.to_vec()),
767        },
768        chat_endpoint: provider.endpoint.chat_endpoint.clone(),
769        completion_endpoint: provider.endpoint.completion_endpoint.clone(),
770        features: provider.features.clone(),
771        rpm: provider.rpm,
772        latency_p50_ms: provider.latency_p50_ms,
773        ..llm_config::ProviderDef::default()
774    }
775}
776
777fn model_def_from_catalog(model: &CatalogModel) -> llm_config::ModelDef {
778    llm_config::ModelDef {
779        name: model.name.clone(),
780        provider: model.provider.clone(),
781        context_window: model.context_window,
782        runtime_context_window: model.runtime_context_window,
783        stream_timeout: model.stream_timeout,
784        capabilities: model.capability_tags.clone(),
785        pricing: model.pricing.clone(),
786        deprecated: model.deprecation.status == DeprecationStatus::Deprecated,
787        deprecation_note: model.deprecation.note.clone(),
788        superseded_by: model.deprecation.superseded_by.clone(),
789        fast_mode: model
790            .fast_mode
791            .as_ref()
792            .map(|fast| llm_config::FastModeDef {
793                param: fast.param.clone(),
794                value: fast.value.clone(),
795                beta_header: fast.beta_header.clone(),
796                otps_speedup: fast.otps_speedup,
797                status: fast.status.clone(),
798                pricing: fast.pricing.clone(),
799                note: fast.note.clone(),
800            }),
801        quality_tags: model.quality_tags.clone(),
802        availability: match model.availability {
803            ModelAvailabilityStatus::Serverless => llm_config::ModelAvailability::Serverless,
804            ModelAvailabilityStatus::Dedicated => llm_config::ModelAvailability::Dedicated,
805            ModelAvailabilityStatus::Unknown => llm_config::ModelAvailability::Unknown,
806        },
807        tier: Some(model.tier.clone()),
808        open_weight: model.open_weight,
809        strengths: model.strengths.clone(),
810        benchmarks: model.benchmarks.clone(),
811        family: Some(model.family.clone()),
812        lineage: Some(model.lineage.clone()),
813        complementary_with: model.complementary_with.clone(),
814        avoid_as_reviewer_for: model.avoid_as_reviewer_for.clone(),
815    }
816}
817
818fn default_refresh_cache_dir() -> PathBuf {
819    crate::runtime_paths::state_root(&crate::stdlib::process::runtime_root_base())
820        .join("cache")
821        .join(REMOTE_CACHE_DIR)
822}
823
824fn load_fresh_cached_catalog(
825    source_url: &str,
826    cache_dir: &std::path::Path,
827) -> Option<(CatalogCacheMetadata, String)> {
828    let (metadata, body) = load_any_cached_catalog(source_url, cache_dir)?;
829    let age = now_ms().saturating_sub(metadata.fetched_at_ms);
830    (age < metadata.ttl_ms).then_some((metadata, body))
831}
832
833fn load_any_cached_catalog(
834    source_url: &str,
835    cache_dir: &std::path::Path,
836) -> Option<(CatalogCacheMetadata, String)> {
837    let metadata = read_cache_metadata(cache_dir)?;
838    if metadata.source_url != source_url {
839        return None;
840    }
841    let body = std::fs::read_to_string(cache_dir.join(REMOTE_CACHE_BODY_FILE)).ok()?;
842    Some((metadata, body))
843}
844
845fn read_cache_metadata(cache_dir: &std::path::Path) -> Option<CatalogCacheMetadata> {
846    let body = std::fs::read_to_string(cache_dir.join(REMOTE_CACHE_META_FILE)).ok()?;
847    serde_json::from_str(&body).ok()
848}
849
850fn write_catalog_cache(
851    cache_dir: &std::path::Path,
852    body: &str,
853    metadata: &CatalogCacheMetadata,
854) -> std::io::Result<()> {
855    std::fs::create_dir_all(cache_dir)?;
856    std::fs::write(cache_dir.join(REMOTE_CACHE_BODY_FILE), body)?;
857    write_cache_metadata(cache_dir, metadata)
858}
859
860fn write_cache_metadata(
861    cache_dir: &std::path::Path,
862    metadata: &CatalogCacheMetadata,
863) -> std::io::Result<()> {
864    std::fs::create_dir_all(cache_dir)?;
865    let body = serde_json::to_string_pretty(metadata).unwrap_or_else(|_| "{}".to_string());
866    std::fs::write(cache_dir.join(REMOTE_CACHE_META_FILE), body)
867}
868
869fn now_ms() -> u64 {
870    harn_clock::now_wall_ms(&harn_clock::RealClock::new()).max(0) as u64
871}
872
873fn refresh_disabled() -> bool {
874    matches!(
875        env_nonempty(HARN_DISABLE_CATALOG_REFRESH_ENV)
876            .as_deref()
877            .map(|value| value.to_ascii_lowercase()),
878        Some(value) if matches!(value.as_str(), "1" | "true" | "yes" | "on")
879    )
880}
881
882fn allow_unsigned_for_url(url: &str) -> bool {
883    if matches!(
884        env_nonempty(HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV)
885            .as_deref()
886            .map(|value| value.to_ascii_lowercase()),
887        Some(value) if matches!(value.as_str(), "1" | "true" | "yes" | "on")
888    ) {
889        return true;
890    }
891    url::Url::parse(url).ok().is_some_and(|parsed| {
892        matches!(
893            parsed.host_str(),
894            Some("localhost") | Some("127.0.0.1") | Some("::1")
895        )
896    })
897}
898
899fn env_nonempty(name: &str) -> Option<String> {
900    std::env::var(name)
901        .ok()
902        .map(|value| value.trim().to_string())
903        .filter(|value| !value.is_empty())
904}
905
906pub fn artifact() -> ProviderCatalogArtifact {
907    let config = llm_config::effective_config();
908    artifact_from_config(&config, CatalogCapabilityOverrides::CurrentThread)
909}
910
911/// Build a catalog artifact for a runtime that has captured explicit provider
912/// and capability overlays. `None` means no override for that layer; unlike
913/// `artifact()`, this does not read thread-local user overrides implicitly.
914pub fn artifact_with_overrides(
915    llm_config_overrides: Option<&llm_config::ProvidersConfig>,
916    llm_capability_overrides: Option<&llm::capabilities::CapabilitiesFile>,
917) -> ProviderCatalogArtifact {
918    let config = llm_config::effective_config_with_user_overrides(llm_config_overrides);
919    artifact_from_config(
920        &config,
921        CatalogCapabilityOverrides::Explicit(llm_capability_overrides),
922    )
923}
924
925#[derive(Clone, Copy)]
926enum CatalogCapabilityOverrides<'a> {
927    CurrentThread,
928    Explicit(Option<&'a llm::capabilities::CapabilitiesFile>),
929}
930
931fn artifact_from_config(
932    config: &llm_config::ProvidersConfig,
933    llm_capability_overrides: CatalogCapabilityOverrides<'_>,
934) -> ProviderCatalogArtifact {
935    let alias_entries = config
936        .aliases
937        .iter()
938        .map(|(name, alias)| (name.clone(), alias.clone()))
939        .collect::<Vec<_>>();
940    let aliases_by_model = aliases_by_model(&alias_entries);
941    let providers = config
942        .providers
943        .iter()
944        .map(|(id, provider)| catalog_provider(id.clone(), provider.clone()))
945        .collect();
946    let models = llm_config::sorted_model_entries_with_config(config)
947        .into_iter()
948        .map(|(id, model)| {
949            catalog_model(
950                id,
951                model,
952                &aliases_by_model,
953                config,
954                llm_capability_overrides,
955            )
956        })
957        .collect::<Vec<_>>();
958    let aliases = alias_entries
959        .iter()
960        .map(|(name, alias)| {
961            catalog_alias(name, alias, config.alias_tool_calling.get(name).cloned())
962        })
963        .collect::<Vec<_>>();
964    let variants = catalog_variants(&models, &aliases);
965
966    ProviderCatalogArtifact {
967        schema_version: PROVIDER_CATALOG_SCHEMA_VERSION,
968        schema: PROVIDER_CATALOG_SCHEMA_ID.to_string(),
969        generated_by: PROVIDER_CATALOG_GENERATOR.to_string(),
970        providers,
971        models,
972        aliases,
973        variants,
974        qc_defaults: config.qc_defaults.clone(),
975    }
976}
977
978pub fn artifact_json() -> Result<String, serde_json::Error> {
979    serde_json::to_string_pretty(&artifact()).map(|mut text| {
980        text.push('\n');
981        text
982    })
983}
984
985pub fn schema_json() -> Result<String, serde_json::Error> {
986    serde_json::to_string_pretty(&schema_value()).map(|mut text| {
987        text.push('\n');
988        text
989    })
990}
991
992pub fn typescript_binding() -> Result<String, serde_json::Error> {
993    let json = artifact_json()?;
994    Ok(format!(
995        "{}{}{}{}{}",
996        generated_header("//", "typescript"),
997        TYPESCRIPT_TYPES,
998        "\nexport const harnProviderCatalog: HarnProviderCatalog = ",
999        json.trim_end(),
1000        ";\n",
1001    ) + TYPESCRIPT_COMPAT_EXPORTS)
1002}
1003
1004pub fn swift_binding() -> Result<String, serde_json::Error> {
1005    let json = artifact_json()?;
1006    Ok(format!(
1007        "{}{}\npublic let harnProviderCatalogJSON = #\"\"\"\n{}\"\"\"#\n",
1008        generated_header("//", "swift"),
1009        SWIFT_TYPES,
1010        json
1011    ))
1012}
1013
1014pub fn validate_artifact(artifact: &ProviderCatalogArtifact) -> ProviderCatalogValidation {
1015    let mut result = ProviderCatalogValidation::default();
1016    if artifact.schema_version != PROVIDER_CATALOG_SCHEMA_VERSION {
1017        result.errors.push(format!(
1018            "schema_version must be {}, got {}",
1019            PROVIDER_CATALOG_SCHEMA_VERSION, artifact.schema_version
1020        ));
1021    }
1022    if artifact.providers.is_empty() {
1023        result.errors.push("catalog has no providers".to_string());
1024    }
1025    if artifact.models.is_empty() {
1026        result.errors.push("catalog has no models".to_string());
1027    }
1028
1029    let provider_ids: BTreeSet<_> = artifact.providers.iter().map(|p| p.id.as_str()).collect();
1030    for provider in &artifact.providers {
1031        if provider.id.trim().is_empty() {
1032            result
1033                .errors
1034                .push("provider id cannot be empty".to_string());
1035        }
1036        if provider.display_name.trim().is_empty() {
1037            result.errors.push(format!(
1038                "provider {} display_name cannot be empty",
1039                provider.id
1040            ));
1041        }
1042        if provider.endpoint.chat_endpoint.trim().is_empty() {
1043            result.errors.push(format!(
1044                "provider {} chat_endpoint cannot be empty",
1045                provider.id
1046            ));
1047        }
1048        if provider.auth.required
1049            && provider.auth.env.is_empty()
1050            && provider.auth.style != "aws_sigv4"
1051        {
1052            result.errors.push(format!(
1053                "provider {} requires auth but declares no auth env keys",
1054                provider.id
1055            ));
1056        }
1057    }
1058
1059    let mut alias_names = BTreeSet::new();
1060    for alias in &artifact.aliases {
1061        if alias.name.trim().is_empty() {
1062            result.errors.push("alias name cannot be empty".to_string());
1063        }
1064        if !alias_names.insert(alias.name.as_str()) {
1065            result
1066                .errors
1067                .push(format!("duplicate alias name {}", alias.name));
1068        }
1069        if !provider_ids.contains(alias.provider.as_str()) {
1070            result.errors.push(format!(
1071                "alias {} references unknown provider {}",
1072                alias.name, alias.provider
1073            ));
1074        }
1075    }
1076
1077    let mut model_ids = BTreeSet::new();
1078    let mut model_pairs = BTreeSet::new();
1079    for model in &artifact.models {
1080        if !model_ids.insert(model.id.as_str()) {
1081            result
1082                .errors
1083                .push(format!("duplicate model id {}", model.id));
1084        }
1085        model_pairs.insert((model.provider.as_str(), model.id.as_str()));
1086        if model.name.trim().is_empty() {
1087            result
1088                .errors
1089                .push(format!("model {} name cannot be empty", model.id));
1090        }
1091        if !provider_ids.contains(model.provider.as_str()) {
1092            result.errors.push(format!(
1093                "model {} references unknown provider {}",
1094                model.id, model.provider
1095            ));
1096        }
1097        validate_token_field(model, "family", &model.family, &mut result);
1098        validate_token_field(model, "lineage", &model.lineage, &mut result);
1099        for family in &model.complementary_with {
1100            validate_token_field(model, "complementary_with", family, &mut result);
1101        }
1102        for selector in &model.avoid_as_reviewer_for {
1103            validate_reviewer_selector(model, selector, &mut result);
1104        }
1105        if model.context_window == 0 {
1106            result.errors.push(format!(
1107                "model {} context_window must be positive",
1108                model.id
1109            ));
1110        }
1111        if let Some(pricing) = &model.pricing {
1112            validate_pricing(model, pricing, &mut result);
1113        }
1114        if model.deprecation.status == DeprecationStatus::Deprecated
1115            && model
1116                .deprecation
1117                .note
1118                .as_deref()
1119                .unwrap_or("")
1120                .trim()
1121                .is_empty()
1122        {
1123            result.errors.push(format!(
1124                "deprecated model {} must include deprecation.note",
1125                model.id
1126            ));
1127        }
1128        if let Some(fast) = &model.fast_mode {
1129            if let Some(pricing) = &fast.pricing {
1130                validate_pricing(model, pricing, &mut result);
1131            }
1132            if let Some(status) = fast.status.as_deref() {
1133                if !matches!(status, "ga" | "research_preview" | "deprecated") {
1134                    result.warnings.push(format!(
1135                        "model {} fast_mode.status {:?} is not one of ga|research_preview|deprecated",
1136                        model.id, status
1137                    ));
1138                }
1139            }
1140        }
1141    }
1142
1143    // Structured supersession pointers must reference a real catalog row so
1144    // `superseded_by` can be trusted as a migration target by downstream
1145    // tooling. A dangling pointer is a soft warning (the row is still
1146    // usable) rather than a hard error, mirroring how `note` is advisory.
1147    for model in &artifact.models {
1148        if let Some(target) = model.deprecation.superseded_by.as_deref() {
1149            if !model_ids.contains(target) {
1150                result.warnings.push(format!(
1151                    "model {} declares superseded_by {} with no matching catalog row",
1152                    model.id, target
1153                ));
1154            }
1155        }
1156    }
1157
1158    let dedicated_pairs: BTreeSet<(&str, &str)> = artifact
1159        .models
1160        .iter()
1161        .filter(|model| model.availability == ModelAvailabilityStatus::Dedicated)
1162        .map(|model| (model.provider.as_str(), model.id.as_str()))
1163        .collect();
1164    for alias in &artifact.aliases {
1165        if !model_pairs.contains(&(alias.provider.as_str(), alias.model_id.as_str())) {
1166            result.errors.push(format!(
1167                "alias {} targets {}/{} without a catalog row",
1168                alias.name, alias.provider, alias.model_id
1169            ));
1170        }
1171        if is_tier_alias(&alias.name)
1172            && dedicated_pairs.contains(&(alias.provider.as_str(), alias.model_id.as_str()))
1173        {
1174            result.warnings.push(format!(
1175                "tier alias {} targets dedicated-only model {}/{}; serverless callers will fail until the dedicated endpoint is provisioned",
1176                alias.name, alias.provider, alias.model_id
1177            ));
1178        }
1179    }
1180
1181    for variant in &artifact.variants {
1182        if variant.id.trim().is_empty() {
1183            result.errors.push("variant id cannot be empty".to_string());
1184        }
1185        if !provider_ids.contains(variant.provider.as_str()) {
1186            result.errors.push(format!(
1187                "variant {} references unknown provider {}",
1188                variant.id, variant.provider
1189            ));
1190        }
1191        if !model_pairs.contains(&(variant.provider.as_str(), variant.model_id.as_str())) {
1192            result.errors.push(format!(
1193                "variant {} targets {}/{} without a catalog row",
1194                variant.id, variant.provider, variant.model_id
1195            ));
1196        }
1197    }
1198
1199    result
1200}
1201
1202pub fn validate_current() -> ProviderCatalogValidation {
1203    validate_artifact(&artifact())
1204}
1205
1206pub fn schema_value() -> Value {
1207    json!({
1208        "$schema": "https://json-schema.org/draft/2020-12/schema",
1209        "$id": PROVIDER_CATALOG_SCHEMA_ID,
1210        "title": "Harn provider catalog",
1211        "type": "object",
1212        "required": ["schema_version", "schema", "generated_by", "providers", "models", "aliases", "variants", "qc_defaults"],
1213        "properties": {
1214            "schema_version": {"const": PROVIDER_CATALOG_SCHEMA_VERSION},
1215            "schema": {"const": PROVIDER_CATALOG_SCHEMA_ID},
1216            "generated_by": {"type": "string"},
1217            "providers": {"type": "array", "items": {"$ref": "#/$defs/provider"}},
1218            "models": {"type": "array", "items": {"$ref": "#/$defs/model"}},
1219            "aliases": {"type": "array", "items": {"$ref": "#/$defs/alias"}},
1220            "variants": {"type": "array", "items": {"$ref": "#/$defs/variant"}},
1221            "qc_defaults": {"type": "object", "additionalProperties": {"type": "string"}}
1222        },
1223        "additionalProperties": false,
1224        "$defs": {
1225            "provider": {
1226                "type": "object",
1227                "required": ["id", "display_name", "classification", "endpoint", "auth", "protocols", "features", "caveats"],
1228                "properties": {
1229                    "id": {"type": "string", "minLength": 1},
1230                    "display_name": {"type": "string", "minLength": 1},
1231                    "icon": {"type": "string"},
1232                    "classification": {"enum": ["hosted", "local"]},
1233                    "endpoint": {"$ref": "#/$defs/endpoint"},
1234                    "auth": {"$ref": "#/$defs/auth"},
1235                    "protocols": {"type": "array", "items": {"type": "string"}},
1236                    "features": {"type": "array", "items": {"type": "string"}},
1237                    "caveats": {"type": "array", "items": {"type": "string"}},
1238                    "rpm": {"type": "integer", "minimum": 1},
1239                    "latency_p50_ms": {"type": "integer", "minimum": 0}
1240                },
1241                "additionalProperties": false
1242            },
1243            "endpoint": {
1244                "type": "object",
1245                "required": ["base_url", "chat_endpoint"],
1246                "properties": {
1247                    "base_url": {"type": "string"},
1248                    "base_url_env": {"type": "string"},
1249                    "chat_endpoint": {"type": "string", "minLength": 1},
1250                    "completion_endpoint": {"type": "string"}
1251                },
1252                "additionalProperties": false
1253            },
1254            "auth": {
1255                "type": "object",
1256                "required": ["style", "env", "required"],
1257                "properties": {
1258                    "style": {"type": "string"},
1259                    "header": {"type": "string"},
1260                    "env": {"type": "array", "items": {"type": "string"}},
1261                    "required": {"type": "boolean"}
1262                },
1263                "additionalProperties": false
1264            },
1265            "alias": {
1266                "type": "object",
1267                "required": ["name", "model_id", "provider"],
1268                "properties": {
1269                    "name": {"type": "string", "minLength": 1},
1270                    "model_id": {"type": "string", "minLength": 1},
1271                    "provider": {"type": "string", "minLength": 1},
1272                    "tool_format": {"type": "string"},
1273                    "tool_calling": {
1274                        "type": "object",
1275                        "properties": {
1276                            "native": {"type": "string"},
1277                            "text": {"type": "string"},
1278                            "streaming_native": {"type": "string"},
1279                            "fallback_mode": {"type": "string"},
1280                            "failure_reason": {"type": "string"},
1281                            "last_probe_at": {"type": "string"}
1282                        },
1283                        "additionalProperties": false
1284                    }
1285                },
1286                "additionalProperties": false
1287            },
1288            "model": {
1289                "type": "object",
1290                "required": [
1291                    "id",
1292                    "name",
1293                    "provider",
1294                    "aliases",
1295                    "context_window",
1296                    "modalities",
1297                    "tool_support",
1298                    "structured_output",
1299                    "format_preferences",
1300                    "reasoning",
1301                    "prompt_cache",
1302                    "deprecation",
1303                    "availability",
1304                    "quality_tags",
1305                    "capability_tags",
1306                    "family",
1307                    "lineage",
1308                    "tier"
1309                ],
1310                "properties": {
1311                    "id": {"type": "string", "minLength": 1},
1312                    "name": {"type": "string", "minLength": 1},
1313                    "provider": {"type": "string", "minLength": 1},
1314                    "aliases": {"type": "array", "items": {"type": "string"}},
1315                    "context_window": {"type": "integer", "minimum": 1},
1316                    "runtime_context_window": {"type": "integer", "minimum": 1},
1317                    "stream_timeout": {"type": "number", "exclusiveMinimum": 0},
1318                    "modalities": {"$ref": "#/$defs/modalities"},
1319                    "tool_support": {"$ref": "#/$defs/tool_support"},
1320                    "structured_output": {"type": "string"},
1321                    "format_preferences": {"$ref": "#/$defs/format_preferences"},
1322                    "reasoning": {"$ref": "#/$defs/reasoning"},
1323                    "prompt_cache": {"type": "boolean"},
1324                    "pricing": {"$ref": "#/$defs/pricing"},
1325                    "deprecation": {"$ref": "#/$defs/deprecation"},
1326                    "availability": {"enum": ["serverless", "dedicated", "unknown"]},
1327                    "quality_tags": {"type": "array", "items": {"type": "string"}},
1328                    "capability_tags": {"type": "array", "items": {"type": "string"}},
1329                    "family": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"},
1330                    "lineage": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"},
1331                    "complementary_with": {"type": "array", "items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"}},
1332                    "avoid_as_reviewer_for": {"type": "array", "items": {"type": "string", "minLength": 1}},
1333                    "tier": {"enum": ["small", "mid", "frontier", "reasoning"]},
1334                    "open_weight": {"type": "boolean"},
1335                    "strengths": {"type": "array", "items": {"type": "string"}},
1336                    "benchmarks": {"type": "object", "additionalProperties": {"type": "number"}},
1337                    "fast_mode": {"$ref": "#/$defs/fast_mode"}
1338                },
1339                "additionalProperties": false
1340            },
1341            "modalities": {
1342                "type": "object",
1343                "required": ["input", "output"],
1344                "properties": {
1345                    "input": {"type": "array", "items": {"type": "string"}, "minItems": 1},
1346                    "output": {"type": "array", "items": {"type": "string"}, "minItems": 1}
1347                },
1348                "additionalProperties": false
1349            },
1350            "tool_support": {
1351                "type": "object",
1352                "required": ["native", "text", "tool_search"],
1353                "properties": {
1354                    "native": {"type": "boolean"},
1355                    "text": {"type": "boolean"},
1356                    "preferred_format": {"type": "string"},
1357                    "parity": {"type": "string"},
1358                    "parity_notes": {"type": "string"},
1359                    "empirical_parity": {"$ref": "#/$defs/tool_empirical_parity"},
1360                    "tool_search": {"type": "array", "items": {"type": "string"}},
1361                    "max_tools": {"type": "integer", "minimum": 1}
1362                },
1363                "additionalProperties": false
1364            },
1365            "tool_empirical_parity": {
1366                "type": "object",
1367                "required": [
1368                    "verdict",
1369                    "preferred_format",
1370                    "confidence",
1371                    "sample_size",
1372                    "last_evaluated",
1373                    "native_pass_rate",
1374                    "text_pass_rate",
1375                    "verifier_divergence_rate"
1376                ],
1377                "properties": {
1378                    "verdict": {"type": "string"},
1379                    "preferred_format": {"type": "string"},
1380                    "confidence": {"type": "string"},
1381                    "sample_size": {"type": "integer", "minimum": 1},
1382                    "last_evaluated": {"type": "string", "minLength": 1},
1383                    "native_pass_rate": {"type": "number", "minimum": 0, "maximum": 1},
1384                    "text_pass_rate": {"type": "number", "minimum": 0, "maximum": 1},
1385                    "verifier_divergence_rate": {"type": "number", "minimum": 0, "maximum": 1}
1386                },
1387                "additionalProperties": false
1388            },
1389            "format_preferences": {
1390                "type": "object",
1391                "required": [
1392                    "prefers_xml_scaffolding",
1393                    "prefers_markdown_scaffolding",
1394                    "structured_output_mode",
1395                    "supports_assistant_prefill",
1396                    "prefers_role_developer",
1397                    "prefers_xml_tools",
1398                    "thinking_block_style"
1399                ],
1400                "properties": {
1401                    "prefers_xml_scaffolding": {"type": "boolean"},
1402                    "prefers_markdown_scaffolding": {"type": "boolean"},
1403                    "structured_output_mode": {"enum": ["native_json", "delimited", "xml_tagged", "none"]},
1404                    "supports_assistant_prefill": {"type": "boolean"},
1405                    "prefers_role_developer": {"type": "boolean"},
1406                    "prefers_xml_tools": {"type": "boolean"},
1407                    "thinking_block_style": {"enum": ["none", "thinking_blocks", "reasoning_summary", "inline"]}
1408                },
1409                "additionalProperties": false
1410            },
1411            "reasoning": {
1412                "type": "object",
1413                "required": ["modes", "effort_supported", "none_supported", "interleaved_supported", "preserve_thinking"],
1414                "properties": {
1415                    "modes": {"type": "array", "items": {"type": "string"}},
1416                    "effort_supported": {"type": "boolean"},
1417                    "none_supported": {"type": "boolean"},
1418                    "interleaved_supported": {"type": "boolean"},
1419                    "preserve_thinking": {"type": "boolean"}
1420                },
1421                "additionalProperties": false
1422            },
1423            "pricing": {
1424                "type": "object",
1425                "required": ["input_per_mtok", "output_per_mtok"],
1426                "properties": {
1427                    "input_per_mtok": {"type": "number", "minimum": 0},
1428                    "output_per_mtok": {"type": "number", "minimum": 0},
1429                    "cache_read_per_mtok": {"type": ["number", "null"], "minimum": 0},
1430                    "cache_write_per_mtok": {"type": ["number", "null"], "minimum": 0}
1431                },
1432                "additionalProperties": false
1433            },
1434            "fast_mode": {
1435                "type": "object",
1436                "required": ["param", "value"],
1437                "properties": {
1438                    "param": {"type": "string", "minLength": 1},
1439                    "value": {"type": "string", "minLength": 1},
1440                    "beta_header": {"type": "string"},
1441                    "otps_speedup": {"type": "number", "exclusiveMinimum": 0},
1442                    "status": {"type": "string"},
1443                    "pricing": {"$ref": "#/$defs/pricing"},
1444                    "note": {"type": "string"}
1445                },
1446                "additionalProperties": false
1447            },
1448            "deprecation": {
1449                "type": "object",
1450                "required": ["status"],
1451                "properties": {
1452                    "status": {"enum": ["active", "deprecated"]},
1453                    "note": {"type": "string"},
1454                    "superseded_by": {"type": "string"}
1455                },
1456                "additionalProperties": false
1457            },
1458            "variant": {
1459                "type": "object",
1460                "required": ["id", "label", "description", "model_id", "provider", "source"],
1461                "properties": {
1462                    "id": {"type": "string", "minLength": 1},
1463                    "label": {"type": "string", "minLength": 1},
1464                    "description": {"type": "string"},
1465                    "model_id": {"type": "string", "minLength": 1},
1466                    "provider": {"type": "string", "minLength": 1},
1467                    "source": {"type": "string", "minLength": 1}
1468                },
1469                "additionalProperties": false
1470            }
1471        }
1472    })
1473}
1474
1475fn catalog_provider(id: String, provider: ProviderDef) -> CatalogProvider {
1476    CatalogProvider {
1477        display_name: provider
1478            .display_name
1479            .clone()
1480            .unwrap_or_else(|| title_case(&id)),
1481        icon: provider.icon.clone(),
1482        classification: provider_classification(&provider),
1483        endpoint: ProviderEndpoint {
1484            base_url: provider.base_url.clone(),
1485            base_url_env: provider.base_url_env.clone(),
1486            chat_endpoint: provider.chat_endpoint.clone(),
1487            completion_endpoint: provider.completion_endpoint.clone(),
1488        },
1489        auth: ProviderAuth {
1490            style: provider.auth_style.clone(),
1491            header: provider.auth_header.clone(),
1492            env: llm_config::auth_env_names(&provider.auth_env),
1493            required: provider.auth_style != "none",
1494        },
1495        protocols: provider_protocols(&id, &provider),
1496        features: provider.features.clone(),
1497        caveats: provider_caveats(&id, &provider),
1498        rpm: provider.rpm,
1499        latency_p50_ms: provider.latency_p50_ms,
1500        id,
1501    }
1502}
1503
1504fn catalog_alias(
1505    name: &str,
1506    alias: &AliasDef,
1507    tool_calling: Option<AliasToolCallingDef>,
1508) -> CatalogAlias {
1509    CatalogAlias {
1510        name: name.to_string(),
1511        model_id: alias.id.clone(),
1512        provider: alias.provider.clone(),
1513        tool_format: alias.tool_format.clone(),
1514        tool_calling,
1515    }
1516}
1517
1518fn catalog_model(
1519    id: String,
1520    model: ModelDef,
1521    aliases_by_model: &BTreeMap<(String, String), Vec<String>>,
1522    config: &llm_config::ProvidersConfig,
1523    llm_capability_overrides: CatalogCapabilityOverrides<'_>,
1524) -> CatalogModel {
1525    let caps = match llm_capability_overrides {
1526        CatalogCapabilityOverrides::CurrentThread => {
1527            llm::capabilities::lookup(&model.provider, &id)
1528        }
1529        CatalogCapabilityOverrides::Explicit(overrides) => {
1530            llm::capabilities::lookup_with_user_overrides(&model.provider, &id, overrides)
1531        }
1532    };
1533    let structured_output = caps
1534        .structured_output
1535        .clone()
1536        .or_else(|| caps.json_schema.clone())
1537        .unwrap_or_else(|| "none".to_string());
1538    let aliases = aliases_by_model
1539        .get(&(model.provider.clone(), id.clone()))
1540        .cloned()
1541        .unwrap_or_default();
1542    let quality_tags = model_quality_tags(&model, &aliases);
1543    let capability_tags = llm_config::capability_tags_from_capabilities(&caps);
1544    CatalogModel {
1545        aliases,
1546        modalities: modalities_from_caps(&caps),
1547        tool_support: ModelToolSupport {
1548            native: caps.native_tools,
1549            text: caps.text_tool_wire_format_supported,
1550            preferred_format: caps.preferred_tool_format.clone(),
1551            parity: caps.tool_mode_parity.clone(),
1552            parity_notes: caps.tool_mode_parity_notes.clone(),
1553            empirical_parity: None,
1554            tool_search: caps.tool_search.clone(),
1555            max_tools: caps.max_tools,
1556        },
1557        structured_output,
1558        format_preferences: ModelFormatPreferences {
1559            prefers_xml_scaffolding: caps.prefers_xml_scaffolding,
1560            prefers_markdown_scaffolding: caps.prefers_markdown_scaffolding,
1561            structured_output_mode: caps.structured_output_mode.clone(),
1562            supports_assistant_prefill: caps.supports_assistant_prefill,
1563            prefers_role_developer: caps.prefers_role_developer,
1564            prefers_xml_tools: caps.prefers_xml_tools,
1565            thinking_block_style: caps.thinking_block_style.clone(),
1566        },
1567        reasoning: ModelReasoning {
1568            modes: caps.thinking_modes.clone(),
1569            effort_supported: caps.reasoning_effort_supported,
1570            none_supported: caps.reasoning_none_supported,
1571            interleaved_supported: caps.interleaved_thinking_supported,
1572            preserve_thinking: caps.preserve_thinking,
1573        },
1574        prompt_cache: caps.prompt_caching,
1575        pricing: model.pricing.clone(),
1576        deprecation: ModelDeprecation {
1577            status: if model.deprecated {
1578                DeprecationStatus::Deprecated
1579            } else {
1580                DeprecationStatus::Active
1581            },
1582            note: model.deprecation_note.clone(),
1583            superseded_by: model.superseded_by.clone(),
1584        },
1585        availability: ModelAvailabilityStatus::from(model.availability),
1586        quality_tags,
1587        capability_tags,
1588        family: llm_config::model_family_with_config(config, &model.provider, &id),
1589        lineage: llm_config::model_lineage_with_config(config, &model.provider, &id),
1590        complementary_with: model.complementary_with.clone(),
1591        avoid_as_reviewer_for: model.avoid_as_reviewer_for.clone(),
1592        tier: llm_config::model_tier_with_config(config, &id),
1593        open_weight: model.open_weight,
1594        strengths: model.strengths.clone(),
1595        benchmarks: model.benchmarks.clone(),
1596        fast_mode: model.fast_mode.as_ref().map(|fm| ModelFastMode {
1597            param: fm.param.clone(),
1598            value: fm.value.clone(),
1599            beta_header: fm.beta_header.clone(),
1600            otps_speedup: fm.otps_speedup,
1601            status: fm.status.clone(),
1602            pricing: fm.pricing.clone(),
1603            note: fm.note.clone(),
1604        }),
1605        id,
1606        name: model.name,
1607        provider: model.provider,
1608        context_window: model.context_window,
1609        runtime_context_window: model.runtime_context_window,
1610        stream_timeout: model.stream_timeout,
1611    }
1612}
1613
1614fn model_quality_tags(model: &ModelDef, aliases: &[String]) -> Vec<String> {
1615    let mut tags: BTreeSet<String> = model.quality_tags.iter().cloned().collect();
1616    for alias in aliases {
1617        match alias.as_str() {
1618            "frontier" | "tier/frontier" => {
1619                tags.insert("frontier".to_string());
1620            }
1621            "mid" | "tier/mid" => {
1622                tags.insert("balanced".to_string());
1623            }
1624            "small" | "tier/small" => {
1625                tags.insert("small".to_string());
1626            }
1627            _ => {}
1628        }
1629    }
1630    if is_local_provider(&model.provider) {
1631        tags.insert("local".to_string());
1632    }
1633    tags.into_iter().collect()
1634}
1635
1636fn aliases_by_model(aliases: &[(String, AliasDef)]) -> BTreeMap<(String, String), Vec<String>> {
1637    let mut by_model: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
1638    for (name, alias) in aliases {
1639        by_model
1640            .entry((alias.provider.clone(), alias.id.clone()))
1641            .or_default()
1642            .push(name.clone());
1643    }
1644    for names in by_model.values_mut() {
1645        names.sort();
1646    }
1647    by_model
1648}
1649
1650fn modalities_from_caps(caps: &llm::capabilities::Capabilities) -> ModelModalities {
1651    let mut input = vec!["text".to_string()];
1652    if caps.vision || caps.vision_supported {
1653        input.push("image".to_string());
1654    }
1655    if caps.audio {
1656        input.push("audio".to_string());
1657    }
1658    if caps.pdf {
1659        input.push("pdf".to_string());
1660    }
1661    if caps.video {
1662        input.push("video".to_string());
1663    }
1664    ModelModalities {
1665        input,
1666        output: vec!["text".to_string()],
1667    }
1668}
1669
1670fn catalog_variants(models: &[CatalogModel], aliases: &[CatalogAlias]) -> Vec<CatalogVariant> {
1671    let mut variants = Vec::new();
1672    for (id, label, description, alias_name) in [
1673        (
1674            "fast",
1675            "Fast",
1676            "Lowest-latency general coding-agent route.",
1677            "small",
1678        ),
1679        (
1680            "balanced",
1681            "Balanced",
1682            "Default cost/quality tradeoff for routine coding-agent work.",
1683            "mid",
1684        ),
1685        (
1686            "high-reasoning",
1687            "High reasoning",
1688            "Frontier route for hard planning, repair, and review tasks.",
1689            "frontier",
1690        ),
1691    ] {
1692        if let Some(alias) = aliases.iter().find(|alias| alias.name == alias_name) {
1693            variants.push(CatalogVariant {
1694                id: id.to_string(),
1695                label: label.to_string(),
1696                description: description.to_string(),
1697                model_id: alias.model_id.clone(),
1698                provider: alias.provider.clone(),
1699                source: format!("alias:{alias_name}"),
1700            });
1701        }
1702    }
1703    push_variant_from_model(
1704        &mut variants,
1705        "local",
1706        "Local",
1707        "Best local/offline model route in the checked-in catalog.",
1708        models
1709            .iter()
1710            .filter(|model| is_local_provider(&model.provider))
1711            .max_by_key(|model| model.context_window),
1712    );
1713    push_variant_from_model(
1714        &mut variants,
1715        "cheap",
1716        "Cheap",
1717        "Lowest known hosted input+output token price.",
1718        models
1719            .iter()
1720            .filter(|model| !is_local_provider(&model.provider))
1721            .min_by(|left, right| {
1722                pricing_total(left)
1723                    .partial_cmp(&pricing_total(right))
1724                    .unwrap_or(std::cmp::Ordering::Equal)
1725            }),
1726    );
1727    push_variant_from_model(
1728        &mut variants,
1729        "vision-capable",
1730        "Vision capable",
1731        "A model route that accepts image input.",
1732        models
1733            .iter()
1734            .filter(|model| model.modalities.input.iter().any(|mode| mode == "image"))
1735            .max_by_key(|model| model.context_window),
1736    );
1737    push_variant_from_model(
1738        &mut variants,
1739        "long-context",
1740        "Long context",
1741        "Largest context-window route in the checked-in catalog.",
1742        models.iter().max_by_key(|model| model.context_window),
1743    );
1744    variants
1745}
1746
1747fn push_variant_from_model(
1748    variants: &mut Vec<CatalogVariant>,
1749    id: &str,
1750    label: &str,
1751    description: &str,
1752    model: Option<&CatalogModel>,
1753) {
1754    if let Some(model) = model {
1755        variants.push(CatalogVariant {
1756            id: id.to_string(),
1757            label: label.to_string(),
1758            description: description.to_string(),
1759            model_id: model.id.clone(),
1760            provider: model.provider.clone(),
1761            source: "catalog".to_string(),
1762        });
1763    }
1764}
1765
1766fn pricing_total(model: &CatalogModel) -> f64 {
1767    model
1768        .pricing
1769        .as_ref()
1770        .map(|pricing| pricing.input_per_mtok + pricing.output_per_mtok)
1771        .unwrap_or(f64::MAX)
1772}
1773
1774fn validate_pricing(
1775    model: &CatalogModel,
1776    pricing: &ModelPricing,
1777    result: &mut ProviderCatalogValidation,
1778) {
1779    for (field, value) in [
1780        ("input_per_mtok", Some(pricing.input_per_mtok)),
1781        ("output_per_mtok", Some(pricing.output_per_mtok)),
1782        ("cache_read_per_mtok", pricing.cache_read_per_mtok),
1783        ("cache_write_per_mtok", pricing.cache_write_per_mtok),
1784    ] {
1785        if value.is_some_and(|value| value < 0.0) {
1786            result.errors.push(format!(
1787                "model {} pricing.{} must be non-negative",
1788                model.id, field
1789            ));
1790        }
1791    }
1792}
1793
1794fn validate_token_field(
1795    model: &CatalogModel,
1796    field: &str,
1797    value: &str,
1798    result: &mut ProviderCatalogValidation,
1799) {
1800    if !is_catalog_token(value) {
1801        result.errors.push(format!(
1802            "model {} {field} must be a lowercase catalog token, got {:?}",
1803            model.id, value
1804        ));
1805    }
1806}
1807
1808fn validate_reviewer_selector(
1809    model: &CatalogModel,
1810    value: &str,
1811    result: &mut ProviderCatalogValidation,
1812) {
1813    if value.trim().is_empty() {
1814        result.errors.push(format!(
1815            "model {} avoid_as_reviewer_for cannot contain an empty selector",
1816            model.id
1817        ));
1818    }
1819}
1820
1821fn is_catalog_token(value: &str) -> bool {
1822    let mut chars = value.chars();
1823    let Some(first) = chars.next() else {
1824        return false;
1825    };
1826    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
1827        return false;
1828    }
1829    chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1830}
1831
1832fn provider_classification(provider: &ProviderDef) -> ProviderClassification {
1833    if provider.auth_style == "none"
1834        || provider.base_url.contains("localhost")
1835        || provider.base_url.contains("127.0.0.1")
1836    {
1837        ProviderClassification::Local
1838    } else {
1839        ProviderClassification::Hosted
1840    }
1841}
1842
1843fn provider_protocols(id: &str, provider: &ProviderDef) -> Vec<String> {
1844    match id {
1845        "anthropic" => vec!["anthropic_messages".to_string()],
1846        "gemini" => vec!["gemini_generate_content".to_string()],
1847        "vertex" => vec!["vertex_generate_content".to_string()],
1848        "bedrock" => vec!["bedrock_converse".to_string()],
1849        "azure_openai" => vec!["azure_openai_chat_completions".to_string()],
1850        "ollama" if provider.chat_endpoint.starts_with("/api/") => {
1851            vec!["ollama_native".to_string()]
1852        }
1853        _ => vec!["openai_chat_completions".to_string()],
1854    }
1855}
1856
1857fn provider_caveats(id: &str, provider: &ProviderDef) -> Vec<String> {
1858    let mut caveats = Vec::new();
1859    if provider.auth_style == "aws_sigv4" {
1860        caveats.push("Credentials are resolved through the AWS SDK chain.".to_string());
1861    }
1862    if id == "azure_openai" {
1863        caveats.push("The Harn model field names the Azure deployment.".to_string());
1864    }
1865    if id == "ollama" && provider.chat_endpoint == "/api/chat" {
1866        caveats.push(
1867            "Native Ollama chat returns NDJSON and can apply model-family parsers.".to_string(),
1868        );
1869    }
1870    caveats
1871}
1872
1873fn is_local_provider(provider: &str) -> bool {
1874    matches!(
1875        provider,
1876        "ollama" | "local" | "llamacpp" | "mlx" | "vllm" | "tgi"
1877    )
1878}
1879
1880fn is_tier_alias(name: &str) -> bool {
1881    matches!(
1882        name,
1883        "frontier"
1884            | "mid"
1885            | "small"
1886            | "tier/frontier"
1887            | "tier/mid"
1888            | "tier/small"
1889            | "sonnet"
1890            | "opus"
1891            | "haiku"
1892    )
1893}
1894
1895fn title_case(id: &str) -> String {
1896    id.split('_')
1897        .map(|part| {
1898            let mut chars = part.chars();
1899            match chars.next() {
1900                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1901                None => String::new(),
1902            }
1903        })
1904        .collect::<Vec<_>>()
1905        .join(" ")
1906}
1907
1908fn generated_header(comment: &str, language: &str) -> String {
1909    format!(
1910        "{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"
1911    )
1912}
1913
1914const TYPESCRIPT_TYPES: &str = r#"export interface HarnProviderCatalog {
1915  schema_version: 2
1916  schema: string
1917  generated_by: string
1918  providers: HarnCatalogProvider[]
1919  models: HarnCatalogModel[]
1920  aliases: HarnCatalogAlias[]
1921  variants: HarnCatalogVariant[]
1922  qc_defaults: Record<string, string>
1923}
1924
1925export interface HarnCatalogProvider {
1926  id: string
1927  display_name: string
1928  icon?: string
1929  classification: "hosted" | "local"
1930  endpoint: HarnProviderEndpoint
1931  auth: HarnProviderAuth
1932  protocols: string[]
1933  features: string[]
1934  caveats: string[]
1935  rpm?: number
1936  latency_p50_ms?: number
1937}
1938
1939export interface HarnProviderEndpoint {
1940  base_url: string
1941  base_url_env?: string
1942  chat_endpoint: string
1943  completion_endpoint?: string
1944}
1945
1946export interface HarnProviderAuth {
1947  style: string
1948  header?: string
1949  env: string[]
1950  required: boolean
1951}
1952
1953export interface HarnCatalogAlias {
1954  name: string
1955  model_id: string
1956  provider: string
1957  tool_format?: string
1958  tool_calling?: HarnAliasToolCalling
1959}
1960
1961export interface HarnAliasToolCalling {
1962  native?: string
1963  text?: string
1964  streaming_native?: string
1965  fallback_mode?: string
1966  failure_reason?: string
1967  last_probe_at?: string
1968}
1969
1970export interface HarnCatalogModel {
1971  id: string
1972  name: string
1973  provider: string
1974  aliases: string[]
1975  context_window: number
1976  runtime_context_window?: number
1977  stream_timeout?: number
1978  modalities: { input: string[]; output: string[] }
1979  tool_support: {
1980    native: boolean
1981    text: boolean
1982    preferred_format?: string
1983    parity?: string
1984    parity_notes?: string
1985    empirical_parity?: HarnToolEmpiricalParity
1986    tool_search: string[]
1987    max_tools?: number
1988  }
1989  structured_output: string
1990  format_preferences: {
1991    prefers_xml_scaffolding: boolean
1992    prefers_markdown_scaffolding: boolean
1993    structured_output_mode: "native_json" | "delimited" | "xml_tagged" | "none"
1994    supports_assistant_prefill: boolean
1995    prefers_role_developer: boolean
1996    prefers_xml_tools: boolean
1997    thinking_block_style: "none" | "thinking_blocks" | "reasoning_summary" | "inline"
1998  }
1999  reasoning: {
2000    modes: string[]
2001    effort_supported: boolean
2002    none_supported: boolean
2003    interleaved_supported: boolean
2004    preserve_thinking: boolean
2005  }
2006  prompt_cache: boolean
2007  pricing?: HarnModelPricing
2008  deprecation: { status: "active" | "deprecated"; note?: string; superseded_by?: string }
2009  availability: "serverless" | "dedicated" | "unknown"
2010  quality_tags: string[]
2011  capability_tags: string[]
2012  family: string
2013  lineage: string
2014  complementary_with?: string[]
2015  avoid_as_reviewer_for?: string[]
2016  tier: "small" | "mid" | "frontier" | "reasoning"
2017  open_weight?: boolean
2018  strengths?: string[]
2019  benchmarks?: Record<string, number>
2020  fast_mode?: HarnModelFastMode
2021}
2022
2023export interface HarnToolEmpiricalParity {
2024  verdict: string
2025  preferred_format: string
2026  confidence: string
2027  sample_size: number
2028  last_evaluated: string
2029  native_pass_rate: number
2030  text_pass_rate: number
2031  verifier_divergence_rate: number
2032}
2033
2034export interface HarnModelPricing {
2035  input_per_mtok: number
2036  output_per_mtok: number
2037  cache_read_per_mtok?: number | null
2038  cache_write_per_mtok?: number | null
2039}
2040
2041export interface HarnModelFastMode {
2042  param: string
2043  value: string
2044  beta_header?: string
2045  otps_speedup?: number
2046  status?: string
2047  pricing?: HarnModelPricing
2048  note?: string
2049}
2050
2051export interface HarnCatalogVariant {
2052  id: string
2053  label: string
2054  description: string
2055  model_id: string
2056  provider: string
2057  source: string
2058}
2059
2060export interface CatalogEntry {
2061  id: string
2062  name: string
2063  provider: string
2064  contextWindow: number
2065  runtimeContextWindow?: number
2066  capabilities: string[]
2067  family: string
2068  lineage: string
2069  pricing?: {
2070    inputPerMTok: number
2071    outputPerMTok: number
2072    cacheReadPerMTok?: number | null
2073    cacheWritePerMTok?: number | null
2074  }
2075  streamTimeout?: number
2076}
2077
2078export interface CatalogAlias {
2079  alias: string
2080  id: string
2081  provider: string
2082  toolFormat?: string
2083  toolCalling?: HarnAliasToolCalling
2084}
2085
2086"#;
2087
2088const TYPESCRIPT_COMPAT_EXPORTS: &str = r#"
2089export const MODEL_CATALOG: readonly CatalogEntry[] = harnProviderCatalog.models.map((model) => ({
2090  id: model.id,
2091  name: model.name,
2092  provider: model.provider,
2093  contextWindow: model.context_window,
2094  runtimeContextWindow: model.runtime_context_window,
2095  capabilities: model.capability_tags,
2096  family: model.family,
2097  lineage: model.lineage,
2098  pricing: model.pricing
2099    ? {
2100        inputPerMTok: model.pricing.input_per_mtok,
2101        outputPerMTok: model.pricing.output_per_mtok,
2102        cacheReadPerMTok: model.pricing.cache_read_per_mtok,
2103        cacheWritePerMTok: model.pricing.cache_write_per_mtok,
2104      }
2105    : undefined,
2106  streamTimeout: model.stream_timeout,
2107}))
2108
2109export const ALIASES: readonly CatalogAlias[] = harnProviderCatalog.aliases.map((alias) => ({
2110  alias: alias.name,
2111  id: alias.model_id,
2112  provider: alias.provider,
2113  toolFormat: alias.tool_format,
2114  toolCalling: alias.tool_calling,
2115}))
2116
2117export const QC_DEFAULTS: Readonly<Record<string, string>> = harnProviderCatalog.qc_defaults
2118
2119export function pricingFor(modelId: string): CatalogEntry["pricing"] | undefined {
2120  return entryFor(modelId)?.pricing
2121}
2122
2123export function entryFor(modelId: string): CatalogEntry | undefined {
2124  return MODEL_CATALOG.find((entry) => entry.id === modelId)
2125}
2126
2127export function aliasesByProvider(provider: string): readonly CatalogAlias[] {
2128  return ALIASES.filter((alias) => alias.provider === provider)
2129}
2130
2131export function qcDefaultModel(provider: string): string | undefined {
2132  return QC_DEFAULTS[provider]
2133}
2134"#;
2135
2136const SWIFT_TYPES: &str = r#"public struct HarnProviderCatalog: Codable, Sendable, Equatable {
2137    public let schemaVersion: Int
2138    public let schema: String
2139    public let generatedBy: String
2140    public let providers: [HarnCatalogProvider]
2141    public let models: [HarnCatalogModel]
2142    public let aliases: [HarnCatalogAlias]
2143    public let variants: [HarnCatalogVariant]
2144    public let qcDefaults: [String: String]
2145
2146    enum CodingKeys: String, CodingKey {
2147        case schemaVersion = "schema_version"
2148        case schema
2149        case generatedBy = "generated_by"
2150        case providers
2151        case models
2152        case aliases
2153        case variants
2154        case qcDefaults = "qc_defaults"
2155    }
2156}
2157
2158public struct HarnCatalogProvider: Codable, Sendable, Equatable {
2159    public let id: String
2160    public let displayName: String
2161    public let icon: String?
2162    public let classification: String
2163    public let endpoint: HarnProviderEndpoint
2164    public let auth: HarnProviderAuth
2165    public let protocols: [String]
2166    public let features: [String]
2167    public let caveats: [String]
2168    public let rpm: Int?
2169    public let latencyP50Ms: Int?
2170
2171    enum CodingKeys: String, CodingKey {
2172        case id
2173        case displayName = "display_name"
2174        case icon
2175        case classification
2176        case endpoint
2177        case auth
2178        case protocols
2179        case features
2180        case caveats
2181        case rpm
2182        case latencyP50Ms = "latency_p50_ms"
2183    }
2184}
2185
2186public struct HarnProviderEndpoint: Codable, Sendable, Equatable {
2187    public let baseURL: String
2188    public let baseURLEnv: String?
2189    public let chatEndpoint: String
2190    public let completionEndpoint: String?
2191
2192    enum CodingKeys: String, CodingKey {
2193        case baseURL = "base_url"
2194        case baseURLEnv = "base_url_env"
2195        case chatEndpoint = "chat_endpoint"
2196        case completionEndpoint = "completion_endpoint"
2197    }
2198}
2199
2200public struct HarnProviderAuth: Codable, Sendable, Equatable {
2201    public let style: String
2202    public let header: String?
2203    public let env: [String]
2204    public let required: Bool
2205}
2206
2207public struct HarnCatalogAlias: Codable, Sendable, Equatable {
2208    public let name: String
2209    public let modelID: String
2210    public let provider: String
2211    public let toolFormat: String?
2212    public let toolCalling: HarnAliasToolCalling?
2213
2214    enum CodingKeys: String, CodingKey {
2215        case name
2216        case modelID = "model_id"
2217        case provider
2218        case toolFormat = "tool_format"
2219        case toolCalling = "tool_calling"
2220    }
2221}
2222
2223public struct HarnAliasToolCalling: Codable, Sendable, Equatable {
2224    public let native: String?
2225    public let text: String?
2226    public let streamingNative: String?
2227    public let fallbackMode: String?
2228    public let failureReason: String?
2229    public let lastProbeAt: String?
2230
2231    enum CodingKeys: String, CodingKey {
2232        case native
2233        case text
2234        case streamingNative = "streaming_native"
2235        case fallbackMode = "fallback_mode"
2236        case failureReason = "failure_reason"
2237        case lastProbeAt = "last_probe_at"
2238    }
2239}
2240
2241public struct HarnCatalogModel: Codable, Sendable, Equatable {
2242    public let id: String
2243    public let name: String
2244    public let provider: String
2245    public let aliases: [String]
2246    public let contextWindow: Int
2247    public let runtimeContextWindow: Int?
2248    public let streamTimeout: Double?
2249    public let modalities: HarnModelModalities
2250    public let toolSupport: HarnModelToolSupport
2251    public let structuredOutput: String
2252    public let formatPreferences: HarnModelFormatPreferences
2253    public let reasoning: HarnModelReasoning
2254    public let promptCache: Bool
2255    public let pricing: HarnModelPricing?
2256    public let deprecation: HarnModelDeprecation
2257    public let availability: String
2258    public let qualityTags: [String]
2259    public let capabilityTags: [String]
2260    public let family: String
2261    public let lineage: String
2262    public let complementaryWith: [String]
2263    public let avoidAsReviewerFor: [String]
2264    /// Popular-consensus tier label: "small" | "mid" | "frontier" | "reasoning".
2265    public let tier: String
2266    /// True when weights are downloadable / self-hostable; nil when the
2267    /// catalog row predates the field.
2268    public let openWeight: Bool?
2269    /// Workload-shaped strength tags (`coding`, `summarization`, `vision`, ...).
2270    public let strengths: [String]
2271    /// Public benchmark numbers keyed by `snake_case` identifier.
2272    public let benchmarks: [String: Double]
2273    /// Accelerated-serving ("fast mode") tier metadata, when offered.
2274    public let fastMode: HarnModelFastMode?
2275
2276    enum CodingKeys: String, CodingKey {
2277        case id
2278        case name
2279        case provider
2280        case aliases
2281        case contextWindow = "context_window"
2282        case runtimeContextWindow = "runtime_context_window"
2283        case streamTimeout = "stream_timeout"
2284        case modalities
2285        case toolSupport = "tool_support"
2286        case structuredOutput = "structured_output"
2287        case formatPreferences = "format_preferences"
2288        case reasoning
2289        case promptCache = "prompt_cache"
2290        case pricing
2291        case deprecation
2292        case availability
2293        case qualityTags = "quality_tags"
2294        case capabilityTags = "capability_tags"
2295        case family
2296        case lineage
2297        case complementaryWith = "complementary_with"
2298        case avoidAsReviewerFor = "avoid_as_reviewer_for"
2299        case tier
2300        case openWeight = "open_weight"
2301        case strengths
2302        case benchmarks
2303        case fastMode = "fast_mode"
2304    }
2305
2306    public init(from decoder: Decoder) throws {
2307        let container = try decoder.container(keyedBy: CodingKeys.self)
2308        id = try container.decode(String.self, forKey: .id)
2309        name = try container.decode(String.self, forKey: .name)
2310        provider = try container.decode(String.self, forKey: .provider)
2311        aliases = try container.decode([String].self, forKey: .aliases)
2312        contextWindow = try container.decode(Int.self, forKey: .contextWindow)
2313        runtimeContextWindow = try container.decodeIfPresent(Int.self, forKey: .runtimeContextWindow)
2314        streamTimeout = try container.decodeIfPresent(Double.self, forKey: .streamTimeout)
2315        modalities = try container.decode(HarnModelModalities.self, forKey: .modalities)
2316        toolSupport = try container.decode(HarnModelToolSupport.self, forKey: .toolSupport)
2317        structuredOutput = try container.decode(String.self, forKey: .structuredOutput)
2318        formatPreferences = try container.decode(HarnModelFormatPreferences.self, forKey: .formatPreferences)
2319        reasoning = try container.decode(HarnModelReasoning.self, forKey: .reasoning)
2320        promptCache = try container.decode(Bool.self, forKey: .promptCache)
2321        pricing = try container.decodeIfPresent(HarnModelPricing.self, forKey: .pricing)
2322        deprecation = try container.decode(HarnModelDeprecation.self, forKey: .deprecation)
2323        availability = try container.decode(String.self, forKey: .availability)
2324        qualityTags = try container.decode([String].self, forKey: .qualityTags)
2325        capabilityTags = try container.decode([String].self, forKey: .capabilityTags)
2326        family = try container.decode(String.self, forKey: .family)
2327        lineage = try container.decode(String.self, forKey: .lineage)
2328        complementaryWith = try container.decodeIfPresent([String].self, forKey: .complementaryWith) ?? []
2329        avoidAsReviewerFor = try container.decodeIfPresent([String].self, forKey: .avoidAsReviewerFor) ?? []
2330        tier = try container.decode(String.self, forKey: .tier)
2331        openWeight = try container.decodeIfPresent(Bool.self, forKey: .openWeight)
2332        strengths = try container.decodeIfPresent([String].self, forKey: .strengths) ?? []
2333        benchmarks = try container.decodeIfPresent([String: Double].self, forKey: .benchmarks) ?? [:]
2334        fastMode = try container.decodeIfPresent(HarnModelFastMode.self, forKey: .fastMode)
2335    }
2336}
2337
2338public struct HarnModelModalities: Codable, Sendable, Equatable {
2339    public let input: [String]
2340    public let output: [String]
2341}
2342
2343public struct HarnModelToolSupport: Codable, Sendable, Equatable {
2344    public let native: Bool
2345    public let text: Bool
2346    public let preferredFormat: String?
2347    public let parity: String?
2348    public let parityNotes: String?
2349    public let empiricalParity: HarnToolEmpiricalParity?
2350    public let toolSearch: [String]
2351    public let maxTools: Int?
2352
2353    enum CodingKeys: String, CodingKey {
2354        case native
2355        case text
2356        case preferredFormat = "preferred_format"
2357        case parity
2358        case parityNotes = "parity_notes"
2359        case empiricalParity = "empirical_parity"
2360        case toolSearch = "tool_search"
2361        case maxTools = "max_tools"
2362    }
2363}
2364
2365public struct HarnToolEmpiricalParity: Codable, Sendable, Equatable {
2366    public let verdict: String
2367    public let preferredFormat: String
2368    public let confidence: String
2369    public let sampleSize: Int
2370    public let lastEvaluated: String
2371    public let nativePassRate: Double
2372    public let textPassRate: Double
2373    public let verifierDivergenceRate: Double
2374
2375    enum CodingKeys: String, CodingKey {
2376        case verdict
2377        case preferredFormat = "preferred_format"
2378        case confidence
2379        case sampleSize = "sample_size"
2380        case lastEvaluated = "last_evaluated"
2381        case nativePassRate = "native_pass_rate"
2382        case textPassRate = "text_pass_rate"
2383        case verifierDivergenceRate = "verifier_divergence_rate"
2384    }
2385}
2386
2387public struct HarnModelFormatPreferences: Codable, Sendable, Equatable {
2388    public let prefersXMLScaffolding: Bool
2389    public let prefersMarkdownScaffolding: Bool
2390    public let structuredOutputMode: String
2391    public let supportsAssistantPrefill: Bool
2392    public let prefersRoleDeveloper: Bool
2393    public let prefersXMLTools: Bool
2394    public let thinkingBlockStyle: String
2395
2396    enum CodingKeys: String, CodingKey {
2397        case prefersXMLScaffolding = "prefers_xml_scaffolding"
2398        case prefersMarkdownScaffolding = "prefers_markdown_scaffolding"
2399        case structuredOutputMode = "structured_output_mode"
2400        case supportsAssistantPrefill = "supports_assistant_prefill"
2401        case prefersRoleDeveloper = "prefers_role_developer"
2402        case prefersXMLTools = "prefers_xml_tools"
2403        case thinkingBlockStyle = "thinking_block_style"
2404    }
2405}
2406
2407public struct HarnModelReasoning: Codable, Sendable, Equatable {
2408    public let modes: [String]
2409    public let effortSupported: Bool
2410    public let noneSupported: Bool
2411    public let interleavedSupported: Bool
2412    public let preserveThinking: Bool
2413
2414    enum CodingKeys: String, CodingKey {
2415        case modes
2416        case effortSupported = "effort_supported"
2417        case noneSupported = "none_supported"
2418        case interleavedSupported = "interleaved_supported"
2419        case preserveThinking = "preserve_thinking"
2420    }
2421}
2422
2423public struct HarnModelPricing: Codable, Sendable, Equatable {
2424    public let inputPerMTok: Double
2425    public let outputPerMTok: Double
2426    public let cacheReadPerMTok: Double?
2427    public let cacheWritePerMTok: Double?
2428
2429    enum CodingKeys: String, CodingKey {
2430        case inputPerMTok = "input_per_mtok"
2431        case outputPerMTok = "output_per_mtok"
2432        case cacheReadPerMTok = "cache_read_per_mtok"
2433        case cacheWritePerMTok = "cache_write_per_mtok"
2434    }
2435}
2436
2437public struct HarnModelDeprecation: Codable, Sendable, Equatable {
2438    public let status: String
2439    public let note: String?
2440    public let supersededBy: String?
2441
2442    enum CodingKeys: String, CodingKey {
2443        case status
2444        case note
2445        case supersededBy = "superseded_by"
2446    }
2447}
2448
2449public struct HarnModelFastMode: Codable, Sendable, Equatable {
2450    public let param: String
2451    public let value: String
2452    public let betaHeader: String?
2453    public let otpsSpeedup: Double?
2454    public let status: String?
2455    public let pricing: HarnModelPricing?
2456    public let note: String?
2457
2458    enum CodingKeys: String, CodingKey {
2459        case param
2460        case value
2461        case betaHeader = "beta_header"
2462        case otpsSpeedup = "otps_speedup"
2463        case status
2464        case pricing
2465        case note
2466    }
2467}
2468
2469public struct HarnCatalogVariant: Codable, Sendable, Equatable {
2470    public let id: String
2471    public let label: String
2472    public let description: String
2473    public let modelID: String
2474    public let provider: String
2475    public let source: String
2476
2477    enum CodingKeys: String, CodingKey {
2478        case id
2479        case label
2480        case description
2481        case modelID = "model_id"
2482        case provider
2483        case source
2484    }
2485}
2486"#;
2487
2488#[cfg(test)]
2489mod tests {
2490    use super::*;
2491    use ed25519_dalek::{Signer as _, SigningKey};
2492    use std::io::{Read, Write};
2493    use std::net::TcpListener;
2494    use std::sync::{Mutex, MutexGuard};
2495
2496    static RUNTIME_REFRESH_TEST_LOCK: Mutex<()> = Mutex::new(());
2497
2498    struct OverrideGuard;
2499
2500    impl Drop for OverrideGuard {
2501        fn drop(&mut self) {
2502            llm_config::clear_user_overrides();
2503        }
2504    }
2505
2506    struct RuntimeCatalogGuard {
2507        _lock: MutexGuard<'static, ()>,
2508        _runtime_paths_env_lock: MutexGuard<'static, ()>,
2509        state_dir: tempfile::TempDir,
2510        previous_state_dir: Option<String>,
2511        previous_allow_unsigned: Option<String>,
2512        previous_disable_refresh: Option<String>,
2513        previous_trusted_keys: Option<String>,
2514    }
2515
2516    impl RuntimeCatalogGuard {
2517        fn new() -> Self {
2518            let lock = RUNTIME_REFRESH_TEST_LOCK
2519                .lock()
2520                .unwrap_or_else(|poisoned| poisoned.into_inner());
2521            let runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
2522                .lock()
2523                .unwrap_or_else(|poisoned| poisoned.into_inner());
2524            let state_dir = tempfile::tempdir().expect("temp state dir");
2525            let previous_state_dir = std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok();
2526            let previous_allow_unsigned =
2527                std::env::var(HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV).ok();
2528            let previous_disable_refresh = std::env::var(HARN_DISABLE_CATALOG_REFRESH_ENV).ok();
2529            let previous_trusted_keys = std::env::var(HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV).ok();
2530            unsafe {
2531                std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, state_dir.path());
2532                std::env::remove_var(HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV);
2533                std::env::remove_var(HARN_DISABLE_CATALOG_REFRESH_ENV);
2534                std::env::remove_var(HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV);
2535            }
2536            llm_config::clear_runtime_catalog_overlay();
2537            Self {
2538                _lock: lock,
2539                _runtime_paths_env_lock: runtime_paths_env_lock,
2540                state_dir,
2541                previous_state_dir,
2542                previous_allow_unsigned,
2543                previous_disable_refresh,
2544                previous_trusted_keys,
2545            }
2546        }
2547    }
2548
2549    impl Drop for RuntimeCatalogGuard {
2550        fn drop(&mut self) {
2551            llm_config::clear_runtime_catalog_overlay();
2552            match self.previous_state_dir.as_deref() {
2553                Some(value) => unsafe {
2554                    std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value);
2555                },
2556                None => unsafe { std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV) },
2557            }
2558            restore_env_var(
2559                HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV,
2560                self.previous_allow_unsigned.as_deref(),
2561            );
2562            restore_env_var(
2563                HARN_DISABLE_CATALOG_REFRESH_ENV,
2564                self.previous_disable_refresh.as_deref(),
2565            );
2566            restore_env_var(
2567                HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV,
2568                self.previous_trusted_keys.as_deref(),
2569            );
2570        }
2571    }
2572
2573    fn restore_env_var(name: &str, value: Option<&str>) {
2574        match value {
2575            Some(value) => unsafe { std::env::set_var(name, value) },
2576            None => unsafe { std::env::remove_var(name) },
2577        }
2578    }
2579
2580    fn install_overlay(toml_src: &str) -> OverrideGuard {
2581        let overlay = llm_config::parse_config_toml(toml_src).expect("overlay parses");
2582        llm_config::set_user_overrides(Some(overlay));
2583        OverrideGuard
2584    }
2585
2586    fn remote_catalog_with_extra_model() -> ProviderCatalogArtifact {
2587        let mut remote = artifact();
2588        let mut provider = remote.providers[0].clone();
2589        provider.id = "refreshco".to_string();
2590        provider.display_name = "Refresh Co".to_string();
2591        provider.endpoint.base_url = "https://refresh.example/v1".to_string();
2592        provider.auth.style = "none".to_string();
2593        provider.auth.required = false;
2594        provider.auth.env.clear();
2595        remote.providers.push(provider);
2596
2597        let mut model = remote.models[0].clone();
2598        model.id = "refreshco/new-model".to_string();
2599        model.name = "Refresh Co New Model".to_string();
2600        model.provider = "refreshco".to_string();
2601        model.aliases = vec!["refresh-new".to_string()];
2602        model.context_window = 123_456;
2603        model.deprecation.status = DeprecationStatus::Active;
2604        model.deprecation.note = None;
2605        model.deprecation.superseded_by = None;
2606        remote.models.push(model);
2607
2608        remote.aliases.push(CatalogAlias {
2609            name: "refresh-new".to_string(),
2610            model_id: "refreshco/new-model".to_string(),
2611            provider: "refreshco".to_string(),
2612            tool_format: Some("text".to_string()),
2613            tool_calling: None,
2614        });
2615        remote
2616    }
2617
2618    fn spawn_catalog_stub(body: String) -> (String, std::thread::JoinHandle<()>) {
2619        let listener = TcpListener::bind("127.0.0.1:0").expect("bind catalog stub");
2620        let url = format!("http://{}/catalog.json", listener.local_addr().unwrap());
2621        let handle = std::thread::spawn(move || {
2622            let (mut stream, _) = listener.accept().expect("accept catalog request");
2623            let mut request = [0; 1024];
2624            let _ = stream.read(&mut request);
2625            let response = format!(
2626                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\netag: \"fixture-v1\"\r\ncontent-length: {}\r\n\r\n{}",
2627                body.len(),
2628                body
2629            );
2630            stream
2631                .write_all(response.as_bytes())
2632                .expect("write catalog response");
2633        });
2634        (url, handle)
2635    }
2636
2637    #[test]
2638    fn generated_catalog_validates() {
2639        llm_config::clear_user_overrides();
2640        let report = validate_current();
2641        assert!(
2642            report.errors.is_empty(),
2643            "catalog validation errors: {:?}",
2644            report.errors
2645        );
2646    }
2647
2648    #[tokio::test]
2649    async fn runtime_refresh_installs_valid_remote_catalog_overlay() {
2650        let guard = RuntimeCatalogGuard::new();
2651        let remote = remote_catalog_with_extra_model();
2652        let body = serde_json::to_string(&remote).expect("remote catalog serializes");
2653        let (url, server) = spawn_catalog_stub(body);
2654
2655        let report = refresh_runtime_catalog(CatalogRefreshOptions {
2656            url: Some(url),
2657            force: true,
2658        })
2659        .await;
2660        server.join().expect("catalog server exits");
2661
2662        assert_eq!(report.status, "refreshed");
2663        assert!(report.refreshed);
2664        assert_eq!(report.etag.as_deref(), Some("\"fixture-v1\""));
2665        assert!(guard
2666            .state_dir
2667            .path()
2668            .join("cache/provider-catalog/catalog.json")
2669            .is_file());
2670
2671        let refreshed = llm_config::model_catalog_entry("refreshco/new-model")
2672            .expect("remote model installed into runtime catalog");
2673        assert_eq!(refreshed.name, "Refresh Co New Model");
2674        assert_eq!(refreshed.context_window, 123_456);
2675        assert!(llm_config::known_model_names()
2676            .iter()
2677            .any(|name| name == "refresh-new"));
2678    }
2679
2680    #[tokio::test]
2681    async fn runtime_refresh_rejects_malformed_remote_without_emptying_catalog() {
2682        let _guard = RuntimeCatalogGuard::new();
2683        let baseline_count = llm_config::model_catalog_entries().len();
2684        let (url, server) = spawn_catalog_stub(r#"{"schema_version":2,"models":[]}"#.to_string());
2685
2686        let report = refresh_runtime_catalog(CatalogRefreshOptions {
2687            url: Some(url),
2688            force: true,
2689        })
2690        .await;
2691        server.join().expect("catalog server exits");
2692
2693        assert_eq!(report.status, "fallback");
2694        assert!(report.warning.as_deref().is_some_and(|warning| {
2695            warning.contains("catalog JSON does not match")
2696                || warning.contains("catalog has no providers")
2697                || warning.contains("unsigned")
2698        }));
2699        assert_eq!(llm_config::model_catalog_entries().len(), baseline_count);
2700    }
2701
2702    #[test]
2703    fn signed_catalog_envelope_accepts_trusted_key() {
2704        let _guard = RuntimeCatalogGuard::new();
2705        let catalog = remote_catalog_with_extra_model();
2706        let signing_key = SigningKey::from_bytes(&[42; 32]);
2707        let canonical = serde_json::to_vec(&catalog).expect("catalog canonicalizes");
2708        let signature = signing_key.sign(&canonical);
2709        let public_key = base64::engine::general_purpose::STANDARD
2710            .encode(signing_key.verifying_key().to_bytes());
2711        unsafe {
2712            std::env::set_var(
2713                HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV,
2714                format!("test={public_key}"),
2715            );
2716        }
2717        let document = json!({
2718            "ttlMS": 1_234,
2719            "catalog": catalog,
2720            "signature": {
2721                "algorithm": "ed25519",
2722                "key_id": "test",
2723                "signature": base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()),
2724            },
2725        });
2726
2727        let decoded =
2728            decode_and_validate_document(&document.to_string(), false).expect("signed catalog");
2729
2730        assert_eq!(decoded.ttl_ms, 1_234);
2731        assert!(decoded
2732            .artifact
2733            .models
2734            .iter()
2735            .any(|model| model.id == "refreshco/new-model"));
2736    }
2737
2738    #[test]
2739    fn generated_catalog_derives_quality_tags_from_routes() {
2740        let catalog = artifact();
2741        let frontier = catalog
2742            .models
2743            .iter()
2744            .find(|model| model.aliases.iter().any(|alias| alias == "frontier"))
2745            .expect("frontier alias target is exported");
2746        assert!(frontier.quality_tags.iter().any(|tag| tag == "frontier"));
2747
2748        let local = catalog
2749            .models
2750            .iter()
2751            .find(|model| model.aliases.iter().any(|alias| alias == "local-gemma4"))
2752            .expect("local alias target is exported");
2753        assert!(local.quality_tags.iter().any(|tag| tag == "local"));
2754    }
2755
2756    #[test]
2757    fn validation_rejects_missing_required_metadata() {
2758        let mut catalog = artifact();
2759        catalog.providers[0].display_name.clear();
2760        let report = validate_artifact(&catalog);
2761        assert!(
2762            report
2763                .errors
2764                .iter()
2765                .any(|message| message.contains("display_name cannot be empty")),
2766            "expected provider metadata validation error, got {:?}",
2767            report.errors
2768        );
2769    }
2770
2771    #[test]
2772    fn validation_rejects_duplicate_and_dangling_aliases() {
2773        let mut duplicated = artifact();
2774        duplicated.aliases.push(duplicated.aliases[0].clone());
2775        let duplicate_report = validate_artifact(&duplicated);
2776        assert!(
2777            duplicate_report
2778                .errors
2779                .iter()
2780                .any(|message| message.contains("duplicate alias name")),
2781            "expected duplicate alias validation error, got {:?}",
2782            duplicate_report.errors
2783        );
2784
2785        let mut dangling = artifact();
2786        dangling.aliases[0].model_id = "missing-model".to_string();
2787        let dangling_report = validate_artifact(&dangling);
2788        assert!(
2789            dangling_report
2790                .errors
2791                .iter()
2792                .any(|message| message.contains("without a catalog row")),
2793            "expected dangling alias validation error, got {:?}",
2794            dangling_report.errors
2795        );
2796    }
2797
2798    #[test]
2799    fn overlay_merge_surfaces_private_model() {
2800        let _guard = install_overlay(
2801            r#"
2802[providers.private]
2803display_name = "Private"
2804base_url = "http://127.0.0.1:9000"
2805auth_style = "none"
2806chat_endpoint = "/v1/chat/completions"
2807
2808[aliases]
2809private-fast = { id = "private/fast", provider = "private" }
2810
2811[models."private/fast"]
2812name = "Private Fast"
2813provider = "private"
2814context_window = 8192
2815quality_tags = ["experiment"]
2816"#,
2817        );
2818        let catalog = artifact();
2819        assert!(catalog.providers.iter().any(|p| p.id == "private"));
2820        let model = catalog
2821            .models
2822            .iter()
2823            .find(|model| model.id == "private/fast")
2824            .expect("private model is exported");
2825        assert_eq!(model.aliases, vec!["private-fast"]);
2826        assert_eq!(model.quality_tags, vec!["experiment"]);
2827    }
2828
2829    #[test]
2830    fn cataloged_models_default_to_serverless_availability() {
2831        llm_config::clear_user_overrides();
2832        let catalog = artifact();
2833        let qwen_dedicated = catalog
2834            .models
2835            .iter()
2836            .find(|model| model.id == "Qwen/Qwen3-Coder-Next-FP8")
2837            .expect("Together dedicated route is exported");
2838        assert_eq!(
2839            qwen_dedicated.availability,
2840            ModelAvailabilityStatus::Dedicated
2841        );
2842
2843        let bundled_serverless = catalog
2844            .models
2845            .iter()
2846            .find(|model| model.id == "qwen/qwen3-coder")
2847            .expect("OpenRouter Qwen3 Coder is exported");
2848        assert_eq!(
2849            bundled_serverless.availability,
2850            ModelAvailabilityStatus::Serverless
2851        );
2852    }
2853
2854    #[test]
2855    fn tier_alias_targeting_dedicated_model_emits_warning() {
2856        let _guard = install_overlay(
2857            r#"
2858[providers.together_test]
2859display_name = "Together (test)"
2860base_url = "https://api.together.xyz/v1"
2861auth_style = "bearer"
2862auth_env = "TOGETHER_AI_API_KEY"
2863chat_endpoint = "/chat/completions"
2864
2865[aliases.frontier]
2866id = "Qwen/Test-Dedicated-Only"
2867provider = "together_test"
2868
2869[models."Qwen/Test-Dedicated-Only"]
2870name = "Qwen Dedicated Only"
2871provider = "together_test"
2872context_window = 8192
2873availability = "dedicated"
2874"#,
2875        );
2876        let report = validate_current();
2877        assert!(
2878            report.warnings.iter().any(|message| {
2879                message.contains("tier alias frontier") && message.contains("dedicated-only model")
2880            }),
2881            "expected dedicated-alias warning, got {:?}",
2882            report.warnings
2883        );
2884    }
2885
2886    #[test]
2887    fn overlay_parses_availability_strings() {
2888        let _guard = install_overlay(
2889            r#"
2890[providers.experiment_co]
2891display_name = "Experiment Co"
2892base_url = "https://example.test/v1"
2893auth_style = "bearer"
2894auth_env = "EXPERIMENT_API_KEY"
2895chat_endpoint = "/chat/completions"
2896
2897[models."exp/discovered"]
2898name = "Discovered Route"
2899provider = "experiment_co"
2900context_window = 4096
2901availability = "unknown"
2902"#,
2903        );
2904        let catalog = artifact();
2905        let model = catalog
2906            .models
2907            .iter()
2908            .find(|model| model.id == "exp/discovered")
2909            .expect("overlay model is exported");
2910        assert_eq!(model.availability, ModelAvailabilityStatus::Unknown);
2911    }
2912
2913    #[test]
2914    fn catalog_exports_family_and_lineage_for_hosted_wrappers() {
2915        let catalog = artifact();
2916        let hosted_claude = catalog
2917            .models
2918            .iter()
2919            .find(|model| model.id == "anthropic/claude-sonnet-4-6")
2920            .expect("OpenRouter Claude wrapper is exported");
2921        assert_eq!(hosted_claude.provider, "openrouter");
2922        assert_eq!(hosted_claude.family, "anthropic-claude");
2923        assert_eq!(hosted_claude.lineage, "claude-sonnet-opus");
2924
2925        let direct_gemini = catalog
2926            .models
2927            .iter()
2928            .find(|model| model.id == "gemini-2.5-flash")
2929            .expect("Gemini Flash is exported");
2930        assert_eq!(direct_gemini.family, "google-gemini");
2931        assert_eq!(direct_gemini.lineage, "gemini-flash");
2932    }
2933
2934    #[test]
2935    fn validation_rejects_malformed_family_metadata() {
2936        let mut catalog = artifact();
2937        catalog.models[0].family = "Not Normalized".to_string();
2938        catalog.models[0].lineage.clear();
2939        let report = validate_artifact(&catalog);
2940        assert!(
2941            report
2942                .errors
2943                .iter()
2944                .any(|message| message.contains("family")),
2945            "expected family validation error, got {:?}",
2946            report.errors
2947        );
2948        assert!(
2949            report
2950                .errors
2951                .iter()
2952                .any(|message| message.contains("lineage")),
2953            "expected lineage validation error, got {:?}",
2954            report.errors
2955        );
2956    }
2957
2958    #[test]
2959    fn deprecated_models_require_notes() {
2960        let _guard = install_overlay(
2961            r#"
2962[models."old-model"]
2963name = "Old Model"
2964provider = "openai"
2965context_window = 4096
2966deprecated = true
2967"#,
2968        );
2969        let report = validate_current();
2970        assert!(
2971            report
2972                .errors
2973                .iter()
2974                .any(|message| message.contains("deprecated model old-model")),
2975            "expected deprecation validation error, got {:?}",
2976            report.errors
2977        );
2978    }
2979
2980    #[test]
2981    fn generated_schema_accepts_generated_artifact_shape() {
2982        let schema = schema_value();
2983        assert_eq!(schema["$id"], PROVIDER_CATALOG_SCHEMA_ID);
2984        assert_eq!(
2985            schema["$defs"]["tool_support"]["properties"]["empirical_parity"]["$ref"],
2986            "#/$defs/tool_empirical_parity"
2987        );
2988        assert!(schema["$defs"]["model"]["required"]
2989            .as_array()
2990            .is_some_and(|required| required.iter().any(|field| field == "family")));
2991        assert!(schema["$defs"]["model"]["required"]
2992            .as_array()
2993            .is_some_and(|required| required.iter().any(|field| field == "lineage")));
2994        let artifact_value = serde_json::to_value(artifact()).expect("artifact serializes");
2995        assert_eq!(
2996            artifact_value["schema_version"],
2997            PROVIDER_CATALOG_SCHEMA_VERSION
2998        );
2999        assert!(artifact_value["providers"]
3000            .as_array()
3001            .is_some_and(|v| !v.is_empty()));
3002        assert!(artifact_value["models"]
3003            .as_array()
3004            .is_some_and(|v| !v.is_empty()));
3005        assert!(artifact_value["models"][0]["family"].is_string());
3006        assert!(artifact_value["models"][0]["lineage"].is_string());
3007    }
3008
3009    #[test]
3010    fn downstream_bindings_include_empirical_tool_parity_shape() {
3011        let typescript = typescript_binding().expect("typescript binding renders");
3012        assert!(typescript.contains("empirical_parity?: HarnToolEmpiricalParity"));
3013        assert!(typescript.contains("export interface HarnToolEmpiricalParity"));
3014
3015        let swift = swift_binding().expect("swift binding renders");
3016        assert!(swift.contains("public let empiricalParity: HarnToolEmpiricalParity?"));
3017        assert!(swift.contains("public struct HarnToolEmpiricalParity"));
3018    }
3019
3020    #[test]
3021    fn fast_mode_and_supersession_surface_in_contract() {
3022        let schema = schema_value();
3023        assert_eq!(
3024            schema["$defs"]["model"]["properties"]["fast_mode"]["$ref"],
3025            "#/$defs/fast_mode"
3026        );
3027        assert_eq!(
3028            schema["$defs"]["fast_mode"]["properties"]["pricing"]["$ref"],
3029            "#/$defs/pricing"
3030        );
3031        assert!(schema["$defs"]["deprecation"]["properties"]["superseded_by"].is_object());
3032
3033        let typescript = typescript_binding().expect("typescript binding renders");
3034        assert!(typescript.contains("export interface HarnModelFastMode"));
3035        assert!(typescript.contains("fast_mode?: HarnModelFastMode"));
3036        assert!(typescript.contains("superseded_by?: string"));
3037        assert!(typescript.contains("family: string"));
3038        assert!(typescript.contains("lineage: string"));
3039
3040        let swift = swift_binding().expect("swift binding renders");
3041        assert!(swift.contains("public struct HarnModelFastMode"));
3042        assert!(swift.contains("public let fastMode: HarnModelFastMode?"));
3043        assert!(swift.contains("case supersededBy = \"superseded_by\""));
3044        assert!(swift.contains("public let family: String"));
3045        assert!(swift.contains("public let lineage: String"));
3046    }
3047
3048    #[test]
3049    fn dangling_superseded_by_and_unknown_fast_status_warn() {
3050        let _guard = install_overlay(
3051            r#"
3052[providers.warn_co]
3053display_name = "Warn Co"
3054base_url = "https://example.test/v1"
3055auth_style = "bearer"
3056auth_env = "WARN_API_KEY"
3057chat_endpoint = "/chat/completions"
3058
3059[models."warn/old"]
3060name = "Warn Old"
3061provider = "warn_co"
3062context_window = 4096
3063deprecated = true
3064deprecation_note = "Retiring soon."
3065superseded_by = "warn/does-not-exist"
3066fast_mode = { param = "speed", value = "fast", status = "turbo", pricing = { input_per_mtok = 1.0, output_per_mtok = 2.0 } }
3067"#,
3068        );
3069        let report = validate_current();
3070        assert!(
3071            report
3072                .warnings
3073                .iter()
3074                .any(|message| message.contains("superseded_by warn/does-not-exist")),
3075            "expected dangling superseded_by warning, got {:?}",
3076            report.warnings
3077        );
3078        assert!(
3079            report
3080                .warnings
3081                .iter()
3082                .any(|message| message.contains("fast_mode.status") && message.contains("turbo")),
3083            "expected fast_mode.status warning, got {:?}",
3084            report.warnings
3085        );
3086    }
3087}