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