1use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct HealthcheckDef {
10 pub method: String,
11 #[serde(default)]
12 pub path: Option<String>,
13 #[serde(default)]
14 pub url: Option<String>,
15 #[serde(default)]
16 pub body: Option<String>,
17}
18
19#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
20pub struct LocalRuntimeDef {
21 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub kind: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub command: Option<String>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub prefix_args: Vec<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub model_source: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub model_source_env: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub default_port: Option<u16>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub model_arg: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub served_model_arg: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub host_arg: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub port_arg: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub ctx_arg: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub parallel_arg: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub gpu_layers_arg: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub cache_type_k_arg: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub cache_type_v_arg: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cache_ram_arg: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub enable_lora_arg: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub lora_modules_arg: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub lora_modules_value_format: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub max_lora_rank_arg: Option<String>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub default_args: Vec<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub stop: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub source_url: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub last_verified: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub notes: Option<String>,
90}
91
92#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
93pub struct LocalMemoryDef {
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub measured_resident_gib: Option<f64>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub measured_context_window: Option<u64>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub measured_cache_type: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub base_resident_gib: Option<f64>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub kv_cache_gib_per_1k_ctx: Option<f64>,
110 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
112 pub cache_type_multipliers: BTreeMap<String, f64>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub default_cache_type: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub safety_margin_gib: Option<f64>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub max_recommended_context: Option<u64>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub source_url: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub last_verified: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub notes: Option<String>,
131}
132
133impl LocalMemoryDef {
134 pub fn is_empty(&self) -> bool {
135 self.measured_resident_gib.is_none()
136 && self.measured_context_window.is_none()
137 && self.measured_cache_type.is_none()
138 && self.base_resident_gib.is_none()
139 && self.kv_cache_gib_per_1k_ctx.is_none()
140 && self.cache_type_multipliers.is_empty()
141 && self.default_cache_type.is_none()
142 && self.safety_margin_gib.is_none()
143 && self.max_recommended_context.is_none()
144 && self.source_url.is_none()
145 && self.last_verified.is_none()
146 && self.notes.is_none()
147 }
148}
149
150#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
151pub struct AliasDef {
152 pub id: String,
153 pub provider: String,
154 #[serde(default)]
159 pub tool_format: Option<String>,
160}
161
162#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
163pub struct AliasToolCallingDef {
164 #[serde(default)]
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub native: Option<String>,
167 #[serde(default)]
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub text: Option<String>,
170 #[serde(default)]
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub streaming_native: Option<String>,
173 #[serde(default)]
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub fallback_mode: Option<String>,
176 #[serde(default)]
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub failure_reason: Option<String>,
179 #[serde(default)]
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub last_probe_at: Option<String>,
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
185pub struct ModelPricing {
186 pub input_per_mtok: f64,
187 pub output_per_mtok: f64,
188 #[serde(default)]
189 pub cache_read_per_mtok: Option<f64>,
190 #[serde(default)]
191 pub cache_write_per_mtok: Option<f64>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub input_token_bands: Vec<InputTokenPricingBand>,
198}
199
200#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
201pub struct InputTokenPricingBand {
202 pub minimum_input_tokens: u64,
204 pub input_multiplier: f64,
205 pub output_multiplier: f64,
206}
207
208impl ModelPricing {
209 pub fn for_input_tokens(&self, input_tokens: i64) -> Self {
213 let input_tokens = u64::try_from(input_tokens).unwrap_or(0);
214 let Some(band) = self
215 .input_token_bands
216 .iter()
217 .filter(|band| band.minimum_input_tokens <= input_tokens)
218 .max_by_key(|band| band.minimum_input_tokens)
219 else {
220 return self.clone();
221 };
222 Self {
223 input_per_mtok: self.input_per_mtok * band.input_multiplier,
224 output_per_mtok: self.output_per_mtok * band.output_multiplier,
225 cache_read_per_mtok: self
226 .cache_read_per_mtok
227 .map(|rate| rate * band.input_multiplier),
228 cache_write_per_mtok: self
229 .cache_write_per_mtok
230 .map(|rate| rate * band.input_multiplier),
231 input_token_bands: self.input_token_bands.clone(),
232 }
233 }
234}
235
236#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
239pub struct RateLimitsDef {
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub rpm: Option<u32>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub rph: Option<u32>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub rpd: Option<u32>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub tpm: Option<u64>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub tph: Option<u64>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub tpd: Option<u64>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub input_tpm: Option<u64>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub output_tpm: Option<u64>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub concurrency: Option<u32>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub tier: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub source_url: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub last_verified: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub notes: Option<String>,
279}
280
281impl RateLimitsDef {
282 pub fn is_empty(&self) -> bool {
283 self.rpm.is_none()
284 && self.rph.is_none()
285 && self.rpd.is_none()
286 && self.tpm.is_none()
287 && self.tph.is_none()
288 && self.tpd.is_none()
289 && self.input_tpm.is_none()
290 && self.output_tpm.is_none()
291 && self.concurrency.is_none()
292 && self.tier.is_none()
293 && self.source_url.is_none()
294 && self.last_verified.is_none()
295 && self.notes.is_none()
296 }
297
298 pub fn with_rpm_fallback(mut self, rpm: Option<u32>) -> Option<Self> {
299 if self.rpm.is_none() {
300 self.rpm = rpm;
301 }
302 (!self.is_empty()).then_some(self)
303 }
304}
305
306#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
310pub struct ServingPerformanceDef {
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub observed_ttft_ms: Option<u64>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub output_tokens_per_sec: Option<f64>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub time_to_answer_s: Option<f64>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub source: Option<String>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub source_url: Option<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub last_verified: Option<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub sample_size: Option<u32>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub notes: Option<String>,
337}
338
339impl ServingPerformanceDef {
340 pub fn is_empty(&self) -> bool {
341 self.observed_ttft_ms.is_none()
342 && self.output_tokens_per_sec.is_none()
343 && self.time_to_answer_s.is_none()
344 && self.source.is_none()
345 && self.source_url.is_none()
346 && self.last_verified.is_none()
347 && self.sample_size.is_none()
348 && self.notes.is_none()
349 }
350}
351
352#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
356pub struct ModelArchitectureDef {
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub parameter_count_b: Option<f64>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub active_parameter_count_b: Option<f64>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub moe: Option<bool>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub quantization: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub precision: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub license: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub tokenizer: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub knowledge_cutoff: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub source_url: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub last_verified: Option<String>,
387}
388
389impl ModelArchitectureDef {
390 pub fn is_empty(&self) -> bool {
391 self.parameter_count_b.is_none()
392 && self.active_parameter_count_b.is_none()
393 && self.moe.is_none()
394 && self.quantization.is_none()
395 && self.precision.is_none()
396 && self.license.is_none()
397 && self.tokenizer.is_none()
398 && self.knowledge_cutoff.is_none()
399 && self.source_url.is_none()
400 && self.last_verified.is_none()
401 }
402}
403
404#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
406pub struct ServingTierRequestDef {
407 pub param: String,
410 pub value: String,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub beta_header: Option<String>,
415}
416
417#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
422#[serde(rename_all = "snake_case")]
423pub enum ServingTierMode {
424 Synchronous,
425}
426
427#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
429#[serde(rename_all = "snake_case")]
430pub enum ServingTierEconomics {
431 Discounted,
432 Standard,
433 Premium,
434}
435
436#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
442pub struct ServingTierDef {
443 pub id: String,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub label: Option<String>,
448 pub mode: ServingTierMode,
449 pub economics: ServingTierEconomics,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub request: Option<ServingTierRequestDef>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub otps_speedup: Option<f64>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub cost_multiplier: Option<f64>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub discount_percent: Option<u32>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub status: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub pricing: Option<ModelPricing>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub latency: Option<String>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub reliability: Option<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub quota: Option<String>,
479 #[serde(default, skip_serializing_if = "Vec::is_empty")]
481 pub suitable_workloads: Vec<String>,
482 #[serde(default, skip_serializing_if = "Vec::is_empty")]
484 pub unsuitable_workloads: Vec<String>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub note: Option<String>,
488}
489
490#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
503pub struct ModelLadderDef {
504 #[serde(default)]
506 pub steps: Vec<ModelLadderStepDef>,
507 #[serde(default)]
509 pub label: Option<String>,
510}
511
512#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
527pub struct ModelLadderStepDef {
528 pub model: String,
529 #[serde(default)]
530 pub provider: Option<String>,
531 #[serde(default)]
532 pub label: Option<String>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub when: Option<String>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub options: Option<BTreeMap<String, toml::Value>>,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub family: Option<String>,
549 #[serde(default, skip_serializing_if = "Vec::is_empty")]
553 pub capabilities: Vec<String>,
554}
555
556#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
557pub struct ModelDef {
558 pub name: String,
559 #[serde(default)]
561 pub blurb: Option<String>,
562 pub provider: String,
563 pub context_window: u64,
564 #[serde(default)]
567 pub logical_model: Option<String>,
568 #[serde(default)]
572 pub equivalence_group: Option<String>,
573 #[serde(default)]
576 pub served_variant: Option<String>,
577 #[serde(default)]
581 pub wire_model: Option<String>,
582 #[serde(default)]
585 pub api_dialect: Option<String>,
586 #[serde(default)]
588 pub rate_limits: Option<RateLimitsDef>,
589 #[serde(default)]
591 pub performance: Option<ServingPerformanceDef>,
592 #[serde(default)]
594 pub architecture: Option<ModelArchitectureDef>,
595 #[serde(default)]
597 pub local_memory: Option<LocalMemoryDef>,
598 #[serde(default)]
599 pub runtime_context_window: Option<u64>,
600 #[serde(default)]
601 pub stream_timeout: Option<f64>,
602 #[serde(default)]
603 pub capabilities: Vec<String>,
604 #[serde(default)]
605 pub pricing: Option<ModelPricing>,
606 #[serde(default)]
607 pub deprecated: bool,
608 #[serde(default)]
609 pub deprecation_note: Option<String>,
610 #[serde(default)]
618 pub superseded_by: Option<String>,
619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
624 pub serving_tiers: Vec<ServingTierDef>,
625 #[serde(default)]
629 pub quality_tags: Vec<String>,
630 #[serde(default)]
636 pub availability: ModelAvailability,
637 #[serde(default)]
644 pub tier: Option<String>,
645 #[serde(default)]
650 pub open_weight: Option<bool>,
651 #[serde(default)]
656 pub strengths: Vec<String>,
657 #[serde(default)]
663 pub benchmarks: BTreeMap<String, f64>,
664 #[serde(default)]
669 pub family: Option<String>,
670 #[serde(default)]
672 pub lineage: Option<String>,
673 #[serde(default)]
675 pub complementary_with: Vec<String>,
676 #[serde(default)]
679 pub avoid_as_reviewer_for: Vec<String>,
680}
681
682#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
683#[serde(rename_all = "snake_case")]
684pub enum ModelAvailability {
685 #[default]
689 Serverless,
690 Dedicated,
694 Unknown,
698}
699
700impl ModelAvailability {
701 pub fn as_str(self) -> &'static str {
702 match self {
703 Self::Serverless => "serverless",
704 Self::Dedicated => "dedicated",
705 Self::Unknown => "unknown",
706 }
707 }
708
709 pub fn parse(value: &str) -> Option<Self> {
710 match value {
711 "serverless" => Some(Self::Serverless),
712 "dedicated" => Some(Self::Dedicated),
713 "unknown" => Some(Self::Unknown),
714 _ => None,
715 }
716 }
717}
718
719#[cfg(test)]
720mod ladder_step_tests {
721 use super::{ModelLadderDef, ModelLadderStepDef};
722
723 #[test]
724 fn all_added_fields_round_trip() {
725 let mut options = std::collections::BTreeMap::new();
726 options.insert("temperature".to_string(), toml::Value::Float(0.2));
727 options.insert("max_tokens".to_string(), toml::Value::Integer(512));
728 let step = ModelLadderStepDef {
729 model: "claude-haiku-4-5".to_string(),
730 provider: Some("anthropic".to_string()),
731 label: Some("cheap".to_string()),
732 when: Some("transport_failure".to_string()),
733 options: Some(options),
734 family: Some("haiku".to_string()),
735 capabilities: vec!["vision".to_string(), "tools".to_string()],
736 };
737 let json = serde_json::to_string(&step).expect("serialize");
738 let back: ModelLadderStepDef = serde_json::from_str(&json).expect("deserialize");
739 assert_eq!(step, back);
740 assert!(json.contains("\"family\":\"haiku\""));
741 assert!(json.contains("\"capabilities\":[\"vision\",\"tools\"]"));
742 assert!(json.contains("\"when\":\"transport_failure\""));
743 assert!(json.contains("\"temperature\":0.2"));
744 }
745
746 #[test]
747 fn unset_added_fields_are_absent_from_serialized_output() {
748 let step = ModelLadderStepDef {
753 model: "mock-cheap".to_string(),
754 provider: Some("mock".to_string()),
755 label: None,
756 when: None,
757 options: None,
758 family: None,
759 capabilities: Vec::new(),
760 };
761 let json = serde_json::to_string(&step).expect("serialize");
762 assert_eq!(
763 json,
764 r#"{"model":"mock-cheap","provider":"mock","label":null}"#
765 );
766 for absent in ["family", "capabilities", "when", "options"] {
767 assert!(
768 !json.contains(absent),
769 "unexpected key {absent:?} in {json}"
770 );
771 }
772 }
773
774 #[test]
775 fn deserializes_without_added_fields() {
776 let step: ModelLadderStepDef =
779 serde_json::from_str(r#"{"model":"mock-cheap"}"#).expect("deserialize legacy");
780 assert_eq!(step.when, None);
781 assert_eq!(step.family, None);
782 assert!(step.options.is_none());
783 assert!(step.capabilities.is_empty());
784 }
785
786 #[test]
787 fn catalog_toml_row_retains_when_and_options() {
788 let toml_src = r#"
792label = "with overrides"
793steps = [
794 { model = "haiku", label = "cheap", when = "transport_failure", family = "haiku", capabilities = ["tools"], options = { temperature = 0.1, max_tokens = 256 } },
795 { model = "opus", label = "frontier", family = "opus" },
796]
797"#;
798 let def: ModelLadderDef = toml::from_str(toml_src).expect("parse ladder toml");
799 assert_eq!(def.steps.len(), 2);
800 let cheap = &def.steps[0];
801 assert_eq!(cheap.when.as_deref(), Some("transport_failure"));
802 assert_eq!(cheap.family.as_deref(), Some("haiku"));
803 assert_eq!(cheap.capabilities, vec!["tools".to_string()]);
804 let opts = cheap.options.as_ref().expect("options present");
805 assert_eq!(opts.get("temperature"), Some(&toml::Value::Float(0.1)));
806 assert_eq!(opts.get("max_tokens"), Some(&toml::Value::Integer(256)));
807 assert_eq!(def.steps[1].family.as_deref(), Some("opus"));
808 }
809}