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}