Skip to main content

harn_vm/llm_config/
config.rs

1//! Aggregate provider/model catalog document: `ProvidersConfig`, its overlay
2//! merge semantics, field-wise `[patch.models]` application, and the
3//! tier/inference rule DTOs.
4use std::collections::BTreeMap;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use serde::Deserialize;
8
9use super::*;
10
11#[derive(Debug, Clone, Deserialize, Default)]
12pub struct ProvidersConfig {
13    #[serde(default)]
14    pub default_provider: Option<String>,
15    #[serde(default)]
16    pub providers: BTreeMap<String, ProviderDef>,
17    #[serde(default)]
18    pub aliases: BTreeMap<String, AliasDef>,
19    #[serde(default)]
20    pub alias_tool_calling: BTreeMap<String, AliasToolCallingDef>,
21    #[serde(default)]
22    pub models: BTreeMap<String, ModelDef>,
23    #[serde(default)]
24    pub qc_defaults: BTreeMap<String, String>,
25    #[serde(default)]
26    pub inference_rules: Vec<InferenceRule>,
27    #[serde(default)]
28    pub tier_rules: Vec<TierRule>,
29    #[serde(default)]
30    pub tier_defaults: TierDefaults,
31    #[serde(default)]
32    pub model_defaults: BTreeMap<String, BTreeMap<String, toml::Value>>,
33    #[serde(default)]
34    pub model_roles: BTreeMap<String, BTreeMap<String, toml::Value>>,
35    #[serde(default)]
36    pub suppress: SuppressDef,
37    #[serde(default)]
38    pub patch: PatchDef,
39    /// `[model_ladders.<name>]` tables: named model-fallback ladders that a
40    /// `ladder: "<name>"` option on `llm_call` resolves and lowers onto the
41    /// routing chain. Keyed by ladder name.
42    #[serde(default)]
43    pub model_ladders: BTreeMap<String, ModelLadderDef>,
44    /// Host-facing model-selection metadata. This never changes routing by
45    /// itself; it only describes how clients may present catalog choices.
46    #[serde(default)]
47    pub presentation: PresentationConfig,
48}
49
50/// Field-wise catalog patches applied on top of merged model rows.
51///
52/// Overlays have three complementary tools for adjusting the baseline
53/// catalog, from coarsest to finest:
54///
55/// 1. **Whole-row replace** — `[models.<id>]` replaces the entire model row.
56///    Use it to add a new route or when the overlay intentionally owns every
57///    field of the row.
58/// 2. **Field patch** — `[patch.models.<id>]` merges individual fields into
59///    the existing row, leaving every unmentioned field at its baseline
60///    value. Use it to tweak one knob (a `stream_timeout`, one pricing rate)
61///    without copying the row verbatim and silently freezing the rest of its
62///    fields against upstream catalog updates.
63/// 3. **Route suppression** — `[suppress]` hides baseline routes from the
64///    exported/served artifact entirely (see [`SuppressDef`]).
65///
66/// Patch semantics:
67/// - Nested tables merge recursively; scalars **and arrays** replace the
68///   base value wholesale (there is deliberately no per-element array merge).
69/// - Within a single overlay, `[models.<id>]` whole-row replacement applies
70///   BEFORE `[patch.models.<id>]`, so patch fields win over the same
71///   overlay's whole-row fields.
72/// - Patches are STICKY across layers: once accumulated, a patch re-applies
73///   after every later layer's merge, including a later layer's whole-row
74///   replacement of the same id. A patch means "always tweak this field",
75///   not "tweak it once".
76/// - A patch whose target row does not exist yet stays in the accumulator
77///   silently and applies as soon as a later layer contributes the row;
78///   [`ProvidersConfig::dangling_model_patches`] reports the leftovers for
79///   doctor/export validation.
80/// - A patch that produces a type-invalid row warns once per process and
81///   keeps the unpatched row.
82#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
83pub struct PatchDef {
84    /// `[patch.models.<id>]` tables: partial `ModelDef` field sets merged
85    /// field-wise into the model row with the same catalog id.
86    #[serde(default)]
87    pub models: BTreeMap<String, toml::Value>,
88}
89
90/// Routes hidden from the exported/served provider catalog artifact.
91///
92/// Lets an overlay drop baseline routes that are broken or unusable for the
93/// embedding product (e.g. a dedicated-only serving route, or a local image
94/// with a broken server-side tool parser) without forking the baseline
95/// catalog. Suppression is artifact-level presentation: it removes the model
96/// row, its aliases, and any recommendation variant derived from it, but does
97/// not block runtime resolution of an explicitly requested model id.
98///
99/// This is one of three overlay tools (see [`PatchDef`] for the full set):
100/// whole-row `[models.<id>]` replacement, field-wise `[patch.models.<id>]`
101/// patches, and `[suppress]` route suppression. Combined with whole-row
102/// `models` replacement, suppression also expresses route renames: define
103/// the row under the new id and suppress the old one.
104#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
105pub struct SuppressDef {
106    /// `"provider:model_id"` selectors. Split on the FIRST colon only —
107    /// model ids may themselves contain colons (e.g. Ollama image tags such
108    /// as `ollama:qwen3.6:35b-a3b-coding-nvfp4`). Entries without a colon
109    /// match nothing.
110    #[serde(default)]
111    pub routes: Vec<String>,
112}
113
114impl ProvidersConfig {
115    pub fn is_empty(&self) -> bool {
116        self.default_provider.is_none()
117            && self.providers.is_empty()
118            && self.aliases.is_empty()
119            && self.alias_tool_calling.is_empty()
120            && self.models.is_empty()
121            && self.qc_defaults.is_empty()
122            && self.inference_rules.is_empty()
123            && self.tier_rules.is_empty()
124            && self.model_defaults.is_empty()
125            && self.model_roles.is_empty()
126            && self.suppress.routes.is_empty()
127            && self.patch.models.is_empty()
128            && self.model_ladders.is_empty()
129            && self.presentation.is_empty()
130            && self.tier_defaults.default == default_mid()
131    }
132
133    /// `[patch.models]` ids with no matching model row in the merged config.
134    ///
135    /// Dangling patches are not an error at merge time — the row may arrive
136    /// from a later layer — but doctor/export surfaces can report leftovers
137    /// so a typo'd id doesn't silently patch nothing.
138    pub fn dangling_model_patches(&self) -> Vec<&str> {
139        self.patch
140            .models
141            .keys()
142            .filter(|id| !self.models.contains_key(*id))
143            .map(String::as_str)
144            .collect()
145    }
146
147    pub fn merge_from(&mut self, overlay: &ProvidersConfig) {
148        for (name, provider) in &overlay.providers {
149            match self.providers.get_mut(name) {
150                Some(existing) => existing.merge_from(provider),
151                None => {
152                    self.providers.insert(name.clone(), provider.clone());
153                }
154            }
155        }
156        self.aliases.extend(overlay.aliases.clone());
157        self.alias_tool_calling
158            .extend(overlay.alias_tool_calling.clone());
159        self.models.extend(overlay.models.clone());
160        self.qc_defaults.extend(overlay.qc_defaults.clone());
161
162        // `[patch.models]` field-wise patches. Two deliberate ordering rules
163        // (see [`PatchDef`]):
164        //   1. Within one overlay, the whole-row `models` replacement above
165        //      lands first, then patches — so `[patch.models.X]` fields win
166        //      over the same overlay's `[models.X]` row.
167        //   2. Patches are sticky: the accumulator re-applies after EVERY
168        //      layer's merge, so a later layer's whole-row replacement still
169        //      gets earlier layers' field tweaks re-applied on top. A patch
170        //      means "always tweak this field", not "tweak it once".
171        // Per-id patches from later layers deep-merge into the accumulator
172        // (later layer wins per field), so two layers patching different
173        // fields of the same row both stay sticky.
174        // Short-circuit when no layer has contributed a patch so existing
175        // patch-free configs pay nothing here.
176        if !overlay.patch.models.is_empty() || !self.patch.models.is_empty() {
177            for (id, patch) in &overlay.patch.models {
178                match self.patch.models.get_mut(id) {
179                    Some(existing) => deep_merge_toml(existing, patch),
180                    None => {
181                        self.patch.models.insert(id.clone(), patch.clone());
182                    }
183                }
184            }
185            apply_model_patches(&mut self.models, &self.patch.models);
186        }
187
188        if overlay.default_provider.is_some() {
189            self.default_provider = overlay.default_provider.clone();
190        }
191
192        if !overlay.inference_rules.is_empty() {
193            let mut merged = overlay.inference_rules.clone();
194            merged.extend(self.inference_rules.clone());
195            self.inference_rules = merged;
196        }
197
198        if !overlay.tier_rules.is_empty() {
199            let mut merged = overlay.tier_rules.clone();
200            merged.extend(self.tier_rules.clone());
201            self.tier_rules = merged;
202        }
203
204        if overlay.tier_defaults.default != default_mid() {
205            self.tier_defaults = overlay.tier_defaults.clone();
206        }
207
208        for (pattern, defaults) in &overlay.model_defaults {
209            self.model_defaults
210                .entry(pattern.clone())
211                .or_default()
212                .extend(defaults.clone());
213        }
214
215        for (role, defaults) in &overlay.model_roles {
216            self.model_roles
217                .entry(role.clone())
218                .or_default()
219                .extend(defaults.clone());
220        }
221
222        for route in &overlay.suppress.routes {
223            if !self.suppress.routes.contains(route) {
224                self.suppress.routes.push(route.clone());
225            }
226        }
227
228        // Named model ladders: later layers replace a same-named ladder
229        // wholesale (a ladder is an ordered list; per-step merge has no
230        // sensible meaning), matching `[models]` whole-row replace semantics.
231        self.model_ladders.extend(overlay.model_ladders.clone());
232
233        // Presentation rows are stable-id records. A later layer owns and
234        // replaces the complete same-id row, just like `[models]` and named
235        // model ladders; ordered dimensions/presets have no useful field-wise
236        // merge semantics.
237        self.presentation
238            .variants
239            .extend(overlay.presentation.variants.clone());
240        self.presentation
241            .families
242            .extend(overlay.presentation.families.clone());
243    }
244}
245
246/// Recursively merge `overlay` into `base`. Tables merge key-by-key; every
247/// other value shape — scalars AND arrays — replaces the base value
248/// wholesale. Replacing arrays instead of merging them is the documented
249/// convention: there is no sane universal element-wise merge for lists like
250/// `capabilities` or `strengths`, so a patch that names an array owns it.
251fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
252    match (base, overlay) {
253        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
254            for (key, overlay_value) in overlay_table {
255                match base_table.get_mut(key) {
256                    Some(base_value) => deep_merge_toml(base_value, overlay_value),
257                    None => {
258                        base_table.insert(key.clone(), overlay_value.clone());
259                    }
260                }
261            }
262        }
263        (base_slot, overlay_value) => *base_slot = overlay_value.clone(),
264    }
265}
266
267/// True once a type-invalid `[patch.models]` entry has been reported.
268/// Patches re-apply on every layer merge (stickiness), so an unconditional
269/// eprintln would repeat the same diagnostic once per layer per process.
270static MODEL_PATCH_TYPE_ERROR_WARNED: AtomicBool = AtomicBool::new(false);
271
272/// Apply every accumulated `[patch.models]` entry to its matching model row.
273///
274/// Patch application is `ModelDef -> toml::Value -> deep merge -> ModelDef`,
275/// so a patch can only express states the row schema can represent. Ids with
276/// no matching row are skipped (see
277/// [`ProvidersConfig::dangling_model_patches`]). A patch that produces a
278/// type-invalid row warns once (matching the `read_external_config` eprintln
279/// precedent) and keeps the unpatched row, so one bad overlay field can't
280/// take out the whole catalog entry.
281fn apply_model_patches(
282    models: &mut BTreeMap<String, ModelDef>,
283    patches: &BTreeMap<String, toml::Value>,
284) {
285    for (id, patch) in patches {
286        let Some(base) = models.get(id) else {
287            continue;
288        };
289        match patched_model_row(base, patch) {
290            Ok(patched) => {
291                models.insert(id.clone(), patched);
292            }
293            Err(error) => {
294                if !MODEL_PATCH_TYPE_ERROR_WARNED.swap(true, Ordering::Relaxed) {
295                    eprintln!(
296                        "[llm_config] invalid [patch.models.\"{id}\"] overlay \
297                         (keeping the unpatched row): {error}"
298                    );
299                }
300            }
301        }
302    }
303}
304
305/// Produce the patched version of one model row, or a description of why the
306/// patch does not typecheck against the row schema.
307fn patched_model_row(base: &ModelDef, patch: &toml::Value) -> Result<ModelDef, String> {
308    let mut value = toml::Value::try_from(base)
309        .map_err(|error| format!("serialize base row for patching: {error}"))?;
310    deep_merge_toml(&mut value, patch);
311    ModelDef::deserialize(value).map_err(|error| error.to_string())
312}
313
314#[derive(Debug, Clone, Deserialize)]
315pub struct InferenceRule {
316    #[serde(default)]
317    pub pattern: Option<String>,
318    #[serde(default)]
319    pub contains: Option<String>,
320    #[serde(default)]
321    pub exact: Option<String>,
322    pub provider: String,
323}
324
325#[derive(Debug, Clone, Deserialize)]
326pub struct TierRule {
327    #[serde(default)]
328    pub pattern: Option<String>,
329    #[serde(default)]
330    pub contains: Option<String>,
331    #[serde(default)]
332    pub exact: Option<String>,
333    pub tier: String,
334}
335
336#[derive(Debug, Clone, Deserialize)]
337pub struct TierDefaults {
338    #[serde(default = "default_mid")]
339    pub default: String,
340}
341
342impl Default for TierDefaults {
343    fn default() -> Self {
344        Self {
345            default: default_mid(),
346        }
347    }
348}
349
350fn default_mid() -> String {
351    "mid".to_string()
352}