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}