feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
+316
View File
@@ -0,0 +1,316 @@
use anyhow::{Context, Result};
use sqlx::SqlitePool;
use core_api::provider::LlmStrength;
use super::{LlmModelRecord, LlmProviderRecord};
// ── Provider rows ─────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ProviderRow {
id: i64,
name: String,
r#type: String,
api_key: Option<String>,
base_url: Option<String>,
description: Option<String>,
}
pub async fn load_all_providers(pool: &SqlitePool) -> Result<Vec<LlmProviderRecord>> {
let rows = sqlx::query_as::<_, ProviderRow>(
"SELECT id, name, type, api_key, base_url, description FROM llm_providers WHERE removed_at IS NULL ORDER BY name ASC",
)
.fetch_all(pool)
.await
.context("llm_providers: load_all")?;
rows.into_iter().map(provider_row_to_record).collect()
}
pub async fn insert_provider(pool: &SqlitePool, r: &LlmProviderRecord) -> Result<i64> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO llm_providers (name, type, api_key, base_url, description)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING id",
)
.bind(&r.name)
.bind(&r.provider)
.bind(&r.api_key)
.bind(&r.base_url)
.bind(&r.description)
.fetch_one(pool)
.await
.context("llm_providers: insert")?;
Ok(id)
}
pub async fn update_provider(pool: &SqlitePool, id: i64, r: &LlmProviderRecord) -> Result<()> {
sqlx::query(
"UPDATE llm_providers
SET name=?1, type=?2, api_key=?3, base_url=?4, description=?5
WHERE id=?6",
)
.bind(&r.name)
.bind(&r.provider)
.bind(&r.api_key)
.bind(&r.base_url)
.bind(&r.description)
.bind(id)
.execute(pool)
.await
.context("llm_providers: update")?;
Ok(())
}
pub async fn delete_provider(pool: &SqlitePool, id: i64) -> Result<()> {
// Cascade soft-delete all models belonging to this provider.
sqlx::query(
"UPDATE llm_models SET removed_at = datetime('now') WHERE provider_id = ?1 AND removed_at IS NULL",
)
.bind(id)
.execute(pool)
.await
.context("llm_models: cascade soft-delete for provider")?;
// Remove the API key and mark the provider removed.
sqlx::query(
"UPDATE llm_providers SET removed_at = datetime('now'), api_key = NULL WHERE id = ?1",
)
.bind(id)
.execute(pool)
.await
.context("llm_providers: soft-delete")?;
Ok(())
}
// ── Model rows ────────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ModelRow {
id: i64,
provider_id: i64,
model_id: String,
name: String,
strength: Option<String>,
scope: String,
is_default: i64,
priority: i64,
extra_params: Option<String>,
context_length: Option<i64>,
max_output_tokens: Option<i64>,
knowledge_cutoff: Option<String>,
capabilities: String,
reasoning: Option<String>,
}
pub async fn load_all_models(pool: &SqlitePool) -> Result<Vec<LlmModelRecord>> {
let rows = sqlx::query_as::<_, ModelRow>(
"SELECT id, provider_id, model_id, name, strength, scope, is_default, priority, extra_params,
context_length, max_output_tokens, knowledge_cutoff, capabilities, reasoning
FROM llm_models
WHERE removed_at IS NULL
ORDER BY priority ASC, name ASC",
)
.fetch_all(pool)
.await
.context("llm_models: load_all")?;
rows.into_iter().map(model_row_to_record).collect()
}
pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result<i64> {
let scope = serde_json::to_string(&r.scope)?;
let extra_params = r.extra_params.as_ref().map(|v| v.to_string());
let capabilities = serde_json::to_string(&r.capabilities)?;
let reasoning = r.reasoning.as_ref().map(|v| v.to_string());
// A model row is never hard-deleted, only soft-deleted via `removed_at`:
// history and telemetry name it, and `llm_requests` keeps only the model's
// name, so the row is what maps that name back to a provider. The unique
// identity is `name` (also the resolution key), so re-adding a previously
// removed model with the same alias would collide with the lingering
// soft-deleted row. Upsert on `name` so that existing row is revived
// (removed_at cleared) and every field overwritten.
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO llm_models (provider_id, model_id, name, strength, scope, is_default, priority, extra_params,
context_length, max_output_tokens, knowledge_cutoff, capabilities, reasoning)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(name) DO UPDATE SET
provider_id = excluded.provider_id,
model_id = excluded.model_id,
strength = excluded.strength,
scope = excluded.scope,
is_default = excluded.is_default,
priority = excluded.priority,
extra_params = excluded.extra_params,
context_length = excluded.context_length,
max_output_tokens = excluded.max_output_tokens,
knowledge_cutoff = excluded.knowledge_cutoff,
capabilities = excluded.capabilities,
reasoning = excluded.reasoning,
removed_at = NULL
RETURNING id",
)
.bind(r.provider_id)
.bind(&r.model_id)
.bind(&r.name)
.bind(r.strength.map(strength_str))
.bind(scope)
.bind(r.is_default as i64)
.bind(r.priority as i64)
.bind(extra_params)
.bind(r.context_length)
.bind(r.max_output_tokens)
.bind(&r.knowledge_cutoff)
.bind(capabilities)
.bind(reasoning)
.fetch_one(pool)
.await
.context("llm_models: insert")?;
Ok(id)
}
pub async fn update_model(pool: &SqlitePool, id: i64, r: &LlmModelRecord) -> Result<()> {
let scope = serde_json::to_string(&r.scope)?;
let extra_params = r.extra_params.as_ref().map(|v| v.to_string());
let capabilities = serde_json::to_string(&r.capabilities)?;
let reasoning = r.reasoning.as_ref().map(|v| v.to_string());
sqlx::query(
"UPDATE llm_models
SET provider_id=?1, model_id=?2, name=?3, strength=?4,
scope=?5, is_default=?6, priority=?7, extra_params=?8,
context_length=?9, max_output_tokens=?10, knowledge_cutoff=?11, capabilities=?12,
reasoning=?13
WHERE id=?14",
)
.bind(r.provider_id)
.bind(&r.model_id)
.bind(&r.name)
.bind(r.strength.map(strength_str))
.bind(scope)
.bind(r.is_default as i64)
.bind(r.priority as i64)
.bind(extra_params)
.bind(r.context_length)
.bind(r.max_output_tokens)
.bind(&r.knowledge_cutoff)
.bind(capabilities)
.bind(reasoning)
.bind(id)
.execute(pool)
.await
.context("llm_models: update")?;
Ok(())
}
pub async fn delete_model(pool: &SqlitePool, id: i64) -> Result<()> {
sqlx::query("UPDATE llm_models SET removed_at = datetime('now') WHERE id = ?1")
.bind(id)
.execute(pool)
.await
.context("llm_models: soft-delete")?;
Ok(())
}
/// Update catalog-sourced metadata for a model identified by `provider_id` and `model_id`.
/// Used by the sync logic in `LlmManager::list_provider_models`.
pub async fn update_model_metadata(
pool: &SqlitePool,
provider_id: i64,
model_id: &str,
context_length: Option<i64>,
max_output_tokens: Option<i64>,
knowledge_cutoff: Option<&str>,
capabilities: &[String],
) -> Result<()> {
let caps = serde_json::to_string(capabilities)?;
sqlx::query(
"UPDATE llm_models
SET context_length = COALESCE(?1, context_length),
max_output_tokens = COALESCE(?2, max_output_tokens),
knowledge_cutoff = COALESCE(?3, knowledge_cutoff),
capabilities = ?4
WHERE provider_id = ?5 AND model_id = ?6 AND removed_at IS NULL",
)
.bind(context_length)
.bind(max_output_tokens)
.bind(knowledge_cutoff)
.bind(caps)
.bind(provider_id)
.bind(model_id)
.execute(pool)
.await
.context("llm_models: update_model_metadata")?;
Ok(())
}
pub async fn clear_default(pool: &SqlitePool) -> Result<()> {
sqlx::query("UPDATE llm_models SET is_default=0")
.execute(pool)
.await
.context("llm_models: clear_default")?;
Ok(())
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn provider_row_to_record(r: ProviderRow) -> Result<LlmProviderRecord> {
Ok(LlmProviderRecord {
id: r.id,
name: r.name,
provider: r.r#type,
api_key: r.api_key,
base_url: r.base_url,
description: r.description,
})
}
fn model_row_to_record(r: ModelRow) -> Result<LlmModelRecord> {
let scope: Vec<String> = serde_json::from_str(&r.scope).unwrap_or_default();
let extra_params = r.extra_params
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
let capabilities: Vec<String> = serde_json::from_str(&r.capabilities).unwrap_or_default();
let reasoning = r.reasoning
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
Ok(LlmModelRecord {
id: r.id,
provider_id: r.provider_id,
model_id: r.model_id,
name: r.name,
strength: r.strength.as_deref().and_then(parse_strength),
scope,
is_default: r.is_default != 0,
priority: r.priority as i32,
extra_params,
context_length: r.context_length,
max_output_tokens: r.max_output_tokens,
knowledge_cutoff: r.knowledge_cutoff,
capabilities,
reasoning,
})
}
pub fn strength_str(s: LlmStrength) -> &'static str {
match s {
LlmStrength::VeryLow => "very_low",
LlmStrength::Low => "low",
LlmStrength::Average => "average",
LlmStrength::High => "high",
LlmStrength::VeryHigh => "very_high",
}
}
fn parse_strength(s: &str) -> Option<LlmStrength> {
match s {
"very_low" => Some(LlmStrength::VeryLow),
"low" => Some(LlmStrength::Low),
"average" => Some(LlmStrength::Average),
"high" => Some(LlmStrength::High),
"very_high" => Some(LlmStrength::VeryHigh),
_ => None,
}
}
+582
View File
@@ -0,0 +1,582 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use indexmap::IndexMap;
use sqlx::SqlitePool;
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::chatbot::ChatbotClient;
use crate::chatbot::logging::{LoggingChatbotClient, LogSaveFlags};
use core_api::provider::LlmStrength;
use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode};
use super::providers::RemoteLlmModelInfo;
use super::{ClientStatus, LlmEntry, LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord};
use super::db;
const FAILURE_DEGRADED: u32 = 3;
const FAILURE_DOWN: u32 = 5;
const CATALOG_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const MODEL_META_TTL: Duration = Duration::from_secs(60 * 60); // 1 hour
pub const AUTO_CLIENT: &str = "auto";
struct CachedCatalog {
models: Vec<RemoteLlmModelInfo>,
fetched_at: Instant,
}
struct CachedModelMeta {
info: RemoteLlmModelInfo,
fetched_at: Instant,
}
struct HealthState {
status: ClientStatus,
consecutive_failures: u32,
last_error: Option<String>,
}
impl Default for HealthState {
fn default() -> Self {
Self { status: ClientStatus::Healthy, consecutive_failures: 0, last_error: None }
}
}
struct ModelSlot {
provider: LlmProviderRecord,
model: LlmModelRecord,
entry: Arc<LlmEntry>,
health: HealthState,
}
struct ManagerState {
/// Keyed by model.name, ordered by priority ASC.
models: IndexMap<String, ModelSlot>,
/// Keyed by provider.id.
providers: IndexMap<i64, LlmProviderRecord>,
default: String,
}
pub struct LlmManager {
pool: Arc<SqlitePool>,
registry: Arc<ProviderRegistry>,
state: RwLock<ManagerState>,
/// In-memory model catalog cache, keyed by provider_id. TTL = 24h.
catalog: RwLock<HashMap<i64, CachedCatalog>>,
/// Per-model metadata cache, keyed by model display name. TTL = 1h.
model_meta_cache: RwLock<HashMap<String, CachedModelMeta>>,
/// When `Some`, every LLM entry is wrapped with [`LoggingChatbotClient`].
log_flags: Option<LogSaveFlags>,
}
impl LlmManager {
pub async fn new(
pool: Arc<SqlitePool>,
registry: Arc<ProviderRegistry>,
log_flags: Option<LogSaveFlags>,
) -> Result<Arc<Self>> {
let mgr = Arc::new(Self {
pool,
registry,
state: RwLock::new(ManagerState {
models: IndexMap::new(),
providers: IndexMap::new(),
default: String::new(),
}),
catalog: RwLock::new(HashMap::new()),
model_meta_cache: RwLock::new(HashMap::new()),
log_flags,
});
mgr.reload().await?;
Ok(mgr)
}
// ── Public: resolution ────────────────────────────────────────────────────
pub async fn resolve(
&self,
client_name: Option<&str>,
required_scope: Option<&str>,
required_strength: Option<LlmStrength>,
) -> Result<(String, Arc<LlmEntry>)> {
let name = match client_name {
None | Some(AUTO_CLIENT) => {
let (name, entry) = self.select(required_scope, required_strength).await?;
self.maybe_refresh_meta(&name).await;
return Ok((name, entry));
}
Some(n) => {
let state = self.state.read().await;
if !state.models.contains_key(n) {
anyhow::bail!("LLM model '{n}' not found");
}
n.to_string()
}
};
self.maybe_refresh_meta(&name).await;
let state = self.state.read().await;
let entry = state.models.get(&name).map(|s| s.entry.clone())
.with_context(|| format!("LLM model '{name}' not found after refresh"))?;
Ok((name, entry))
}
/// If the per-model metadata cache is stale (or missing) for `name`,
/// fetch fresh data from the provider and update the entry if successful.
async fn maybe_refresh_meta(&self, name: &str) {
{
let cache = self.model_meta_cache.read().await;
if let Some(entry) = cache.get(name) {
if entry.fetched_at.elapsed() < MODEL_META_TTL {
return;
}
}
}
let (provider_id, model_id) = {
let state = self.state.read().await;
match state.models.get(name) {
Some(slot) => (slot.provider.id, slot.model.model_id.clone()),
None => return,
}
};
let remote: RemoteLlmModelInfo = match self.fetch_model_info(provider_id, &model_id).await {
Some(m) => m,
None => return,
};
let now = Instant::now();
let mut cache = self.model_meta_cache.write().await;
cache.insert(name.to_string(), CachedModelMeta { info: remote.clone(), fetched_at: now });
if let Some(ctx) = remote.context_length {
let mut state = self.state.write().await;
if let Some(slot) = state.models.get_mut(name) {
let old_ctx = slot.entry.context_length;
if Some(ctx as i64) != old_ctx {
slot.entry = Arc::new(LlmEntry {
context_length: Some(ctx as i64),
..(*slot.entry).clone()
});
}
}
}
}
async fn fetch_model_info(&self, provider_id: i64, model_id: &str) -> Option<RemoteLlmModelInfo> {
let record = self.state.read().await.providers.get(&provider_id).cloned()?;
let provider = self.registry.get(&record.provider)?;
provider.llm_model_info(&record, model_id).await.ok().flatten()
}
pub async fn get(&self, name: &str) -> Option<Arc<LlmEntry>> {
self.state.read().await.models.get(name).map(|s| s.entry.clone())
}
pub async fn default_name(&self) -> String {
self.state.read().await.default.clone()
}
/// Returns ["auto", <model1>, <model2>, …] for the frontend selector.
pub async fn client_names(&self) -> Vec<String> {
let mut names = vec![AUTO_CLIENT.to_string()];
names.extend(self.state.read().await.models.keys().cloned());
names
}
// ── Public: health reporting ──────────────────────────────────────────────
pub async fn mark_success(&self, name: &str) {
let mut state = self.state.write().await;
if let Some(slot) = state.models.get_mut(name) {
let h = &mut slot.health;
if h.consecutive_failures > 0 {
info!(model = name, "LLM model recovered");
}
h.consecutive_failures = 0;
h.last_error = None;
h.status = ClientStatus::Healthy;
}
}
pub async fn mark_failure(&self, name: &str, error: &str) {
let mut state = self.state.write().await;
if let Some(slot) = state.models.get_mut(name) {
let h = &mut slot.health;
h.consecutive_failures += 1;
h.last_error = Some(error.to_string());
h.status = if h.consecutive_failures >= FAILURE_DOWN {
warn!(model = name, failures = h.consecutive_failures, "LLM model marked DOWN");
ClientStatus::Down
} else if h.consecutive_failures >= FAILURE_DEGRADED {
warn!(model = name, failures = h.consecutive_failures, "LLM model marked DEGRADED");
ClientStatus::Degraded
} else {
ClientStatus::Healthy
};
}
}
// ── Public: provider CRUD ─────────────────────────────────────────────────
pub async fn add_provider(&self, record: LlmProviderRecord) -> Result<i64> {
let id = db::insert_provider(&self.pool, &record).await?;
self.reload().await?;
Ok(id)
}
pub async fn update_provider(&self, id: i64, record: LlmProviderRecord) -> Result<()> {
db::update_provider(&self.pool, id, &record).await?;
self.reload().await
}
pub async fn delete_provider(&self, id: i64) -> Result<()> {
db::delete_provider(&self.pool, id).await?;
self.reload().await
}
pub async fn get_provider(&self, id: i64) -> Option<LlmProviderRecord> {
self.state.read().await.providers.get(&id).cloned()
}
/// Returns the ApiProvider implementation for the given provider record id.
pub async fn get_api_provider(&self, id: i64) -> Option<Arc<dyn ApiProvider>> {
let record = self.state.read().await.providers.get(&id).cloned()?;
self.registry.get(&record.provider)
}
/// Returns the remote model catalog for a provider, using a 24h in-memory cache.
/// After fetching, syncs context/token/capability metadata to existing DB model records.
pub async fn list_provider_models(&self, id: i64) -> Result<Vec<RemoteLlmModelInfo>> {
{
let cache = self.catalog.read().await;
if let Some(entry) = cache.get(&id) {
if entry.fetched_at.elapsed() < CATALOG_TTL {
return Ok(entry.models.clone());
}
}
}
let record = self.state.read().await.providers.get(&id).cloned()
.ok_or_else(|| anyhow::anyhow!("provider {id} not found"))?;
let provider = self.registry.get(&record.provider)
.ok_or_else(|| anyhow::anyhow!("unknown provider type '{}' for provider {id}", record.provider))?;
let mut models = provider.list_llm_models(&record).await?
.ok_or_else(|| anyhow::anyhow!("this provider does not support model listing"))?;
// Fill the reasoning descriptor per catalog model for the add-from-catalog
// UI. Providers that already populate a precise descriptor in their
// listing (e.g. OpenRouter from each model's `reasoning` object) keep it;
// the rest fall back to the capability-based `reasoning_mode`.
for m in &mut models {
if m.reasoning.is_none() {
m.reasoning = provider.reasoning_mode(&m.id, &m.capabilities);
}
}
for remote in &models {
db::update_model_metadata(
&self.pool, id, &remote.id,
remote.context_length.map(|v| v as i64),
remote.max_completion_tokens.map(|v| v as i64),
remote.knowledge_cutoff.as_deref(),
&remote.capabilities,
).await.ok();
}
self.catalog.write().await.insert(id, CachedCatalog {
models: models.clone(),
fetched_at: Instant::now(),
});
Ok(models)
}
pub async fn list_providers_info(&self) -> Vec<LlmProviderInfo> {
self.state.read().await.providers.values().map(|p| {
let supported_types = self.registry.get(&p.provider)
.map(|prov| prov.supported_types().to_vec())
.unwrap_or_default();
LlmProviderInfo {
id: p.id,
name: p.name.clone(),
provider: p.provider.clone(),
base_url: p.base_url.clone(),
description: p.description.clone(),
supported_types,
}
}).collect()
}
// ── Public: model CRUD ────────────────────────────────────────────────────
pub async fn add_model(&self, model: LlmModelRecord) -> Result<i64> {
if model.is_default {
db::clear_default(&self.pool).await?;
}
let id = db::insert_model(&self.pool, &model).await?;
self.reload().await?;
Ok(id)
}
pub async fn update_model(&self, id: i64, model: LlmModelRecord) -> Result<()> {
if model.is_default {
db::clear_default(&self.pool).await?;
}
db::update_model(&self.pool, id, &model).await?;
self.reload().await
}
pub async fn delete_model(&self, id: i64) -> Result<()> {
db::delete_model(&self.pool, id).await?;
self.reload().await
}
pub async fn get_model(&self, id: i64) -> Option<LlmModelRecord> {
self.state.read().await.models.values()
.find(|s| s.model.id == id)
.map(|s| s.model.clone())
}
/// Reasoning control descriptor for a (provider, model_id) pair — used by the
/// "add model" form to render the right control before the model is saved.
/// Capabilities are unknown at this point (manual entry), so an empty slice
/// is passed; catalog-based flows use the descriptor attached to each model.
pub async fn reasoning_mode_for(&self, provider_id: i64, model_id: &str) -> Option<ReasoningMode> {
let record = self.state.read().await.providers.get(&provider_id).cloned()?;
let provider = self.registry.get(&record.provider)?;
provider.reasoning_mode(model_id, &[])
}
pub async fn list_models_info(&self) -> Vec<LlmModelInfo> {
let state = self.state.read().await;
let catalog = self.catalog.read().await;
state.models.values().map(|slot| {
let cached = catalog.get(&slot.provider.id)
.and_then(|c| c.models.iter().find(|m| m.id == slot.model.model_id));
let reasoning_mode = self.registry.get(&slot.provider.provider)
.and_then(|p| p.reasoning_mode(&slot.model.model_id, &slot.model.capabilities));
LlmModelInfo {
id: slot.model.id,
provider_id: slot.provider.id,
provider_name: slot.provider.name.clone(),
model_id: slot.model.model_id.clone(),
name: slot.model.name.clone(),
strength: slot.model.strength,
scope: slot.model.scope.clone(),
is_default: slot.model.is_default,
priority: slot.model.priority,
extra_params: slot.model.extra_params.clone(),
context_length: slot.model.context_length,
max_output_tokens: slot.model.max_output_tokens,
knowledge_cutoff: slot.model.knowledge_cutoff.clone(),
capabilities: slot.model.capabilities.clone(),
status: slot.health.status,
last_error: slot.health.last_error.clone(),
price_input_per_million: cached.and_then(|m| m.price_input_per_million),
price_output_per_million: cached.and_then(|m| m.price_output_per_million),
reasoning: slot.model.reasoning.clone(),
reasoning_mode,
}
}).collect()
}
// ── Public: selection ─────────────────────────────────────────────────────
pub async fn select_excluding(
&self,
excluded: &[&str],
required_scope: Option<&str>,
required_strength: Option<LlmStrength>,
) -> Result<(String, Arc<LlmEntry>)> {
let state = self.state.read().await;
let mut slots: Vec<(&String, &ModelSlot)> = state.models.iter()
.filter(|(name, _)| !excluded.contains(&name.as_str()))
.collect();
if slots.is_empty() {
anyhow::bail!("no alternative LLM models available");
}
sort_slots_for_agent(&mut slots, required_scope, required_strength);
if let Some((name, slot)) = slots.iter().find(|(_, s)| s.health.status != ClientStatus::Down) {
return Ok((name.to_string(), slot.entry.clone()));
}
if let Some((name, slot)) = slots.first() {
warn!(model = %name, "all alternative LLM models are DOWN — using best available");
return Ok((name.to_string(), slot.entry.clone()));
}
anyhow::bail!("no alternative LLM models available");
}
async fn select(
&self,
required_scope: Option<&str>,
required_strength: Option<LlmStrength>,
) -> Result<(String, Arc<LlmEntry>)> {
let state = self.state.read().await;
if state.models.is_empty() {
anyhow::bail!("no LLM models configured — add one via the UI");
}
let mut slots: Vec<(&String, &ModelSlot)> = state.models.iter().collect();
sort_slots_for_agent(&mut slots, required_scope, required_strength);
if let Some((name, slot)) = slots.iter().find(|(_, s)| s.health.status != ClientStatus::Down) {
return Ok((name.to_string(), slot.entry.clone()));
}
if let Some((name, slot)) = slots.first() {
warn!(model = %name, "all LLM models are DOWN — using strongest as emergency fallback");
return Ok((name.to_string(), slot.entry.clone()));
}
anyhow::bail!("no LLM models available");
}
// ── Private ───────────────────────────────────────────────────────────────
async fn reload(&self) -> Result<()> {
let provider_records = db::load_all_providers(&self.pool).await?;
let model_records = db::load_all_models(&self.pool).await?;
let providers: IndexMap<i64, LlmProviderRecord> = provider_records
.into_iter()
.map(|p| (p.id, p))
.collect();
let mut models: IndexMap<String, ModelSlot> = IndexMap::new();
let mut default = String::new();
for model in model_records {
let provider = match providers.get(&model.provider_id) {
Some(p) => p.clone(),
None => {
warn!(model = %model.name, provider_id = model.provider_id, "orphaned model — provider not found, skipping");
continue;
}
};
let log_config = self.log_flags.map(|f| (Arc::clone(&self.pool), f));
let entry = match build_entry(&self.registry, &provider, &model, model.id, log_config) {
Ok(e) => Arc::new(e),
Err(e) => {
warn!(model = %model.name, error = %e, "failed to build LLM entry, skipping");
continue;
}
};
if model.is_default || default.is_empty() {
default = model.name.clone();
}
models.insert(model.name.clone(), ModelSlot {
provider,
model,
entry,
health: HealthState::default(),
});
}
let mut state = self.state.write().await;
for (name, slot) in state.models.iter() {
if let Some(new_slot) = models.get_mut(name) {
new_slot.health.status = slot.health.status;
new_slot.health.consecutive_failures = slot.health.consecutive_failures;
new_slot.health.last_error = slot.health.last_error.clone();
}
}
state.models = models;
state.providers = providers;
state.default = default;
Ok(())
}
}
// ── Builder ───────────────────────────────────────────────────────────────────
fn build_entry(
registry: &ProviderRegistry,
provider: &LlmProviderRecord,
model: &LlmModelRecord,
model_db_id: i64,
log_config: Option<(Arc<SqlitePool>, LogSaveFlags)>,
) -> Result<LlmEntry> {
let built = registry.get(&provider.provider)
.ok_or_else(|| anyhow::anyhow!("unknown provider type '{}'", provider.provider))?
.build_llm(provider, model)
.ok_or_else(|| anyhow::anyhow!("provider '{}' does not support LLM", provider.provider))??;
let inner = built.client;
let prompt_cache = built.prompt_cache;
let extra = model.extra_params.clone();
let client: Arc<dyn ChatbotClient> = match log_config {
Some((pool, flags)) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name, flags)),
None => inner,
};
Ok(LlmEntry {
client,
model: model.model_id.clone(),
model_db_id,
strength: model.strength,
scope: model.scope.clone(),
extra_params: extra,
context_length: model.context_length,
prompt_cache,
})
}
// ── Sorting helpers ───────────────────────────────────────────────────────────
pub fn sort_models_for_agent(
mut models: Vec<LlmModelInfo>,
scope: Option<&str>,
strength: Option<LlmStrength>,
) -> Vec<LlmModelInfo> {
models.sort_by_key(|m| (model_tier(m.strength, m.scope.as_slice(), scope, strength), m.priority));
models
}
fn sort_slots_for_agent(
slots: &mut Vec<(&String, &ModelSlot)>,
scope: Option<&str>,
strength: Option<LlmStrength>,
) {
slots.sort_by_key(|(_, s)| (
model_tier(s.model.strength, s.model.scope.as_slice(), scope, strength),
s.model.priority,
));
}
fn model_tier(
model_strength: Option<LlmStrength>,
model_scope: &[String],
req_scope: Option<&str>,
req_strength: Option<LlmStrength>,
) -> u8 {
let strength_ok = match (req_strength, model_strength) {
(Some(req), Some(avail)) => avail >= req,
(Some(_), None) => false,
(None, _) => true,
};
// Prefer exact strength match over over-qualified models so that e.g. an
// agent with strength=low picks the `low` model before `average`.
let exact_match = match (req_strength, model_strength) {
(Some(req), Some(avail)) => avail == req,
_ => true,
};
let scope_ok = req_scope.map_or(true, |sc| model_scope.iter().any(|x| x == sc));
match (strength_ok && scope_ok, exact_match && scope_ok, strength_ok) {
(true, true, _) => 0, // exact strength + scope ok
(true, false, _) => 1, // over-qualified but scope ok
(false, _, true) => 2, // strength ok, scope mismatch
_ => 3, // doesn't meet minimum bar
}
}
+82
View File
@@ -0,0 +1,82 @@
pub(crate) mod db;
pub mod manager;
pub mod providers;
use std::sync::Arc;
use crate::chatbot::ChatbotClient;
use crate::provider::ServiceType;
pub use core_api::provider::{LlmProviderRecord, LlmModelRecord, LlmStrength, ReasoningMode};
pub use manager::{LlmManager, sort_models_for_agent};
/// A resolved, ready-to-use LLM client with its associated metadata.
#[derive(Clone)]
pub struct LlmEntry {
pub client: Arc<dyn ChatbotClient>,
pub model: String,
pub model_db_id: i64,
pub strength: Option<LlmStrength>,
pub scope: Vec<String>,
pub extra_params: Option<serde_json::Value>,
/// Max input context window in tokens, if known.
pub context_length: Option<i64>,
/// When true, prompt-caching hints are injected into requests.
pub prompt_cache: bool,
}
// ── Provider ──────────────────────────────────────────────────────────────────
/// Public provider metadata (no api_key).
#[derive(Debug, Clone, serde::Serialize)]
pub struct LlmProviderInfo {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub provider: String,
pub base_url: Option<String>,
pub description: Option<String>,
/// Service types this provider supports (from ProviderRegistry at runtime).
pub supported_types: Vec<ServiceType>,
}
/// Public model metadata for API responses (includes provider name for convenience).
#[derive(Debug, Clone, serde::Serialize)]
pub struct LlmModelInfo {
pub id: i64,
pub provider_id: i64,
pub provider_name: String,
pub model_id: String,
pub name: String,
pub strength: Option<LlmStrength>,
pub scope: Vec<String>,
pub is_default: bool,
pub priority: i32,
pub extra_params: Option<serde_json::Value>,
pub context_length: Option<i64>,
pub max_output_tokens: Option<i64>,
pub knowledge_cutoff: Option<String>,
pub capabilities: Vec<String>,
pub status: ClientStatus,
pub last_error: Option<String>,
/// Input (prompt) price per million tokens (USD) from the provider catalog cache.
pub price_input_per_million: Option<f64>,
/// Output (completion) price per million tokens (USD) from the provider catalog cache.
pub price_output_per_million: Option<f64>,
/// Currently-selected reasoning value (string for a `ValueSet`, number for a
/// `Range`, or `None`). Round-trips to the edit form.
pub reasoning: Option<serde_json::Value>,
/// Reasoning control descriptor for this model (drives the UI control), or
/// `None` if the model does not support reasoning.
pub reasoning_mode: Option<ReasoningMode>,
}
// ── Health ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientStatus {
Healthy,
Degraded,
Down,
}
@@ -0,0 +1,119 @@
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use crate::chatbot::anthropic::AnthropicClient;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
pub struct AnthropicProvider {
http: reqwest::Client,
}
impl AnthropicProvider {
pub fn new() -> Self {
Self { http: reqwest::Client::new() }
}
}
#[async_trait::async_trait]
impl ApiProvider for AnthropicProvider {
fn type_id(&self) -> &'static str { "anthropic" }
fn display_name(&self) -> &'static str { "Anthropic" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm]
}
async fn list_llm_models(&self, _record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
Ok(None)
}
async fn llm_model_info(&self, record: &LlmProviderRecord, model_id: &str) -> Result<Option<RemoteLlmModelInfo>> {
let api_key = record.api_key.as_deref()
.ok_or_else(|| anyhow!("provider '{}': api_key required for anthropic model_info", record.name))?;
let url = format!("https://api.anthropic.com/v1/models/{model_id}");
let resp: serde_json::Value = self.http
.get(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.send()
.await
.map_err(|e| anyhow!("Anthropic model_info request failed: {e}"))?
.json()
.await
.map_err(|e| anyhow!("Anthropic model_info response parse failed: {e}"))?;
let id = resp["id"].as_str().ok_or_else(|| anyhow!("missing 'id' in Anthropic response"))?.to_string();
let name = resp["display_name"].as_str().unwrap_or(&id).to_string();
Ok(Some(RemoteLlmModelInfo {
id,
name,
context_length: resp["context_window"].as_u64(),
max_completion_tokens: resp["max_output_tokens"].as_u64(),
knowledge_cutoff: None,
capabilities: vec![],
vision: None,
price_input_per_million: None,
price_output_per_million: None,
reasoning: None,
}))
}
fn reasoning_mode(&self, model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> {
// Extended thinking → numeric token budget. Available on Claude 3.7 and
// the 4.x/5.x families (not the 3.5/3-opus generation).
let id = model_id.to_lowercase();
let supports = capabilities.iter().any(|c| c == "reasoning")
|| id.contains("3-7")
|| id.contains("-4") || id.contains("-5")
|| id.contains("opus-4") || id.contains("sonnet-4") || id.contains("haiku-4");
if supports {
Some(ReasoningMode::Range {
min: 1024,
max: 32_000,
step: Some(1024),
default: Some(8192),
unit: Some("tokens".to_string()),
})
} else {
None
}
}
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
// value is a JSON number (budget_tokens).
let budget = value.as_i64().filter(|n| *n > 0)?;
Some(serde_json::json!({
"thinking": { "type": "enabled", "budget_tokens": budget }
}))
}
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some((|| {
let key = record.api_key.as_deref()
.with_context(|| format!("provider '{}': api_key required for anthropic", record.name))?;
// Merge model extra_params + reasoning (thinking) into the request body.
let extra = extra_with_reasoning(self, model);
Ok(BuiltLlmClient {
client: Arc::new(AnthropicClient::with_extra_body(key, extra)),
prompt_cache: false,
})
})())
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "anthropic",
display_name: "Anthropic",
description: None,
color: "#d4a574",
icon: "bi-chat-square-dots",
fields: &[
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
],
}
}
}
@@ -0,0 +1,146 @@
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use crate::chatbot::openai::OpenAiClient;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
pub struct DeepSeekProvider {
http: reqwest::Client,
}
impl DeepSeekProvider {
pub fn new() -> Self {
Self { http: reqwest::Client::new() }
}
fn known_context_length(model_id: &str) -> Option<u64> {
let id = model_id.to_lowercase();
if id.contains("coder") { Some(16384) }
else if id.contains("reasoner") { Some(65536) }
else if id.starts_with("deepseek-v4") { Some(1_048_576) }
else if id.starts_with("deepseek-chat") || id.starts_with("deepseek-v3") { Some(65536) }
else { None }
}
fn known_max_output(model_id: &str) -> Option<u64> {
if model_id.to_lowercase().starts_with("deepseek-v4") { Some(393_216) } else { None }
}
fn known_capabilities(model_id: &str) -> Vec<String> {
let mut caps = vec!["function_calling".to_string()];
if model_id.to_lowercase().contains("reasoner") {
caps.push("reasoning".to_string());
}
caps
}
}
#[async_trait::async_trait]
impl ApiProvider for DeepSeekProvider {
fn type_id(&self) -> &'static str { "deepseek" }
fn display_name(&self) -> &'static str { "DeepSeek" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm]
}
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
let api_key = record.api_key.as_deref()
.ok_or_else(|| anyhow!("provider '{}': api_key required for deepseek model listing", record.name))?;
let resp: serde_json::Value = self.http
.get("https://api.deepseek.com/models")
.bearer_auth(api_key)
.send()
.await
.map_err(|e| anyhow!("DeepSeek request failed: {e}"))?
.error_for_status()
.map_err(|e| anyhow!("DeepSeek error response: {e}"))?
.json()
.await
.map_err(|e| anyhow!("DeepSeek response parse failed: {e}"))?;
let models = resp["data"]
.as_array()
.ok_or_else(|| anyhow!("unexpected DeepSeek response shape"))?
.iter()
.filter_map(|m| {
let id = m["id"].as_str()?.to_string();
let name = id.clone();
let context_length = Self::known_context_length(&id).or_else(|| m["context_length"].as_u64());
let capabilities = Self::known_capabilities(&id);
let max_output = Self::known_max_output(&id);
Some(RemoteLlmModelInfo {
id, name, context_length,
max_completion_tokens: max_output,
knowledge_cutoff: None,
capabilities,
vision: None,
price_input_per_million: None,
price_output_per_million: None,
reasoning: None,
})
})
.collect();
Ok(Some(models))
}
fn reasoning_mode(&self, model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> {
// Thinking mode (thinking.type) + graded reasoning_effort. "disabled"
// turns thinking off; effort levels low/medium map to high, xhigh to max.
let id = model_id.to_lowercase();
if capabilities.iter().any(|c| c == "reasoning")
|| id.contains("reasoner")
|| id.starts_with("deepseek-v4")
{
Some(ReasoningMode::ValueSet {
values: ["disabled", "low", "medium", "high", "xhigh", "max"]
.iter().map(|s| s.to_string()).collect(),
default: Some("high".to_string()),
})
} else {
None
}
}
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
// "disabled" → thinking off; "enabled" → thinking on (no effort);
// any effort level → thinking on + `reasoning_effort`.
match value.as_str()? {
"disabled" => Some(serde_json::json!({ "thinking": { "type": "disabled" } })),
"enabled" => Some(serde_json::json!({ "thinking": { "type": "enabled" } })),
effort => Some(serde_json::json!({
"thinking": { "type": "enabled" },
"reasoning_effort": effort,
})),
}
}
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some((|| {
let key = record.api_key.as_deref()
.with_context(|| format!("provider '{}': api_key required for deepseek", record.name))?;
let extra = extra_with_reasoning(self, model);
Ok(BuiltLlmClient {
client: Arc::new(OpenAiClient::new("https://api.deepseek.com/v1", key, extra, false)),
prompt_cache: false,
})
})())
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "deepseek",
display_name: "DeepSeek",
description: None,
color: "#0ea5e9",
icon: "bi-search",
fields: &[
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
],
}
}
}
@@ -0,0 +1,86 @@
use std::sync::Arc;
use anyhow::{Result, anyhow};
use crate::chatbot::lm_studio::LmStudioClient;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::RemoteLlmModelInfo;
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType};
pub struct LmStudioProvider {
http: reqwest::Client,
}
impl LmStudioProvider {
pub fn new() -> Self {
Self { http: reqwest::Client::new() }
}
fn base_url(record: &LlmProviderRecord) -> String {
record.base_url.clone()
.unwrap_or_else(|| "http://localhost:1234/v1".to_string())
}
}
#[async_trait::async_trait]
impl ApiProvider for LmStudioProvider {
fn type_id(&self) -> &'static str { "lm_studio" }
fn display_name(&self) -> &'static str { "LM Studio" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm]
}
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
let url = format!("{}/models", Self::base_url(record).trim_end_matches('/'));
let resp: serde_json::Value = self.http
.get(&url)
.send()
.await
.map_err(|e| anyhow!("LM Studio request failed: {e}"))?
.json()
.await
.map_err(|e| anyhow!("LM Studio response parse failed: {e}"))?;
let models = resp["data"]
.as_array()
.ok_or_else(|| anyhow!("unexpected LM Studio response shape"))?
.iter()
.filter_map(|m| {
let id = m["id"].as_str()?.to_string();
Some(RemoteLlmModelInfo {
name: id.clone(), id,
context_length: None,
max_completion_tokens: None,
knowledge_cutoff: None,
capabilities: vec![],
vision: None,
price_input_per_million: None,
price_output_per_million: None,
reasoning: None,
})
})
.collect();
Ok(Some(models))
}
fn build_llm(&self, record: &LlmProviderRecord, _model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some(Ok(BuiltLlmClient {
client: Arc::new(LmStudioClient::new(record.base_url.as_deref())),
prompt_cache: false,
}))
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "lm_studio",
display_name: "LM Studio",
description: Some("Local models via LM Studio"),
color: "#6b7280",
icon: "bi-window-stack",
fields: &[
ProviderField { key: "base_url", label: "Base URL", required: false, secret: false },
],
}
}
}
@@ -0,0 +1,39 @@
pub mod anthropic;
pub mod deepseek;
pub mod lm_studio;
pub mod ollama;
pub mod openai;
pub mod openrouter;
pub mod zai;
// Re-export so existing code that uses `providers::ServiceType` / `providers::RemoteLlmModelInfo` keeps working.
pub use crate::provider::ServiceType;
pub use core_api::provider::RemoteLlmModelInfo;
use core_api::provider::{ApiProvider, LlmModelRecord};
/// Computes the `extra_params` an OpenAI-compatible client should be built with,
/// given a model's stored `extra_params` and its selected reasoning value. The
/// provider translates the reasoning value into a request fragment via
/// `reasoning_request`; that fragment's top-level keys are merged over
/// `extra_params` (reasoning wins on conflict). Returns `None` when neither is set.
pub(crate) fn extra_with_reasoning(
provider: &dyn ApiProvider,
model: &LlmModelRecord,
) -> Option<serde_json::Value> {
let reasoning = model.reasoning.as_ref().and_then(|v| provider.reasoning_request(v));
match (model.extra_params.clone(), reasoning) {
(base, None) => base,
(None, overlay) => overlay,
(Some(mut base), Some(overlay)) => {
match (base.as_object_mut(), overlay.as_object()) {
(Some(b), Some(o)) => {
for (k, v) in o { b.insert(k.clone(), v.clone()); }
Some(base)
}
// Non-object base: the reasoning overlay takes precedence.
_ => Some(overlay),
}
}
}
}
@@ -0,0 +1,123 @@
use std::sync::Arc;
use anyhow::{Result, anyhow};
use crate::chatbot::ollama::OllamaClient;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::RemoteLlmModelInfo;
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType};
pub struct OllamaProvider {
http: reqwest::Client,
}
impl OllamaProvider {
pub fn new() -> Self {
Self { http: reqwest::Client::new() }
}
fn base_url(record: &LlmProviderRecord) -> String {
record.base_url.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string())
}
fn parse_model_info(show: &serde_json::Value, model_id: &str) -> RemoteLlmModelInfo {
let context_length = show["model_info"]["llm.context_length"]
.as_u64()
.or_else(|| {
show["model_info"]["llm.context_length"]
.as_str()
.and_then(|s| s.parse::<u64>().ok())
});
RemoteLlmModelInfo {
name: model_id.to_string(),
id: model_id.to_string(),
context_length,
max_completion_tokens: None,
knowledge_cutoff: None,
capabilities: vec![],
vision: None,
price_input_per_million: None,
price_output_per_million: None,
reasoning: None,
}
}
}
#[async_trait::async_trait]
impl ApiProvider for OllamaProvider {
fn type_id(&self) -> &'static str { "ollama" }
fn display_name(&self) -> &'static str { "Ollama" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm]
}
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
let url = format!("{}/api/tags", Self::base_url(record).trim_end_matches('/'));
let resp: serde_json::Value = self.http
.get(&url)
.send()
.await
.map_err(|e| anyhow!("Ollama request failed: {e}"))?
.json()
.await
.map_err(|e| anyhow!("Ollama response parse failed: {e}"))?;
let models = resp["models"]
.as_array()
.ok_or_else(|| anyhow!("unexpected Ollama response shape"))?
.iter()
.filter_map(|m| {
let id = m["name"].as_str()?.to_string();
Some(RemoteLlmModelInfo {
name: id.clone(), id,
context_length: None,
max_completion_tokens: None,
knowledge_cutoff: None,
capabilities: vec![],
vision: None,
price_input_per_million: None,
price_output_per_million: None,
reasoning: None,
})
})
.collect();
Ok(Some(models))
}
async fn llm_model_info(&self, record: &LlmProviderRecord, model_id: &str) -> Result<Option<RemoteLlmModelInfo>> {
let url = format!("{}/api/show", Self::base_url(record).trim_end_matches('/'));
let body = serde_json::json!({ "name": model_id });
let resp: serde_json::Value = self.http
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow!("Ollama model_info request failed: {e}"))?
.json()
.await
.map_err(|e| anyhow!("Ollama model_info response parse failed: {e}"))?;
Ok(Some(Self::parse_model_info(&resp, model_id)))
}
fn build_llm(&self, record: &LlmProviderRecord, _model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some(Ok(BuiltLlmClient {
client: Arc::new(OllamaClient::new(record.base_url.as_deref())),
prompt_cache: false,
}))
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "ollama",
display_name: "Ollama",
description: Some("Local models via Ollama"),
color: "#f97316",
icon: "bi-terminal",
fields: &[
ProviderField { key: "base_url", label: "Base URL", required: false, secret: false },
],
}
}
}
@@ -0,0 +1,99 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use crate::chatbot::openai::OpenAiClient;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
use crate::transcribe::TranscribeModelRecord;
use crate::transcribe::openai_audio::OpenAiAudioTranscriber;
use crate::tts::TtsModelRecord;
use crate::tts::openai_tts::OpenAiTtsSynthesiser;
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
pub struct OpenAiProvider;
#[async_trait::async_trait]
impl ApiProvider for OpenAiProvider {
fn type_id(&self) -> &'static str { "open_ai" }
fn display_name(&self) -> &'static str { "OpenAI" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm, ServiceType::Transcribe, ServiceType::Tts]
}
async fn list_llm_models(&self, _record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
Ok(None)
}
fn reasoning_mode(&self, model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> {
// Reasoning ("o" series and other reasoning models) → effort levels.
let id = model_id.to_lowercase();
let is_reasoning = capabilities.iter().any(|c| c == "reasoning")
|| id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4")
|| id.starts_with("gpt-5");
if is_reasoning {
Some(ReasoningMode::ValueSet {
values: vec!["low".to_string(), "medium".to_string(), "high".to_string()],
default: Some("medium".to_string()),
})
} else {
None
}
}
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
let effort = value.as_str()?;
Some(serde_json::json!({ "reasoning_effort": effort }))
}
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some((|| {
let key = record.api_key.as_deref()
.with_context(|| format!("provider '{}': api_key required for open_ai", record.name))?;
let extra = extra_with_reasoning(self, model);
Ok(BuiltLlmClient {
client: Arc::new(OpenAiClient::new("https://api.openai.com/v1", key, extra, false)),
prompt_cache: false,
})
})())
}
fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option<Result<Arc<dyn crate::tts::TextToSpeech>>> {
Some((|| {
let base_url = record.base_url.clone()
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for open_ai", record.name))?;
Ok(Arc::new(OpenAiTtsSynthesiser::new(
&model.name, base_url, api_key, &model.model_id,
model.voice_id.clone(), model.instructions.clone(), model.response_format.clone(),
)) as Arc<dyn crate::tts::TextToSpeech>)
})())
}
fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option<Result<Arc<dyn crate::transcribe::Transcribe>>> {
Some((|| {
let base_url = record.base_url.clone()
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for open_ai", record.name))?;
Ok(Arc::new(OpenAiAudioTranscriber::new(
&model.name, base_url, api_key, &model.model_id, model.language.clone(),
)) as Arc<dyn crate::transcribe::Transcribe>)
})())
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "open_ai",
display_name: "OpenAI",
description: None,
color: "#10a37f",
icon: "bi-lightning-charge",
fields: &[
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
ProviderField { key: "base_url", label: "Base URL (optional)", required: false, secret: false },
],
}
}
}
@@ -0,0 +1,215 @@
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use crate::chatbot::openai::OpenAiClient;
use crate::image_generate::ImageGenerateModelRecord;
use crate::image_generate::openrouter_image::OpenRouterImageGenerator;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
use crate::transcribe::TranscribeModelRecord;
use crate::transcribe::openai_audio::OpenAiAudioTranscriber;
use crate::tts::TtsModelRecord;
use crate::tts::openai_tts::OpenAiTtsSynthesiser;
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
pub struct OpenRouterProvider {
http: reqwest::Client,
}
impl OpenRouterProvider {
pub fn new() -> Self {
Self { http: reqwest::Client::new() }
}
/// Builds a `ReasoningMode` from OpenRouter's per-model `reasoning` object
/// (`/api/v1/models`): `supported_efforts` → discrete effort levels,
/// otherwise `supports_max_tokens` → a token-budget range. Non-reasoning
/// models omit the object entirely → `None`. The UI's "— off —" option
/// (a null value → no reasoning param sent) covers disabling.
fn parse_reasoning(v: &serde_json::Value) -> Option<ReasoningMode> {
if !v.is_object() {
return None;
}
let default = v["default_effort"].as_str().map(String::from);
let efforts: Vec<String> = v["supported_efforts"].as_array()
.map(|a| a.iter().filter_map(|e| e.as_str().map(String::from)).collect())
.unwrap_or_default();
if !efforts.is_empty() {
Some(ReasoningMode::ValueSet { values: efforts, default })
} else if v["supports_max_tokens"].as_bool().unwrap_or(false) {
Some(ReasoningMode::Range {
min: 1024, max: 32_000, step: Some(1024), default: Some(8192),
unit: Some("tokens".to_string()),
})
} else {
None
}
}
async fn fetch_catalog(&self, api_key: &str) -> Result<Vec<RemoteLlmModelInfo>> {
let resp: serde_json::Value = self.http
.get("https://openrouter.ai/api/v1/models")
.bearer_auth(api_key)
.send()
.await
.map_err(|e| anyhow!("OpenRouter request failed: {e}"))?
.json()
.await
.map_err(|e| anyhow!("OpenRouter response parse failed: {e}"))?;
let models = resp["data"]
.as_array()
.ok_or_else(|| anyhow!("unexpected OpenRouter response shape"))?
.iter()
.filter_map(|m| {
let id = m["id"].as_str()?.to_string();
let name = m["name"].as_str().unwrap_or(&id).to_string();
let context_length = m["context_length"].as_u64();
let price_input = m["pricing"]["prompt"].as_str()
.and_then(|s| s.parse::<f64>().ok())
.map(|v| v * 1_000_000.0);
let price_output = m["pricing"]["completion"].as_str()
.and_then(|s| s.parse::<f64>().ok())
.map(|v| v * 1_000_000.0);
let capabilities = {
let mut caps = vec!["function_calling".to_string()];
if let Some(params) = m["supported_parameters"].as_array() {
for p in params {
if let Some(s) = p.as_str() {
match s {
"tools" => caps.push("function_calling".to_string()),
"vision" | "image" => caps.push("vision".to_string()),
"stream" => caps.push("streaming".to_string()),
"reasoning" | "reasoning_effort" => caps.push("reasoning".to_string()),
_ => {}
}
}
}
}
caps.sort();
caps.dedup();
caps
};
let vision = Some(capabilities.contains(&"vision".to_string()));
let reasoning = Self::parse_reasoning(&m["reasoning"]);
Some(RemoteLlmModelInfo {
id, name, context_length,
max_completion_tokens: None,
knowledge_cutoff: None,
capabilities,
vision,
price_input_per_million: price_input,
price_output_per_million: price_output,
reasoning,
})
})
.collect();
Ok(models)
}
}
#[async_trait::async_trait]
impl ApiProvider for OpenRouterProvider {
fn type_id(&self) -> &'static str { "openrouter" }
fn display_name(&self) -> &'static str { "OpenRouter" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm, ServiceType::Transcribe, ServiceType::ImageGenerate, ServiceType::Tts]
}
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
let api_key = record.api_key.as_deref()
.ok_or_else(|| anyhow!("provider '{}': api_key required for openrouter model listing", record.name))?;
Ok(Some(self.fetch_catalog(api_key).await?))
}
fn reasoning_mode(&self, _model_id: &str, capabilities: &[String]) -> Option<ReasoningMode> {
// Fallback for stored/manually-added models with no catalog descriptor.
// The precise per-model set comes from `parse_reasoning` in the catalog;
// here we offer OpenRouter's full accepted effort set.
if capabilities.iter().any(|c| c == "reasoning") {
Some(ReasoningMode::ValueSet {
values: ["minimal", "low", "medium", "high", "xhigh", "max"]
.iter().map(|s| s.to_string()).collect(),
default: Some("medium".to_string()),
})
} else {
None
}
}
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
// String → effort level; number → token budget (max_tokens).
if let Some(effort) = value.as_str() {
Some(serde_json::json!({ "reasoning": { "effort": effort } }))
} else {
let budget = value.as_i64().filter(|n| *n > 0)?;
Some(serde_json::json!({ "reasoning": { "max_tokens": budget } }))
}
}
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some((|| {
let key = record.api_key.as_deref()
.with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?;
// Anthropic prompt-caching only works for models served by Anthropic on OpenRouter.
let prompt_cache = model.model_id.starts_with("anthropic/");
let extra = extra_with_reasoning(self, model);
Ok(BuiltLlmClient {
client: Arc::new(OpenAiClient::new("https://openrouter.ai/api/v1", key, extra, prompt_cache)),
prompt_cache,
})
})())
}
fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option<Result<Arc<dyn crate::tts::TextToSpeech>>> {
Some((|| {
let base_url = record.base_url.clone()
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?;
Ok(Arc::new(OpenAiTtsSynthesiser::new(
&model.name, base_url, api_key, &model.model_id,
model.voice_id.clone(), model.instructions.clone(), model.response_format.clone(),
)) as Arc<dyn crate::tts::TextToSpeech>)
})())
}
fn build_transcriber(&self, record: &LlmProviderRecord, model: &TranscribeModelRecord) -> Option<Result<Arc<dyn crate::transcribe::Transcribe>>> {
Some((|| {
let base_url = record.base_url.clone()
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?;
Ok(Arc::new(OpenAiAudioTranscriber::new(
&model.name, base_url, api_key, &model.model_id, model.language.clone(),
)) as Arc<dyn crate::transcribe::Transcribe>)
})())
}
fn build_image_generator(&self, record: &LlmProviderRecord, model: &ImageGenerateModelRecord) -> Option<Result<Arc<dyn crate::image_generate::ImageGenerate>>> {
Some((|| {
let base_url = record.base_url.clone()
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
let api_key = record.api_key.clone()
.with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?;
Ok(Arc::new(OpenRouterImageGenerator::new(
&model.name, base_url, api_key, &model.model_id,
)) as Arc<dyn crate::image_generate::ImageGenerate>)
})())
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "openrouter",
display_name: "OpenRouter",
description: None,
color: "#8b5cf6",
icon: "bi-hdd-stack",
fields: &[
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
],
}
}
}
+145
View File
@@ -0,0 +1,145 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use crate::chatbot::openai::OpenAiClient;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
/// Z.AI (Zhipu AI) — OpenAI-compatible GLM API.
///
/// Endpoint `https://api.z.ai/api/paas/v4/chat/completions`; `OpenAiClient`
/// appends `/chat/completions`, so the base URL is `.../paas/v4`.
///
/// Z.AI exposes no `GET /models` endpoint, so the model catalog is a curated
/// static list of the currently published GLM models.
pub struct ZaiProvider;
impl ZaiProvider {
pub fn new() -> Self {
Self
}
/// Base URL for the OpenAI-compatible chat endpoint (without `/chat/completions`).
const BASE_URL: &'static str = "https://api.z.ai/api/paas/v4";
/// Curated GLM catalog. Z.AI has no `GET /models` endpoint; this mirrors the
/// model menu published on the Z.AI console.
fn catalog() -> &'static [&'static str] {
&[
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5-turbo",
"glm-4.7",
"glm-4.6",
"glm-4.5",
"glm-4-32b-0414-128k",
]
}
fn known_context_length(model_id: &str) -> Option<u64> {
let id = model_id.to_lowercase();
if id.contains("128k") { Some(131_072) }
else if id.starts_with("glm-5") { Some(1_048_576) } // GLM-5.x: 1M context (per Z.AI)
else if id.starts_with("glm-4.7") { Some(200_000) }
else if id.starts_with("glm-4.6") { Some(200_000) }
else if id.starts_with("glm-4.5") { Some(131_072) }
else { None }
}
}
#[async_trait::async_trait]
impl ApiProvider for ZaiProvider {
fn type_id(&self) -> &'static str { "zai" }
fn display_name(&self) -> &'static str { "Z.AI" }
fn supported_types(&self) -> &'static [ServiceType] {
&[ServiceType::Llm]
}
async fn list_llm_models(&self, _record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
let models = Self::catalog()
.iter()
.map(|id| RemoteLlmModelInfo {
id: id.to_string(),
name: id.to_string(),
context_length: Self::known_context_length(id),
max_completion_tokens: None,
knowledge_cutoff: None,
capabilities: vec!["function_calling".to_string()],
vision: Some(false),
price_input_per_million: None,
price_output_per_million: None,
reasoning: None,
})
.collect();
Ok(Some(models))
}
fn reasoning_mode(&self, model_id: &str, _capabilities: &[String]) -> Option<ReasoningMode> {
let id = model_id.to_lowercase();
// GLM-5.2 (and above) additionally expose a graded `reasoning_effort`
// on top of the thinking toggle, so offer the effort levels directly
// ("disabled" turns thinking off).
if id.starts_with("glm-5.2") {
Some(ReasoningMode::ValueSet {
values: ["disabled", "minimal", "low", "medium", "high", "xhigh", "max"]
.iter().map(|s| s.to_string()).collect(),
default: Some("max".to_string()),
})
// Deep-thinking toggle (thinking.type) is supported by the GLM-5.x
// series and GLM-4.5/4.6/4.7 (but not the older glm-4-32b).
} else if id.starts_with("glm-5")
|| id.starts_with("glm-4.7")
|| id.starts_with("glm-4.6")
|| id.starts_with("glm-4.5")
{
Some(ReasoningMode::ValueSet {
values: vec!["disabled".to_string(), "enabled".to_string()],
default: Some("enabled".to_string()),
})
} else {
None
}
}
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
// "disabled" → thinking off; "enabled" → thinking on (no effort);
// any effort level → thinking on + `reasoning_effort` (GLM-5.2+).
match value.as_str()? {
"disabled" => Some(serde_json::json!({ "thinking": { "type": "disabled" } })),
"enabled" => Some(serde_json::json!({ "thinking": { "type": "enabled" } })),
effort => Some(serde_json::json!({
"thinking": { "type": "enabled" },
"reasoning_effort": effort,
})),
}
}
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some((|| {
let key = record.api_key.as_deref()
.with_context(|| format!("provider '{}': api_key required for zai", record.name))?;
let extra = extra_with_reasoning(self, model);
Ok(BuiltLlmClient {
client: Arc::new(OpenAiClient::new(Self::BASE_URL, key, extra, false)),
prompt_cache: false,
})
})())
}
fn ui_meta(&self) -> ProviderUiMeta {
ProviderUiMeta {
type_id: "zai",
display_name: "Z.AI",
description: Some("Zhipu AI GLM models (OpenAI-compatible)"),
color: "#4f46e5",
icon: "bi-stars",
fields: &[
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
],
}
}
}