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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user