Skip to main content

harn_vm/llm_config/
model_def.rs

1//! Model catalog DTOs: per-route serving definitions and the sub-records
2//! (pricing, rate limits, serving performance, architecture, serving tiers,
3//! local runtime/memory, and aliases) that make up a `ModelDef`.
4use 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    /// Lifecycle style: `daemon_api` for runtimes with their own resident
22    /// daemon (Ollama), `managed_process` for Harn-spawned servers.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub kind: Option<String>,
25    /// Command Harn should execute for managed-process runtimes.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub command: Option<String>,
28    /// Arguments that must appear immediately after the command, before model
29    /// and server flags. Used by CLIs such as `vllm serve ...`.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub prefix_args: Vec<String>,
32    /// Default model source/path/repo. User overlays may set this; embedded
33    /// catalog rows avoid machine-specific absolute paths except examples.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub model_source: Option<String>,
36    /// Environment variable that can provide a model source.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub model_source_env: Option<String>,
39    /// Default port when the provider base URL has none.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub default_port: Option<u16>,
42    /// Argument names used by the runtime CLI.
43    #[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    /// Flag that enables adapter-aware serving for LoRA-capable runtimes.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub enable_lora_arg: Option<String>,
66    /// Flag that accepts one or more LoRA module specs.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub lora_modules_arg: Option<String>,
69    /// Runtime value shape for LoRA module specs. Defaults to `name_path`.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub lora_modules_value_format: Option<String>,
72    /// Optional rank-limit flag for runtimes that need an explicit ceiling.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub max_lora_rank_arg: Option<String>,
75    /// Extra arguments Harn applies by default when launching this runtime.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub default_args: Vec<String>,
78    /// Stop strategy: `keep_alive_zero`, `pid`, or `external`.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub stop: Option<String>,
81    /// Official docs/source URL for the lifecycle contract.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub source_url: Option<String>,
84    /// YYYY-MM-DD date when the local runtime row was last verified.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub last_verified: Option<String>,
87    /// Short operational note surfaced by CLI docs/help.
88    #[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    /// Empirical resident memory observed for this route/runtime.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub measured_resident_gib: Option<f64>,
97    /// Context size used for the empirical measurement.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub measured_context_window: Option<u64>,
100    /// KV-cache type used for the empirical measurement.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub measured_cache_type: Option<String>,
103    /// Approximate non-context resident footprint for this model/runtime.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub base_resident_gib: Option<f64>,
106    /// Approximate GiB consumed by KV cache per 1,000 context tokens at the
107    /// default cache type.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub kv_cache_gib_per_1k_ctx: Option<f64>,
110    /// Cache-type multiplier relative to `kv_cache_gib_per_1k_ctx`.
111    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
112    pub cache_type_multipliers: BTreeMap<String, f64>,
113    /// Cache type assumed when the launch command does not set K/V cache.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub default_cache_type: Option<String>,
116    /// Minimum headroom Harn should leave for the OS and other apps.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub safety_margin_gib: Option<f64>,
119    /// Highest context Harn should recommend automatically from this row.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub max_recommended_context: Option<u64>,
122    /// Official or empirical source for the sizing row.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub source_url: Option<String>,
125    /// YYYY-MM-DD date when the sizing row was last verified.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub last_verified: Option<String>,
128    /// Short operational note surfaced by CLI diagnostics/docs.
129    #[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    /// Per-model tool format override: "native" or "text". When set, this
155    /// takes precedence over the provider-level default. Models with strong
156    /// tool-calling fine-tuning (Kimi-K2.5, GPT-4o) should use "native";
157    /// models better served by text-based tool calling use "text".
158    #[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    /// Whole-request pricing that activates once provider-reported input usage
193    /// reaches a threshold. Providers such as OpenAI and Gemini charge every
194    /// token in a long-context request at the selected band's rates rather
195    /// than applying marginal pricing only above the boundary.
196    #[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    /// Inclusive lower bound for this whole-request rate.
203    pub minimum_input_tokens: u64,
204    pub input_multiplier: f64,
205    pub output_multiplier: f64,
206}
207
208impl ModelPricing {
209    /// Resolve the whole-request rates for provider-reported input usage.
210    /// `max_by_key` keeps runtime selection correct even before catalog
211    /// validation reports an authoring-order mistake.
212    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/// Provider or model quota metadata. Providers publish these along several
237/// axes, and any one exhausted bucket can trigger throttling.
238#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
239pub struct RateLimitsDef {
240    /// Requests per minute.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub rpm: Option<u32>,
243    /// Requests per hour.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub rph: Option<u32>,
246    /// Requests per day.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub rpd: Option<u32>,
249    /// Total tokens per minute.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub tpm: Option<u64>,
252    /// Total tokens per hour.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub tph: Option<u64>,
255    /// Total tokens per day.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub tpd: Option<u64>,
258    /// Input tokens per minute, when the provider splits input/output quotas.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub input_tpm: Option<u64>,
261    /// Output tokens per minute, when the provider splits input/output quotas.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub output_tpm: Option<u64>,
264    /// Concurrent in-flight requests, if published.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub concurrency: Option<u32>,
267    /// Account tier or route class these limits describe.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub tier: Option<String>,
270    /// Official source URL for the row.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub source_url: Option<String>,
273    /// YYYY-MM-DD date when the row was last verified.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub last_verified: Option<String>,
276    /// Free-text caveat for account-dependent or burst limits.
277    #[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/// Optional provider/model serving-performance observation. This records
307/// benchmark or live-probe facts, not a hard runtime contract; callers should
308/// treat missing fields as unknown and stale dates as advisory.
309#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
310pub struct ServingPerformanceDef {
311    /// Observed time-to-first-token in milliseconds.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub observed_ttft_ms: Option<u64>,
314    /// Observed output generation rate in tokens per second.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub output_tokens_per_sec: Option<f64>,
317    /// End-to-end time-to-answer in seconds for the cited benchmark, when
318    /// reported separately from TTFT/generation rate.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub time_to_answer_s: Option<f64>,
321    /// Source label, e.g. `artificial_analysis`, `harn_probe`, or
322    /// `provider_blog`.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub source: Option<String>,
325    /// Source URL for the observation.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub source_url: Option<String>,
328    /// YYYY-MM-DD date when the observation was last verified.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub last_verified: Option<String>,
331    /// Number of requests or benchmark samples behind this row, if known.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub sample_size: Option<u32>,
334    /// Short caveat such as streaming mode, warm/cold route, or prompt shape.
335    #[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/// Logical-model facts separated from provider serving routes. These fields
353/// describe the underlying weights or public model family, not Harn's alias or
354/// provider/model selector.
355#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
356pub struct ModelArchitectureDef {
357    /// Total parameter count in billions.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub parameter_count_b: Option<f64>,
360    /// Active parameter count in billions for MoE models.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub active_parameter_count_b: Option<f64>,
363    /// True for mixture-of-experts models.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub moe: Option<bool>,
366    /// Quantization advertised by this route, if route-specific.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub quantization: Option<String>,
369    /// Numeric precision advertised by this route, if known.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub precision: Option<String>,
372    /// License identifier or short label.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub license: Option<String>,
375    /// Tokenizer family or implementation hint.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub tokenizer: Option<String>,
378    /// Public knowledge cutoff claim, when published.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub knowledge_cutoff: Option<String>,
381    /// Official source URL for these facts.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub source_url: Option<String>,
384    /// YYYY-MM-DD date when these facts were last verified.
385    #[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/// Provider request knob that selects a non-default serving tier.
405#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
406pub struct ServingTierRequestDef {
407    /// Request field that opts into the tier (for example `speed` for
408    /// Anthropic or `service_tier` for OpenAI/Gemini).
409    pub param: String,
410    /// Value to send on `param` (for example `fast`, `flex`, or `priority`).
411    pub value: String,
412    /// Provider beta/feature header required to use the tier, if any.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub beta_header: Option<String>,
415}
416
417/// Whether a serving tier is synchronous request handling or some other
418/// provider execution lane. Batch APIs remain represented by the separate
419/// async `batch` capability; discounted synchronous lanes such as Gemini Flex
420/// belong here instead of overloading `batch_api`.
421#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
422#[serde(rename_all = "snake_case")]
423pub enum ServingTierMode {
424    Synchronous,
425}
426
427/// Economic shape of a serving tier relative to the default synchronous API.
428#[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/// Optional non-default synchronous serving tier for a model. Off by default:
437/// its presence only describes provider capability. Callers must explicitly
438/// opt in via the declared request knob, so nothing here changes default
439/// behavior. Batch APIs are intentionally not modeled here; they remain the
440/// separate async `batch` capability used by `harn models batch`.
441#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
442pub struct ServingTierDef {
443    /// Stable tier id, e.g. `fast`, `flex`, or `priority`.
444    pub id: String,
445    /// Human-readable display label for CLI/catalog renderers.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub label: Option<String>,
448    pub mode: ServingTierMode,
449    pub economics: ServingTierEconomics,
450    /// Request knob for tiers selected per request. Some tiers may be
451    /// informational/account-level only and omit a knob.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub request: Option<ServingTierRequestDef>,
454    /// Output-tokens-per-second speedup vs standard serving (e.g. 2.5).
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub otps_speedup: Option<f64>,
457    /// Price multiplier relative to default synchronous rates, when public.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub cost_multiplier: Option<f64>,
460    /// Discount percentage relative to default synchronous rates, when public.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub discount_percent: Option<u32>,
463    /// Lifecycle of the tier: "ga" | "research_preview" | "deprecated".
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub status: Option<String>,
466    /// Absolute per-MTok rates charged while the tier is active. Prefer this
467    /// over a multiplier when the provider prices the tier asymmetrically.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub pricing: Option<ModelPricing>,
470    /// Latency expectation for humans and planners.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub latency: Option<String>,
473    /// Reliability/availability expectation for humans and planners.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub reliability: Option<String>,
476    /// Quota-pool or eligibility notes.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub quota: Option<String>,
479    /// Workloads this tier is suitable for (e.g. `offline_eval`, `corpus`).
480    #[serde(default, skip_serializing_if = "Vec::is_empty")]
481    pub suitable_workloads: Vec<String>,
482    /// Workloads this tier should generally avoid (e.g. `interactive_chat`).
483    #[serde(default, skip_serializing_if = "Vec::is_empty")]
484    pub unsuitable_workloads: Vec<String>,
485    /// Free-text note: constraints, deprecation timeline, cache behavior, etc.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub note: Option<String>,
488}
489
490/// A named model-fallback ladder declared in the catalog under
491/// `[model_ladders.<name>]`. A `models`/`ladder` option on `llm_call`
492/// lowers a ladder onto the first-class `routing_policy` chain: each step
493/// is one transport attempt, and the loop advances to the next step ONLY
494/// on transport-class failures (connection/timeout/429/5xx/throttled).
495///
496/// This data-driven declaration follows the same spirit as `serving_tiers`
497/// (#4017): a capability/behavior encoded as catalog data rather than
498/// hand-rolled at each downstream call site (harn-bump-fleet,
499/// harn-cloud free_tier_pool, burin-code all shipped their own copy).
500// NB: `PartialEq` only (no `Eq`): `ModelLadderStepDef::options` holds
501// `toml::Value`, which carries floats and therefore is not `Eq`.
502#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
503pub struct ModelLadderDef {
504    /// Ordered ladder steps, cheapest/first to most-capable/last.
505    #[serde(default)]
506    pub steps: Vec<ModelLadderStepDef>,
507    /// Optional human-readable label surfaced on the routing envelope.
508    #[serde(default)]
509    pub label: Option<String>,
510}
511
512/// One rung of a [`ModelLadderDef`]. Full parity with the `.harn`
513/// `ModelLadderStep` alias — `{model, provider?, label?, when?, options?,
514/// family?, capabilities?}` — which is also the shape accepted by the
515/// `models:` option and the `model_ladder(...)` std constructor. Provider is
516/// optional: when omitted it is inferred from the model id (or the call's base
517/// provider) at lowering time.
518///
519/// `options` carries per-step sampling/timeout overrides (same whitelist as
520/// inline `models:` steps); catalog ladders honor them identically instead of
521/// silently dropping them. `when`, `family`, and `capabilities` are
522/// informational to Harn's own ladder lowering (they do not affect transport
523/// failover) but are carried through so catalog and inline ladders declare the
524/// same shape and downstream selectors (e.g. harn-cloud free-tier routing) can
525/// read them. All added fields are optional and serde-absent when unset.
526#[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    /// Conditional-routing predicate hint (e.g. `"transport_failure"`). Mirror
534    /// of the `.harn` alias `when?` field. Informational to lowering today.
535    /// Absent from serialized output when unset.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub when: Option<String>,
538    /// Per-step sampling/timeout overrides (temperature, max_tokens, top_p,
539    /// seed, timeout_ms, fast, ...), same whitelist as inline `models:` steps.
540    /// Absent from serialized output when unset.
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub options: Option<BTreeMap<String, toml::Value>>,
543    /// Normalized model-family token (e.g. `"haiku"`, `"sonnet"`) carried for
544    /// downstream selectors such as harn-cloud's free-tier routing. Purely
545    /// informational to Harn's own ladder lowering — it does not affect
546    /// transport failover. Absent from serialized output when unset.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub family: Option<String>,
549    /// Capability tags this rung claims (e.g. `["vision", "tools"]`). Carried
550    /// for downstream capability-aware routing; informational to Harn's own
551    /// ladder lowering. Absent from serialized output when empty.
552    #[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    /// One-sentence, plain-language trade-off description for model pickers.
560    #[serde(default)]
561    pub blurb: Option<String>,
562    pub provider: String,
563    pub context_window: u64,
564    /// Provider-independent logical model id, when multiple serving routes map
565    /// to the same weights or model family.
566    #[serde(default)]
567    pub logical_model: Option<String>,
568    /// Equivalence class for failover/escalation candidates. Entries in the
569    /// same group are capability-compatible alternatives, not byte-identical
570    /// APIs; callers must still re-render transcripts for the target provider.
571    #[serde(default)]
572    pub equivalence_group: Option<String>,
573    /// Serving-route detail such as "serverless", "priority", "fp8", or a
574    /// provider route slug. This is intentionally separate from `name`.
575    #[serde(default)]
576    pub served_variant: Option<String>,
577    /// Provider-native model id to send on the wire. Defaults to the catalog
578    /// key. Required when two providers expose the same native id and Harn
579    /// needs a unique catalog key for each route.
580    #[serde(default)]
581    pub wire_model: Option<String>,
582    /// Preferred API dialect for the route, e.g. `openai_chat`,
583    /// `openai_responses`, `anthropic_messages`, `gemini_generate_content`.
584    #[serde(default)]
585    pub api_dialect: Option<String>,
586    /// Route-specific token/request quota metadata.
587    #[serde(default)]
588    pub rate_limits: Option<RateLimitsDef>,
589    /// Optional route-level serving performance observations.
590    #[serde(default)]
591    pub performance: Option<ServingPerformanceDef>,
592    /// Underlying model architecture facts separated from the provider id.
593    #[serde(default)]
594    pub architecture: Option<ModelArchitectureDef>,
595    /// Local launch memory-sizing hints used by `harn local launch`.
596    #[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    /// Structured replacement pointer: the catalog id of the model that
611    /// supersedes this one (e.g. an older Opus row points at the newest
612    /// Opus). Lets release tooling express "migrate to X" in a
613    /// machine-readable way instead of burying it in `deprecation_note`
614    /// free text. A model may be superseded without being `deprecated`
615    /// (a newer option exists but this one is still fully supported);
616    /// pair it with `deprecated = true` once a sunset is announced.
617    #[serde(default)]
618    pub superseded_by: Option<String>,
619    /// Non-default synchronous serving tiers exposed by the provider, such as
620    /// premium fast/priority queues or discounted best-effort Flex lanes. Off
621    /// by default — see [`ServingTierDef`]. Empty for models with no alternate
622    /// synchronous serving path.
623    #[serde(default, skip_serializing_if = "Vec::is_empty")]
624    pub serving_tiers: Vec<ServingTierDef>,
625    /// Loose catalog annotations for selectors and UI. Conventional tags
626    /// include `avoid_reviewer` for routes that should not be auto-selected as
627    /// independent reviewers even when they are routable and cheap.
628    #[serde(default)]
629    pub quality_tags: Vec<String>,
630    /// Whether the model can be reached over a normal API-key serverless call,
631    /// or only via a dedicated/provisioned endpoint that the caller must spin
632    /// up out-of-band. Providers like Together list dedicated-only routes
633    /// alongside serverless ones in `/v1/models`, so this metadata lets clients
634    /// avoid presenting them as one-click options.
635    #[serde(default)]
636    pub availability: ModelAvailability,
637    /// Popular-consensus tier label. Enum-typed string: "small" | "mid" |
638    /// "frontier" | "reasoning". Self-declared per model (no pattern-matched
639    /// rule table) so the catalog is the single source of truth. When None
640    /// the resolver returns the catalog default ("mid"). Use the richer
641    /// `strengths` + `benchmarks` fields to pick models for specific
642    /// workloads — `tier` exists only as a coarse popular-consensus shortcut.
643    #[serde(default)]
644    pub tier: Option<String>,
645    /// True when the model weights are downloadable / self-hostable
646    /// (open-weight / open-source license, regardless of commercial-use
647    /// restrictions). False when weights are closed (Anthropic, OpenAI,
648    /// Google, etc.). None when the catalog row predates the migration.
649    #[serde(default)]
650    pub open_weight: Option<bool>,
651    /// Workload-shaped strength tags. Conventional values include
652    /// `coding`, `summarization`, `long_context`, `tool_use`, `reasoning`,
653    /// `vision`, `speed`, `cheap`, `agentic`. Selectors should treat
654    /// missing entries as "no claim" rather than "no strength."
655    #[serde(default)]
656    pub strengths: Vec<String>,
657    /// Public benchmark numbers, keyed by a snake_case identifier
658    /// (`swe_bench_verified`, `humaneval`, `aa_intelligence_index`, etc.).
659    /// Values are the raw published scores. The selector layer is free
660    /// to normalize per benchmark; the catalog records the canonical
661    /// score so future readers can audit the source.
662    #[serde(default)]
663    pub benchmarks: BTreeMap<String, f64>,
664    /// Normalized model-family token used as a diversity signal for
665    /// reviewer selection. Distinct from provider: hosted wrappers should
666    /// keep the underlying family (for example OpenRouter-hosted Claude
667    /// still uses `anthropic-claude`).
668    #[serde(default)]
669    pub family: Option<String>,
670    /// Narrower family lineage used by option-pack calibration.
671    #[serde(default)]
672    pub lineage: Option<String>,
673    /// Preferred reviewer families for critique/review workloads.
674    #[serde(default)]
675    pub complementary_with: Vec<String>,
676    /// Author families, lineages, model ids, or provider/model selectors
677    /// this row should not review.
678    #[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    /// Reachable through the provider's normal API-key path with no extra
686    /// setup. The default for cataloged hosted/local models: by cataloging a
687    /// row we are claiming the route works out of the box.
688    #[default]
689    Serverless,
690    /// Requires the caller to provision a dedicated endpoint before requests
691    /// will succeed. The catalog row exists for selection/pricing UI, but
692    /// hosts must not auto-route to it.
693    Dedicated,
694    /// Availability is not known ahead of time. Used for routes that were
695    /// surfaced dynamically (e.g. through `/v1/models`) without a static
696    /// claim from Harn or the user.
697    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        // A step that sets none of the added fields must serialize
749        // byte-identically to the pre-existing {model, provider?, label?}
750        // shape: the new keys are entirely absent (not `null`, not `[]`), so
751        // already-serialized catalog bundles/records stay unchanged.
752        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        // Records written before this change (no added keys) still
777        // deserialize, defaulting the new fields.
778        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        // A `[model_ladders.*]` catalog row carrying when/options/family/
789        // capabilities parses WITHOUT silently discarding them — previously
790        // these keys had no home on the DTO and were dropped on the floor.
791        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}