localharness 0.54.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
//! Per-tenant LLM model selection — which backend the in-tab agent uses.
//!
//! The choice is a single model ID persisted to `.lh_model` in this
//! origin's OPFS root (same pattern as [`super::key_store`] /
//! `.lh_system_prompt.txt`), read on session start by
//! [`super::chat::start_session`]. A `gemini-*` id routes to the Gemini
//! backend; a `claude-*` id routes to the Anthropic backend. Both reach
//! the model through the credit proxy in credits mode (the proxy is
//! multi-provider) and BYOK still works for Gemini.
//!
//! Unlike the encrypted `.lh_api_key`, the model id is not a secret, so
//! it's stored as plaintext UTF-8.


const MODEL_FILE: &str = ".lh_model";

/// Default model id when none is persisted — the platform's Gemini default.
/// Aliases the crate-canonical [`crate::types::DEFAULT_MODEL`] so a model-id
/// flip in ONE place propagates here (no re-typed literal to drift).
pub(crate) const DEFAULT_MODEL: &str = crate::types::DEFAULT_MODEL;

/// The selectable models, as `(id, label)` pairs. Drives the admin
/// selector template AND is the allowlist [`save`] validates against, so a
/// stale/garbage `.lh_model` can never route to an unknown model.
///
/// The ids REFERENCE the canonical backend constants rather than re-typing
/// literals — a rename in `types`/`anthropic::wire` auto-propagates here, so
/// the selector can never advertise a dead id (the model-id-flip drift trap;
/// browser-app always pulls the `anthropic` feature, so the consts resolve).
/// `gpt-*` is intentionally absent until the OpenAI selector path is wired
/// (proxy `OPENAI_API_KEY`).
///
/// The "Local (Gemma)" entry is **feature-gated on `local`**: it's appended
/// ONLY when the heavy in-browser Burn-wgpu backend is actually compiled into
/// this bundle (the `browser-app-local` feature). The default browser bundle
/// omits it, so the selector can never advertise a model `start_session` can't
/// route (selecting it would hit the "compiled without `local`" error path).
/// `gemma-3-270m` stays a literal (the `local` backend isn't always present to
/// const-reference).
// Selectable set is deliberately just Gemini Flash + Claude Opus (on-chain
// feedback #26: drop Sonnet + Haiku from the picker, label the Gemini entry
// "Gemini Flash"). The anthropic::{DEFAULT_MODEL,SONNET_MODEL} (Haiku/Sonnet)
// consts stay DEFINED in the backend — the difficulty router still uses them
// to downgrade routine turns behind the scenes — they're just no longer
// user-selectable here.
#[cfg(not(feature = "local"))]
pub(crate) const MODELS: &[(&str, &str)] = &[
    (crate::types::DEFAULT_MODEL, "Gemini Flash"),
    (crate::backends::anthropic::OPUS_MODEL, "Claude Opus"),
];
#[cfg(feature = "local")]
pub(crate) const MODELS: &[(&str, &str)] = &[
    (crate::types::DEFAULT_MODEL, "Gemini Flash"),
    (crate::backends::anthropic::OPUS_MODEL, "Claude Opus"),
    ("gemma-3-270m", "Local (Gemma)"),
];

/// True for a Claude/Anthropic model id (`claude-*`). Everything else is
/// treated as a Gemini id by [`super::chat::start_session`].
pub(crate) fn is_anthropic(model: &str) -> bool {
    model.starts_with("claude-")
}

/// True for the in-browser local model id (`gemma-*`). Routes to the local
/// (Burn-wgpu) backend rather than the credit proxy / a network API.
pub(crate) fn is_local(model: &str) -> bool {
    model.starts_with("gemma-")
}

/// The thinking-budget CEILING a session on `model` is built with — the
/// SINGLE SOURCE OF TRUTH for both `chat::session::start_session`'s
/// `with_thinking(...)` and the difficulty router's per-turn clamp. The router
/// may lower a routine turn below this, but never raises it past what the
/// user's model selection implies. `None` for backends without thinking
/// control (local Gemma), so the router leaves them alone.
///
/// - Gemini → [`crate::types::ThinkingLevel::High`] (the deep-think in-tab default).
/// - Claude Haiku → `Medium`; other Claude (Sonnet/Opus) → `High`.
/// - Local (Gemma) → `None`.
pub(crate) fn session_thinking_ceiling(model: &str) -> Option<crate::types::ThinkingLevel> {
    use crate::types::ThinkingLevel;
    if is_local(model) {
        None
    } else if is_anthropic(model) {
        if model.contains("haiku") {
            Some(ThinkingLevel::Medium)
        } else {
            Some(ThinkingLevel::High)
        }
    } else {
        // Gemini path.
        Some(ThinkingLevel::High)
    }
}

/// A human-readable description of `model` for the system prompt, e.g.
/// "Claude Opus (claude-opus-4-8) via the Anthropic backend" — the friendly
/// [`MODELS`] label + the raw id + the backend, so the agent can answer "which
/// model are you?" instead of guessing (on-chain feedback).
pub(crate) fn describe(model: &str) -> String {
    let label = MODELS
        .iter()
        .find(|(id, _)| *id == model)
        .map(|(_, label)| *label)
        .unwrap_or(model);
    let backend = if is_anthropic(model) {
        "the Anthropic backend"
    } else if is_local(model) {
        "the local in-browser Gemma backend (no network)"
    } else {
        "the Gemini backend"
    };
    format!("{label} ({model}) via {backend}")
}

/// Read the persisted model id, validated against [`MODELS`]. A missing,
/// empty, or unrecognised file falls back to [`DEFAULT_MODEL`] — the
/// selector is never left pointing at a model the bundle can't route.
pub(crate) async fn load() -> String {
    let fs = super::shared_opfs();
    let chosen = fs
        .read(MODEL_FILE)
        .await
        .ok()
        .and_then(|bytes| String::from_utf8(bytes).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_default();
    if MODELS.iter().any(|(id, _)| *id == chosen) {
        chosen
    } else {
        DEFAULT_MODEL.to_string()
    }
}

/// Persist `model` as the new selection. Rejects an id not in [`MODELS`]
/// so the file can only ever hold a routable model. Best-effort write.
pub(crate) async fn save(model: &str) {
    if !MODELS.iter().any(|(id, _)| *id == model) {
        return;
    }
    let fs = super::shared_opfs();
    if let Err(err) = fs.write_atomic(MODEL_FILE, model.as_bytes()).await {
        web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
            "model save: {err}"
        )));
    }
}