Skip to main content

steeldb/agent/
native.rs

1//! Native in-process LLM inference (pure-Rust [candle]) — runs the tuned Qwen3-1.7B + our shipped LoRA
2//! fully offline, with no Python `serve_openai.py` and no HTTP server. This is the ollama-parity path:
3//! one binary, models resolved via [`crate::paths`].
4//!
5//! Two backends behind one enum:
6//! - **Quantized (default, fast, small).** A Q4_K GGUF built once from the fp16 base + our LoRA and cached
7//!   at `~/.steeldb/models/steeldb-qwen3-1.7b-q4k.gguf` (~1.1 GB, ~1.1 GB RAM). This is what runs after
8//!   first setup — comparable to ollama's footprint.
9//! - **Full fp16.** Loads the base safetensors and folds the LoRA at load (~3.4 GB). Used only to *build*
10//!   the quantized GGUF (a one-time step), or via `STEELDB_NATIVE_FP16=1`.
11//!
12//! LoRA merge folds the PEFT deltas (`W' = W + (α/r)·BA`) into each targeted projection. Generation
13//! speaks the Qwen3 ChatML template (tools rendered into the system turn as `<tools>` JSON); the returned
14//! text is handed to the agent loop, where [`crate::agent::harness::QwenHarness`] recovers `<tool_call>`
15//! blocks exactly as for the HTTP Paddock provider.
16
17use 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
33/// Cached quantized model filename under the model root.
34pub const GGUF_NAME: &str = "steeldb-qwen3-1.7b-q4k.gguf";
35
36/// Sampling / decoding knobs — mirror `train/serve_openai.py` (low temperature, repetition penalty).
37#[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
53/// CPU (f32/quantized) by default: candle 0.9's Metal backend has no `rms-norm` kernel, which Qwen3
54/// requires. Opt into Metal with `STEELDB_NATIVE_METAL=1` once a candle version ships the kernel.
55fn 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    /// Stop tokens: `<|im_end|>` (151645) and `<|endoftext|>` (151643).
90    eos: Vec<u32>,
91    gen: GenConfig,
92}
93
94impl NativeLlm {
95    /// Load the tuned model, preferring the cached quantized GGUF. If it's absent, build it once from the
96    /// fp16 base + LoRA (heavy, ~3.4 GB) and cache it, then load quantized. Set `STEELDB_NATIVE_FP16=1` to
97    /// skip quantization and run the merged fp16 model directly.
98    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    /// Load the merged fp16 model (also the first stage of building the GGUF).
118    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    /// Temperature decode from a fully-rendered prompt. Returns the assistant text, stop tokens trimmed.
140    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            // Full model returns [1,1,vocab]; quantized returns [1,vocab]. Flatten to 1-D vocab.
157            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
177/// Drop a leading Qwen3 `<think>…</think>` reasoning block (often empty in tool-use turns) so it doesn't
178/// leak into the answer or confuse the harness's tool-call recovery.
179fn 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
203/// Load every base safetensors shard into one tensor map (kept in stored dtype).
204fn 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
221/// Fold PEFT LoRA deltas into the base tensor map in place (f32 math): key
222/// `base_model.model.<base>.lora_{A,B}.weight` → base weight `<base>.weight`, `W' = W + (α/r)·B·A`.
223fn 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
253/// Build the cached Q4_K GGUF from the fp16 base + LoRA: merge, map HF tensor names to the llama.cpp
254/// convention, quantize matmuls to Q4_K (norms kept F16), and write with the metadata `quantized_qwen3`
255/// requires. One-time; the result loads fast on every subsequent run.
256pub fn build_gguf(base_dir: &Path, lora_dir: Option<&Path>, out: &Path) -> candle_core::Result<()> {
257    let device = Device::Cpu; // quantization runs on CPU
258    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    // f32 view of an HF tensor by key.
265    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    // Collect (gguf_name, QTensor). Big matmuls → Q4_K; norms/embeddings kept F16 for precision.
270    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
317/// Render the neutral message stream into a Qwen3 ChatML prompt. Tools (when present) are declared in the
318/// system turn as `<tools>` JSON — the format the Qwen3 template and our LoRA were trained on.
319fn 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
385/// Provider adapter: an in-process [`NativeLlm`] behind the async [`LlmProvider`] trait. Generation is
386/// CPU-bound and `&mut`, so it runs under a mutex on a blocking thread.
387pub 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    // The 1.7B on-device model hallucinates figures when phrasing tool results; prefer the deterministic
404    // template. Turn on a large provider (Bedrock / OpenRouter) for natural-language synthesis.
405    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        // system turn carries the tool declaration in the Qwen3 <tools> block
441        assert!(p.starts_with("<|im_start|>system\nYou are SteelDB."));
442        assert!(p.contains("<tools>") && p.contains("\"name\":\"ikl_query\""));
443        // assistant tool call + tool response rendered in Qwen3 form
444        assert!(p.contains("<tool_call>") && p.contains("\"name\":\"ikl_query\""));
445        assert!(p.contains("<tool_response>\ncount=5\n</tool_response>"));
446        // ends primed for the assistant to continue
447        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}