hypersteeldb 0.5.2

A database that compiles questions instead of guessing answers: typed vocabulary discovered from your documents, queries type-checked before they run, roaring-bitmap set algebra over reified hyperedges, and Dempster-Shafer evidence with an explicit conflict guard.
Documentation
//! Provider configuration — pick the LLM backend at runtime. Bedrock (Claude) or a local
//! Paddock/OpenAI-compatible server (e.g. Qwen3.5-2B). Each arm is gated by its cargo feature; a
//! disabled backend returns a clear error rather than failing to compile.

use crate::agent::provider::LlmProvider;

/// Locate + parse the config file: `$STEELDB_CONFIG`, else `./steeldb.json`, else `~/.steeldb/config.json`.
fn load_config_file() -> Option<serde_json::Value> {
    let candidates = [
        std::env::var("STEELDB_CONFIG").ok().map(std::path::PathBuf::from),
        Some(std::path::PathBuf::from("steeldb.json")),
        std::env::var("HOME").ok().map(|h| std::path::PathBuf::from(h).join(".steeldb/config.json")),
    ];
    for path in candidates.into_iter().flatten() {
        if let Ok(bytes) = std::fs::read(&path) {
            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
                return Some(v);
            }
        }
    }
    None
}

#[derive(Clone, Debug)]
pub enum ProviderConfig {
    /// Claude on AWS Bedrock via the Converse API. `region: None` uses the environment/default chain.
    Bedrock { model_id: String, region: Option<String> },
    /// Local Paddock (OpenAI-compatible `/chat/completions`). `base_url` includes the `/v1` path.
    Paddock { base_url: String, model: String, api_key: Option<String> },
    /// Native in-process inference (pure-Rust candle): tuned Qwen3-1.7B + shipped LoRA, fully offline,
    /// no server. `base_dir`/`lora_dir` = `None` resolves to the HF cache / bundled defaults at build.
    Native { base_dir: Option<std::path::PathBuf>, lora_dir: Option<std::path::PathBuf> },
    /// Cactus Needle: the 26M tool-use-native model via its `/generate` server (needle-code torch port).
    Needle { base_url: String },
}

impl ProviderConfig {
    pub fn bedrock_default() -> Self {
        ProviderConfig::Bedrock {
            model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
            region: None,
        }
    }
    pub fn paddock_default() -> Self {
        ProviderConfig::Paddock {
            base_url: "http://localhost:8080/v1".to_string(),
            model: "Qwen/Qwen3.5-2B".to_string(),
            api_key: None,
        }
    }

    /// Resolve the provider in layers: **built-in defaults → config file → environment**. This is the
    /// product path: a user points SteelDB at ANY OpenAI-compatible endpoint (OpenRouter, OpenAI,
    /// Together, Groq, vLLM, a local server) with one small config file — no code, no rebuild.
    ///
    /// Config file (`./steeldb.json` or `$STEELDB_CONFIG` or `~/.steeldb/config.json`):
    /// ```json
    /// { "llm": { "provider": "openai", "base_url": "https://openrouter.ai/api/v1",
    ///            "model": "anthropic/claude-3.5-sonnet", "api_key": "sk-or-..." } }
    /// ```
    /// `provider: "openai"` (or "paddock"/"local") → the OpenAI-compatible client; `"bedrock"` → Claude
    /// on AWS. Env vars (`STEELDB_LLM`, `STEELDB_PADDOCK_URL`, `STEELDB_PADDOCK_MODEL`, `STEELDB_API_KEY`,
    /// `STEELDB_BEDROCK_MODEL`, `AWS_REGION`) override the file.
    pub fn resolve(default_base_url: &str, default_model: &str) -> ProviderConfig {
        // 1) defaults (local Qwen unless the caller passes otherwise)
        let mut base_url = default_base_url.to_string();
        let mut model = default_model.to_string();
        let mut api_key: Option<String> = None;
        let mut bedrock = false;
        let mut native = false;
        // External (cloud/OpenAI-compatible) providers are opt-in only: SteelDB's default is the complete
        // on-device path (Needle2 + deterministic template synthesis). Set true only when the user
        // explicitly asks for an external OpenAI-compatible endpoint.
        let mut paddock_explicit = false;
        let mut needle_url = "http://localhost:8080".to_string();
        let mut bedrock_model = "us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string();

        // 2) config file
        if let Some(cfg) = load_config_file() {
            if let Some(llm) = cfg.get("llm") {
                match llm.get("provider").and_then(|v| v.as_str()) {
                    Some("bedrock") => bedrock = true,
                    Some("native") => native = true,
                    Some("needle") => {} // the default; nothing to flip
                    Some("openai") | Some("paddock") | Some("local") => paddock_explicit = true,
                    _ => {}
                }
                if let Some(u) = llm.get("needle_url").and_then(|v| v.as_str()) {
                    needle_url = u.to_string();
                }
                if let Some(u) = llm.get("base_url").and_then(|v| v.as_str()) {
                    base_url = u.to_string();
                }
                if let Some(m) = llm.get("model").and_then(|v| v.as_str()) {
                    model = m.to_string();
                    if bedrock {
                        bedrock_model = m.to_string();
                    }
                }
                if let Some(k) = llm.get("api_key").and_then(|v| v.as_str()) {
                    api_key = Some(k.to_string());
                }
            }
        }

        // 3) env overrides
        match std::env::var("STEELDB_LLM").as_deref() {
            Ok("bedrock") => bedrock = true,
            Ok("native") => native = true,
            Ok("needle") => {
                bedrock = false;
                native = false;
                paddock_explicit = false;
            }
            Ok("paddock") | Ok("openai") | Ok("local") => {
                bedrock = false;
                native = false;
                paddock_explicit = true;
            }
            _ => {}
        }
        if let Ok(u) = std::env::var("STEELDB_NEEDLE_URL") {
            needle_url = u;
        }
        if let Ok(u) = std::env::var("STEELDB_PADDOCK_URL") {
            base_url = u;
        }
        if let Ok(m) = std::env::var("STEELDB_PADDOCK_MODEL") {
            model = m;
        }
        if let Ok(m) = std::env::var("STEELDB_BEDROCK_MODEL") {
            bedrock_model = m;
            bedrock = true;
        }
        if let Ok(k) = std::env::var("STEELDB_API_KEY").or_else(|_| std::env::var("OPENROUTER_API_KEY")).or_else(|_| std::env::var("OPENAI_API_KEY")) {
            api_key = Some(k);
        }

        // Priority: explicit external opt-ins first, else the complete on-device default (Needle2).
        if bedrock {
            ProviderConfig::Bedrock { model_id: bedrock_model, region: std::env::var("AWS_REGION").ok() }
        } else if paddock_explicit {
            ProviderConfig::Paddock { base_url, model, api_key }
        } else if native {
            ProviderConfig::Native { base_dir: None, lora_dir: None }
        } else {
            // Default = self-contained on-device: Needle2 drives tool selection, templates synthesise.
            let _ = (base_url, model, api_key);
            ProviderConfig::Needle { base_url: needle_url }
        }
    }

    /// Read a config from env: `STEELDB_LLM=bedrock|paddock` plus backend-specific vars
    /// (`STEELDB_BEDROCK_MODEL`, `AWS_REGION`; `STEELDB_PADDOCK_URL`, `STEELDB_PADDOCK_MODEL`).
    pub fn from_env() -> ProviderConfig {
        match std::env::var("STEELDB_LLM").as_deref() {
            Ok("paddock") => {
                let mut c = ProviderConfig::paddock_default();
                if let ProviderConfig::Paddock { base_url, model, .. } = &mut c {
                    if let Ok(u) = std::env::var("STEELDB_PADDOCK_URL") {
                        *base_url = u;
                    }
                    if let Ok(m) = std::env::var("STEELDB_PADDOCK_MODEL") {
                        *model = m;
                    }
                }
                c
            }
            _ => {
                let mut c = ProviderConfig::bedrock_default();
                if let ProviderConfig::Bedrock { model_id, region } = &mut c {
                    if let Ok(m) = std::env::var("STEELDB_BEDROCK_MODEL") {
                        *model_id = m;
                    }
                    if let Ok(r) = std::env::var("AWS_REGION") {
                        *region = Some(r);
                    }
                }
                c
            }
        }
    }

    /// Construct the provider. Errors if the selected backend's feature is not compiled in.
    pub async fn build(&self) -> Result<Box<dyn LlmProvider>, String> {
        match self {
            ProviderConfig::Bedrock { model_id, region } => {
                #[cfg(feature = "bedrock")]
                {
                    let p = crate::agent::bedrock::BedrockProvider::new(model_id.clone(), region.clone()).await?;
                    Ok(Box::new(p))
                }
                #[cfg(not(feature = "bedrock"))]
                {
                    let _ = (model_id, region);
                    Err("bedrock backend not compiled in (build with --features bedrock)".to_string())
                }
            }
            ProviderConfig::Paddock { base_url, model, api_key } => {
                #[cfg(feature = "paddock")]
                {
                    // Wrap the local model in the Qwen harness (tool-call recovery + MECE/tool guidance)
                    // so a small model completes the agent loop reliably.
                    let p = crate::agent::paddock::PaddockProvider::new(base_url.clone(), model.clone(), api_key.clone());
                    Ok(crate::agent::harness::QwenHarness::wrap(Box::new(p)))
                }
                #[cfg(not(feature = "paddock"))]
                {
                    let _ = (base_url, model, api_key);
                    Err("paddock backend not compiled in (build with --features paddock)".to_string())
                }
            }
            ProviderConfig::Native { base_dir, lora_dir } => {
                #[cfg(feature = "native")]
                {
                    let base = base_dir.clone().or_else(native_base_dir).ok_or_else(|| {
                        "native LLM base weights not found — set STEELDB_QWEN_BASE, run `steeldb pull`, or place Qwen3-1.7B under ~/.steeldb/models/qwen3-1.7b".to_string()
                    })?;
                    let lora = lora_dir.clone().or_else(|| {
                        crate::paths::model_dir("lora-default", "STEELDB_LORA", "adapter_model.safetensors")
                    });
                    // Loading + LoRA merge is heavy and blocking; keep it off the async reactor.
                    let (base2, lora2) = (base.clone(), lora.clone());
                    let llm = tokio::task::spawn_blocking(move || crate::agent::native::NativeLlm::load(&base2, lora2.as_deref()))
                        .await
                        .map_err(|e| format!("native load task: {e}"))??;
                    let p = crate::agent::native::NativeProvider::new(llm);
                    Ok(crate::agent::harness::QwenHarness::wrap(Box::new(p)))
                }
                #[cfg(not(feature = "native"))]
                {
                    let _ = (base_dir, lora_dir);
                    Err("native backend not compiled in (build with --features native)".to_string())
                }
            }
            ProviderConfig::Needle { base_url } => {
                #[cfg(feature = "needle")]
                {
                    // Needle emits structured tool-call JSON natively, so it is NOT wrapped in the Qwen
                    // harness (which recovers Qwen's <tool_call> text blocks).
                    Ok(Box::new(crate::agent::needle::NeedleProvider::new(base_url.clone())))
                }
                #[cfg(not(feature = "needle"))]
                {
                    let _ = base_url;
                    Err("needle backend not compiled in (build with --features needle)".to_string())
                }
            }
        }
    }
}

/// True when native inference can actually run: base weights resolvable and the LoRA present. Callers
/// use this to decide whether to default to offline native inference.
#[cfg(feature = "native")]
pub fn native_base_available() -> bool {
    native_base_dir().is_some()
}

/// Resolve the Qwen3-1.7B base weights directory for native inference: `STEELDB_QWEN_BASE`, else
/// `~/.steeldb/models/qwen3-1.7b`, else the latest Hugging Face cache snapshot. Base weights are large
/// and fetched (not shipped); only our LoRA is bundled.
#[cfg(feature = "native")]
fn native_base_dir() -> Option<std::path::PathBuf> {
    use std::path::PathBuf;
    let has_model = |d: &PathBuf| d.join("config.json").exists() && d.join("tokenizer.json").exists();
    if let Ok(p) = std::env::var("STEELDB_QWEN_BASE") {
        let p = PathBuf::from(p);
        if has_model(&p) {
            return Some(p);
        }
    }
    if let Ok(home) = std::env::var("HOME") {
        let d = PathBuf::from(&home).join(".steeldb/models/qwen3-1.7b");
        if has_model(&d) {
            return Some(d);
        }
        // Hugging Face cache: models--Qwen--Qwen3-1.7B/snapshots/<hash>/
        let snaps = PathBuf::from(&home).join(".cache/huggingface/hub/models--Qwen--Qwen3-1.7B/snapshots");
        if let Ok(rd) = std::fs::read_dir(&snaps) {
            let mut dirs: Vec<PathBuf> = rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| has_model(p)).collect();
            dirs.sort();
            if let Some(d) = dirs.pop() {
                return Some(d);
            }
        }
    }
    None
}