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}
193
194/// Provider or model quota metadata. Providers publish these along several
195/// axes, and any one exhausted bucket can trigger throttling.
196#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
197pub struct RateLimitsDef {
198    /// Requests per minute.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub rpm: Option<u32>,
201    /// Requests per hour.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub rph: Option<u32>,
204    /// Requests per day.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub rpd: Option<u32>,
207    /// Total tokens per minute.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub tpm: Option<u64>,
210    /// Total tokens per hour.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub tph: Option<u64>,
213    /// Total tokens per day.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub tpd: Option<u64>,
216    /// Input tokens per minute, when the provider splits input/output quotas.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub input_tpm: Option<u64>,
219    /// Output tokens per minute, when the provider splits input/output quotas.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub output_tpm: Option<u64>,
222    /// Concurrent in-flight requests, if published.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub concurrency: Option<u32>,
225    /// Account tier or route class these limits describe.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub tier: Option<String>,
228    /// Official source URL for the row.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub source_url: Option<String>,
231    /// YYYY-MM-DD date when the row was last verified.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub last_verified: Option<String>,
234    /// Free-text caveat for account-dependent or burst limits.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub notes: Option<String>,
237}
238
239impl RateLimitsDef {
240    pub fn is_empty(&self) -> bool {
241        self.rpm.is_none()
242            && self.rph.is_none()
243            && self.rpd.is_none()
244            && self.tpm.is_none()
245            && self.tph.is_none()
246            && self.tpd.is_none()
247            && self.input_tpm.is_none()
248            && self.output_tpm.is_none()
249            && self.concurrency.is_none()
250            && self.tier.is_none()
251            && self.source_url.is_none()
252            && self.last_verified.is_none()
253            && self.notes.is_none()
254    }
255
256    pub fn with_rpm_fallback(mut self, rpm: Option<u32>) -> Option<Self> {
257        if self.rpm.is_none() {
258            self.rpm = rpm;
259        }
260        (!self.is_empty()).then_some(self)
261    }
262}
263
264/// Optional provider/model serving-performance observation. This records
265/// benchmark or live-probe facts, not a hard runtime contract; callers should
266/// treat missing fields as unknown and stale dates as advisory.
267#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
268pub struct ServingPerformanceDef {
269    /// Observed time-to-first-token in milliseconds.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub observed_ttft_ms: Option<u64>,
272    /// Observed output generation rate in tokens per second.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub output_tokens_per_sec: Option<f64>,
275    /// End-to-end time-to-answer in seconds for the cited benchmark, when
276    /// reported separately from TTFT/generation rate.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub time_to_answer_s: Option<f64>,
279    /// Source label, e.g. `artificial_analysis`, `harn_probe`, or
280    /// `provider_blog`.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub source: Option<String>,
283    /// Source URL for the observation.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub source_url: Option<String>,
286    /// YYYY-MM-DD date when the observation was last verified.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub last_verified: Option<String>,
289    /// Number of requests or benchmark samples behind this row, if known.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub sample_size: Option<u32>,
292    /// Short caveat such as streaming mode, warm/cold route, or prompt shape.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub notes: Option<String>,
295}
296
297impl ServingPerformanceDef {
298    pub fn is_empty(&self) -> bool {
299        self.observed_ttft_ms.is_none()
300            && self.output_tokens_per_sec.is_none()
301            && self.time_to_answer_s.is_none()
302            && self.source.is_none()
303            && self.source_url.is_none()
304            && self.last_verified.is_none()
305            && self.sample_size.is_none()
306            && self.notes.is_none()
307    }
308}
309
310/// Logical-model facts separated from provider serving routes. These fields
311/// describe the underlying weights or public model family, not Harn's alias or
312/// provider/model selector.
313#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
314pub struct ModelArchitectureDef {
315    /// Total parameter count in billions.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub parameter_count_b: Option<f64>,
318    /// Active parameter count in billions for MoE models.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub active_parameter_count_b: Option<f64>,
321    /// True for mixture-of-experts models.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub moe: Option<bool>,
324    /// Quantization advertised by this route, if route-specific.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub quantization: Option<String>,
327    /// Numeric precision advertised by this route, if known.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub precision: Option<String>,
330    /// License identifier or short label.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub license: Option<String>,
333    /// Tokenizer family or implementation hint.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub tokenizer: Option<String>,
336    /// Public knowledge cutoff claim, when published.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub knowledge_cutoff: Option<String>,
339    /// Official source URL for these facts.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub source_url: Option<String>,
342    /// YYYY-MM-DD date when these facts were last verified.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub last_verified: Option<String>,
345}
346
347impl ModelArchitectureDef {
348    pub fn is_empty(&self) -> bool {
349        self.parameter_count_b.is_none()
350            && self.active_parameter_count_b.is_none()
351            && self.moe.is_none()
352            && self.quantization.is_none()
353            && self.precision.is_none()
354            && self.license.is_none()
355            && self.tokenizer.is_none()
356            && self.knowledge_cutoff.is_none()
357            && self.source_url.is_none()
358            && self.last_verified.is_none()
359    }
360}
361
362/// Provider request knob that selects a non-default serving tier.
363#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
364pub struct ServingTierRequestDef {
365    /// Request field that opts into the tier (for example `speed` for
366    /// Anthropic or `service_tier` for OpenAI/Gemini).
367    pub param: String,
368    /// Value to send on `param` (for example `fast`, `flex`, or `priority`).
369    pub value: String,
370    /// Provider beta/feature header required to use the tier, if any.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub beta_header: Option<String>,
373}
374
375/// Whether a serving tier is synchronous request handling or some other
376/// provider execution lane. Batch APIs remain represented by the separate
377/// async `batch` capability; discounted synchronous lanes such as Gemini Flex
378/// belong here instead of overloading `batch_api`.
379#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
380#[serde(rename_all = "snake_case")]
381pub enum ServingTierMode {
382    Synchronous,
383}
384
385/// Economic shape of a serving tier relative to the default synchronous API.
386#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
387#[serde(rename_all = "snake_case")]
388pub enum ServingTierEconomics {
389    Discounted,
390    Standard,
391    Premium,
392}
393
394/// Optional non-default synchronous serving tier for a model. Off by default:
395/// its presence only describes provider capability. Callers must explicitly
396/// opt in via the declared request knob, so nothing here changes default
397/// behavior. Batch APIs are intentionally not modeled here; they remain the
398/// separate async `batch` capability used by `harn models batch`.
399#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
400pub struct ServingTierDef {
401    /// Stable tier id, e.g. `fast`, `flex`, or `priority`.
402    pub id: String,
403    /// Human-readable display label for CLI/catalog renderers.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub label: Option<String>,
406    pub mode: ServingTierMode,
407    pub economics: ServingTierEconomics,
408    /// Request knob for tiers selected per request. Some tiers may be
409    /// informational/account-level only and omit a knob.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub request: Option<ServingTierRequestDef>,
412    /// Output-tokens-per-second speedup vs standard serving (e.g. 2.5).
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub otps_speedup: Option<f64>,
415    /// Price multiplier relative to default synchronous rates, when public.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub cost_multiplier: Option<f64>,
418    /// Discount percentage relative to default synchronous rates, when public.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub discount_percent: Option<u32>,
421    /// Lifecycle of the tier: "ga" | "research_preview" | "deprecated".
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub status: Option<String>,
424    /// Absolute per-MTok rates charged while the tier is active. Prefer this
425    /// over a multiplier when the provider prices the tier asymmetrically.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub pricing: Option<ModelPricing>,
428    /// Latency expectation for humans and planners.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub latency: Option<String>,
431    /// Reliability/availability expectation for humans and planners.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub reliability: Option<String>,
434    /// Quota-pool or eligibility notes.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub quota: Option<String>,
437    /// Workloads this tier is suitable for (e.g. `offline_eval`, `corpus`).
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub suitable_workloads: Vec<String>,
440    /// Workloads this tier should generally avoid (e.g. `interactive_chat`).
441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
442    pub unsuitable_workloads: Vec<String>,
443    /// Free-text note: constraints, deprecation timeline, cache behavior, etc.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub note: Option<String>,
446}
447
448/// A named model-fallback ladder declared in the catalog under
449/// `[model_ladders.<name>]`. A `models`/`ladder` option on `llm_call`
450/// lowers a ladder onto the first-class `routing_policy` chain: each step
451/// is one transport attempt, and the loop advances to the next step ONLY
452/// on transport-class failures (connection/timeout/429/5xx/throttled).
453///
454/// This data-driven declaration follows the same spirit as `serving_tiers`
455/// (#4017): a capability/behavior encoded as catalog data rather than
456/// hand-rolled at each downstream call site (harn-bump-fleet,
457/// harn-cloud free_tier_pool, burin-code all shipped their own copy).
458// NB: `PartialEq` only (no `Eq`): `ModelLadderStepDef::options` holds
459// `toml::Value`, which carries floats and therefore is not `Eq`.
460#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
461pub struct ModelLadderDef {
462    /// Ordered ladder steps, cheapest/first to most-capable/last.
463    #[serde(default)]
464    pub steps: Vec<ModelLadderStepDef>,
465    /// Optional human-readable label surfaced on the routing envelope.
466    #[serde(default)]
467    pub label: Option<String>,
468}
469
470/// One rung of a [`ModelLadderDef`]. Full parity with the `.harn`
471/// `ModelLadderStep` alias — `{model, provider?, label?, when?, options?,
472/// family?, capabilities?}` — which is also the shape accepted by the
473/// `models:` option and the `model_ladder(...)` std constructor. Provider is
474/// optional: when omitted it is inferred from the model id (or the call's base
475/// provider) at lowering time.
476///
477/// `options` carries per-step sampling/timeout overrides (same whitelist as
478/// inline `models:` steps); catalog ladders honor them identically instead of
479/// silently dropping them. `when`, `family`, and `capabilities` are
480/// informational to Harn's own ladder lowering (they do not affect transport
481/// failover) but are carried through so catalog and inline ladders declare the
482/// same shape and downstream selectors (e.g. harn-cloud free-tier routing) can
483/// read them. All added fields are optional and serde-absent when unset.
484#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
485pub struct ModelLadderStepDef {
486    pub model: String,
487    #[serde(default)]
488    pub provider: Option<String>,
489    #[serde(default)]
490    pub label: Option<String>,
491    /// Conditional-routing predicate hint (e.g. `"transport_failure"`). Mirror
492    /// of the `.harn` alias `when?` field. Informational to lowering today.
493    /// Absent from serialized output when unset.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub when: Option<String>,
496    /// Per-step sampling/timeout overrides (temperature, max_tokens, top_p,
497    /// seed, timeout_ms, fast, ...), same whitelist as inline `models:` steps.
498    /// Absent from serialized output when unset.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub options: Option<BTreeMap<String, toml::Value>>,
501    /// Normalized model-family token (e.g. `"haiku"`, `"sonnet"`) carried for
502    /// downstream selectors such as harn-cloud's free-tier routing. Purely
503    /// informational to Harn's own ladder lowering — it does not affect
504    /// transport failover. Absent from serialized output when unset.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub family: Option<String>,
507    /// Capability tags this rung claims (e.g. `["vision", "tools"]`). Carried
508    /// for downstream capability-aware routing; informational to Harn's own
509    /// ladder lowering. Absent from serialized output when empty.
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub capabilities: Vec<String>,
512}
513
514#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
515pub struct ModelDef {
516    pub name: String,
517    pub provider: String,
518    pub context_window: u64,
519    /// Provider-independent logical model id, when multiple serving routes map
520    /// to the same weights or model family.
521    #[serde(default)]
522    pub logical_model: Option<String>,
523    /// Equivalence class for failover/escalation candidates. Entries in the
524    /// same group are capability-compatible alternatives, not byte-identical
525    /// APIs; callers must still re-render transcripts for the target provider.
526    #[serde(default)]
527    pub equivalence_group: Option<String>,
528    /// Serving-route detail such as "serverless", "priority", "fp8", or a
529    /// provider route slug. This is intentionally separate from `name`.
530    #[serde(default)]
531    pub served_variant: Option<String>,
532    /// Provider-native model id to send on the wire. Defaults to the catalog
533    /// key. Required when two providers expose the same native id and Harn
534    /// needs a unique catalog key for each route.
535    #[serde(default)]
536    pub wire_model: Option<String>,
537    /// Preferred API dialect for the route, e.g. `openai_chat`,
538    /// `openai_responses`, `anthropic_messages`, `gemini_generate_content`.
539    #[serde(default)]
540    pub api_dialect: Option<String>,
541    /// Route-specific token/request quota metadata.
542    #[serde(default)]
543    pub rate_limits: Option<RateLimitsDef>,
544    /// Optional route-level serving performance observations.
545    #[serde(default)]
546    pub performance: Option<ServingPerformanceDef>,
547    /// Underlying model architecture facts separated from the provider id.
548    #[serde(default)]
549    pub architecture: Option<ModelArchitectureDef>,
550    /// Local launch memory-sizing hints used by `harn local launch`.
551    #[serde(default)]
552    pub local_memory: Option<LocalMemoryDef>,
553    #[serde(default)]
554    pub runtime_context_window: Option<u64>,
555    #[serde(default)]
556    pub stream_timeout: Option<f64>,
557    #[serde(default)]
558    pub capabilities: Vec<String>,
559    #[serde(default)]
560    pub pricing: Option<ModelPricing>,
561    #[serde(default)]
562    pub deprecated: bool,
563    #[serde(default)]
564    pub deprecation_note: Option<String>,
565    /// Structured replacement pointer: the catalog id of the model that
566    /// supersedes this one (e.g. an older Opus row points at the newest
567    /// Opus). Lets release tooling express "migrate to X" in a
568    /// machine-readable way instead of burying it in `deprecation_note`
569    /// free text. A model may be superseded without being `deprecated`
570    /// (a newer option exists but this one is still fully supported);
571    /// pair it with `deprecated = true` once a sunset is announced.
572    #[serde(default)]
573    pub superseded_by: Option<String>,
574    /// Non-default synchronous serving tiers exposed by the provider, such as
575    /// premium fast/priority queues or discounted best-effort Flex lanes. Off
576    /// by default — see [`ServingTierDef`]. Empty for models with no alternate
577    /// synchronous serving path.
578    #[serde(default, skip_serializing_if = "Vec::is_empty")]
579    pub serving_tiers: Vec<ServingTierDef>,
580    #[serde(default)]
581    pub quality_tags: Vec<String>,
582    /// Whether the model can be reached over a normal API-key serverless call,
583    /// or only via a dedicated/provisioned endpoint that the caller must spin
584    /// up out-of-band. Providers like Together list dedicated-only routes
585    /// alongside serverless ones in `/v1/models`, so this metadata lets clients
586    /// avoid presenting them as one-click options.
587    #[serde(default)]
588    pub availability: ModelAvailability,
589    /// Popular-consensus tier label. Enum-typed string: "small" | "mid" |
590    /// "frontier" | "reasoning". Self-declared per model (no pattern-matched
591    /// rule table) so the catalog is the single source of truth. When None
592    /// the resolver returns the catalog default ("mid"). Use the richer
593    /// `strengths` + `benchmarks` fields to pick models for specific
594    /// workloads — `tier` exists only as a coarse popular-consensus shortcut.
595    #[serde(default)]
596    pub tier: Option<String>,
597    /// True when the model weights are downloadable / self-hostable
598    /// (open-weight / open-source license, regardless of commercial-use
599    /// restrictions). False when weights are closed (Anthropic, OpenAI,
600    /// Google, etc.). None when the catalog row predates the migration.
601    #[serde(default)]
602    pub open_weight: Option<bool>,
603    /// Workload-shaped strength tags. Conventional values include
604    /// `coding`, `summarization`, `long_context`, `tool_use`, `reasoning`,
605    /// `vision`, `speed`, `cheap`, `agentic`. Selectors should treat
606    /// missing entries as "no claim" rather than "no strength."
607    #[serde(default)]
608    pub strengths: Vec<String>,
609    /// Public benchmark numbers, keyed by a snake_case identifier
610    /// (`swe_bench_verified`, `humaneval`, `aa_intelligence_index`, etc.).
611    /// Values are the raw published scores. The selector layer is free
612    /// to normalize per benchmark; the catalog records the canonical
613    /// score so future readers can audit the source.
614    #[serde(default)]
615    pub benchmarks: BTreeMap<String, f64>,
616    /// Normalized model-family token used as a diversity signal for
617    /// reviewer selection. Distinct from provider: hosted wrappers should
618    /// keep the underlying family (for example OpenRouter-hosted Claude
619    /// still uses `anthropic-claude`).
620    #[serde(default)]
621    pub family: Option<String>,
622    /// Narrower family lineage used by option-pack calibration.
623    #[serde(default)]
624    pub lineage: Option<String>,
625    /// Preferred reviewer families for critique/review workloads.
626    #[serde(default)]
627    pub complementary_with: Vec<String>,
628    /// Author families, lineages, model ids, or provider/model selectors
629    /// this row should not review.
630    #[serde(default)]
631    pub avoid_as_reviewer_for: Vec<String>,
632}
633
634#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
635#[serde(rename_all = "snake_case")]
636pub enum ModelAvailability {
637    /// Reachable through the provider's normal API-key path with no extra
638    /// setup. The default for cataloged hosted/local models: by cataloging a
639    /// row we are claiming the route works out of the box.
640    #[default]
641    Serverless,
642    /// Requires the caller to provision a dedicated endpoint before requests
643    /// will succeed. The catalog row exists for selection/pricing UI, but
644    /// hosts must not auto-route to it.
645    Dedicated,
646    /// Availability is not known ahead of time. Used for routes that were
647    /// surfaced dynamically (e.g. through `/v1/models`) without a static
648    /// claim from Harn or the user.
649    Unknown,
650}
651
652impl ModelAvailability {
653    pub fn as_str(self) -> &'static str {
654        match self {
655            Self::Serverless => "serverless",
656            Self::Dedicated => "dedicated",
657            Self::Unknown => "unknown",
658        }
659    }
660
661    pub fn parse(value: &str) -> Option<Self> {
662        match value {
663            "serverless" => Some(Self::Serverless),
664            "dedicated" => Some(Self::Dedicated),
665            "unknown" => Some(Self::Unknown),
666            _ => None,
667        }
668    }
669}
670
671#[cfg(test)]
672mod ladder_step_tests {
673    use super::{ModelLadderDef, ModelLadderStepDef};
674
675    #[test]
676    fn all_added_fields_round_trip() {
677        let mut options = std::collections::BTreeMap::new();
678        options.insert("temperature".to_string(), toml::Value::Float(0.2));
679        options.insert("max_tokens".to_string(), toml::Value::Integer(512));
680        let step = ModelLadderStepDef {
681            model: "claude-haiku-4-5".to_string(),
682            provider: Some("anthropic".to_string()),
683            label: Some("cheap".to_string()),
684            when: Some("transport_failure".to_string()),
685            options: Some(options),
686            family: Some("haiku".to_string()),
687            capabilities: vec!["vision".to_string(), "tools".to_string()],
688        };
689        let json = serde_json::to_string(&step).expect("serialize");
690        let back: ModelLadderStepDef = serde_json::from_str(&json).expect("deserialize");
691        assert_eq!(step, back);
692        assert!(json.contains("\"family\":\"haiku\""));
693        assert!(json.contains("\"capabilities\":[\"vision\",\"tools\"]"));
694        assert!(json.contains("\"when\":\"transport_failure\""));
695        assert!(json.contains("\"temperature\":0.2"));
696    }
697
698    #[test]
699    fn unset_added_fields_are_absent_from_serialized_output() {
700        // A step that sets none of the added fields must serialize
701        // byte-identically to the pre-existing {model, provider?, label?}
702        // shape: the new keys are entirely absent (not `null`, not `[]`), so
703        // already-serialized catalog bundles/records stay unchanged.
704        let step = ModelLadderStepDef {
705            model: "mock-cheap".to_string(),
706            provider: Some("mock".to_string()),
707            label: None,
708            when: None,
709            options: None,
710            family: None,
711            capabilities: Vec::new(),
712        };
713        let json = serde_json::to_string(&step).expect("serialize");
714        assert_eq!(
715            json,
716            r#"{"model":"mock-cheap","provider":"mock","label":null}"#
717        );
718        for absent in ["family", "capabilities", "when", "options"] {
719            assert!(
720                !json.contains(absent),
721                "unexpected key {absent:?} in {json}"
722            );
723        }
724    }
725
726    #[test]
727    fn deserializes_without_added_fields() {
728        // Records written before this change (no added keys) still
729        // deserialize, defaulting the new fields.
730        let step: ModelLadderStepDef =
731            serde_json::from_str(r#"{"model":"mock-cheap"}"#).expect("deserialize legacy");
732        assert_eq!(step.when, None);
733        assert_eq!(step.family, None);
734        assert!(step.options.is_none());
735        assert!(step.capabilities.is_empty());
736    }
737
738    #[test]
739    fn catalog_toml_row_retains_when_and_options() {
740        // A `[model_ladders.*]` catalog row carrying when/options/family/
741        // capabilities parses WITHOUT silently discarding them — previously
742        // these keys had no home on the DTO and were dropped on the floor.
743        let toml_src = r#"
744label = "with overrides"
745steps = [
746  { model = "haiku", label = "cheap", when = "transport_failure", family = "haiku", capabilities = ["tools"], options = { temperature = 0.1, max_tokens = 256 } },
747  { model = "opus", label = "frontier", family = "opus" },
748]
749"#;
750        let def: ModelLadderDef = toml::from_str(toml_src).expect("parse ladder toml");
751        assert_eq!(def.steps.len(), 2);
752        let cheap = &def.steps[0];
753        assert_eq!(cheap.when.as_deref(), Some("transport_failure"));
754        assert_eq!(cheap.family.as_deref(), Some("haiku"));
755        assert_eq!(cheap.capabilities, vec!["tools".to_string()]);
756        let opts = cheap.options.as_ref().expect("options present");
757        assert_eq!(opts.get("temperature"), Some(&toml::Value::Float(0.1)));
758        assert_eq!(opts.get("max_tokens"), Some(&toml::Value::Integer(256)));
759        assert_eq!(def.steps[1].family.as_deref(), Some("opus"));
760    }
761}