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, fast mode,
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/// Optional accelerated-serving ("fast mode") tier for a model. Off by
363/// default: its presence only *describes* that the provider offers a
364/// faster, premium-priced serving path running the same weights — callers
365/// must explicitly opt in via the provider's request knob, so nothing here
366/// changes default behavior. Deliberately provider-agnostic: Anthropic
367/// exposes the tier as `speed = "fast"` (beta-gated), while OpenAI uses
368/// `service_tier = "fast"` / `"priority"`. Premium pricing is stored as
369/// absolute per-MTok rates rather than a single multiplier because
370/// providers price the tier asymmetrically (Anthropic Opus 4.8 is 2x
371/// standard; Opus 4.7 fast mode is 6x).
372#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
373pub struct FastModeDef {
374    /// Request field that opts into the fast tier (e.g. "speed" for
375    /// Anthropic, "service_tier" for OpenAI).
376    pub param: String,
377    /// Value to send on `param` (e.g. "fast", "priority").
378    pub value: String,
379    /// Provider beta/feature header required to use the tier, if any
380    /// (e.g. Anthropic "fast-mode-2026-02-01").
381    #[serde(default)]
382    pub beta_header: Option<String>,
383    /// Output-tokens-per-second speedup vs standard serving (e.g. 2.5).
384    #[serde(default)]
385    pub otps_speedup: Option<f64>,
386    /// Lifecycle of the fast tier: "ga" | "research_preview" |
387    /// "deprecated". None when unspecified.
388    #[serde(default)]
389    pub status: Option<String>,
390    /// Premium pricing charged while the fast tier is active (absolute
391    /// per-MTok rates, not a multiplier on standard pricing).
392    #[serde(default)]
393    pub pricing: Option<ModelPricing>,
394    /// Free-text note: constraints, deprecation timeline, etc.
395    #[serde(default)]
396    pub note: Option<String>,
397}
398
399/// A named model-fallback ladder declared in the catalog under
400/// `[model_ladders.<name>]`. A `models`/`ladder` option on `llm_call`
401/// lowers a ladder onto the first-class `routing_policy` chain: each step
402/// is one transport attempt, and the loop advances to the next step ONLY
403/// on transport-class failures (connection/timeout/429/5xx/throttled).
404///
405/// This data-driven declaration follows the same spirit as `fast_mode`
406/// (#4017): a capability/behavior encoded as catalog data rather than
407/// hand-rolled at each downstream call site (harn-bump-fleet,
408/// harn-cloud free_tier_pool, burin-code all shipped their own copy).
409// NB: `PartialEq` only (no `Eq`): `ModelLadderStepDef::options` holds
410// `toml::Value`, which carries floats and therefore is not `Eq`.
411#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
412pub struct ModelLadderDef {
413    /// Ordered ladder steps, cheapest/first to most-capable/last.
414    #[serde(default)]
415    pub steps: Vec<ModelLadderStepDef>,
416    /// Optional human-readable label surfaced on the routing envelope.
417    #[serde(default)]
418    pub label: Option<String>,
419}
420
421/// One rung of a [`ModelLadderDef`]. Full parity with the `.harn`
422/// `ModelLadderStep` alias — `{model, provider?, label?, when?, options?,
423/// family?, capabilities?}` — which is also the shape accepted by the
424/// `models:` option and the `model_ladder(...)` std constructor. Provider is
425/// optional: when omitted it is inferred from the model id (or the call's base
426/// provider) at lowering time.
427///
428/// `options` carries per-step sampling/timeout overrides (same whitelist as
429/// inline `models:` steps); catalog ladders honor them identically instead of
430/// silently dropping them. `when`, `family`, and `capabilities` are
431/// informational to Harn's own ladder lowering (they do not affect transport
432/// failover) but are carried through so catalog and inline ladders declare the
433/// same shape and downstream selectors (e.g. harn-cloud free-tier routing) can
434/// read them. All added fields are optional and serde-absent when unset.
435#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
436pub struct ModelLadderStepDef {
437    pub model: String,
438    #[serde(default)]
439    pub provider: Option<String>,
440    #[serde(default)]
441    pub label: Option<String>,
442    /// Conditional-routing predicate hint (e.g. `"transport_failure"`). Mirror
443    /// of the `.harn` alias `when?` field. Informational to lowering today.
444    /// Absent from serialized output when unset.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub when: Option<String>,
447    /// Per-step sampling/timeout overrides (temperature, max_tokens, top_p,
448    /// seed, timeout_ms, fast, ...), same whitelist as inline `models:` steps.
449    /// Absent from serialized output when unset.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub options: Option<BTreeMap<String, toml::Value>>,
452    /// Normalized model-family token (e.g. `"haiku"`, `"sonnet"`) carried for
453    /// downstream selectors such as harn-cloud's free-tier routing. Purely
454    /// informational to Harn's own ladder lowering — it does not affect
455    /// transport failover. Absent from serialized output when unset.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub family: Option<String>,
458    /// Capability tags this rung claims (e.g. `["vision", "tools"]`). Carried
459    /// for downstream capability-aware routing; informational to Harn's own
460    /// ladder lowering. Absent from serialized output when empty.
461    #[serde(default, skip_serializing_if = "Vec::is_empty")]
462    pub capabilities: Vec<String>,
463}
464
465#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
466pub struct ModelDef {
467    pub name: String,
468    pub provider: String,
469    pub context_window: u64,
470    /// Provider-independent logical model id, when multiple serving routes map
471    /// to the same weights or model family.
472    #[serde(default)]
473    pub logical_model: Option<String>,
474    /// Equivalence class for failover/escalation candidates. Entries in the
475    /// same group are capability-compatible alternatives, not byte-identical
476    /// APIs; callers must still re-render transcripts for the target provider.
477    #[serde(default)]
478    pub equivalence_group: Option<String>,
479    /// Serving-route detail such as "serverless", "priority", "fp8", or a
480    /// provider route slug. This is intentionally separate from `name`.
481    #[serde(default)]
482    pub served_variant: Option<String>,
483    /// Provider-native model id to send on the wire. Defaults to the catalog
484    /// key. Required when two providers expose the same native id and Harn
485    /// needs a unique catalog key for each route.
486    #[serde(default)]
487    pub wire_model: Option<String>,
488    /// Preferred API dialect for the route, e.g. `openai_chat`,
489    /// `openai_responses`, `anthropic_messages`, `gemini_generate_content`.
490    #[serde(default)]
491    pub api_dialect: Option<String>,
492    /// Route-specific token/request quota metadata.
493    #[serde(default)]
494    pub rate_limits: Option<RateLimitsDef>,
495    /// Optional route-level serving performance observations.
496    #[serde(default)]
497    pub performance: Option<ServingPerformanceDef>,
498    /// Underlying model architecture facts separated from the provider id.
499    #[serde(default)]
500    pub architecture: Option<ModelArchitectureDef>,
501    /// Local launch memory-sizing hints used by `harn local launch`.
502    #[serde(default)]
503    pub local_memory: Option<LocalMemoryDef>,
504    #[serde(default)]
505    pub runtime_context_window: Option<u64>,
506    #[serde(default)]
507    pub stream_timeout: Option<f64>,
508    #[serde(default)]
509    pub capabilities: Vec<String>,
510    #[serde(default)]
511    pub pricing: Option<ModelPricing>,
512    #[serde(default)]
513    pub deprecated: bool,
514    #[serde(default)]
515    pub deprecation_note: Option<String>,
516    /// Structured replacement pointer: the catalog id of the model that
517    /// supersedes this one (e.g. an older Opus row points at the newest
518    /// Opus). Lets release tooling express "migrate to X" in a
519    /// machine-readable way instead of burying it in `deprecation_note`
520    /// free text. A model may be superseded without being `deprecated`
521    /// (a newer option exists but this one is still fully supported);
522    /// pair it with `deprecated = true` once a sunset is announced.
523    #[serde(default)]
524    pub superseded_by: Option<String>,
525    /// Accelerated-serving ("fast mode") tier metadata, when the model's
526    /// provider offers one. Off by default — see [`FastModeDef`]. None for
527    /// models with no faster serving path.
528    #[serde(default)]
529    pub fast_mode: Option<FastModeDef>,
530    #[serde(default)]
531    pub quality_tags: Vec<String>,
532    /// Whether the model can be reached over a normal API-key serverless call,
533    /// or only via a dedicated/provisioned endpoint that the caller must spin
534    /// up out-of-band. Providers like Together list dedicated-only routes
535    /// alongside serverless ones in `/v1/models`, so this metadata lets clients
536    /// avoid presenting them as one-click options.
537    #[serde(default)]
538    pub availability: ModelAvailability,
539    /// Popular-consensus tier label. Enum-typed string: "small" | "mid" |
540    /// "frontier" | "reasoning". Self-declared per model (no pattern-matched
541    /// rule table) so the catalog is the single source of truth. When None
542    /// the resolver returns the catalog default ("mid"). Use the richer
543    /// `strengths` + `benchmarks` fields to pick models for specific
544    /// workloads — `tier` exists only as a coarse popular-consensus shortcut.
545    #[serde(default)]
546    pub tier: Option<String>,
547    /// True when the model weights are downloadable / self-hostable
548    /// (open-weight / open-source license, regardless of commercial-use
549    /// restrictions). False when weights are closed (Anthropic, OpenAI,
550    /// Google, etc.). None when the catalog row predates the migration.
551    #[serde(default)]
552    pub open_weight: Option<bool>,
553    /// Workload-shaped strength tags. Conventional values include
554    /// `coding`, `summarization`, `long_context`, `tool_use`, `reasoning`,
555    /// `vision`, `speed`, `cheap`, `agentic`. Selectors should treat
556    /// missing entries as "no claim" rather than "no strength."
557    #[serde(default)]
558    pub strengths: Vec<String>,
559    /// Public benchmark numbers, keyed by a snake_case identifier
560    /// (`swe_bench_verified`, `humaneval`, `aa_intelligence_index`, etc.).
561    /// Values are the raw published scores. The selector layer is free
562    /// to normalize per benchmark; the catalog records the canonical
563    /// score so future readers can audit the source.
564    #[serde(default)]
565    pub benchmarks: BTreeMap<String, f64>,
566    /// Normalized model-family token used as a diversity signal for
567    /// reviewer selection. Distinct from provider: hosted wrappers should
568    /// keep the underlying family (for example OpenRouter-hosted Claude
569    /// still uses `anthropic-claude`).
570    #[serde(default)]
571    pub family: Option<String>,
572    /// Narrower family lineage used by option-pack calibration.
573    #[serde(default)]
574    pub lineage: Option<String>,
575    /// Preferred reviewer families for critique/review workloads.
576    #[serde(default)]
577    pub complementary_with: Vec<String>,
578    /// Author families, lineages, model ids, or provider/model selectors
579    /// this row should not review.
580    #[serde(default)]
581    pub avoid_as_reviewer_for: Vec<String>,
582}
583
584#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
585#[serde(rename_all = "snake_case")]
586pub enum ModelAvailability {
587    /// Reachable through the provider's normal API-key path with no extra
588    /// setup. The default for cataloged hosted/local models: by cataloging a
589    /// row we are claiming the route works out of the box.
590    #[default]
591    Serverless,
592    /// Requires the caller to provision a dedicated endpoint before requests
593    /// will succeed. The catalog row exists for selection/pricing UI, but
594    /// hosts must not auto-route to it.
595    Dedicated,
596    /// Availability is not known ahead of time. Used for routes that were
597    /// surfaced dynamically (e.g. through `/v1/models`) without a static
598    /// claim from Harn or the user.
599    Unknown,
600}
601
602impl ModelAvailability {
603    pub fn as_str(self) -> &'static str {
604        match self {
605            Self::Serverless => "serverless",
606            Self::Dedicated => "dedicated",
607            Self::Unknown => "unknown",
608        }
609    }
610
611    pub fn parse(value: &str) -> Option<Self> {
612        match value {
613            "serverless" => Some(Self::Serverless),
614            "dedicated" => Some(Self::Dedicated),
615            "unknown" => Some(Self::Unknown),
616            _ => None,
617        }
618    }
619}
620
621#[cfg(test)]
622mod ladder_step_tests {
623    use super::{ModelLadderDef, ModelLadderStepDef};
624
625    #[test]
626    fn all_added_fields_round_trip() {
627        let mut options = std::collections::BTreeMap::new();
628        options.insert("temperature".to_string(), toml::Value::Float(0.2));
629        options.insert("max_tokens".to_string(), toml::Value::Integer(512));
630        let step = ModelLadderStepDef {
631            model: "claude-haiku-4-5".to_string(),
632            provider: Some("anthropic".to_string()),
633            label: Some("cheap".to_string()),
634            when: Some("transport_failure".to_string()),
635            options: Some(options),
636            family: Some("haiku".to_string()),
637            capabilities: vec!["vision".to_string(), "tools".to_string()],
638        };
639        let json = serde_json::to_string(&step).expect("serialize");
640        let back: ModelLadderStepDef = serde_json::from_str(&json).expect("deserialize");
641        assert_eq!(step, back);
642        assert!(json.contains("\"family\":\"haiku\""));
643        assert!(json.contains("\"capabilities\":[\"vision\",\"tools\"]"));
644        assert!(json.contains("\"when\":\"transport_failure\""));
645        assert!(json.contains("\"temperature\":0.2"));
646    }
647
648    #[test]
649    fn unset_added_fields_are_absent_from_serialized_output() {
650        // A step that sets none of the added fields must serialize
651        // byte-identically to the pre-existing {model, provider?, label?}
652        // shape: the new keys are entirely absent (not `null`, not `[]`), so
653        // already-serialized catalog bundles/records stay unchanged.
654        let step = ModelLadderStepDef {
655            model: "mock-cheap".to_string(),
656            provider: Some("mock".to_string()),
657            label: None,
658            when: None,
659            options: None,
660            family: None,
661            capabilities: Vec::new(),
662        };
663        let json = serde_json::to_string(&step).expect("serialize");
664        assert_eq!(
665            json,
666            r#"{"model":"mock-cheap","provider":"mock","label":null}"#
667        );
668        for absent in ["family", "capabilities", "when", "options"] {
669            assert!(
670                !json.contains(absent),
671                "unexpected key {absent:?} in {json}"
672            );
673        }
674    }
675
676    #[test]
677    fn deserializes_without_added_fields() {
678        // Records written before this change (no added keys) still
679        // deserialize, defaulting the new fields.
680        let step: ModelLadderStepDef =
681            serde_json::from_str(r#"{"model":"mock-cheap"}"#).expect("deserialize legacy");
682        assert_eq!(step.when, None);
683        assert_eq!(step.family, None);
684        assert!(step.options.is_none());
685        assert!(step.capabilities.is_empty());
686    }
687
688    #[test]
689    fn catalog_toml_row_retains_when_and_options() {
690        // A `[model_ladders.*]` catalog row carrying when/options/family/
691        // capabilities parses WITHOUT silently discarding them — previously
692        // these keys had no home on the DTO and were dropped on the floor.
693        let toml_src = r#"
694label = "with overrides"
695steps = [
696  { model = "haiku", label = "cheap", when = "transport_failure", family = "haiku", capabilities = ["tools"], options = { temperature = 0.1, max_tokens = 256 } },
697  { model = "opus", label = "frontier", family = "opus" },
698]
699"#;
700        let def: ModelLadderDef = toml::from_str(toml_src).expect("parse ladder toml");
701        assert_eq!(def.steps.len(), 2);
702        let cheap = &def.steps[0];
703        assert_eq!(cheap.when.as_deref(), Some("transport_failure"));
704        assert_eq!(cheap.family.as_deref(), Some("haiku"));
705        assert_eq!(cheap.capabilities, vec!["tools".to_string()]);
706        let opts = cheap.options.as_ref().expect("options present");
707        assert_eq!(opts.get("temperature"), Some(&toml::Value::Float(0.1)));
708        assert_eq!(opts.get("max_tokens"), Some(&toml::Value::Integer(256)));
709        assert_eq!(def.steps[1].family.as_deref(), Some("opus"));
710    }
711}