khive_runtime/
engine_config_peers.rs1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use super::ConfigError;
6use crate::config::{parse_embedding_model_alias, sanitize_key};
7
8#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
10#[serde(deny_unknown_fields)]
11pub struct EngineConfig {
12 pub name: String,
13 #[serde(default = "unit_weight")]
14 pub weight: f64,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub dims: Option<i64>,
17}
18
19impl EngineConfig {
20 pub(crate) fn check_dimensions(&self, actual: usize) -> Result<(), ConfigError> {
21 if let Some(expected) = self.dims {
22 if usize::try_from(expected).ok() != Some(actual) {
23 return Err(ConfigError::EngineDimensionMismatch {
24 name: self.name.clone(),
25 expected,
26 actual,
27 });
28 }
29 }
30 Ok(())
31 }
32}
33
34fn unit_weight() -> f64 {
35 1.0
36}
37
38pub(crate) fn canonical_engine_name(name: &str) -> String {
39 parse_embedding_model_alias(name)
40 .map(|model| model.to_string())
41 .unwrap_or_else(|| name.to_owned())
42}
43
44pub(crate) fn validate_peer_engines(engines: &[EngineConfig]) -> Result<(), ConfigError> {
45 let mut names = HashSet::new();
46 let mut canonical_names = HashSet::new();
47 let mut keys = HashMap::new();
48 for engine in engines {
49 if engine.name.trim().is_empty() {
50 return Err(ConfigError::InvalidEngineName {
51 name: engine.name.clone(),
52 });
53 }
54 if !names.insert(&engine.name) {
55 return Err(ConfigError::DuplicateName {
56 name: engine.name.clone(),
57 });
58 }
59 let canonical = canonical_engine_name(&engine.name);
60 if !canonical_names.insert(canonical.clone()) {
61 return Err(ConfigError::AliasCollision {
62 name: engine.name.clone(),
63 canonical,
64 });
65 }
66 let key = sanitize_key(&canonical);
67 if let Some(other) = keys.insert(key.clone(), canonical.clone()) {
68 return Err(ConfigError::EngineKeyCollision {
69 name: canonical,
70 other,
71 key,
72 });
73 }
74 if !engine.weight.is_finite() || engine.weight <= 0.0 {
75 return Err(ConfigError::InvalidEngineWeight {
76 name: engine.name.clone(),
77 value: engine.weight,
78 });
79 }
80 if let Some(value) = engine.dims {
81 if value <= 0 || value > i64::from(u32::MAX) {
82 return Err(ConfigError::InvalidEngineDimensions {
83 name: engine.name.clone(),
84 value,
85 });
86 }
87 }
88 }
89 if let Some(engine) = engines.iter().find(|engine| engine.weight != 1.0) {
90 return Err(ConfigError::UnsupportedEngineWeight {
91 name: engine.name.clone(),
92 });
93 }
94 Ok(())
95}
96
97#[derive(Deserialize)]
98#[serde(deny_unknown_fields)]
99struct LegacyEngine {
100 name: String,
101 model: String,
102 #[serde(default)]
103 default: bool,
104 fusion_weight: Option<f64>,
105 dims: Option<i64>,
106}
107
108pub(super) fn normalize_engine_input(
110 document: &mut toml::Value,
111 path: &std::path::Path,
112) -> Result<bool, ConfigError> {
113 let Some(value) = document.get_mut("engines") else {
114 return Ok(false);
115 };
116 let Some(entries) = value.as_array_mut() else {
117 return Ok(true);
119 };
120 let legacy = entries.iter().any(|entry| {
121 ["model", "default", "fusion_weight"]
122 .iter()
123 .any(|key| entry.get(key).is_some())
124 });
125 if !legacy {
126 return Ok(true);
127 }
128 let mut parsed = Vec::with_capacity(entries.len());
129 let mut labels = HashSet::new();
130 let mut providers = HashSet::new();
131 for entry in entries.iter() {
132 if entry.get("weight").is_some() || entry.get("model").is_none() {
133 return Err(ConfigError::EngineKeyConflict {
134 name: entry
135 .get("name")
136 .and_then(toml::Value::as_str)
137 .unwrap_or("<missing>")
138 .to_owned(),
139 });
140 }
141 let old: LegacyEngine = entry
142 .clone()
143 .try_into()
144 .map_err(|source| ConfigError::Parse {
145 path: path.to_path_buf(),
146 source,
147 })?;
148 if !labels.insert(old.name.clone()) {
149 return Err(ConfigError::DuplicateName { name: old.name });
150 }
151 let model =
152 parse_embedding_model_alias(&old.model).ok_or_else(|| ConfigError::UnknownModel {
153 name: old.name.clone(),
154 model: old.model.clone(),
155 })?;
156 let name = model.to_string();
157 if !providers.insert(name.clone()) {
158 return Err(ConfigError::AliasCollision {
159 name: old.name,
160 canonical: name,
161 });
162 }
163 parsed.push((
164 old.default,
165 EngineConfig {
166 name,
167 weight: old.fusion_weight.unwrap_or(1.0),
168 dims: old.dims,
169 },
170 ));
171 }
172 let found = parsed.iter().filter(|(default, _)| *default).count();
173 if found != 1 {
174 return Err(ConfigError::DefaultCount { found });
175 }
176 let first = parsed
177 .iter()
178 .position(|(default, _)| *default)
179 .expect("one default");
180 let primary = parsed.remove(first);
181 parsed.insert(0, primary);
182 *entries = parsed
183 .into_iter()
184 .map(|(_, engine)| {
185 let mut table = toml::map::Map::new();
186 table.insert("name".into(), toml::Value::String(engine.name));
187 table.insert("weight".into(), toml::Value::Float(engine.weight));
188 if let Some(dims) = engine.dims {
189 table.insert("dims".into(), toml::Value::Integer(dims));
190 }
191 toml::Value::Table(table)
192 })
193 .collect();
194 tracing::warn!("legacy engine configuration converted to ordered peers; migrate model/default/fusion_weight to name/weight");
195 Ok(true)
196}