1use crate::agent::provider::LlmProvider;
18use crate::agent::types::{Block, Msg, Role, Stop, ToolSpec, Turn};
19use async_trait::async_trait;
20use candle_core::quantized::{gguf_file, GgmlDType, QTensor};
21use candle_core::{DType, Device, Tensor};
22use candle_nn::VarBuilder;
23use candle_transformers::generation::LogitsProcessor;
24use candle_transformers::models::qwen3::{Config, ModelForCausalLM};
25use candle_transformers::models::quantized_qwen3::ModelWeights as QModelWeights;
26use candle_transformers::utils::apply_repeat_penalty;
27use serde_json::json;
28use std::collections::HashMap;
29use std::path::{Path, PathBuf};
30use std::sync::{Arc, Mutex};
31use tokenizers::Tokenizer;
32
33pub const GGUF_NAME: &str = "steeldb-qwen3-1.7b-q4k.gguf";
35
36#[derive(Clone, Copy, Debug)]
38pub struct GenConfig {
39 pub max_tokens: usize,
40 pub temperature: f64,
41 pub top_p: f64,
42 pub repeat_penalty: f32,
43 pub repeat_last_n: usize,
44 pub seed: u64,
45}
46
47impl Default for GenConfig {
48 fn default() -> Self {
49 GenConfig { max_tokens: 1024, temperature: 0.2, top_p: 0.9, repeat_penalty: 1.15, repeat_last_n: 128, seed: 0 }
50 }
51}
52
53fn best_device() -> Device {
56 #[cfg(target_os = "macos")]
57 if std::env::var("STEELDB_NATIVE_METAL").as_deref() == Ok("1") {
58 if let Ok(d) = Device::new_metal(0) {
59 return d;
60 }
61 }
62 Device::Cpu
63}
64
65enum Backend {
66 Full(ModelForCausalLM),
67 Quant(QModelWeights),
68}
69
70impl Backend {
71 fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
72 match self {
73 Backend::Full(m) => m.forward(input, offset),
74 Backend::Quant(m) => m.forward(input, offset),
75 }
76 }
77 fn clear_kv_cache(&mut self) {
78 match self {
79 Backend::Full(m) => m.clear_kv_cache(),
80 Backend::Quant(m) => m.clear_kv_cache(),
81 }
82 }
83}
84
85pub struct NativeLlm {
86 model: Backend,
87 tokenizer: Tokenizer,
88 device: Device,
89 eos: Vec<u32>,
91 gen: GenConfig,
92}
93
94impl NativeLlm {
95 pub fn load(base_dir: &Path, lora_dir: Option<&Path>) -> Result<NativeLlm, String> {
99 let tokenizer = Tokenizer::from_file(base_dir.join("tokenizer.json")).map_err(|e| format!("tokenizer: {e}"))?;
100
101 if std::env::var("STEELDB_NATIVE_FP16").as_deref() == Ok("1") {
102 return Self::load_fp16(base_dir, lora_dir, tokenizer);
103 }
104
105 let gguf = crate::paths::model_roots()
106 .into_iter()
107 .map(|r| r.join(GGUF_NAME))
108 .find(|p| p.exists())
109 .unwrap_or_else(|| default_gguf_path());
110 if !gguf.exists() {
111 eprintln!("native: building quantized model (one-time) → {}", gguf.display());
112 build_gguf(base_dir, lora_dir, &gguf).map_err(|e| format!("build quantized GGUF: {e}"))?;
113 }
114 Self::load_gguf(&gguf, tokenizer)
115 }
116
117 fn load_fp16(base_dir: &Path, lora_dir: Option<&Path>, tokenizer: Tokenizer) -> Result<NativeLlm, String> {
119 let device = best_device();
120 let cfg = read_config(base_dir).map_err(|e| e.to_string())?;
121 let mut tensors = load_base_tensors(base_dir, &device).map_err(|e| e.to_string())?;
122 if let Some(dir) = lora_dir {
123 merge_lora(&mut tensors, dir, &device).map_err(|e| format!("merge LoRA: {e}"))?;
124 }
125 let dtype = if device.is_cpu() { DType::F32 } else { DType::BF16 };
126 let vb = VarBuilder::from_tensors(tensors, dtype, &device);
127 let model = ModelForCausalLM::new(&cfg, vb).map_err(|e| format!("build model: {e}"))?;
128 Ok(NativeLlm { model: Backend::Full(model), tokenizer, device, eos: vec![151645, 151643], gen: GenConfig::default() })
129 }
130
131 fn load_gguf(gguf: &Path, tokenizer: Tokenizer) -> Result<NativeLlm, String> {
132 let device = best_device();
133 let mut f = std::fs::File::open(gguf).map_err(|e| format!("open {}: {e}", gguf.display()))?;
134 let content = gguf_file::Content::read(&mut f).map_err(|e| format!("read gguf: {e}"))?;
135 let model = QModelWeights::from_gguf(content, &mut f, &device).map_err(|e| format!("load gguf model: {e}"))?;
136 Ok(NativeLlm { model: Backend::Quant(model), tokenizer, device, eos: vec![151645, 151643], gen: GenConfig::default() })
137 }
138
139 pub fn generate(&mut self, prompt: &str) -> Result<String, String> {
141 self.model.clear_kv_cache();
142 let enc = self.tokenizer.encode(prompt, false).map_err(|e| format!("encode: {e}"))?;
143 let mut tokens: Vec<u32> = enc.get_ids().to_vec();
144 let mut logits_proc = if self.gen.temperature <= 0.0 {
145 LogitsProcessor::new(self.gen.seed, None, None)
146 } else {
147 LogitsProcessor::new(self.gen.seed, Some(self.gen.temperature), Some(self.gen.top_p))
148 };
149
150 let mut out_tokens: Vec<u32> = Vec::new();
151 let mut offset = 0usize;
152 for step in 0..self.gen.max_tokens {
153 let ctx = if step == 0 { &tokens[..] } else { &tokens[tokens.len() - 1..] };
154 let input = Tensor::new(ctx, &self.device).map_err(|e| e.to_string())?.unsqueeze(0).map_err(|e| e.to_string())?;
155 let logits = self.model.forward(&input, offset).map_err(|e| format!("forward: {e}"))?;
156 let logits = logits.flatten_all().map_err(|e| e.to_string())?.to_dtype(DType::F32).map_err(|e| e.to_string())?;
158 let logits = if self.gen.repeat_penalty != 1.0 {
159 let start = tokens.len().saturating_sub(self.gen.repeat_last_n);
160 apply_repeat_penalty(&logits, self.gen.repeat_penalty, &tokens[start..]).map_err(|e| e.to_string())?
161 } else {
162 logits
163 };
164 let next = logits_proc.sample(&logits).map_err(|e| format!("sample: {e}"))?;
165 offset += ctx.len();
166 if self.eos.contains(&next) {
167 break;
168 }
169 tokens.push(next);
170 out_tokens.push(next);
171 }
172 let text = self.tokenizer.decode(&out_tokens, true).map_err(|e| format!("decode: {e}"))?;
173 Ok(strip_think(&text))
174 }
175}
176
177fn strip_think(s: &str) -> String {
180 let t = s.trim_start();
181 if let Some(rest) = t.strip_prefix("<think>") {
182 if let Some(end) = rest.find("</think>") {
183 return rest[end + "</think>".len()..].trim_start().to_string();
184 }
185 }
186 s.trim().to_string()
187}
188
189fn default_gguf_path() -> PathBuf {
190 if let Ok(home) = std::env::var("HOME") {
191 let d = PathBuf::from(home).join(".steeldb/models");
192 let _ = std::fs::create_dir_all(&d);
193 return d.join(GGUF_NAME);
194 }
195 PathBuf::from(GGUF_NAME)
196}
197
198fn read_config(base_dir: &Path) -> candle_core::Result<Config> {
199 let bytes = std::fs::read(base_dir.join("config.json")).map_err(candle_core::Error::wrap)?;
200 serde_json::from_slice(&bytes).map_err(candle_core::Error::wrap)
201}
202
203fn load_base_tensors(base_dir: &Path, device: &Device) -> candle_core::Result<HashMap<String, Tensor>> {
205 let mut shards: Vec<PathBuf> = std::fs::read_dir(base_dir)
206 .map_err(candle_core::Error::wrap)?
207 .filter_map(|e| e.ok().map(|e| e.path()))
208 .filter(|p| p.extension().map(|x| x == "safetensors").unwrap_or(false))
209 .collect();
210 shards.sort();
211 if shards.is_empty() {
212 candle_core::bail!("no *.safetensors in {}", base_dir.display());
213 }
214 let mut tensors = HashMap::new();
215 for shard in &shards {
216 tensors.extend(candle_core::safetensors::load(shard, device)?);
217 }
218 Ok(tensors)
219}
220
221fn merge_lora(tensors: &mut HashMap<String, Tensor>, lora_dir: &Path, device: &Device) -> candle_core::Result<()> {
224 let cfg: serde_json::Value =
225 serde_json::from_slice(&std::fs::read(lora_dir.join("adapter_config.json")).map_err(candle_core::Error::wrap)?)
226 .map_err(candle_core::Error::wrap)?;
227 let r = cfg.get("r").and_then(|v| v.as_f64()).unwrap_or(16.0);
228 let alpha = cfg.get("lora_alpha").and_then(|v| v.as_f64()).unwrap_or(r);
229 let scale = alpha / r;
230
231 let lora = candle_core::safetensors::load(lora_dir.join("adapter_model.safetensors"), device)?;
232 let mut targets: Vec<String> =
233 lora.keys().filter_map(|k| k.strip_suffix(".lora_A.weight").map(|s| s.to_string())).collect();
234 targets.sort();
235 let mut merged = 0usize;
236 for t in &targets {
237 let base_key = format!("{}.weight", t.strip_prefix("base_model.model.").unwrap_or(t));
238 let (Some(a), Some(b)) = (lora.get(&format!("{t}.lora_A.weight")), lora.get(&format!("{t}.lora_B.weight"))) else {
239 continue;
240 };
241 let Some(w) = tensors.get(&base_key) else { continue };
242 let delta = (b.to_dtype(DType::F32)?.matmul(&a.to_dtype(DType::F32)?)? * scale)?;
243 let w2 = (w.to_dtype(DType::F32)? + delta)?;
244 tensors.insert(base_key, w2);
245 merged += 1;
246 }
247 if merged == 0 {
248 candle_core::bail!("LoRA merged 0 tensors — key mismatch with base model");
249 }
250 Ok(())
251}
252
253pub fn build_gguf(base_dir: &Path, lora_dir: Option<&Path>, out: &Path) -> candle_core::Result<()> {
257 let device = Device::Cpu; let cfg = read_config(base_dir)?;
259 let mut t = load_base_tensors(base_dir, &device)?;
260 if let Some(dir) = lora_dir {
261 merge_lora(&mut t, dir, &device)?;
262 }
263
264 let f32 = |t: &HashMap<String, Tensor>, k: &str| -> candle_core::Result<Tensor> {
266 t.get(k).ok_or_else(|| candle_core::Error::Msg(format!("missing tensor {k}")))?.to_dtype(DType::F32)
267 };
268
269 let mut q: Vec<(String, QTensor)> = Vec::new();
271 let mut push = |name: String, src: &Tensor, dtype: GgmlDType| -> candle_core::Result<()> {
272 q.push((name, QTensor::quantize(src, dtype)?));
273 Ok(())
274 };
275
276 push("token_embd.weight".into(), &f32(&t, "model.embed_tokens.weight")?, GgmlDType::Q4K)?;
277 push("output_norm.weight".into(), &f32(&t, "model.norm.weight")?, GgmlDType::F16)?;
278 for i in 0..cfg.num_hidden_layers {
279 let p = format!("model.layers.{i}");
280 let g = format!("blk.{i}");
281 push(format!("{g}.attn_q.weight"), &f32(&t, &format!("{p}.self_attn.q_proj.weight"))?, GgmlDType::Q4K)?;
282 push(format!("{g}.attn_k.weight"), &f32(&t, &format!("{p}.self_attn.k_proj.weight"))?, GgmlDType::Q4K)?;
283 push(format!("{g}.attn_v.weight"), &f32(&t, &format!("{p}.self_attn.v_proj.weight"))?, GgmlDType::Q4K)?;
284 push(format!("{g}.attn_output.weight"), &f32(&t, &format!("{p}.self_attn.o_proj.weight"))?, GgmlDType::Q4K)?;
285 push(format!("{g}.attn_q_norm.weight"), &f32(&t, &format!("{p}.self_attn.q_norm.weight"))?, GgmlDType::F16)?;
286 push(format!("{g}.attn_k_norm.weight"), &f32(&t, &format!("{p}.self_attn.k_norm.weight"))?, GgmlDType::F16)?;
287 push(format!("{g}.ffn_gate.weight"), &f32(&t, &format!("{p}.mlp.gate_proj.weight"))?, GgmlDType::Q4K)?;
288 push(format!("{g}.ffn_up.weight"), &f32(&t, &format!("{p}.mlp.up_proj.weight"))?, GgmlDType::Q4K)?;
289 push(format!("{g}.ffn_down.weight"), &f32(&t, &format!("{p}.mlp.down_proj.weight"))?, GgmlDType::Q4K)?;
290 push(format!("{g}.attn_norm.weight"), &f32(&t, &format!("{p}.input_layernorm.weight"))?, GgmlDType::F16)?;
291 push(format!("{g}.ffn_norm.weight"), &f32(&t, &format!("{p}.post_attention_layernorm.weight"))?, GgmlDType::F16)?;
292 }
293
294 let md: Vec<(&str, gguf_file::Value)> = vec![
295 ("general.architecture", gguf_file::Value::String("qwen3".into())),
296 ("qwen3.block_count", gguf_file::Value::U32(cfg.num_hidden_layers as u32)),
297 ("qwen3.context_length", gguf_file::Value::U32(cfg.max_position_embeddings as u32)),
298 ("qwen3.embedding_length", gguf_file::Value::U32(cfg.hidden_size as u32)),
299 ("qwen3.attention.head_count", gguf_file::Value::U32(cfg.num_attention_heads as u32)),
300 ("qwen3.attention.head_count_kv", gguf_file::Value::U32(cfg.num_key_value_heads as u32)),
301 ("qwen3.attention.key_length", gguf_file::Value::U32(cfg.head_dim as u32)),
302 ("qwen3.attention.layer_norm_rms_epsilon", gguf_file::Value::F32(cfg.rms_norm_eps as f32)),
303 ("qwen3.rope.freq_base", gguf_file::Value::F32(cfg.rope_theta as f32)),
304 ];
305 let md_ref: Vec<(&str, &gguf_file::Value)> = md.iter().map(|(k, v)| (*k, v)).collect();
306 let tensors_ref: Vec<(&str, &QTensor)> = q.iter().map(|(n, t)| (n.as_str(), t)).collect();
307
308 let tmp = out.with_extension("gguf.tmp");
309 {
310 let mut w = std::fs::File::create(&tmp).map_err(candle_core::Error::wrap)?;
311 gguf_file::write(&mut w, &md_ref, &tensors_ref)?;
312 }
313 std::fs::rename(&tmp, out).map_err(candle_core::Error::wrap)?;
314 Ok(())
315}
316
317fn to_chatml(system: &str, msgs: &[Msg], tools: &[ToolSpec]) -> String {
320 let mut p = String::from("<|im_start|>system\n");
321 p.push_str(system);
322 if !tools.is_empty() {
323 p.push_str(
324 "\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n",
325 );
326 for t in tools {
327 let sig = json!({"type": "function", "function": {"name": t.name, "description": t.description, "parameters": t.schema}});
328 p.push_str(&sig.to_string());
329 p.push('\n');
330 }
331 p.push_str(
332 "</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>",
333 );
334 }
335 p.push_str("<|im_end|>\n");
336
337 for m in msgs {
338 match m.role {
339 Role::User => {
340 let mut text = String::new();
341 for b in &m.blocks {
342 match b {
343 Block::Text(t) => {
344 if !text.is_empty() {
345 text.push('\n');
346 }
347 text.push_str(t);
348 }
349 Block::ToolResult { content, .. } => {
350 p.push_str("<|im_start|>user\n<tool_response>\n");
351 p.push_str(content);
352 p.push_str("\n</tool_response><|im_end|>\n");
353 }
354 _ => {}
355 }
356 }
357 if !text.is_empty() {
358 p.push_str("<|im_start|>user\n");
359 p.push_str(&text);
360 p.push_str("<|im_end|>\n");
361 }
362 }
363 Role::Assistant => {
364 p.push_str("<|im_start|>assistant\n");
365 for b in &m.blocks {
366 match b {
367 Block::Text(t) => p.push_str(t),
368 Block::ToolUse { name, input, .. } => {
369 let call = json!({"name": name, "arguments": input});
370 p.push_str("\n<tool_call>\n");
371 p.push_str(&call.to_string());
372 p.push_str("\n</tool_call>");
373 }
374 _ => {}
375 }
376 }
377 p.push_str("<|im_end|>\n");
378 }
379 }
380 }
381 p.push_str("<|im_start|>assistant\n");
382 p
383}
384
385pub struct NativeProvider {
388 llm: Arc<Mutex<NativeLlm>>,
389}
390
391impl NativeProvider {
392 pub fn new(llm: NativeLlm) -> NativeProvider {
393 NativeProvider { llm: Arc::new(Mutex::new(llm)) }
394 }
395}
396
397#[async_trait]
398impl LlmProvider for NativeProvider {
399 fn name(&self) -> &str {
400 "native"
401 }
402
403 fn synthesizes(&self) -> bool {
406 false
407 }
408
409 async fn chat(&self, system: &str, msgs: &[Msg], tools: &[ToolSpec]) -> Result<Turn, String> {
410 let prompt = to_chatml(system, msgs, tools);
411 let llm = self.llm.clone();
412 let text = tokio::task::spawn_blocking(move || {
413 let mut guard = llm.lock().map_err(|_| "native model mutex poisoned".to_string())?;
414 guard.generate(&prompt)
415 })
416 .await
417 .map_err(|e| format!("native inference task: {e}"))??;
418 Ok(Turn { text, tool_uses: Vec::new(), stop: Stop::EndTurn })
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use serde_json::json;
426
427 #[test]
428 fn chatml_renders_qwen3_tool_template() {
429 let tools = vec![ToolSpec {
430 name: "ikl_query".into(),
431 description: "run a token query".into(),
432 schema: json!({"type": "object", "properties": {"q": {"type": "string"}}}),
433 }];
434 let msgs = vec![
435 Msg::user_text("how many electric vehicles?"),
436 Msg { role: Role::Assistant, blocks: vec![Block::ToolUse { id: "1".into(), name: "ikl_query".into(), input: json!({"q": "powertrain/electric"}) }] },
437 Msg { role: Role::User, blocks: vec![Block::ToolResult { id: "1".into(), content: "count=5".into(), is_error: false }] },
438 ];
439 let p = to_chatml("You are SteelDB.", &msgs, &tools);
440 assert!(p.starts_with("<|im_start|>system\nYou are SteelDB."));
442 assert!(p.contains("<tools>") && p.contains("\"name\":\"ikl_query\""));
443 assert!(p.contains("<tool_call>") && p.contains("\"name\":\"ikl_query\""));
445 assert!(p.contains("<tool_response>\ncount=5\n</tool_response>"));
446 assert!(p.trim_end().ends_with("<|im_start|>assistant"));
448 }
449
450 #[test]
451 fn strip_think_removes_leading_block() {
452 assert_eq!(strip_think("<think>\n\n</think>\n\nThe answer is 38m."), "The answer is 38m.");
453 assert_eq!(strip_think("<think>reasoning here</think>done"), "done");
454 assert_eq!(strip_think("no think block"), "no think block");
455 }
456
457 #[test]
458 fn chatml_without_tools_has_no_tools_block() {
459 let p = to_chatml("sys", &[Msg::user_text("hi")], &[]);
460 assert!(!p.contains("<tools>"));
461 assert!(p.contains("<|im_start|>user\nhi<|im_end|>"));
462 }
463}