1use crate::{registry::Architecture, source::ResolvedModelSource};
4use ferrum_types::{
5 Activation, AttentionConfig, FerrumError, ModelInfo, ModelType, NormType, Result, RopeScaling,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use tracing::{debug, warn};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ModelDefinition {
15 pub architecture: Architecture,
17 pub hidden_size: usize,
19 pub intermediate_size: usize,
21 pub vocab_size: usize,
23 pub num_hidden_layers: usize,
25 pub num_attention_heads: usize,
27 pub num_key_value_heads: Option<usize>,
29 pub max_position_embeddings: usize,
31 pub rope_theta: Option<f64>,
33 pub rope_scaling: Option<RopeScaling>,
35 pub norm_type: NormType,
37 pub norm_eps: f64,
39 pub attention_config: AttentionConfig,
41 pub activation: Activation,
43 #[serde(flatten)]
45 pub extra_params: serde_json::Value,
46}
47
48impl Default for ModelDefinition {
49 fn default() -> Self {
50 Self {
51 architecture: Architecture::Llama,
52 hidden_size: 4096,
53 intermediate_size: 11008,
54 vocab_size: 32000,
55 num_hidden_layers: 32,
56 num_attention_heads: 32,
57 num_key_value_heads: None,
58 max_position_embeddings: 2048,
59 rope_theta: Some(10000.0),
60 rope_scaling: None,
61 norm_type: NormType::RMSNorm,
62 norm_eps: 1e-6,
63 attention_config: AttentionConfig {
64 attention_bias: false,
65 sliding_window: None,
66 },
67 activation: Activation::SiLU,
68 extra_params: serde_json::Value::Object(serde_json::Map::new()),
69 }
70 }
71}
72
73impl ModelDefinition {
74 pub fn to_model_info(&self, model_id: impl Into<String>) -> ModelInfo {
76 use ferrum_types::{DataType, Device};
77
78 let model_id_str = model_id.into();
79
80 let params = self.estimate_parameters();
82
83 ModelInfo {
84 model_id: ferrum_types::ModelId::new(model_id_str.clone()),
85 model_type: ModelType::Custom(format!("{:?}", self.architecture)),
86 num_parameters: params as u64,
87 hidden_size: self.hidden_size,
88 num_layers: self.num_hidden_layers,
89 num_heads: self.num_attention_heads,
90 num_kv_heads: self.num_key_value_heads.unwrap_or(self.num_attention_heads),
91 vocab_size: self.vocab_size,
92 max_sequence_length: self.max_position_embeddings,
93 dtype: DataType::FP16, device: Device::CPU, version: None,
96 license: None,
97 metadata: HashMap::new(),
98 }
99 }
100
101 fn estimate_parameters(&self) -> usize {
103 let embedding_params = self.vocab_size * self.hidden_size;
105 let layer_params = self.num_hidden_layers
106 * (
107 4 * self.hidden_size * self.hidden_size +
109 3 * self.hidden_size * self.intermediate_size +
111 2 * self.hidden_size
113 );
114 let lm_head_params = self.vocab_size * self.hidden_size;
115
116 embedding_params + layer_params + lm_head_params
117 }
118}
119
120#[derive(Debug, Default)]
122pub struct ConfigManager {
123 _cache: HashMap<String, ModelDefinition>,
124}
125
126impl ConfigManager {
127 pub fn new() -> Self {
128 Self {
129 _cache: HashMap::new(),
130 }
131 }
132
133 pub async fn load_from_source(
135 &mut self,
136 source: &ResolvedModelSource,
137 ) -> Result<ModelDefinition> {
138 self.load_from_path(&source.local_path).await
139 }
140
141 pub async fn load_from_path(&mut self, path: &Path) -> Result<ModelDefinition> {
143 let config_path = path.join("config.json");
144
145 if !config_path.exists() {
146 return Err(FerrumError::model(format!(
147 "config.json not found in model directory: {:?}",
148 path
149 )));
150 }
151
152 debug!("Loading model config from: {:?}", config_path);
153
154 let content = tokio::fs::read_to_string(&config_path)
155 .await
156 .map_err(|e| FerrumError::io(format!("Failed to read config.json: {}", e)))?;
157
158 self.load_from_bytes(content.as_bytes())
159 }
160
161 pub fn load_from_bytes(&mut self, config_json: &[u8]) -> Result<ModelDefinition> {
165 let raw_config: serde_json::Value = serde_json::from_slice(config_json)
166 .map_err(|e| FerrumError::model(format!("Failed to parse config.json: {}", e)))?;
167
168 self.parse_config(&raw_config)
169 }
170
171 #[doc(hidden)]
173 pub(crate) fn parse_config_for_tests(
174 &mut self,
175 raw: &serde_json::Value,
176 ) -> Result<ModelDefinition> {
177 self.parse_config(raw)
178 }
179
180 fn parse_config(&mut self, raw: &serde_json::Value) -> Result<ModelDefinition> {
182 let obj = raw
183 .as_object()
184 .ok_or_else(|| FerrumError::model("config.json root is not an object"))?;
185
186 let architecture = self.detect_architecture(raw)?;
188
189 let text_merged: Option<serde_json::Map<String, serde_json::Value>> =
196 if architecture == Architecture::Gemma3 {
197 obj.get("text_config")
198 .and_then(|v| v.as_object())
199 .map(|tc| {
200 let mut m = obj.clone();
201 for (k, v) in tc {
202 m.insert(k.clone(), v.clone());
203 }
204 m
205 })
206 } else {
207 None
208 };
209 let obj = text_merged.as_ref().unwrap_or(obj);
210
211 let text_cfg = obj.get("text_config");
213 let hidden_size = obj
214 .get("hidden_size")
215 .and_then(|v| v.as_u64())
216 .or_else(|| {
217 text_cfg
218 .and_then(|tc| tc.get("hidden_size"))
219 .and_then(|v| v.as_u64())
220 })
221 .unwrap_or(4096) as usize;
222
223 let intermediate_size = obj
224 .get("intermediate_size")
225 .and_then(|v| v.as_u64())
226 .or_else(|| obj.get("ffn_dim").and_then(|v| v.as_u64()))
227 .unwrap_or(11008) as usize;
228
229 let vocab_size = obj
231 .get("vocab_size")
232 .and_then(|v| v.as_u64())
233 .or_else(|| {
234 text_cfg
235 .and_then(|tc| tc.get("vocab_size"))
236 .and_then(|v| v.as_u64())
237 })
238 .unwrap_or(0) as usize;
239
240 let num_hidden_layers = obj
241 .get("num_hidden_layers")
242 .and_then(|v| v.as_u64())
243 .or_else(|| obj.get("n_layer").and_then(|v| v.as_u64()))
244 .unwrap_or(32) as usize;
245
246 let num_attention_heads = obj
247 .get("num_attention_heads")
248 .and_then(|v| v.as_u64())
249 .or_else(|| obj.get("n_head").and_then(|v| v.as_u64()))
250 .unwrap_or(32) as usize;
251
252 let num_key_value_heads = obj
253 .get("num_key_value_heads")
254 .and_then(|v| v.as_u64())
255 .map(|v| v as usize);
256
257 let max_position_embeddings = obj
258 .get("max_position_embeddings")
259 .and_then(|v| v.as_u64())
260 .or_else(|| obj.get("n_positions").and_then(|v| v.as_u64()))
261 .unwrap_or(2048) as usize;
262
263 let rope_theta = obj
264 .get("rope_theta")
265 .and_then(|v| v.as_f64())
266 .or_else(|| obj.get("rotary_emb_base").and_then(|v| v.as_f64()))
267 .or_else(|| {
268 obj.get("rope_parameters")
269 .and_then(|v| v.as_object())
270 .and_then(|rope| rope.get("rope_theta"))
271 .and_then(|v| v.as_f64())
272 });
273
274 let rope_scaling = obj
276 .get("rope_scaling")
277 .and_then(|v| serde_json::from_value(v.clone()).ok());
278
279 let norm_type = if obj.get("rms_norm_eps").is_some() {
281 NormType::RMSNorm
282 } else {
283 NormType::LayerNorm
284 };
285
286 let norm_eps = obj
287 .get("rms_norm_eps")
288 .or_else(|| obj.get("layer_norm_eps"))
289 .or_else(|| obj.get("layer_norm_epsilon"))
290 .and_then(|v| v.as_f64())
291 .unwrap_or(1e-6);
292
293 let attention_bias = obj
295 .get("attention_bias")
296 .and_then(|v| v.as_bool())
297 .unwrap_or(false);
298
299 let sliding_window = obj
300 .get("sliding_window")
301 .and_then(|v| v.as_u64())
302 .map(|v| v as usize);
303
304 let activation = obj
307 .get("hidden_act")
308 .or_else(|| obj.get("hidden_activation"))
309 .and_then(|v| v.as_str())
310 .map(|s| match s {
311 "gelu" | "gelu_new" => Activation::GELU,
312 "gelu_pytorch_tanh" => Activation::GeluTanh,
313 "silu" => Activation::SiLU,
314 "relu" => Activation::ReLU,
315 "swish" => Activation::Swish,
316 _ => {
317 warn!("Unknown activation function: {}, defaulting to SiLU", s);
318 Activation::SiLU
319 }
320 })
321 .unwrap_or(Activation::SiLU);
322
323 Ok(ModelDefinition {
324 architecture,
325 hidden_size,
326 intermediate_size,
327 vocab_size,
328 num_hidden_layers,
329 num_attention_heads,
330 num_key_value_heads,
331 max_position_embeddings,
332 rope_theta,
333 rope_scaling,
334 norm_type,
335 norm_eps,
336 attention_config: AttentionConfig {
337 attention_bias,
338 sliding_window,
339 },
340 activation,
341 extra_params: serde_json::Value::Object(obj.clone()),
345 })
346 }
347
348 fn detect_architecture(&self, config: &serde_json::Value) -> Result<Architecture> {
350 let obj = config
351 .as_object()
352 .ok_or_else(|| FerrumError::model("config.json root is not an object"))?;
353
354 if let Some(architectures) = obj.get("architectures").and_then(|v| v.as_array()) {
358 if let Some(arch) = architectures.first().and_then(|v| v.as_str()) {
359 return Ok(Architecture::from_str(arch));
360 }
361 }
362
363 if let Some(model_type) = obj.get("model_type").and_then(|v| v.as_str()) {
366 return Ok(Architecture::from_str(model_type));
367 }
368
369 warn!("Could not detect architecture, using default (Llama)");
370 Ok(Architecture::Llama)
371 }
372
373 pub fn infer_model_type(&self, definition: &ModelDefinition) -> ModelType {
375 ModelType::Custom(format!("{:?}", definition.architecture))
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn concrete_architecture_identity_precedes_broad_model_type() {
385 let manager = ConfigManager::new();
386 let config = serde_json::json!({
387 "model_type": "qwen3",
388 "architectures": ["LlamaForCausalLM"]
389 });
390
391 assert_eq!(
392 manager.detect_architecture(&config).unwrap(),
393 Architecture::Llama
394 );
395 }
396}