mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(usage): keep OpenCode incomplete sessions retryable --------- Co-authored-by: Eira Hazel <kip3vx9ma@mozmail.com> Co-authored-by: Eria hazel <git config --global user.email your@email.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
committed by
GitHub
parent
2a24da517f
commit
0527002cca
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SessionSyncResult, AppError> {
|
||||
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<Vec<(String, i64)>, 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<OpenCodeMessageQueryResult, AppError> {
|
||||
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<OpenCodeMessageData> {
|
||||
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<bool, AppError> {
|
||||
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::<i64>::None, // first_token_ms
|
||||
200i64, // status_code
|
||||
Option::<String>::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)]);
|
||||
}
|
||||
}
|
||||
@@ -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<bool, AppError> {
|
||||
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()?;
|
||||
|
||||
Reference in New Issue
Block a user