Files
Skald-Circle/crates/skald-core/src/llm/providers/anthropic.rs
T
dguiducci 178a38357e 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.
2026-07-10 16:48:51 +01:00

120 lines
4.5 KiB
Rust

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 },
],
}
}
}