From 0527002ccaa5fcd8bc917d9658445bdee966f3f0 Mon Sep 17 00:00:00 2001 From: "@nothingness0db nothingness0db" <169134653+nothingness0db@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:26:24 +0800 Subject: [PATCH] feat(usage): add OpenCode session usage sync (#3215) * feat(usage): add OpenCode session usage sync Add OpenCode as a fourth app type in the usage statistics system. Reads per-message token data from opencode's local SQLite database (~/.local/share/opencode/opencode.db) and imports into proxy_request_logs. - New session_usage_opencode.rs module following Codex/Gemini pattern - Parses assistant message.data JSON for tokens, cost, model - Adds "opencode" to AppType union and filter tabs - Updates dedup filters to include opencode_session data_source - Adds i18n keys for all 4 locales * fix(usage): add opencode to UsageHero title themes * fix: respect XDG_DATA_HOME and platform defaults for OpenCode DB path - Support OPENCODE_DB env var override (absolute and relative paths) - Use ~/Library/Application Support/opencode/ on macOS - Use XDG_DATA_HOME/opencode/ when set - Fall back to ~/.local/share/opencode/ on Linux - Rename misleading test to test_parse_message_data_ignores_role * fix(usage): use ~/.local/share/opencode on all platforms OpenCode relies on xdg-basedir, which ignores macOS/Windows conventions, so its DB always lives at ~/.local/share/opencode. The previous macOS default pointed at ~/Library/Application Support/opencode, which does not exist, making the sync a silent no-op for macOS users without XDG_DATA_HOME set. * fix(usage): include opencode -wal mtime in freshness check OpenCode runs its SQLite DB in WAL mode; new commits land in the -wal file and the main DB file's mtime only advances on checkpoint. Keying the freshness gate solely on opencode.db could skip newly written sessions until a checkpoint occurred. Take the max of the db and -wal mtimes instead. * style(usage): apply cargo fmt to opencode session sync Fixes Backend Checks cargo fmt --check failure. Co-Authored-By: Claude Opus 4.8 * fix(usage): ignore empty OPENCODE_DB and XDG_DATA_HOME env vars An empty OPENCODE_DB collapsed the path to the data dir (dropping the opencode.db filename); an empty XDG_DATA_HOME produced a relative "opencode" path. Treat empty strings as unset, per the XDG spec. Co-Authored-By: Claude Opus 4.8 * fix(usage): harden OpenCode session sync error handling and labeling - Map '_opencode_session' provider_id to 'OpenCode (Session)' display name - Return accurate inserted flag from insert_opencode_message (was always true) - Do not advance file/session sync_state when a session errors, so failed inserts are retried next run instead of being permanently skipped - Surface per-message insert failures into the sync result errors - Add opencode_session data-source icon and i18n labels (zh/zh-TW/en/ja) - Add provider-stats labeling test for opencode session rows * fix(usage): only import finalized OpenCode messages An in-progress assistant message holds partial tokens; OpenCode updates the same message_id with final values later. Since request_id is fixed and the insert uses INSERT OR IGNORE, a partial row could never be corrected. Skip messages without time.completed so each turn is imported once with final usage. Co-Authored-By: Claude Opus 4.8 * fix(usage): keep OpenCode incomplete sessions retryable --------- Co-authored-by: Eira Hazel Co-authored-by: Eria hazel Co-authored-by: Claude Opus 4.8 Co-authored-by: Jason --- src-tauri/src/commands/usage.rs | 13 + src-tauri/src/lib.rs | 8 + src-tauri/src/opencode_config.rs | 34 ++ src-tauri/src/services/mod.rs | 1 + .../src/services/session_usage_opencode.rs | 574 ++++++++++++++++++ src-tauri/src/services/usage_stats.rs | 38 +- src/components/usage/DataSourceBar.tsx | 1 + src/components/usage/UsageHero.tsx | 4 + src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh-TW.json | 6 +- src/i18n/locales/zh.json | 6 +- src/types/usage.ts | 3 +- 13 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 src-tauri/src/services/session_usage_opencode.rs diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 677a6be1f..0a178159c 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -276,6 +276,19 @@ pub fn sync_session_usage( } } + // 同步 OpenCode 使用数据 + match crate::services::session_usage_opencode::sync_opencode_usage(&state.db) { + Ok(opencode_result) => { + result.imported += opencode_result.imported; + result.skipped += opencode_result.skipped; + result.files_scanned += opencode_result.files_scanned; + result.errors.extend(opencode_result.errors); + } + Err(e) => { + result.errors.push(format!("OpenCode 同步失败: {e}")); + } + } + Ok(result) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bca4dd261..599ed5faa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1025,6 +1025,10 @@ pub fn run() { "Gemini usage initial sync", crate::services::session_usage_gemini::sync_gemini_usage(db), ); + run_step( + "OpenCode usage initial sync", + crate::services::session_usage_opencode::sync_opencode_usage(db), + ); // 定期同步 let mut interval = tokio::time::interval(std::time::Duration::from_secs( @@ -1045,6 +1049,10 @@ pub fn run() { "Gemini usage periodic sync", crate::services::session_usage_gemini::sync_gemini_usage(db), ); + run_step( + "OpenCode usage periodic sync", + crate::services::session_usage_opencode::sync_opencode_usage(db), + ); } }); }); diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 4e659e874..437c2a980 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -46,6 +46,40 @@ pub fn get_opencode_config_path() -> PathBuf { get_opencode_dir().join("opencode.json") } +/// 获取 OpenCode SQLite 数据库路径 +/// 优先级: OPENCODE_DB 环境变量 > XDG_DATA_HOME > ~/.local/share/opencode +pub fn get_opencode_db_path() -> PathBuf { + // 支持 OPENCODE_DB 环境变量覆盖(忽略空字符串) + if let Ok(custom_path) = std::env::var("OPENCODE_DB") { + if !custom_path.is_empty() { + let path = PathBuf::from(&custom_path); + if path.is_absolute() { + return path; + } + // 相对路径基于数据目录 + return get_opencode_data_dir().join(path); + } + } + + get_opencode_data_dir().join("opencode.db") +} + +fn get_opencode_data_dir() -> PathBuf { + // 尊重 XDG_DATA_HOME(按 XDG 规范,空字符串视为未设置) + if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") { + if !xdg_data.is_empty() { + return PathBuf::from(xdg_data).join("opencode"); + } + } + + // OpenCode 使用 xdg-basedir,不遵守 macOS/Windows 平台约定, + // 所有平台默认都落在 ~/.local/share/opencode + crate::config::get_home_dir() + .join(".local") + .join("share") + .join("opencode") +} + #[allow(dead_code)] pub fn get_opencode_env_path() -> PathBuf { get_opencode_dir().join(".env") diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 255cd0f66..ae43e079c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -16,6 +16,7 @@ pub mod s3_sync; pub mod session_usage; pub mod session_usage_codex; pub mod session_usage_gemini; +pub mod session_usage_opencode; pub mod skill; pub mod speedtest; pub mod sql_helpers; diff --git a/src-tauri/src/services/session_usage_opencode.rs b/src-tauri/src/services/session_usage_opencode.rs new file mode 100644 index 000000000..83574bb62 --- /dev/null +++ b/src-tauri/src/services/session_usage_opencode.rs @@ -0,0 +1,574 @@ +//! OpenCode 会话日志使用追踪 +//! +//! 从 ~/.local/share/opencode/opencode.db (SQLite) 中提取精确 token 使用数据。 +//! +//! ## 数据流 +//! ```text +//! ~/.local/share/opencode/opencode.db +//! → session 表获取所有会话 +//! → message 表获取 assistant 消息 +//! → 解析 data JSON 提取 tokens/cost/model +//! → proxy_request_logs 表 +//! ``` + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::opencode_config::get_opencode_db_path; +use crate::proxy::usage::calculator::CostCalculator; +use crate::proxy::usage::parser::TokenUsage; +use crate::services::session_usage::{ + get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult, +}; +use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey}; +use rust_decimal::Decimal; +use std::fs; +use std::time::SystemTime; + +/// 从 opencode message.data JSON 中提取的 token 和费用数据 +struct OpenCodeMessageData { + input_tokens: u32, + output_tokens: u32, + reasoning_tokens: u32, + cache_read_tokens: u32, + cache_write_tokens: u32, + cost: f64, + model_id: String, + timestamp_ms: i64, +} + +struct OpenCodeMessageQueryResult { + messages: Vec<(String, OpenCodeMessageData)>, + has_incomplete_usage: bool, +} + +/// 同步 OpenCode 使用数据 +pub fn sync_opencode_usage(db: &Database) -> Result { + let db_path = get_opencode_db_path(); + + if !db_path.exists() { + return Ok(SessionSyncResult { + imported: 0, + skipped: 0, + files_scanned: 0, + errors: vec![], + }); + } + + let db_path_str = db_path.to_string_lossy().to_string(); + + // 检查文件修改时间。 + // opencode 的数据库运行在 WAL 模式:新提交先落在 -wal 文件里, + // 主库文件只有在 checkpoint 时才更新。因此必须同时考虑 -wal 的 + // mtime,否则会在 checkpoint 之前漏掉刚写入的会话。 + let metadata = fs::metadata(&db_path) + .map_err(|e| AppError::Config(format!("无法读取 opencode.db 元数据: {e}")))?; + let mut file_modified = metadata_modified_nanos(&metadata); + + let wal_path = db_path.with_extension("db-wal"); + if let Ok(wal_meta) = fs::metadata(&wal_path) { + file_modified = file_modified.max(metadata_modified_nanos(&wal_meta)); + } + + let (last_modified, _last_offset) = get_sync_state(db, &db_path_str)?; + + // 文件未变化则跳过 + if file_modified <= last_modified { + return Ok(SessionSyncResult { + imported: 0, + skipped: 0, + files_scanned: 1, + errors: vec![], + }); + } + + // 打开 opencode 的 SQLite 数据库(只读) + let opencode_conn = + rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| AppError::Database(format!("无法打开 opencode.db: {e}")))?; + + let mut result = SessionSyncResult { + imported: 0, + skipped: 0, + files_scanned: 1, + errors: vec![], + }; + let mut has_sync_errors = false; + + // 查询所有会话 + let sessions = query_sessions(&opencode_conn)?; + + for (session_id, time_updated) in &sessions { + // 检查会话是否需要重新同步 + let sync_key = format!("{db_path_str}:{session_id}"); + let (sess_last_modified, _) = get_sync_state(db, &sync_key)?; + if *time_updated <= sess_last_modified { + continue; // 会话未更新,跳过 + } + + let mut session_had_error = false; + + // 查询该会话的所有 assistant 消息 + let mut session_has_incomplete_usage = false; + match query_assistant_messages(&opencode_conn, session_id) { + Ok(query_result) => { + session_has_incomplete_usage = query_result.has_incomplete_usage; + for (message_id, msg_data) in &query_result.messages { + let request_id = format!("opencode_session:{session_id}:{message_id}"); + + match insert_opencode_message(db, &request_id, msg_data, session_id) { + Ok(true) => result.imported += 1, + Ok(false) => result.skipped += 1, + Err(e) => { + let msg = format!("OpenCode 消息插入失败 {request_id}: {e}"); + log::warn!("[OPENCODE-SYNC] {msg}"); + result.errors.push(msg); + result.skipped += 1; + session_had_error = true; + } + } + } + } + Err(e) => { + let msg = format!("OpenCode 会话消息查询失败 {session_id}: {e}"); + log::warn!("[OPENCODE-SYNC] {msg}"); + result.errors.push(msg); + session_had_error = true; + } + } + + if session_had_error { + has_sync_errors = true; + continue; + } + + if session_has_incomplete_usage { + continue; + } + + // 更新会话级同步状态。失败时不要推进文件级状态,确保下次可重试。 + if let Err(e) = update_sync_state(db, &sync_key, *time_updated, 0) { + let msg = format!("OpenCode 会话同步状态更新失败 {session_id}: {e}"); + log::warn!("[OPENCODE-SYNC] {msg}"); + result.errors.push(msg); + has_sync_errors = true; + } + } + + // 仅在本轮完全成功时推进文件级状态;否则保留下次重试入口。 + if !has_sync_errors { + update_sync_state(db, &db_path_str, file_modified, 0)?; + } + + if result.imported > 0 { + log::info!( + "[OPENCODE-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个会话", + result.imported, + result.skipped, + sessions.len() + ); + } + + Ok(result) +} + +/// 查询所有会话的 (id, sync_watermark) +fn query_sessions(conn: &rusqlite::Connection) -> Result, AppError> { + let mut stmt = conn + .prepare( + "SELECT s.id, + MAX(s.time_updated, COALESCE(MAX(m.time_updated), s.time_updated)) AS sync_watermark + FROM session s + LEFT JOIN message m ON m.session_id = s.id + GROUP BY s.id + ORDER BY sync_watermark", + ) + .map_err(|e| AppError::Database(format!("准备会话查询失败: {e}")))?; + + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .map_err(|e| AppError::Database(format!("查询会话失败: {e}")))?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row.map_err(|e| AppError::Database(format!("读取会话行失败: {e}")))?); + } + + Ok(sessions) +} + +/// 查询某会话的已完成 assistant 消息,并标记是否还有未完成 usage 消息。 +fn query_assistant_messages( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result { + let mut stmt = conn + .prepare("SELECT id, data FROM message WHERE session_id = ?1 ORDER BY time_created") + .map_err(|e| AppError::Database(format!("准备消息查询失败: {e}")))?; + + let rows = stmt + .query_map([session_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| AppError::Database(format!("查询消息失败: {e}")))?; + + let mut messages = Vec::new(); + let mut has_incomplete_usage = false; + for row in rows { + let (message_id, data_json) = + row.map_err(|e| AppError::Database(format!("读取消息行失败: {e}")))?; + + // 只处理 assistant 消息 + let value: serde_json::Value = match serde_json::from_str(&data_json) { + Ok(v) => v, + Err(_) => continue, + }; + + if value.get("role").and_then(|r| r.as_str()) != Some("assistant") { + continue; + } + + // 必须有 tokens 字段 + if value.get("tokens").is_none() { + continue; + } + + // 跳过未完成的消息:进行中只有半截 token,且因 INSERT OR IGNORE 无法回填 + if value.get("time").and_then(|t| t.get("completed")).is_none() { + has_incomplete_usage = true; + continue; + } + + if let Some(msg_data) = parse_message_data(&value) { + messages.push((message_id, msg_data)); + } + } + + Ok(OpenCodeMessageQueryResult { + messages, + has_incomplete_usage, + }) +} + +/// 解析 opencode message.data JSON 为结构化数据 +fn parse_message_data(value: &serde_json::Value) -> Option { + let tokens = value.get("tokens")?; + + let input_tokens = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let output_tokens = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let reasoning_tokens = tokens + .get("reasoning") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + let cache_obj = tokens.get("cache"); + let cache_read_tokens = cache_obj + .and_then(|c| c.get("read")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let cache_write_tokens = cache_obj + .and_then(|c| c.get("write")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + // 跳过全零 token 的消息 + if input_tokens == 0 + && output_tokens == 0 + && reasoning_tokens == 0 + && cache_read_tokens == 0 + && cache_write_tokens == 0 + { + return None; + } + + let cost = value.get("cost").and_then(|v| v.as_f64()).unwrap_or(0.0); + + let model_id = value + .get("modelID") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let timestamp_ms = value + .get("time") + .and_then(|t| t.get("created")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + Some(OpenCodeMessageData { + input_tokens, + output_tokens, + reasoning_tokens, + cache_read_tokens, + cache_write_tokens, + cost, + model_id, + timestamp_ms, + }) +} + +/// 插入单条 OpenCode 消息记录到 proxy_request_logs +fn insert_opencode_message( + db: &Database, + request_id: &str, + msg: &OpenCodeMessageData, + session_id: &str, +) -> Result { + let conn = lock_conn!(db.conn); + + let created_at = if msg.timestamp_ms > 0 { + msg.timestamp_ms / 1000 + } else { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }; + + // OpenCode 使用 Anthropic 风格:input 是新鲜输入,cache 单独计 + // output 包含 reasoning tokens(按输出计费) + let output_with_reasoning = msg.output_tokens + msg.reasoning_tokens; + + let dedup_key = DedupKey { + app_type: "opencode", + model: &msg.model_id, + input_tokens: msg.input_tokens, + output_tokens: output_with_reasoning, + cache_read_tokens: msg.cache_read_tokens, + cache_creation_tokens: msg.cache_write_tokens, + created_at, + }; + if should_skip_session_insert(&conn, request_id, &dedup_key)? { + return Ok(false); + } + + // 如果 opencode 已经提供了费用,直接使用;否则从模型定价计算 + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = + if msg.cost > 0.0 { + // opencode 已计算费用,直接使用 + // 简化处理:全部放入 total_cost(opencode 的 cost 是聚合值,无法精确拆分) + ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + msg.cost.to_string(), + ) + } else { + // opencode 费用为 0(如免费模型),尝试用 cc-switch 自带的模型定价计算 + let usage = TokenUsage { + input_tokens: msg.input_tokens, + output_tokens: output_with_reasoning, + cache_read_tokens: msg.cache_read_tokens, + cache_creation_tokens: msg.cache_write_tokens, + model: Some(msg.model_id.clone()), + message_id: None, + }; + + match find_model_pricing(&conn, &msg.model_id) { + Some(pricing) => { + let cost = CostCalculator::calculate_for_app( + "opencode", + &usage, + &pricing, + Decimal::from(1), + ); + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } + None => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ), + } + }; + + let inserted_rows = conn.execute( + "INSERT OR IGNORE INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + rusqlite::params![ + request_id, + "_opencode_session", // provider_id + "opencode", // app_type + msg.model_id, + msg.model_id, // request_model = model + msg.input_tokens, + output_with_reasoning, + msg.cache_read_tokens, + msg.cache_write_tokens, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms + Option::::None, // first_token_ms + 200i64, // status_code + Option::::None,// error_message + Some(session_id.to_string()), + Some("opencode_session"), // provider_type + 1i64, // is_streaming + "1.0", // cost_multiplier + created_at, + "opencode_session", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入 OpenCode 会话日志失败: {e}")))?; + + Ok(inserted_rows > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_message_data_full() { + let json: serde_json::Value = serde_json::json!({ + "role": "assistant", + "cost": 0.0023113, + "tokens": { + "total": 56554, + "input": 3272, + "output": 383, + "reasoning": 419, + "cache": { + "write": 0, + "read": 52480 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1779755333700i64, + "completed": 1779755350639i64 + } + }); + let data = parse_message_data(&json).unwrap(); + assert_eq!(data.input_tokens, 3272); + assert_eq!(data.output_tokens, 383); + assert_eq!(data.reasoning_tokens, 419); + assert_eq!(data.cache_read_tokens, 52480); + assert_eq!(data.cache_write_tokens, 0); + assert!((data.cost - 0.0023113).abs() < 1e-10); + assert_eq!(data.model_id, "deepseek-v4-pro"); + assert_eq!(data.timestamp_ms, 1779755333700); + } + + #[test] + fn test_parse_message_data_missing_cache() { + let json: serde_json::Value = serde_json::json!({ + "role": "assistant", + "cost": 0.0, + "tokens": { + "input": 1000, + "output": 200 + }, + "modelID": "mimo-v2.5-pro", + "time": { "created": 1779755333700i64 } + }); + let data = parse_message_data(&json).unwrap(); + assert_eq!(data.input_tokens, 1000); + assert_eq!(data.output_tokens, 200); + assert_eq!(data.reasoning_tokens, 0); + assert_eq!(data.cache_read_tokens, 0); + assert_eq!(data.cache_write_tokens, 0); + } + + #[test] + fn test_parse_message_data_skips_zero_tokens() { + let json: serde_json::Value = serde_json::json!({ + "role": "assistant", + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { "read": 0, "write": 0 } + }, + "modelID": "test" + }); + assert!(parse_message_data(&json).is_none()); + } + + #[test] + fn test_parse_message_data_ignores_role() { + // parse_message_data does not filter by role; that's the caller's job + let json: serde_json::Value = serde_json::json!({ + "role": "user", + "tokens": { "input": 100, "output": 0 } + }); + let data = parse_message_data(&json).unwrap(); + assert_eq!(data.input_tokens, 100); + } + + #[test] + fn test_query_assistant_messages_skips_incomplete() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, data TEXT);", + ) + .unwrap(); + + let done = serde_json::json!({ + "role": "assistant", + "tokens": { "input": 1000, "output": 200 }, + "modelID": "m", + "time": { "created": 1, "completed": 2 } + }) + .to_string(); + let in_progress = serde_json::json!({ + "role": "assistant", + "tokens": { "input": 500, "output": 0 }, + "modelID": "m", + "time": { "created": 3 } + }) + .to_string(); + + conn.execute( + "INSERT INTO message VALUES ('done', 's1', 1, ?1), ('wip', 's1', 2, ?2)", + rusqlite::params![done, in_progress], + ) + .unwrap(); + + let result = query_assistant_messages(&conn, "s1").unwrap(); + // 只返回已完成(带 time.completed)的消息,半截的被跳过 + assert_eq!(result.messages.len(), 1); + assert_eq!(result.messages[0].0, "done"); + assert!(result.has_incomplete_usage); + } + + #[test] + fn test_query_sessions_uses_message_update_watermark() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE session (id TEXT, time_updated INTEGER); + CREATE TABLE message ( + id TEXT, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + INSERT INTO session VALUES ('s1', 100); + INSERT INTO message VALUES ('m1', 's1', 90, 200, '{}');", + ) + .unwrap(); + + let sessions = query_sessions(&conn).unwrap(); + assert_eq!(sessions, vec![("s1".to_string(), 200)]); + } +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index b2e22c6ea..b3f2c0290 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -203,6 +203,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { WHEN '_session' THEN 'Claude (Session)' \ WHEN '_codex_session' THEN 'Codex (Session)' \ WHEN '_gemini_session' THEN 'Gemini (Session)' \ + WHEN '_opencode_session' THEN 'OpenCode (Session)' \ ELSE {log_alias}.provider_id END)" ) } @@ -223,7 +224,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { let proxy_data_source = data_source_expr("proxy_dedup"); format!( "NOT ( - {data_source} IN ('session_log', 'codex_session', 'gemini_session') + {data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session') AND EXISTS ( SELECT 1 FROM proxy_request_logs proxy_dedup @@ -238,7 +239,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { proxy_dedup.cache_creation_tokens = {log_alias}.cache_creation_tokens OR ( {log_alias}.cache_creation_tokens = 0 - AND {data_source} IN ('codex_session', 'gemini_session') + AND {data_source} IN ('codex_session', 'gemini_session', 'opencode_session') ) ) AND proxy_dedup.created_at BETWEEN @@ -298,7 +299,7 @@ pub(crate) fn has_matching_proxy_usage_log( key: &DedupKey, ) -> Result { let allow_missing_cache_creation = - matches!(key.app_type, "codex" | "gemini") && key.cache_creation_tokens == 0; + matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0; let l_data_source = data_source_expr("l"); let sql = format!( @@ -2832,6 +2833,37 @@ mod tests { Ok(()) } + #[test] + fn test_get_provider_stats_labels_opencode_session_provider() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + insert_usage_log( + &conn, + "opencode-session", + "opencode", + "_opencode_session", + "opencode-model", + "opencode_session", + 1000, + 100, + 50, + 0, + 0, + 200, + "0.01", + )?; + } + + let stats = db.get_provider_stats(None, None, Some("opencode"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].provider_id, "_opencode_session"); + assert_eq!(stats[0].provider_name, "OpenCode (Session)"); + + Ok(()) + } + #[test] fn test_get_provider_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src/components/usage/DataSourceBar.tsx b/src/components/usage/DataSourceBar.tsx index 7dabc51a8..13c12beee 100644 --- a/src/components/usage/DataSourceBar.tsx +++ b/src/components/usage/DataSourceBar.tsx @@ -17,6 +17,7 @@ const DATA_SOURCE_ICONS: Record = { codex_db: , codex_session: , gemini_session: , + opencode_session: , }; export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) { diff --git a/src/components/usage/UsageHero.tsx b/src/components/usage/UsageHero.tsx index dc7b5f28c..f12dd7542 100644 --- a/src/components/usage/UsageHero.tsx +++ b/src/components/usage/UsageHero.tsx @@ -54,6 +54,10 @@ const TITLE_THEMES: Record = { accent: "text-sky-600 dark:text-sky-400", iconBg: "bg-sky-500/10", }, + opencode: { + accent: "text-purple-600 dark:text-purple-400", + iconBg: "bg-purple-500/10", + }, }; /** diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 97bc83b85..8fb4a8256 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1374,7 +1374,8 @@ "all": "All", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "rawInputLabel": "Raw", "dataSources": "Data Sources", @@ -1383,7 +1384,8 @@ "session_log": "Session Log", "codex_db": "Codex DB", "codex_session": "Codex Session", - "gemini_session": "Gemini Session" + "gemini_session": "Gemini Session", + "opencode_session": "OpenCode Session" }, "sessionSync": { "trigger": "Sync session logs", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 60bfc17e8..c59ca40a3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1374,7 +1374,8 @@ "all": "すべて", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "rawInputLabel": "原始", "dataSources": "データソース", @@ -1383,7 +1384,8 @@ "session_log": "セッションログ", "codex_db": "Codex DB", "codex_session": "Codex セッション", - "gemini_session": "Gemini セッション" + "gemini_session": "Gemini セッション", + "opencode_session": "OpenCode セッション" }, "sessionSync": { "trigger": "セッションログを同期", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index bd603123f..8ef165340 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1320,7 +1320,8 @@ "all": "全部", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "rawInputLabel": "原始", "dataSources": "資料來源", @@ -1329,7 +1330,8 @@ "session_log": "工作階段日誌", "codex_db": "Codex 資料庫", "codex_session": "Codex 工作階段日誌", - "gemini_session": "Gemini 工作階段日誌" + "gemini_session": "Gemini 工作階段日誌", + "opencode_session": "OpenCode 工作階段日誌" }, "sessionSync": { "trigger": "同步工作階段日誌", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 785474820..e416252d0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1374,7 +1374,8 @@ "all": "全部", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "rawInputLabel": "原始", "dataSources": "数据来源", @@ -1383,7 +1384,8 @@ "session_log": "会话日志", "codex_db": "Codex 数据库", "codex_session": "Codex 会话日志", - "gemini_session": "Gemini 会话日志" + "gemini_session": "Gemini 会话日志", + "opencode_session": "OpenCode 会话日志" }, "sessionSync": { "trigger": "同步会话日志", diff --git a/src/types/usage.ts b/src/types/usage.ts index 316d3481d..efb885088 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -146,7 +146,7 @@ export interface UsageRangeSelection { * it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have * no proxy handler at all — they appear only as managed apps elsewhere. */ -export type AppType = "claude" | "codex" | "gemini"; +export type AppType = "claude" | "codex" | "gemini" | "opencode"; export type AppTypeFilter = "all" | AppType; @@ -154,6 +154,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray = [ "claude", "codex", "gemini", + "opencode", ]; /**