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, PartialEq, Eq)]
9pub struct HealthcheckDef {
10    pub method: String,
11    #[serde(default)]
12    pub path: Option<String>,
13    #[serde(default)]
14    pub url: Option<String>,
15    #[serde(default)]
16    pub body: Option<String>,
17}
18
19/// How Harn interacts with a catalog-declared local provider runtime.
20///
21/// The snake-case wire values are part of the checked-in provider catalog.
22#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum LocalRuntimeKind {
25    /// A runtime with its own resident daemon that Harn can warm through an API.
26    DaemonApi,
27    /// A server process Harn starts and tracks by PID.
28    ManagedProcess,
29    /// A user-managed endpoint Harn can inspect and select but never owns.
30    External,
31}
32
33/// How Harn can safely stop a catalog-declared local provider runtime.
34///
35/// This remains distinct from [`LocalRuntimeKind`]: a future daemon API may
36/// acquire a different stop protocol without changing process ownership.
37#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum LocalRuntimeStop {
40    /// Ask a daemon API to unload its resident model without killing a process.
41    KeepAliveZero,
42    /// Stop only the PID Harn recorded when it launched the process.
43    Pid,
44    /// Leave the runtime entirely under user or host management.
45    External,
46}
47
48/// Request and lifecycle protocol exposed by a catalog-declared local runtime.
49///
50/// Provider identity is not a protocol contract: several providers can expose
51/// the same server API, while one provider may evolve independently. Local
52/// lifecycle code therefore dispatches through this closed catalog value.
53#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum LocalRuntimeWireProtocol {
56    /// Ollama's `/api/*` lifecycle endpoints and request shapes.
57    OllamaApi,
58    /// The OpenAI-compatible `/v1/models` readiness surface.
59    OpenAiCompatible,
60}
61
62/// A complete, coherent local-runtime lifecycle contract.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct LocalRuntimeLifecycle {
65    pub kind: LocalRuntimeKind,
66    pub stop: LocalRuntimeStop,
67    pub wire_protocol: LocalRuntimeWireProtocol,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum LocalRuntimeLifecycleError {
72    MissingKind,
73    MissingStop,
74    MissingWireProtocol,
75    Incoherent {
76        kind: LocalRuntimeKind,
77        stop: LocalRuntimeStop,
78        wire_protocol: LocalRuntimeWireProtocol,
79    },
80}
81
82impl std::fmt::Display for LocalRuntimeLifecycleError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::MissingKind => f.write_str("local_runtime.kind cannot be empty"),
86            Self::MissingStop => f.write_str("local_runtime.stop cannot be empty"),
87            Self::MissingWireProtocol => {
88                f.write_str("local_runtime.wire_protocol cannot be empty")
89            }
90            Self::Incoherent {
91                kind,
92                stop,
93                wire_protocol,
94            } => write!(
95                f,
96                "local_runtime kind={kind:?}, stop={stop:?}, wire_protocol={wire_protocol:?} is incoherent"
97            ),
98        }
99    }
100}
101
102#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
103pub struct LocalRuntimeDef {
104    /// Lifecycle style: `daemon_api` for runtimes with their own resident
105    /// daemon (Ollama), `managed_process` for Harn-spawned servers, or
106    /// `external` for a user-managed endpoint Harn can inspect and select.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub kind: Option<LocalRuntimeKind>,
109    /// API wire contract used for lifecycle requests and readiness probes.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub wire_protocol: Option<LocalRuntimeWireProtocol>,
112    /// Command Harn should execute for managed-process runtimes.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub command: Option<String>,
115    /// Arguments that must appear immediately after the command, before model
116    /// and server flags. Used by CLIs such as `vllm serve ...`.
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub prefix_args: Vec<String>,
119    /// Default model source/path/repo. User overlays may set this; embedded
120    /// catalog rows avoid machine-specific absolute paths except examples.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub model_source: Option<String>,
123    /// Environment variable that can provide a model source.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub model_source_env: Option<String>,
126    /// Default port when the provider base URL has none.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub default_port: Option<u16>,
129    /// Argument names used by the runtime CLI.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub model_arg: Option<String>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub served_model_arg: Option<String>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub host_arg: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub port_arg: Option<String>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub ctx_arg: Option<String>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub parallel_arg: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub gpu_layers_arg: Option<String>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub cache_type_k_arg: Option<String>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub cache_type_v_arg: Option<String>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub cache_ram_arg: Option<String>,
150    /// Argument that accepts a JSON object configuring the runtime's chat
151    /// template parser.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub chat_template_kwargs_arg: Option<String>,
154    /// Flag that enables the runtime's Jinja chat-template parser.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub jinja_arg: Option<String>,
157    /// Argument that selects the runtime's reasoning mode.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub reasoning_arg: Option<String>,
160    /// Argument that selects the runtime's reasoning extraction format.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub reasoning_format_arg: Option<String>,
163    /// Argument that selects the runtime's flash-attention mode.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub flash_attn_arg: Option<String>,
166    /// Flag that enables the runtime's metrics endpoint.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub metrics_arg: Option<String>,
169    /// Flag that enables adapter-aware serving for LoRA-capable runtimes.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub enable_lora_arg: Option<String>,
172    /// Flag that accepts one or more LoRA module specs.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub lora_modules_arg: Option<String>,
175    /// Runtime value shape for LoRA module specs. Defaults to `name_path`.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub lora_modules_value_format: Option<String>,
178    /// Optional rank-limit flag for runtimes that need an explicit ceiling.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub max_lora_rank_arg: Option<String>,
181    /// Extra arguments Harn applies by default when launching this runtime.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub default_args: Vec<String>,
184    /// Stop strategy: `keep_alive_zero`, `pid`, or `external`.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub stop: Option<LocalRuntimeStop>,
187    /// Official docs/source URL for the lifecycle contract.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub source_url: Option<String>,
190    /// YYYY-MM-DD date when the local runtime row was last verified.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub last_verified: Option<String>,
193    /// Short operational note surfaced by CLI docs/help.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub notes: Option<String>,
196}
197
198impl LocalRuntimeDef {
199    /// Return the lifecycle contract only when its ownership, stop, and wire
200    /// semantics agree. Consumers call this once at their boundary instead of
201    /// re-encoding provider-name conditionals at every operation.
202    pub fn lifecycle(&self) -> Result<LocalRuntimeLifecycle, LocalRuntimeLifecycleError> {
203        let kind = self.kind.ok_or(LocalRuntimeLifecycleError::MissingKind)?;
204        let stop = self.stop.ok_or(LocalRuntimeLifecycleError::MissingStop)?;
205        let wire_protocol = self
206            .wire_protocol
207            .ok_or(LocalRuntimeLifecycleError::MissingWireProtocol)?;
208        let coherent = matches!(
209            (kind, stop, wire_protocol),
210            (
211                LocalRuntimeKind::DaemonApi,
212                LocalRuntimeStop::KeepAliveZero,
213                LocalRuntimeWireProtocol::OllamaApi,
214            ) | (
215                LocalRuntimeKind::ManagedProcess,
216                LocalRuntimeStop::Pid,
217                LocalRuntimeWireProtocol::OpenAiCompatible,
218            ) | (
219                LocalRuntimeKind::External,
220                LocalRuntimeStop::External,
221                LocalRuntimeWireProtocol::OpenAiCompatible,
222            )
223        );
224        if !coherent {
225            return Err(LocalRuntimeLifecycleError::Incoherent {
226                kind,
227                stop,
228                wire_protocol,
229            });
230        }
231        Ok(LocalRuntimeLifecycle {
232            kind,
233            stop,
234            wire_protocol,
235        })
236    }
237}
238
239#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
240pub struct LocalMemoryDef {
241    /// Empirical resident memory observed for this route/runtime.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub measured_resident_gib: Option<f64>,
244    /// Context size used for the empirical measurement.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub measured_context_window: Option<u64>,
247    /// KV-cache type used for the empirical measurement.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub measured_cache_type: Option<String>,
250    /// Approximate non-context resident footprint for this model/runtime.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub base_resident_gib: Option<f64>,
253    /// Approximate GiB consumed by KV cache per 1,000 context tokens at the
254    /// default cache type.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub kv_cache_gib_per_1k_ctx: Option<f64>,
257    /// Cache-type multiplier relative to `kv_cache_gib_per_1k_ctx`.
258    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259    pub cache_type_multipliers: BTreeMap<String, f64>,
260    /// Cache type assumed when the launch command does not set K/V cache.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub default_cache_type: Option<String>,
263    /// Minimum headroom Harn should leave for the OS and other apps.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub safety_margin_gib: Option<f64>,
266    /// Highest context Harn should recommend automatically from this row.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub max_recommended_context: Option<u64>,
269    /// Official or empirical source for the sizing row.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub source_url: Option<String>,
272    /// YYYY-MM-DD date when the sizing row was last verified.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub last_verified: Option<String>,
275    /// Short operational note surfaced by CLI diagnostics/docs.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub notes: Option<String>,
278}
279
280impl LocalMemoryDef {
281    pub fn is_empty(&self) -> bool {
282        self.measured_resident_gib.is_none()
283            && self.measured_context_window.is_none()
284            && self.measured_cache_type.is_none()
285            && self.base_resident_gib.is_none()
286            && self.kv_cache_gib_per_1k_ctx.is_none()
287            && self.cache_type_multipliers.is_empty()
288            && self.default_cache_type.is_none()
289            && self.safety_margin_gib.is_none()
290            && self.max_recommended_context.is_none()
291            && self.source_url.is_none()
292            && self.last_verified.is_none()
293            && self.notes.is_none()
294    }
295}
296
297#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
298pub struct AliasDef {
299    pub id: String,
300    pub provider: String,
301    /// Per-model tool format override: "native" or "text". When set, this
302    /// takes precedence over the provider-level default. Models with strong
303    /// tool-calling fine-tuning (Kimi-K2.5, GPT-4o) should use "native";
304    /// models better served by text-based tool calling use "text".
305    #[serde(default)]
306    pub tool_format: Option<String>,
307}
308
309#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
310pub struct AliasToolCallingDef {
311    #[serde(default)]
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub native: Option<String>,
314    #[serde(default)]
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub text: Option<String>,
317    #[serde(default)]
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub streaming_native: Option<String>,
320    #[serde(default)]
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub fallback_mode: Option<String>,
323    #[serde(default)]
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub failure_reason: Option<String>,
326    #[serde(default)]
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub last_probe_at: Option<String>,
329}
330
331#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
332pub struct ModelPricing {
333    pub input_per_mtok: f64,
334    pub output_per_mtok: f64,
335    #[serde(default)]
336    pub cache_read_per_mtok: Option<f64>,
337    #[serde(default)]
338    pub cache_write_per_mtok: Option<f64>,
339    /// Whole-request pricing that activates once provider-reported input usage
340    /// reaches a threshold. Providers such as OpenAI and Gemini charge every
341    /// token in a long-context request at the selected band's rates rather
342    /// than applying marginal pricing only above the boundary.
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub input_token_bands: Vec<InputTokenPricingBand>,
345}
346
347#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
348pub struct InputTokenPricingBand {
349    /// Inclusive lower bound for this whole-request rate.
350    pub minimum_input_tokens: u64,
351    pub input_multiplier: f64,
352    pub output_multiplier: f64,
353}
354
355impl ModelPricing {
356    /// Resolve the whole-request rates for provider-reported input usage.
357    /// `max_by_key` keeps runtime selection correct even before catalog
358    /// validation reports an authoring-order mistake.
359    pub fn for_input_tokens(&self, input_tokens: i64) -> Self {
360        let input_tokens = u64::try_from(input_tokens).unwrap_or(0);
361        let Some(band) = self
362            .input_token_bands
363            .iter()
364            .filter(|band| band.minimum_input_tokens <= input_tokens)
365            .max_by_key(|band| band.minimum_input_tokens)
366        else {
367            return self.clone();
368        };
369        Self {
370            input_per_mtok: self.input_per_mtok * band.input_multiplier,
371            output_per_mtok: self.output_per_mtok * band.output_multiplier,
372            cache_read_per_mtok: self
373                .cache_read_per_mtok
374                .map(|rate| rate * band.input_multiplier),
375            cache_write_per_mtok: self
376                .cache_write_per_mtok
377                .map(|rate| rate * band.input_multiplier),
378            input_token_bands: self.input_token_bands.clone(),
379        }
380    }
381}
382
383/// Provider or model quota metadata. Providers publish these along several
384/// axes, and any one exhausted bucket can trigger throttling.
385#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
386pub struct RateLimitsDef {
387    /// Requests per minute.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub rpm: Option<u32>,
390    /// Requests per hour.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub rph: Option<u32>,
393    /// Requests per day.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub rpd: Option<u32>,
396    /// Total tokens per minute.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub tpm: Option<u64>,
399    /// Total tokens per hour.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub tph: Option<u64>,
402    /// Total tokens per day.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub tpd: Option<u64>,
405    /// Input tokens per minute, when the provider splits input/output quotas.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub input_tpm: Option<u64>,
408    /// Output tokens per minute, when the provider splits input/output quotas.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub output_tpm: Option<u64>,
411    /// Concurrent in-flight requests, if published.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub concurrency: Option<u32>,
414    /// Account tier or route class these limits describe.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub tier: Option<String>,
417    /// Official source URL for the row.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub source_url: Option<String>,
420    /// YYYY-MM-DD date when the row was last verified.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub last_verified: Option<String>,
423    /// Free-text caveat for account-dependent or burst limits.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub notes: Option<String>,
426}
427
428impl RateLimitsDef {
429    pub fn is_empty(&self) -> bool {
430        self.rpm.is_none()
431            && self.rph.is_none()
432            && self.rpd.is_none()
433            && self.tpm.is_none()
434            && self.tph.is_none()
435            && self.tpd.is_none()
436            && self.input_tpm.is_none()
437            && self.output_tpm.is_none()
438            && self.concurrency.is_none()
439            && self.tier.is_none()
440            && self.source_url.is_none()
441            && self.last_verified.is_none()
442            && self.notes.is_none()
443    }
444
445    pub fn with_rpm_fallback(mut self, rpm: Option<u32>) -> Option<Self> {
446        if self.rpm.is_none() {
447            self.rpm = rpm;
448        }
449        (!self.is_empty()).then_some(self)
450    }
451}
452
453/// Optional provider/model serving-performance observation. This records
454/// benchmark or live-probe facts, not a hard runtime contract; callers should
455/// treat missing fields as unknown and stale dates as advisory.
456#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
457pub struct ServingPerformanceDef {
458    /// Observed time-to-first-token in milliseconds.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub observed_ttft_ms: Option<u64>,
461    /// Observed output generation rate in tokens per second.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub output_tokens_per_sec: Option<f64>,
464    /// End-to-end time-to-answer in seconds for the cited benchmark, when
465    /// reported separately from TTFT/generation rate.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub time_to_answer_s: Option<f64>,
468    /// Source label, e.g. `artificial_analysis`, `harn_probe`, or
469    /// `provider_blog`.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub source: Option<String>,
472    /// Source URL for the observation.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub source_url: Option<String>,
475    /// YYYY-MM-DD date when the observation was last verified.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub last_verified: Option<String>,
478    /// Number of requests or benchmark samples behind this row, if known.
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub sample_size: Option<u32>,
481    /// Short caveat such as streaming mode, warm/cold route, or prompt shape.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub notes: Option<String>,
484}
485
486impl ServingPerformanceDef {
487    pub fn is_empty(&self) -> bool {
488        self.observed_ttft_ms.is_none()
489            && self.output_tokens_per_sec.is_none()
490            && self.time_to_answer_s.is_none()
491            && self.source.is_none()
492            && self.source_url.is_none()
493            && self.last_verified.is_none()
494            && self.sample_size.is_none()
495            && self.notes.is_none()
496    }
497}
498
499/// Logical-model facts separated from provider serving routes. These fields
500/// describe the underlying weights or public model family, not Harn's alias or
501/// provider/model selector.
502#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
503pub struct ModelArchitectureDef {
504    /// Total parameter count in billions.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub parameter_count_b: Option<f64>,
507    /// Active parameter count in billions for MoE models.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub active_parameter_count_b: Option<f64>,
510    /// True for mixture-of-experts models.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub moe: Option<bool>,
513    /// Quantization advertised by this route, if route-specific.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub quantization: Option<String>,
516    /// Numeric precision advertised by this route, if known.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub precision: Option<String>,
519    /// License identifier or short label.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub license: Option<String>,
522    /// Tokenizer family or implementation hint.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub tokenizer: Option<String>,
525    /// Public knowledge cutoff claim, when published.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub knowledge_cutoff: Option<String>,
528    /// Official source URL for these facts.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub source_url: Option<String>,
531    /// YYYY-MM-DD date when these facts were last verified.
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub last_verified: Option<String>,
534}
535
536impl ModelArchitectureDef {
537    pub fn is_empty(&self) -> bool {
538        self.parameter_count_b.is_none()
539            && self.active_parameter_count_b.is_none()
540            && self.moe.is_none()
541            && self.quantization.is_none()
542            && self.precision.is_none()
543            && self.license.is_none()
544            && self.tokenizer.is_none()
545            && self.knowledge_cutoff.is_none()
546            && self.source_url.is_none()
547            && self.last_verified.is_none()
548    }
549}
550
551/// Provider request knob that selects a non-default serving tier.
552#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
553pub struct ServingTierRequestDef {
554    /// Request field that opts into the tier (for example `speed` for
555    /// Anthropic or `service_tier` for OpenAI/Gemini).
556    pub param: String,
557    /// Value to send on `param` (for example `fast`, `flex`, or `priority`).
558    pub value: String,
559    /// Values the provider may echo for this tier in its response. Defaults
560    /// to the request value when omitted. This keeps request vocabulary and
561    /// billing/liveness recognition separate when providers rename a tier but
562    /// retain an older response value during migration.
563    #[serde(default, skip_serializing_if = "Vec::is_empty")]
564    pub response_values: Vec<String>,
565    /// Provider beta/feature header required to use the tier, if any.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub beta_header: Option<String>,
568}
569
570/// Whether a serving tier is synchronous request handling or some other
571/// provider execution lane. Batch APIs remain represented by the separate
572/// async `batch` capability; discounted synchronous lanes such as Gemini Flex
573/// belong here instead of overloading `batch_api`.
574#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
575#[serde(rename_all = "snake_case")]
576pub enum ServingTierMode {
577    Synchronous,
578}
579
580/// Economic shape of a serving tier relative to the default synchronous API.
581#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
582#[serde(rename_all = "snake_case")]
583pub enum ServingTierEconomics {
584    Discounted,
585    Standard,
586    Premium,
587}
588
589/// Optional non-default synchronous serving tier for a model. Off by default:
590/// its presence only describes provider capability. Callers must explicitly
591/// opt in via the declared request knob, so nothing here changes default
592/// behavior. Batch APIs are intentionally not modeled here; they remain the
593/// separate async `batch` capability used by `harn models batch`.
594#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
595pub struct ServingTierDef {
596    /// Stable tier id, e.g. `fast`, `flex`, or `priority`.
597    pub id: String,
598    /// Human-readable display label for CLI/catalog renderers.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub label: Option<String>,
601    pub mode: ServingTierMode,
602    pub economics: ServingTierEconomics,
603    /// Request knob for tiers selected per request. Some tiers may be
604    /// informational/account-level only and omit a knob.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub request: Option<ServingTierRequestDef>,
607    /// Output-tokens-per-second speedup vs standard serving (e.g. 2.5).
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub otps_speedup: Option<f64>,
610    /// Price multiplier relative to default synchronous rates, when public.
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub cost_multiplier: Option<f64>,
613    /// Discount percentage relative to default synchronous rates, when public.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub discount_percent: Option<u32>,
616    /// Lifecycle of the tier: "ga" | "beta" | "research_preview" |
617    /// "deprecated".
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub status: Option<String>,
620    /// Absolute per-MTok rates charged while the tier is active. Prefer this
621    /// over a multiplier when the provider prices the tier asymmetrically.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub pricing: Option<ModelPricing>,
624    /// Latency expectation for humans and planners.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub latency: Option<String>,
627    /// Reliability/availability expectation for humans and planners.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub reliability: Option<String>,
630    /// Quota-pool or eligibility notes.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub quota: Option<String>,
633    /// Workloads this tier is suitable for (e.g. `offline_eval`, `corpus`).
634    #[serde(default, skip_serializing_if = "Vec::is_empty")]
635    pub suitable_workloads: Vec<String>,
636    /// Workloads this tier should generally avoid (e.g. `interactive_chat`).
637    #[serde(default, skip_serializing_if = "Vec::is_empty")]
638    pub unsuitable_workloads: Vec<String>,
639    /// Free-text note: constraints, deprecation timeline, cache behavior, etc.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub note: Option<String>,
642}
643
644/// A named model-fallback ladder declared in the catalog under
645/// `[model_ladders.<name>]`. A `models`/`ladder` option on `llm_call`
646/// lowers a ladder onto the first-class `routing_policy` chain: each step
647/// is one transport attempt, and the loop advances to the next step ONLY
648/// on transport-class failures (connection/timeout/429/5xx/throttled).
649///
650/// This data-driven declaration follows the same spirit as `serving_tiers`
651/// (#4017): a capability/behavior encoded as catalog data rather than
652/// hand-rolled at each downstream call site (harn-bump-fleet,
653/// harn-cloud free_tier_pool, burin-code all shipped their own copy).
654// NB: `PartialEq` only (no `Eq`): `ModelLadderStepDef::options` holds
655// `toml::Value`, which carries floats and therefore is not `Eq`.
656#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
657pub struct ModelLadderDef {
658    /// Ordered ladder steps, cheapest/first to most-capable/last.
659    #[serde(default)]
660    pub steps: Vec<ModelLadderStepDef>,
661    /// Optional human-readable label surfaced on the routing envelope.
662    #[serde(default)]
663    pub label: Option<String>,
664}
665
666/// One rung of a [`ModelLadderDef`]. Full parity with the `.harn`
667/// `ModelLadderStep` alias — `{model, provider?, label?, when?, options?,
668/// family?, capabilities?}` — which is also the shape accepted by the
669/// `models:` option and the `model_ladder(...)` std constructor. Provider is
670/// optional: when omitted it is inferred from the model id (or the call's base
671/// provider) at lowering time.
672///
673/// `options` carries per-step sampling/timeout overrides (same allowlist as
674/// inline `models:` steps); catalog ladders honor them identically instead of
675/// silently dropping them. `when`, `family`, and `capabilities` are
676/// informational to Harn's own ladder lowering (they do not affect transport
677/// failover) but are carried through so catalog and inline ladders declare the
678/// same shape and downstream selectors (e.g. harn-cloud free-tier routing) can
679/// read them. All added fields are optional and serde-absent when unset.
680#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
681pub struct ModelLadderStepDef {
682    pub model: String,
683    #[serde(default)]
684    pub provider: Option<String>,
685    #[serde(default)]
686    pub label: Option<String>,
687    /// Conditional-routing predicate hint (e.g. `"transport_failure"`). Mirror
688    /// of the `.harn` alias `when?` field. Informational to lowering today.
689    /// Absent from serialized output when unset.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub when: Option<String>,
692    /// Per-step sampling/timeout overrides (temperature, max_tokens, top_p,
693    /// seed, timeout_ms, fast, ...), same allowlist as inline `models:` steps.
694    /// Absent from serialized output when unset.
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub options: Option<BTreeMap<String, toml::Value>>,
697    /// Normalized model-family token (e.g. `"haiku"`, `"sonnet"`) carried for
698    /// downstream selectors such as harn-cloud's free-tier routing. Purely
699    /// informational to Harn's own ladder lowering — it does not affect
700    /// transport failover. Absent from serialized output when unset.
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub family: Option<String>,
703    /// Capability tags this rung claims (e.g. `["vision", "tools"]`). Carried
704    /// for downstream capability-aware routing; informational to Harn's own
705    /// ladder lowering. Absent from serialized output when empty.
706    #[serde(default, skip_serializing_if = "Vec::is_empty")]
707    pub capabilities: Vec<String>,
708}
709
710#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
711pub struct ModelDef {
712    pub name: String,
713    /// Compact label for persistent UI chrome. When omitted, provider-catalog
714    /// generation derives one from `name`; full route identity remains in the
715    /// model id, provider, and serving metadata.
716    #[serde(default)]
717    pub display_name: Option<String>,
718    /// One-sentence, plain-language trade-off description for model pickers.
719    #[serde(default)]
720    pub blurb: Option<String>,
721    pub provider: String,
722    pub context_window: u64,
723    /// Provider-independent logical model id, when multiple serving routes map
724    /// to the same weights or model family.
725    #[serde(default)]
726    pub logical_model: Option<String>,
727    /// Equivalence class for failover/escalation candidates. Entries in the
728    /// same group are capability-compatible alternatives, not byte-identical
729    /// APIs; callers must still re-render transcripts for the target provider.
730    #[serde(default)]
731    pub equivalence_group: Option<String>,
732    /// Serving-route detail such as "serverless", "priority", "fp8", or a
733    /// provider route slug. This is intentionally separate from `name`.
734    #[serde(default)]
735    pub served_variant: Option<String>,
736    /// Provider-native model id to send on the wire. Defaults to the catalog
737    /// key. Required when two providers expose the same native id and Harn
738    /// needs a unique catalog key for each route.
739    #[serde(default)]
740    pub wire_model: Option<String>,
741    /// Preferred API dialect for the route, e.g. `openai_chat`,
742    /// `openai_responses`, `anthropic_messages`, `gemini_generate_content`.
743    #[serde(default)]
744    pub api_dialect: Option<String>,
745    /// Route-specific token/request quota metadata.
746    #[serde(default)]
747    pub rate_limits: Option<RateLimitsDef>,
748    /// Optional route-level serving performance observations.
749    #[serde(default)]
750    pub performance: Option<ServingPerformanceDef>,
751    /// Underlying model architecture facts separated from the provider id.
752    #[serde(default)]
753    pub architecture: Option<ModelArchitectureDef>,
754    /// Local launch memory-sizing hints used by `harn local launch`.
755    #[serde(default)]
756    pub local_memory: Option<LocalMemoryDef>,
757    #[serde(default)]
758    pub runtime_context_window: Option<u64>,
759    #[serde(default)]
760    pub stream_timeout: Option<f64>,
761    #[serde(default, skip_serializing_if = "Vec::is_empty")]
762    pub capabilities: Vec<String>,
763    #[serde(default)]
764    pub pricing: Option<ModelPricing>,
765    // Serialized only when true. A field that always serializes cannot tell
766    // "the author never mentioned it" from "the author wrote the default",
767    // which is how a whole-row overlay copy silently un-deprecates a route it
768    // simply forgot to mention.
769    #[serde(default, skip_serializing_if = "is_false")]
770    pub deprecated: bool,
771    #[serde(default)]
772    pub deprecation_note: Option<String>,
773    /// Structured replacement pointer: the catalog id of the model that
774    /// supersedes this one (e.g. an older Opus row points at the newest
775    /// Opus). Lets release tooling express "migrate to X" in a
776    /// machine-readable way instead of burying it in `deprecation_note`
777    /// free text. A model may be superseded without being `deprecated`
778    /// (a newer option exists but this one is still fully supported);
779    /// pair it with `deprecated = true` once a sunset is announced.
780    #[serde(default)]
781    pub superseded_by: Option<String>,
782    /// Non-default synchronous serving tiers exposed by the provider, such as
783    /// premium fast/priority queues or discounted best-effort Flex lanes. Off
784    /// by default — see [`ServingTierDef`]. Empty for models with no alternate
785    /// synchronous serving path.
786    #[serde(default, skip_serializing_if = "Vec::is_empty")]
787    pub serving_tiers: Vec<ServingTierDef>,
788    /// Loose catalog annotations for selectors and UI. Conventional tags
789    /// include `avoid_reviewer` for routes that should not be auto-selected as
790    /// independent reviewers even when they are routable and cheap.
791    #[serde(default, skip_serializing_if = "Vec::is_empty")]
792    pub quality_tags: Vec<String>,
793    /// Whether the model can be reached over a normal API-key serverless call,
794    /// or only via a dedicated/provisioned endpoint that the caller must spin
795    /// up out-of-band. Providers like Together list dedicated-only routes
796    /// alongside serverless ones in `/v1/models`, so this metadata lets clients
797    /// avoid presenting them as one-click options.
798    // Serialized only when non-default, for the reason on `deprecated`.
799    #[serde(default, skip_serializing_if = "ModelAvailability::is_default")]
800    pub availability: ModelAvailability,
801    /// Popular-consensus tier label. Enum-typed string: "small" | "mid" |
802    /// "frontier" | "reasoning". Self-declared per model (no pattern-matched
803    /// rule table) so the catalog is the single source of truth. When None
804    /// the resolver returns the catalog default ("mid"). Use the richer
805    /// `strengths` + `benchmarks` fields to pick models for specific
806    /// workloads — `tier` exists only as a coarse popular-consensus shortcut.
807    #[serde(default)]
808    pub tier: Option<String>,
809    /// True when the model weights are downloadable / self-hostable
810    /// (open-weight / open-source license, regardless of commercial-use
811    /// restrictions). False when weights are closed (Anthropic, OpenAI,
812    /// Google, etc.). None when the catalog row predates the migration.
813    #[serde(default)]
814    pub open_weight: Option<bool>,
815    /// Workload-shaped strength tags. Conventional values include
816    /// `coding`, `summarization`, `long_context`, `tool_use`, `reasoning`,
817    /// `vision`, `speed`, `cheap`, `agentic`. Selectors should treat
818    /// missing entries as "no claim" rather than "no strength."
819    #[serde(default, skip_serializing_if = "Vec::is_empty")]
820    pub strengths: Vec<String>,
821    /// Public benchmark numbers, keyed by a snake_case identifier
822    /// (`swe_bench_verified`, `humaneval`, `aa_intelligence_index`, etc.).
823    /// Values are the raw published scores. The selector layer is free
824    /// to normalize per benchmark; the catalog records the canonical
825    /// score so future readers can audit the source.
826    #[serde(default)]
827    pub benchmarks: BTreeMap<String, f64>,
828    /// Normalized model-family token used as a diversity signal for
829    /// reviewer selection. Distinct from provider: hosted wrappers should
830    /// keep the underlying family (for example OpenRouter-hosted Claude
831    /// still uses `anthropic-claude`).
832    #[serde(default)]
833    pub family: Option<String>,
834    /// Narrower family lineage used by option-pack calibration.
835    #[serde(default)]
836    pub lineage: Option<String>,
837    /// Preferred reviewer families for critique/review workloads.
838    #[serde(default, skip_serializing_if = "Vec::is_empty")]
839    pub complementary_with: Vec<String>,
840    /// Author families, lineages, model ids, or provider/model selectors
841    /// this row should not review.
842    #[serde(default, skip_serializing_if = "Vec::is_empty")]
843    pub avoid_as_reviewer_for: Vec<String>,
844}
845
846fn is_false(value: &bool) -> bool {
847    !*value
848}
849
850#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
851#[serde(rename_all = "snake_case")]
852pub enum ModelAvailability {
853    /// Reachable through the provider's normal API-key path with no extra
854    /// setup. The default for cataloged hosted/local models: by cataloging a
855    /// row we are claiming the route works out of the box.
856    #[default]
857    Serverless,
858    /// Requires the caller to provision a dedicated endpoint before requests
859    /// will succeed. The catalog row exists for selection/pricing UI, but
860    /// hosts must not auto-route to it.
861    Dedicated,
862    /// Availability is not known ahead of time. Used for routes that were
863    /// surfaced dynamically (e.g. through `/v1/models`) without a static
864    /// claim from Harn or the user.
865    Unknown,
866}
867
868impl ModelAvailability {
869    fn is_default(&self) -> bool {
870        *self == Self::default()
871    }
872
873    pub fn as_str(self) -> &'static str {
874        match self {
875            Self::Serverless => "serverless",
876            Self::Dedicated => "dedicated",
877            Self::Unknown => "unknown",
878        }
879    }
880
881    pub fn parse(value: &str) -> Option<Self> {
882        match value {
883            "serverless" => Some(Self::Serverless),
884            "dedicated" => Some(Self::Dedicated),
885            "unknown" => Some(Self::Unknown),
886            _ => None,
887        }
888    }
889}
890
891#[cfg(test)]
892mod ladder_step_tests {
893    use super::{ModelLadderDef, ModelLadderStepDef};
894
895    #[test]
896    fn all_added_fields_round_trip() {
897        let mut options = std::collections::BTreeMap::new();
898        options.insert("temperature".to_string(), toml::Value::Float(0.2));
899        options.insert("max_tokens".to_string(), toml::Value::Integer(512));
900        let step = ModelLadderStepDef {
901            model: "claude-haiku-4-5".to_string(),
902            provider: Some("anthropic".to_string()),
903            label: Some("cheap".to_string()),
904            when: Some("transport_failure".to_string()),
905            options: Some(options),
906            family: Some("haiku".to_string()),
907            capabilities: vec!["vision".to_string(), "tools".to_string()],
908        };
909        let json = serde_json::to_string(&step).expect("serialize");
910        let back: ModelLadderStepDef = serde_json::from_str(&json).expect("deserialize");
911        assert_eq!(step, back);
912        assert!(json.contains("\"family\":\"haiku\""));
913        assert!(json.contains("\"capabilities\":[\"vision\",\"tools\"]"));
914        assert!(json.contains("\"when\":\"transport_failure\""));
915        assert!(json.contains("\"temperature\":0.2"));
916    }
917
918    #[test]
919    fn unset_added_fields_are_absent_from_serialized_output() {
920        // A step that sets none of the added fields must serialize
921        // byte-identically to the pre-existing {model, provider?, label?}
922        // shape: the new keys are entirely absent (not `null`, not `[]`), so
923        // already-serialized catalog bundles/records stay unchanged.
924        let step = ModelLadderStepDef {
925            model: "mock-cheap".to_string(),
926            provider: Some("mock".to_string()),
927            label: None,
928            when: None,
929            options: None,
930            family: None,
931            capabilities: Vec::new(),
932        };
933        let json = serde_json::to_string(&step).expect("serialize");
934        assert_eq!(
935            json,
936            r#"{"model":"mock-cheap","provider":"mock","label":null}"#
937        );
938        for absent in ["family", "capabilities", "when", "options"] {
939            assert!(
940                !json.contains(absent),
941                "unexpected key {absent:?} in {json}"
942            );
943        }
944    }
945
946    #[test]
947    fn deserializes_without_added_fields() {
948        // Records written before this change (no added keys) still
949        // deserialize, defaulting the new fields.
950        let step: ModelLadderStepDef =
951            serde_json::from_str(r#"{"model":"mock-cheap"}"#).expect("deserialize legacy");
952        assert_eq!(step.when, None);
953        assert_eq!(step.family, None);
954        assert!(step.options.is_none());
955        assert!(step.capabilities.is_empty());
956    }
957
958    #[test]
959    fn catalog_toml_row_retains_when_and_options() {
960        // A `[model_ladders.*]` catalog row carrying when/options/family/
961        // capabilities parses WITHOUT silently discarding them — previously
962        // these keys had no home on the DTO and were dropped on the floor.
963        let toml_src = r#"
964label = "with overrides"
965steps = [
966  { model = "haiku", label = "cheap", when = "transport_failure", family = "haiku", capabilities = ["tools"], options = { temperature = 0.1, max_tokens = 256 } },
967  { model = "opus", label = "frontier", family = "opus" },
968]
969"#;
970        let def: ModelLadderDef = toml::from_str(toml_src).expect("parse ladder toml");
971        assert_eq!(def.steps.len(), 2);
972        let cheap = &def.steps[0];
973        assert_eq!(cheap.when.as_deref(), Some("transport_failure"));
974        assert_eq!(cheap.family.as_deref(), Some("haiku"));
975        assert_eq!(cheap.capabilities, vec!["tools".to_string()]);
976        let opts = cheap.options.as_ref().expect("options present");
977        assert_eq!(opts.get("temperature"), Some(&toml::Value::Float(0.1)));
978        assert_eq!(opts.get("max_tokens"), Some(&toml::Value::Integer(256)));
979        assert_eq!(def.steps[1].family.as_deref(), Some("opus"));
980    }
981}