mod.rs
Language Rust31.8 KB882 lines
//! Storage: one SQLite file with full-text search over messages and titles.use std::path::Path;use chrono::{Datelike, TimeZone};use rusqlite::{params, Connection, OptionalExtension, Row};use serde::{Deserialize, Serialize};use ts_rs::TS;use crate::pricing::ModelInfo;use crate::types::{Prices, Usage, UsageSource};mod search;pub use search::fts_query;const MIGRATIONS: &[&str] = &[include_str!("migrations/0001_init.sql"), include_str!("migrations/0002_fts.sql")];#[derive(Debug, thiserror::Error)]pub enum DbError {#[error(transparent)]Sqlite(#[from] rusqlite::Error),#[error(transparent)]Json(#[from] serde_json::Error),#[error("not found: {0}")]NotFound(String),}pub type Result<T> = std::result::Result<T, DbError>;pub fn now_ms() -> i64 {chrono::Utc::now().timestamp_millis()}pub fn new_id() -> String {uuid::Uuid::now_v7().to_string()}/// Start of the calendar month containing `now`, in `now`'s own time zone, as UTC ms.pub fn month_start_ms<Tz: TimeZone>(now: &chrono::DateTime<Tz>) -> i64 {let tz = now.timezone();tz.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0).earliest().map(|d| d.timestamp_millis()).unwrap_or_else(|| now.timestamp_millis())}// ---- types shared with the frontend ---------------------------------------#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct Provider {pub id: String,/// `deepinfra` or `openai_compatible`pub kind: String,pub name: String,pub base_url: String,pub requires_key: bool,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct Conversation {pub id: String,pub title: String,pub provider_id: String,pub model_id: String,pub preset_id: Option<String>,pub system_prompt: String,pub origin: String,pub pinned: bool,pub archived: bool,#[ts(type = "number")]pub created_at: i64,#[ts(type = "number")]pub updated_at: i64,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct ConversationSummary {pub id: String,pub title: String,pub model_id: String,pub pinned: bool,pub origin: String,#[ts(type = "number")]pub updated_at: i64,#[ts(type = "number")]pub cost_nanousd: i64,pub cost_estimated: bool,#[ts(type = "number")]pub message_count: i64,/// Some reply was answered (tokens counted), so $0 means free, not failed.pub has_usage: bool,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct AttachmentMeta {pub id: String,/// `image` or `text`pub kind: String,pub filename: String,pub mime: String,#[ts(type = "number")]pub byte_size: i64,pub width: Option<u32>,pub height: Option<u32>,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct Message {pub id: String,pub conversation_id: String,pub role: String,pub content: String,pub reasoning: Option<String>,/// `complete`, `streaming`, `cancelled` or `error`pub status: String,pub error_kind: Option<String>,pub error_message: Option<String>,pub provider_id: Option<String>,pub model_id: Option<String>,pub usage: Usage,pub usage_source: UsageSource,pub prices: Option<Prices>,#[ts(type = "number")]pub cost_nanousd: i64,pub finish_reason: Option<String>,#[ts(type = "number | null")]pub ttft_ms: Option<i64>,#[ts(type = "number | null")]pub duration_ms: Option<i64>,#[ts(type = "number")]pub created_at: i64,pub attachments: Vec<AttachmentMeta>,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct Preset {pub id: String,pub name: String,pub system_prompt: String,pub provider_id: Option<String>,pub model_id: Option<String>,pub temperature: Option<f32>,pub max_tokens: Option<u32>,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct SearchHit {pub conversation_id: String,pub title: String,pub message_id: Option<String>,/// With `\u{1}` and `\u{2}` around matched terms, for the UI to highlight.pub snippet: String,#[ts(type = "number")]pub created_at: i64,}#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct UsageRow {/// A model id, or a day as YYYY-MM-DD.pub key: String,#[ts(type = "number")]pub cost_nanousd: i64,#[ts(type = "number")]pub prompt_tokens: i64,#[ts(type = "number")]pub completion_tokens: i64,#[ts(type = "number")]pub messages: i64,pub estimated: bool,}/// What the stream produced, written onto the assistant row in one go.#[derive(Debug, Clone, Default)]pub struct Finalize {pub content: String,pub reasoning: Option<String>,pub status: &'static str,pub error_kind: Option<String>,pub error_message: Option<String>,pub usage: Usage,pub usage_source: Option<UsageSource>,pub prices: Option<Prices>,pub cost_nanousd: i64,pub provider_cost_nanousd: Option<i64>,pub finish_reason: Option<String>,pub ttft_ms: Option<i64>,pub duration_ms: Option<i64>,}pub struct NewAttachment<'a> {pub kind: &'a str,pub filename: &'a str,pub mime: &'a str,pub byte_size: i64,pub sha256: &'a str,pub width: Option<u32>,pub height: Option<u32>,pub path: Option<&'a str>,pub text_content: Option<&'a str>,}/// An attachment as the provider request needs it.#[derive(Debug, Clone)]pub struct AttachmentData {pub meta: AttachmentMeta,pub path: Option<String>,pub text_content: Option<String>,}// ---- the database ----------------------------------------------------------pub struct Db {conn: Connection,}const MESSAGE_COLS: &str = "id, conversation_id, role, content, reasoning, status, error_kind, error_message, \provider_id, model_id, prompt_tokens, completion_tokens, cached_tokens, usage_source, \price_in_per_m, price_out_per_m, price_cache_per_m, cost_nanousd, finish_reason, ttft_ms, duration_ms, created_at";const CONVERSATION_COLS: &str ="id, title, provider_id, model_id, preset_id, system_prompt, origin, pinned, archived, created_at, updated_at";fn conversation_row(r: &Row) -> rusqlite::Result<Conversation> {Ok(Conversation {id: r.get(0)?,title: r.get(1)?,provider_id: r.get(2)?,model_id: r.get(3)?,preset_id: r.get(4)?,system_prompt: r.get(5)?,origin: r.get(6)?,pinned: r.get(7)?,archived: r.get(8)?,created_at: r.get(9)?,updated_at: r.get(10)?,})}fn message_row(r: &Row) -> rusqlite::Result<Message> {let pin: Option<f64> = r.get(14)?;let pout: Option<f64> = r.get(15)?;let pcache: Option<f64> = r.get(16)?;Ok(Message {id: r.get(0)?,conversation_id: r.get(1)?,role: r.get(2)?,content: r.get(3)?,reasoning: r.get(4)?,status: r.get(5)?,error_kind: r.get(6)?,error_message: r.get(7)?,provider_id: r.get(8)?,model_id: r.get(9)?,usage: Usage { prompt_tokens: r.get(10)?, completion_tokens: r.get(11)?, cached_tokens: r.get(12)? },usage_source: UsageSource::parse(&r.get::<_, String>(13)?),prices: match (pin, pout) {(Some(i), Some(o)) => Some(Prices { input_per_m: i, output_per_m: o, cache_read_per_m: pcache }),_ => None,},cost_nanousd: r.get(17)?,finish_reason: r.get(18)?,ttft_ms: r.get(19)?,duration_ms: r.get(20)?,created_at: r.get(21)?,attachments: vec![],})}fn attachment_meta_row(r: &Row) -> rusqlite::Result<AttachmentMeta> {Ok(AttachmentMeta {id: r.get(0)?,kind: r.get(1)?,filename: r.get(2)?,mime: r.get(3)?,byte_size: r.get(4)?,width: r.get(5)?,height: r.get(6)?,})}pub const DEEPINFRA_ID: &str = "deepinfra";impl Db {pub fn open(path: &Path) -> Result<Self> {Self::init(Connection::open(path)?)}pub fn open_in_memory() -> Result<Self> {Self::init(Connection::open_in_memory()?)}fn init(conn: Connection) -> Result<Self> {conn.pragma_update(None, "journal_mode", "WAL")?;conn.pragma_update(None, "foreign_keys", "ON")?;conn.busy_timeout(std::time::Duration::from_secs(5))?;let mut db = Self { conn };db.migrate()?;db.seed()?;db.recover_interrupted()?;Ok(db)}fn migrate(&mut self) -> Result<()> {let version: i64 = self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?;for (i, sql) in MIGRATIONS.iter().enumerate().skip(version as usize) {let tx = self.conn.transaction()?;tx.execute_batch(sql)?;tx.pragma_update(None, "user_version", (i + 1) as i64)?;tx.commit()?;}Ok(())}#[cfg(test)]pub(crate) fn schema_version(&self) -> Result<i64> {Ok(self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?)}fn seed(&self) -> Result<()> {self.conn.execute("INSERT OR IGNORE INTO providers (id, kind, name, base_url, requires_key, created_at)VALUES (?1, 'deepinfra', 'DeepInfra', ?2, 1, ?3)",params![DEEPINFRA_ID, crate::provider::DEEPINFRA_BASE, now_ms()],)?;Ok(())}/// A crash or quit mid-stream leaves a row marked streaming; close it off.fn recover_interrupted(&self) -> Result<usize> {Ok(self.conn.execute("UPDATE messages SET status = 'error', error_kind = 'interrupted',error_message = 'Cortex closed before this reply finished.'WHERE status = 'streaming'",[],)?)}// ---- providers ----pub fn list_providers(&self) -> Result<Vec<Provider>> {let mut st = self.conn.prepare("SELECT id, kind, name, base_url, requires_key FROM providers ORDER BY kind = 'deepinfra' DESC, created_at",)?;let rows = st.query_map([], |r| {Ok(Provider {id: r.get(0)?,kind: r.get(1)?,name: r.get(2)?,base_url: r.get(3)?,requires_key: r.get(4)?,})})?;Ok(rows.collect::<rusqlite::Result<_>>()?)}pub fn get_provider(&self, id: &str) -> Result<Provider> {self.list_providers()?.into_iter().find(|p| p.id == id).ok_or_else(|| DbError::NotFound(format!("provider {id}")))}pub fn upsert_provider(&self, p: &Provider) -> Result<()> {self.conn.execute("INSERT INTO providers (id, kind, name, base_url, requires_key, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)ON CONFLICT (id) DO UPDATE SET name = excluded.name, base_url = excluded.base_url,requires_key = excluded.requires_key",params![p.id, p.kind, p.name, p.base_url.trim_end_matches('/'), p.requires_key, now_ms()],)?;Ok(())}pub fn delete_provider(&self, id: &str) -> Result<()> {if id == DEEPINFRA_ID {return Ok(()); // the built-in provider stays}self.conn.execute("DELETE FROM providers WHERE id = ?1", [id])?;self.conn.execute("DELETE FROM model_pricing WHERE provider_id = ?1", [id])?;Ok(())}// ---- conversations ----pub fn create_conversation(&self,provider_id: &str,model_id: &str,system_prompt: &str,preset_id: Option<&str>,origin: &str,) -> Result<Conversation> {let id = new_id();let now = now_ms();self.conn.execute("INSERT INTO conversations (id, title, provider_id, model_id, preset_id, system_prompt, origin, created_at, updated_at)VALUES (?1, 'New chat', ?2, ?3, ?4, ?5, ?6, ?7, ?7)",params![id, provider_id, model_id, preset_id, system_prompt, origin, now],)?;self.get_conversation(&id)}pub fn get_conversation(&self, id: &str) -> Result<Conversation> {self.conn.query_row(&format!("SELECT {CONVERSATION_COLS} FROM conversations WHERE id = ?1"), [id], conversation_row).optional()?.ok_or_else(|| DbError::NotFound(format!("conversation {id}")))}pub fn list_conversations(&self, include_archived: bool, limit: i64) -> Result<Vec<ConversationSummary>> {let mut st = self.conn.prepare("SELECT c.id, c.title, c.model_id, c.pinned, c.origin, c.updated_at,COALESCE(SUM(m.cost_nanousd), 0),COALESCE(MAX(m.usage_source = 'estimated'), 0),COUNT(m.id),COALESCE(MAX(m.role = 'assistant' AND m.usage_source != 'none'), 0)FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.idWHERE (?1 OR c.archived = 0)GROUP BY c.id ORDER BY c.pinned DESC, c.updated_at DESC LIMIT ?2",)?;let rows = st.query_map(params![include_archived, limit], |r| {Ok(ConversationSummary {id: r.get(0)?,title: r.get(1)?,model_id: r.get(2)?,pinned: r.get(3)?,origin: r.get(4)?,updated_at: r.get(5)?,cost_nanousd: r.get(6)?,cost_estimated: r.get(7)?,message_count: r.get(8)?,has_usage: r.get(9)?,})})?;Ok(rows.collect::<rusqlite::Result<_>>()?)}pub fn rename_conversation(&self, id: &str, title: &str) -> Result<()> {let title = title.trim();let title = if title.is_empty() { "New chat" } else { title };self.conn.execute("UPDATE conversations SET title = ?2 WHERE id = ?1", params![id, title])?;Ok(())}pub fn set_pinned(&self, id: &str, pinned: bool) -> Result<()> {self.conn.execute("UPDATE conversations SET pinned = ?2 WHERE id = ?1", params![id, pinned])?;Ok(())}pub fn set_archived(&self, id: &str, archived: bool) -> Result<()> {self.conn.execute("UPDATE conversations SET archived = ?2 WHERE id = ?1", params![id, archived])?;Ok(())}pub fn set_conversation_model(&self, id: &str, provider_id: &str, model_id: &str) -> Result<()> {self.conn.execute("UPDATE conversations SET provider_id = ?2, model_id = ?3 WHERE id = ?1",params![id, provider_id, model_id],)?;Ok(())}pub fn set_system_prompt(&self, id: &str, system_prompt: &str, preset_id: Option<&str>) -> Result<()> {self.conn.execute("UPDATE conversations SET system_prompt = ?2, preset_id = ?3 WHERE id = ?1",params![id, system_prompt, preset_id],)?;Ok(())}pub fn delete_conversation(&self, id: &str) -> Result<()> {self.conn.execute("DELETE FROM conversations WHERE id = ?1", [id])?;Ok(())}fn touch(&self, conversation_id: &str) -> Result<()> {self.conn.execute("UPDATE conversations SET updated_at = ?2 WHERE id = ?1", params![conversation_id, now_ms()])?;Ok(())}// ---- messages ----pub fn insert_user_message(&self,conversation_id: &str,content: &str,attachment_ids: &[String],) -> Result<Message> {let id = new_id();let tx = self.conn.unchecked_transaction()?;tx.execute("INSERT INTO messages (id, conversation_id, role, content, status, created_at) VALUES (?1, ?2, 'user', ?3, 'complete', ?4)",params![id, conversation_id, content, now_ms()],)?;for a in attachment_ids {tx.execute("UPDATE attachments SET message_id = ?1 WHERE id = ?2 AND message_id IS NULL", params![id, a])?;}// The first message names the chat; no model call is spent on a title.tx.execute("UPDATE conversations SET title = ?2 WHERE id = ?1 AND title = 'New chat'",params![conversation_id, title_from(content)],)?;tx.commit()?;self.touch(conversation_id)?;self.get_message(&id)}pub fn insert_assistant_placeholder(&self,conversation_id: &str,provider_id: &str,model_id: &str,) -> Result<Message> {let id = new_id();self.conn.execute("INSERT INTO messages (id, conversation_id, role, content, status, provider_id, model_id, created_at)VALUES (?1, ?2, 'assistant', '', 'streaming', ?3, ?4, ?5)",params![id, conversation_id, provider_id, model_id, now_ms()],)?;self.get_message(&id)}pub fn finalize_assistant(&self, id: &str, f: &Finalize) -> Result<Message> {let (pin, pout, pcache) = match f.prices {Some(p) => (Some(p.input_per_m), Some(p.output_per_m), p.cache_read_per_m),None => (None, None, None),};self.conn.execute("UPDATE messages SET content = ?2, reasoning = ?3, status = ?4, error_kind = ?5, error_message = ?6,prompt_tokens = ?7, completion_tokens = ?8, cached_tokens = ?9, usage_source = ?10,price_in_per_m = ?11, price_out_per_m = ?12, price_cache_per_m = ?13, cost_nanousd = ?14,provider_cost_nanousd = ?15, finish_reason = ?16, ttft_ms = ?17, duration_ms = ?18WHERE id = ?1",params![id,f.content,f.reasoning.as_deref().filter(|s| !s.is_empty()),f.status,f.error_kind,f.error_message,f.usage.prompt_tokens,f.usage.completion_tokens,f.usage.cached_tokens,f.usage_source.unwrap_or(UsageSource::None).as_str(),pin,pout,pcache,f.cost_nanousd,f.provider_cost_nanousd,f.finish_reason,f.ttft_ms,f.duration_ms],)?;let m = self.get_message(id)?;self.touch(&m.conversation_id)?;Ok(m)}pub fn get_message(&self, id: &str) -> Result<Message> {let mut m = self.conn.query_row(&format!("SELECT {MESSAGE_COLS} FROM messages WHERE id = ?1"), [id], message_row).optional()?.ok_or_else(|| DbError::NotFound(format!("message {id}")))?;m.attachments = self.attachments_for(&m.id)?;Ok(m)}pub fn list_messages(&self, conversation_id: &str) -> Result<Vec<Message>> {let mut st = self.conn.prepare(&format!("SELECT {MESSAGE_COLS} FROM messages WHERE conversation_id = ?1 ORDER BY created_at, rowid"))?;let mut msgs: Vec<Message> = st.query_map([conversation_id], message_row)?.collect::<rusqlite::Result<_>>()?;for m in &mut msgs {m.attachments = self.attachments_for(&m.id)?;}Ok(msgs)}/// Delete a message and everything after it (edit and regenerate start here)./// The message's own attachments go back to being drafts, so an edited/// message can be sent again with them.pub fn truncate_from(&self, conversation_id: &str, message_id: &str) -> Result<usize> {let (created, rowid): (i64, i64) = self.conn.query_row("SELECT created_at, rowid FROM messages WHERE id = ?1 AND conversation_id = ?2",params![message_id, conversation_id],|r| Ok((r.get(0)?, r.get(1)?)),)?;self.conn.execute("UPDATE attachments SET message_id = NULL, created_at = ?2 WHERE message_id = ?1",params![message_id, now_ms()],)?;Ok(self.conn.execute("DELETE FROM messages WHERE conversation_id = ?1 AND (created_at > ?2 OR (created_at = ?2 AND rowid >= ?3))",params![conversation_id, created, rowid],)?)}// ---- attachments ----pub fn insert_attachment(&self, a: &NewAttachment) -> Result<AttachmentMeta> {let id = new_id();self.conn.execute("INSERT INTO attachments (id, kind, filename, mime, byte_size, sha256, width, height, path, text_content, created_at)VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",params![id, a.kind, a.filename, a.mime, a.byte_size, a.sha256, a.width, a.height, a.path, a.text_content, now_ms()],)?;Ok(self.attachment(&id)?.meta)}pub fn attachment(&self, id: &str) -> Result<AttachmentData> {self.conn.query_row("SELECT id, kind, filename, mime, byte_size, width, height, path, text_content FROM attachments WHERE id = ?1",[id],|r| Ok(AttachmentData { meta: attachment_meta_row(r)?, path: r.get(7)?, text_content: r.get(8)? }),).optional()?.ok_or_else(|| DbError::NotFound(format!("attachment {id}")))}pub fn attachments_for(&self, message_id: &str) -> Result<Vec<AttachmentMeta>> {let mut st = self.conn.prepare("SELECT id, kind, filename, mime, byte_size, width, height FROM attachments WHERE message_id = ?1 ORDER BY created_at, rowid",)?;let rows = st.query_map([message_id], attachment_meta_row)?;Ok(rows.collect::<rusqlite::Result<_>>()?)}pub fn attachment_data_for(&self, message_id: &str) -> Result<Vec<AttachmentData>> {self.attachments_for(message_id)?.into_iter().map(|a| self.attachment(&a.id)).collect()}/// Paths still referenced by any attachment row (a file may be shared by hash).pub fn attachment_path_in_use(&self, path: &str) -> Result<bool> {Ok(self.conn.query_row("SELECT EXISTS (SELECT 1 FROM attachments WHERE path = ?1)", [path], |r| r.get(0))?)}/// Remove an attachment that was never sent; returns its file path, if any.pub fn delete_draft_attachment(&self, id: &str) -> Result<Option<String>> {let path: Option<Option<String>> = self.conn.query_row("SELECT path FROM attachments WHERE id = ?1 AND message_id IS NULL", [id], |r| r.get(0)).optional()?;self.conn.execute("DELETE FROM attachments WHERE id = ?1 AND message_id IS NULL", [id])?;Ok(path.flatten())}/// Drafts never sent (the composer was cleared or the app closed).pub fn delete_stale_drafts(&self, older_than_ms: i64) -> Result<Vec<String>> {let cutoff = now_ms() - older_than_ms;let mut st = self.conn.prepare("SELECT path FROM attachments WHERE message_id IS NULL AND created_at < ?1 AND path IS NOT NULL",)?;let paths: Vec<String> = st.query_map([cutoff], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;self.conn.execute("DELETE FROM attachments WHERE message_id IS NULL AND created_at < ?1", [cutoff])?;Ok(paths)}// ---- presets ----pub fn list_presets(&self) -> Result<Vec<Preset>> {let mut st = self.conn.prepare("SELECT id, name, system_prompt, provider_id, model_id, temperature, max_tokens FROM presets ORDER BY sort_order, name",)?;let rows = st.query_map([], |r| {Ok(Preset {id: r.get(0)?,name: r.get(1)?,system_prompt: r.get(2)?,provider_id: r.get(3)?,model_id: r.get(4)?,temperature: r.get(5)?,max_tokens: r.get(6)?,})})?;Ok(rows.collect::<rusqlite::Result<_>>()?)}pub fn save_preset(&self, p: &Preset) -> Result<Preset> {let id = if p.id.is_empty() { new_id() } else { p.id.clone() };let now = now_ms();self.conn.execute("INSERT INTO presets (id, name, system_prompt, provider_id, model_id, temperature, max_tokens, created_at, updated_at)VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)ON CONFLICT (id) DO UPDATE SET name = excluded.name, system_prompt = excluded.system_prompt,provider_id = excluded.provider_id, model_id = excluded.model_id, temperature = excluded.temperature,max_tokens = excluded.max_tokens, updated_at = excluded.updated_at",params![id, p.name.trim(), p.system_prompt, p.provider_id, p.model_id, p.temperature, p.max_tokens, now],)?;Ok(Preset { id, ..p.clone() })}pub fn delete_preset(&self, id: &str) -> Result<()> {self.conn.execute("DELETE FROM presets WHERE id = ?1", [id])?;Ok(())}// ---- settings ----pub fn get_setting<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {let raw: Option<String> =self.conn.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| r.get(0)).optional()?;Ok(match raw {Some(s) => Some(serde_json::from_str(&s)?),None => None,})}pub fn set_setting<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {self.conn.execute("INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT (key) DO UPDATE SET value = excluded.value",params![key, serde_json::to_string(value)?],)?;Ok(())}// ---- models and prices ----pub fn replace_models(&self, provider_id: &str, models: &[ModelInfo], source: &str, fetched_at: i64) -> Result<()> {let tx = self.conn.unchecked_transaction()?;tx.execute("DELETE FROM model_pricing WHERE provider_id = ?1", [provider_id])?;for m in models {tx.execute("INSERT INTO model_pricing (provider_id, model_id, input_per_m, output_per_m, cache_read_per_m,context_length, max_tokens, tags, source, fetched_at)VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",params![provider_id,m.id,m.prices.input_per_m,m.prices.output_per_m,m.prices.cache_read_per_m,m.context_length,m.max_tokens,serde_json::to_string(&m.tags)?,source,fetched_at],)?;}tx.commit()?;Ok(())}pub fn list_models(&self, provider_id: &str) -> Result<Vec<ModelInfo>> {let mut st = self.conn.prepare("SELECT model_id, input_per_m, output_per_m, cache_read_per_m, context_length, max_tokens, tagsFROM model_pricing WHERE provider_id = ?1 ORDER BY lower(model_id)",)?;let rows = st.query_map([provider_id], |r| {let tags: String = r.get(6)?;Ok(ModelInfo {provider_id: provider_id.to_string(),id: r.get(0)?,prices: Prices { input_per_m: r.get(1)?, output_per_m: r.get(2)?, cache_read_per_m: r.get(3)? },context_length: r.get(4)?,max_tokens: r.get(5)?,tags: serde_json::from_str(&tags).unwrap_or_default(),})})?;Ok(rows.collect::<rusqlite::Result<_>>()?)}pub fn model(&self, provider_id: &str, model_id: &str) -> Result<Option<ModelInfo>> {Ok(self.list_models(provider_id)?.into_iter().find(|m| m.id == model_id))}/// `(source, fetched_at)` of a provider's current list, if any.pub fn models_source(&self, provider_id: &str) -> Result<Option<(String, i64)>> {Ok(self.conn.query_row("SELECT source, MAX(fetched_at) FROM model_pricing WHERE provider_id = ?1 GROUP BY source ORDER BY 2 DESC LIMIT 1",[provider_id],|r| Ok((r.get(0)?, r.get(1)?)),).optional()?)}// ---- spend ----pub fn spend_since(&self, since_ms: i64) -> Result<(i64, bool)> {Ok(self.conn.query_row("SELECT COALESCE(SUM(cost_nanousd), 0), COALESCE(MAX(usage_source = 'estimated'), 0)FROM messages WHERE role = 'assistant' AND created_at >= ?1",[since_ms],|r| Ok((r.get(0)?, r.get(1)?)),)?)}pub fn usage_by_model(&self, since_ms: i64) -> Result<Vec<UsageRow>> {self.usage_grouped("model_id", since_ms)}/// Per local day: `utc_offset_minutes` is the viewer's offset from UTC.pub fn usage_by_day(&self, since_ms: i64, utc_offset_minutes: i32) -> Result<Vec<UsageRow>> {let expr = format!("strftime('%Y-%m-%d', (created_at / 1000) + {} * 60, 'unixepoch')", utc_offset_minutes);self.usage_grouped(&expr, since_ms)}fn usage_grouped(&self, key_expr: &str, since_ms: i64) -> Result<Vec<UsageRow>> {let mut st = self.conn.prepare(&format!("SELECT {key_expr} AS k, SUM(cost_nanousd), SUM(prompt_tokens), SUM(completion_tokens), COUNT(*),MAX(usage_source = 'estimated')FROM messages WHERE role = 'assistant' AND created_at >= ?1 AND k IS NOT NULLGROUP BY k ORDER BY 2 DESC, k"))?;let rows = st.query_map([since_ms], |r| {Ok(UsageRow {key: r.get(0)?,cost_nanousd: r.get(1)?,prompt_tokens: r.get(2)?,completion_tokens: r.get(3)?,messages: r.get(4)?,estimated: r.get(5)?,})})?;Ok(rows.collect::<rusqlite::Result<_>>()?)}// ---- search ----pub fn search(&self, input: &str, limit: i64) -> Result<Vec<SearchHit>> {let Some(q) = fts_query(input) else { return Ok(vec![]) };let mut st = self.conn.prepare("SELECT conversation_id, title, message_id, snippet, created_at FROM (SELECT c.id AS conversation_id, c.title AS title, m.id AS message_id,snippet(messages_fts, 0, char(1), char(2), '...', 12) AS snippet,m.created_at AS created_at, bm25(messages_fts) AS rankFROM messages_fts JOIN messages m ON m.rowid = messages_fts.rowidJOIN conversations c ON c.id = m.conversation_idWHERE messages_fts MATCH ?1 AND c.archived = 0UNION ALLSELECT c.id, c.title, NULL, snippet(conversations_fts, 0, char(1), char(2), '...', 12),c.updated_at, bm25(conversations_fts) - 5.0FROM conversations_fts JOIN conversations c ON c.rowid = conversations_fts.rowidWHERE conversations_fts MATCH ?1 AND c.archived = 0) ORDER BY rank LIMIT ?2",)?;let rows = st.query_map(params![q, limit], |r| {Ok(SearchHit {conversation_id: r.get(0)?,title: r.get(1)?,message_id: r.get(2)?,snippet: r.get(3)?,created_at: r.get(4)?,})})?;Ok(rows.collect::<rusqlite::Result<_>>()?)}#[cfg(test)]pub(crate) fn conn(&self) -> &Connection {&self.conn}}/// The first line of the first message, trimmed to a sensible length.pub fn title_from(content: &str) -> String {let line = content.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("New chat");let mut t: String = line.chars().take(60).collect();if line.chars().count() > 60 {if let Some(cut) = t.rfind(' ').filter(|&i| i > 30) {t.truncate(cut);}t.push_str("...");}t}#[cfg(test)]mod tests;