1use 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 pub tier: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub open_weight: Option<bool>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub strengths: Vec<String>,
144 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146 pub benchmarks: BTreeMap<String, f64>,
147 #[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 #[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#[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
911pub 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 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}