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