1use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
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, Copy, Deserialize, Serialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum LocalRuntimeKind {
25 DaemonApi,
27 ManagedProcess,
29 External,
31}
32
33#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum LocalRuntimeStop {
40 KeepAliveZero,
42 Pid,
44 External,
46}
47
48#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum LocalRuntimeWireProtocol {
56 OllamaApi,
58 OpenAiCompatible,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct LocalRuntimeLifecycle {
65 pub kind: LocalRuntimeKind,
66 pub stop: LocalRuntimeStop,
67 pub wire_protocol: LocalRuntimeWireProtocol,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum LocalRuntimeLifecycleError {
72 MissingKind,
73 MissingStop,
74 MissingWireProtocol,
75 Incoherent {
76 kind: LocalRuntimeKind,
77 stop: LocalRuntimeStop,
78 wire_protocol: LocalRuntimeWireProtocol,
79 },
80}
81
82impl std::fmt::Display for LocalRuntimeLifecycleError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::MissingKind => f.write_str("local_runtime.kind cannot be empty"),
86 Self::MissingStop => f.write_str("local_runtime.stop cannot be empty"),
87 Self::MissingWireProtocol => {
88 f.write_str("local_runtime.wire_protocol cannot be empty")
89 }
90 Self::Incoherent {
91 kind,
92 stop,
93 wire_protocol,
94 } => write!(
95 f,
96 "local_runtime kind={kind:?}, stop={stop:?}, wire_protocol={wire_protocol:?} is incoherent"
97 ),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
103pub struct LocalRuntimeDef {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub kind: Option<LocalRuntimeKind>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub wire_protocol: Option<LocalRuntimeWireProtocol>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub command: Option<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub prefix_args: Vec<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub model_source: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub model_source_env: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub default_port: Option<u16>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub model_arg: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub served_model_arg: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub host_arg: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub port_arg: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub ctx_arg: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub parallel_arg: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub gpu_layers_arg: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub cache_type_k_arg: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub cache_type_v_arg: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub cache_ram_arg: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub chat_template_kwargs_arg: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub jinja_arg: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub reasoning_arg: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub reasoning_format_arg: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub flash_attn_arg: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub metrics_arg: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub enable_lora_arg: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub lora_modules_arg: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub lora_modules_value_format: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub max_lora_rank_arg: Option<String>,
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub default_args: Vec<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub stop: Option<LocalRuntimeStop>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub source_url: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub last_verified: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub notes: Option<String>,
196}
197
198impl LocalRuntimeDef {
199 pub fn lifecycle(&self) -> Result<LocalRuntimeLifecycle, LocalRuntimeLifecycleError> {
203 let kind = self.kind.ok_or(LocalRuntimeLifecycleError::MissingKind)?;
204 let stop = self.stop.ok_or(LocalRuntimeLifecycleError::MissingStop)?;
205 let wire_protocol = self
206 .wire_protocol
207 .ok_or(LocalRuntimeLifecycleError::MissingWireProtocol)?;
208 let coherent = matches!(
209 (kind, stop, wire_protocol),
210 (
211 LocalRuntimeKind::DaemonApi,
212 LocalRuntimeStop::KeepAliveZero,
213 LocalRuntimeWireProtocol::OllamaApi,
214 ) | (
215 LocalRuntimeKind::ManagedProcess,
216 LocalRuntimeStop::Pid,
217 LocalRuntimeWireProtocol::OpenAiCompatible,
218 ) | (
219 LocalRuntimeKind::External,
220 LocalRuntimeStop::External,
221 LocalRuntimeWireProtocol::OpenAiCompatible,
222 )
223 );
224 if !coherent {
225 return Err(LocalRuntimeLifecycleError::Incoherent {
226 kind,
227 stop,
228 wire_protocol,
229 });
230 }
231 Ok(LocalRuntimeLifecycle {
232 kind,
233 stop,
234 wire_protocol,
235 })
236 }
237}
238
239#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
240pub struct LocalMemoryDef {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub measured_resident_gib: Option<f64>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub measured_context_window: Option<u64>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub measured_cache_type: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub base_resident_gib: Option<f64>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub kv_cache_gib_per_1k_ctx: Option<f64>,
257 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259 pub cache_type_multipliers: BTreeMap<String, f64>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub default_cache_type: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub safety_margin_gib: Option<f64>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub max_recommended_context: Option<u64>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub source_url: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub last_verified: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub notes: Option<String>,
278}
279
280impl LocalMemoryDef {
281 pub fn is_empty(&self) -> bool {
282 self.measured_resident_gib.is_none()
283 && self.measured_context_window.is_none()
284 && self.measured_cache_type.is_none()
285 && self.base_resident_gib.is_none()
286 && self.kv_cache_gib_per_1k_ctx.is_none()
287 && self.cache_type_multipliers.is_empty()
288 && self.default_cache_type.is_none()
289 && self.safety_margin_gib.is_none()
290 && self.max_recommended_context.is_none()
291 && self.source_url.is_none()
292 && self.last_verified.is_none()
293 && self.notes.is_none()
294 }
295}
296
297#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
298pub struct AliasDef {
299 pub id: String,
300 pub provider: String,
301 #[serde(default)]
306 pub tool_format: Option<String>,
307}
308
309#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
310pub struct AliasToolCallingDef {
311 #[serde(default)]
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub native: Option<String>,
314 #[serde(default)]
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub text: Option<String>,
317 #[serde(default)]
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub streaming_native: Option<String>,
320 #[serde(default)]
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub fallback_mode: Option<String>,
323 #[serde(default)]
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub failure_reason: Option<String>,
326 #[serde(default)]
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub last_probe_at: Option<String>,
329}
330
331#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
332pub struct ModelPricing {
333 pub input_per_mtok: f64,
334 pub output_per_mtok: f64,
335 #[serde(default)]
336 pub cache_read_per_mtok: Option<f64>,
337 #[serde(default)]
338 pub cache_write_per_mtok: Option<f64>,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub input_token_bands: Vec<InputTokenPricingBand>,
345}
346
347#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
348pub struct InputTokenPricingBand {
349 pub minimum_input_tokens: u64,
351 pub input_multiplier: f64,
352 pub output_multiplier: f64,
353}
354
355impl ModelPricing {
356 pub fn for_input_tokens(&self, input_tokens: i64) -> Self {
360 let input_tokens = u64::try_from(input_tokens).unwrap_or(0);
361 let Some(band) = self
362 .input_token_bands
363 .iter()
364 .filter(|band| band.minimum_input_tokens <= input_tokens)
365 .max_by_key(|band| band.minimum_input_tokens)
366 else {
367 return self.clone();
368 };
369 Self {
370 input_per_mtok: self.input_per_mtok * band.input_multiplier,
371 output_per_mtok: self.output_per_mtok * band.output_multiplier,
372 cache_read_per_mtok: self
373 .cache_read_per_mtok
374 .map(|rate| rate * band.input_multiplier),
375 cache_write_per_mtok: self
376 .cache_write_per_mtok
377 .map(|rate| rate * band.input_multiplier),
378 input_token_bands: self.input_token_bands.clone(),
379 }
380 }
381}
382
383#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
386pub struct RateLimitsDef {
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub rpm: Option<u32>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub rph: Option<u32>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub rpd: Option<u32>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub tpm: Option<u64>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub tph: Option<u64>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub tpd: Option<u64>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub input_tpm: Option<u64>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub output_tpm: Option<u64>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub concurrency: Option<u32>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub tier: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub source_url: Option<String>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub last_verified: Option<String>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub notes: Option<String>,
426}
427
428impl RateLimitsDef {
429 pub fn is_empty(&self) -> bool {
430 self.rpm.is_none()
431 && self.rph.is_none()
432 && self.rpd.is_none()
433 && self.tpm.is_none()
434 && self.tph.is_none()
435 && self.tpd.is_none()
436 && self.input_tpm.is_none()
437 && self.output_tpm.is_none()
438 && self.concurrency.is_none()
439 && self.tier.is_none()
440 && self.source_url.is_none()
441 && self.last_verified.is_none()
442 && self.notes.is_none()
443 }
444
445 pub fn with_rpm_fallback(mut self, rpm: Option<u32>) -> Option<Self> {
446 if self.rpm.is_none() {
447 self.rpm = rpm;
448 }
449 (!self.is_empty()).then_some(self)
450 }
451}
452
453#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
457pub struct ServingPerformanceDef {
458 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub observed_ttft_ms: Option<u64>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub output_tokens_per_sec: Option<f64>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub time_to_answer_s: Option<f64>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub source: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub source_url: Option<String>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub last_verified: Option<String>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub sample_size: Option<u32>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub notes: Option<String>,
484}
485
486impl ServingPerformanceDef {
487 pub fn is_empty(&self) -> bool {
488 self.observed_ttft_ms.is_none()
489 && self.output_tokens_per_sec.is_none()
490 && self.time_to_answer_s.is_none()
491 && self.source.is_none()
492 && self.source_url.is_none()
493 && self.last_verified.is_none()
494 && self.sample_size.is_none()
495 && self.notes.is_none()
496 }
497}
498
499#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
503pub struct ModelArchitectureDef {
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub parameter_count_b: Option<f64>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub active_parameter_count_b: Option<f64>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub moe: Option<bool>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub quantization: Option<String>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub precision: Option<String>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub license: Option<String>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub tokenizer: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub knowledge_cutoff: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub source_url: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub last_verified: Option<String>,
534}
535
536impl ModelArchitectureDef {
537 pub fn is_empty(&self) -> bool {
538 self.parameter_count_b.is_none()
539 && self.active_parameter_count_b.is_none()
540 && self.moe.is_none()
541 && self.quantization.is_none()
542 && self.precision.is_none()
543 && self.license.is_none()
544 && self.tokenizer.is_none()
545 && self.knowledge_cutoff.is_none()
546 && self.source_url.is_none()
547 && self.last_verified.is_none()
548 }
549}
550
551#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
553pub struct ServingTierRequestDef {
554 pub param: String,
557 pub value: String,
559 #[serde(default, skip_serializing_if = "Vec::is_empty")]
564 pub response_values: Vec<String>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub beta_header: Option<String>,
568}
569
570#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
575#[serde(rename_all = "snake_case")]
576pub enum ServingTierMode {
577 Synchronous,
578}
579
580#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
582#[serde(rename_all = "snake_case")]
583pub enum ServingTierEconomics {
584 Discounted,
585 Standard,
586 Premium,
587}
588
589#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
595pub struct ServingTierDef {
596 pub id: String,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub label: Option<String>,
601 pub mode: ServingTierMode,
602 pub economics: ServingTierEconomics,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub request: Option<ServingTierRequestDef>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub otps_speedup: Option<f64>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub cost_multiplier: Option<f64>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub discount_percent: Option<u32>,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub status: Option<String>,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub pricing: Option<ModelPricing>,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub latency: Option<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub reliability: Option<String>,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub quota: Option<String>,
633 #[serde(default, skip_serializing_if = "Vec::is_empty")]
635 pub suitable_workloads: Vec<String>,
636 #[serde(default, skip_serializing_if = "Vec::is_empty")]
638 pub unsuitable_workloads: Vec<String>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub note: Option<String>,
642}
643
644#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
657pub struct ModelLadderDef {
658 #[serde(default)]
660 pub steps: Vec<ModelLadderStepDef>,
661 #[serde(default)]
663 pub label: Option<String>,
664}
665
666#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
681pub struct ModelLadderStepDef {
682 pub model: String,
683 #[serde(default)]
684 pub provider: Option<String>,
685 #[serde(default)]
686 pub label: Option<String>,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub when: Option<String>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub options: Option<BTreeMap<String, toml::Value>>,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub family: Option<String>,
703 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 pub capabilities: Vec<String>,
708}
709
710#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
711pub struct ModelDef {
712 pub name: String,
713 #[serde(default)]
717 pub display_name: Option<String>,
718 #[serde(default)]
720 pub blurb: Option<String>,
721 pub provider: String,
722 pub context_window: u64,
723 #[serde(default)]
726 pub logical_model: Option<String>,
727 #[serde(default)]
731 pub equivalence_group: Option<String>,
732 #[serde(default)]
735 pub served_variant: Option<String>,
736 #[serde(default)]
740 pub wire_model: Option<String>,
741 #[serde(default)]
744 pub api_dialect: Option<String>,
745 #[serde(default)]
747 pub rate_limits: Option<RateLimitsDef>,
748 #[serde(default)]
750 pub performance: Option<ServingPerformanceDef>,
751 #[serde(default)]
753 pub architecture: Option<ModelArchitectureDef>,
754 #[serde(default)]
756 pub local_memory: Option<LocalMemoryDef>,
757 #[serde(default)]
758 pub runtime_context_window: Option<u64>,
759 #[serde(default)]
760 pub stream_timeout: Option<f64>,
761 #[serde(default, skip_serializing_if = "Vec::is_empty")]
762 pub capabilities: Vec<String>,
763 #[serde(default)]
764 pub pricing: Option<ModelPricing>,
765 #[serde(default, skip_serializing_if = "is_false")]
770 pub deprecated: bool,
771 #[serde(default)]
772 pub deprecation_note: Option<String>,
773 #[serde(default)]
781 pub superseded_by: Option<String>,
782 #[serde(default, skip_serializing_if = "Vec::is_empty")]
787 pub serving_tiers: Vec<ServingTierDef>,
788 #[serde(default, skip_serializing_if = "Vec::is_empty")]
792 pub quality_tags: Vec<String>,
793 #[serde(default, skip_serializing_if = "ModelAvailability::is_default")]
800 pub availability: ModelAvailability,
801 #[serde(default)]
808 pub tier: Option<String>,
809 #[serde(default)]
814 pub open_weight: Option<bool>,
815 #[serde(default, skip_serializing_if = "Vec::is_empty")]
820 pub strengths: Vec<String>,
821 #[serde(default)]
827 pub benchmarks: BTreeMap<String, f64>,
828 #[serde(default)]
833 pub family: Option<String>,
834 #[serde(default)]
836 pub lineage: Option<String>,
837 #[serde(default, skip_serializing_if = "Vec::is_empty")]
839 pub complementary_with: Vec<String>,
840 #[serde(default, skip_serializing_if = "Vec::is_empty")]
843 pub avoid_as_reviewer_for: Vec<String>,
844}
845
846fn is_false(value: &bool) -> bool {
847 !*value
848}
849
850#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
851#[serde(rename_all = "snake_case")]
852pub enum ModelAvailability {
853 #[default]
857 Serverless,
858 Dedicated,
862 Unknown,
866}
867
868impl ModelAvailability {
869 fn is_default(&self) -> bool {
870 *self == Self::default()
871 }
872
873 pub fn as_str(self) -> &'static str {
874 match self {
875 Self::Serverless => "serverless",
876 Self::Dedicated => "dedicated",
877 Self::Unknown => "unknown",
878 }
879 }
880
881 pub fn parse(value: &str) -> Option<Self> {
882 match value {
883 "serverless" => Some(Self::Serverless),
884 "dedicated" => Some(Self::Dedicated),
885 "unknown" => Some(Self::Unknown),
886 _ => None,
887 }
888 }
889}
890
891#[cfg(test)]
892mod ladder_step_tests {
893 use super::{ModelLadderDef, ModelLadderStepDef};
894
895 #[test]
896 fn all_added_fields_round_trip() {
897 let mut options = std::collections::BTreeMap::new();
898 options.insert("temperature".to_string(), toml::Value::Float(0.2));
899 options.insert("max_tokens".to_string(), toml::Value::Integer(512));
900 let step = ModelLadderStepDef {
901 model: "claude-haiku-4-5".to_string(),
902 provider: Some("anthropic".to_string()),
903 label: Some("cheap".to_string()),
904 when: Some("transport_failure".to_string()),
905 options: Some(options),
906 family: Some("haiku".to_string()),
907 capabilities: vec!["vision".to_string(), "tools".to_string()],
908 };
909 let json = serde_json::to_string(&step).expect("serialize");
910 let back: ModelLadderStepDef = serde_json::from_str(&json).expect("deserialize");
911 assert_eq!(step, back);
912 assert!(json.contains("\"family\":\"haiku\""));
913 assert!(json.contains("\"capabilities\":[\"vision\",\"tools\"]"));
914 assert!(json.contains("\"when\":\"transport_failure\""));
915 assert!(json.contains("\"temperature\":0.2"));
916 }
917
918 #[test]
919 fn unset_added_fields_are_absent_from_serialized_output() {
920 let step = ModelLadderStepDef {
925 model: "mock-cheap".to_string(),
926 provider: Some("mock".to_string()),
927 label: None,
928 when: None,
929 options: None,
930 family: None,
931 capabilities: Vec::new(),
932 };
933 let json = serde_json::to_string(&step).expect("serialize");
934 assert_eq!(
935 json,
936 r#"{"model":"mock-cheap","provider":"mock","label":null}"#
937 );
938 for absent in ["family", "capabilities", "when", "options"] {
939 assert!(
940 !json.contains(absent),
941 "unexpected key {absent:?} in {json}"
942 );
943 }
944 }
945
946 #[test]
947 fn deserializes_without_added_fields() {
948 let step: ModelLadderStepDef =
951 serde_json::from_str(r#"{"model":"mock-cheap"}"#).expect("deserialize legacy");
952 assert_eq!(step.when, None);
953 assert_eq!(step.family, None);
954 assert!(step.options.is_none());
955 assert!(step.capabilities.is_empty());
956 }
957
958 #[test]
959 fn catalog_toml_row_retains_when_and_options() {
960 let toml_src = r#"
964label = "with overrides"
965steps = [
966 { model = "haiku", label = "cheap", when = "transport_failure", family = "haiku", capabilities = ["tools"], options = { temperature = 0.1, max_tokens = 256 } },
967 { model = "opus", label = "frontier", family = "opus" },
968]
969"#;
970 let def: ModelLadderDef = toml::from_str(toml_src).expect("parse ladder toml");
971 assert_eq!(def.steps.len(), 2);
972 let cheap = &def.steps[0];
973 assert_eq!(cheap.when.as_deref(), Some("transport_failure"));
974 assert_eq!(cheap.family.as_deref(), Some("haiku"));
975 assert_eq!(cheap.capabilities, vec!["tools".to_string()]);
976 let opts = cheap.options.as_ref().expect("options present");
977 assert_eq!(opts.get("temperature"), Some(&toml::Value::Float(0.1)));
978 assert_eq!(opts.get("max_tokens"), Some(&toml::Value::Integer(256)));
979 assert_eq!(def.steps[1].family.as_deref(), Some("opus"));
980 }
981}