mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
0527002cca
* 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>
268 lines
8.0 KiB
Rust
268 lines
8.0 KiB
Rust
use crate::config::write_json_file;
|
|
use crate::error::AppError;
|
|
use crate::provider::OpenCodeProviderConfig;
|
|
use crate::settings::get_opencode_override_dir;
|
|
use indexmap::IndexMap;
|
|
use serde_json::{json, Map, Value};
|
|
use std::path::PathBuf;
|
|
|
|
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
|
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
|
|
|
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
|
plugin_name == prefix
|
|
|| plugin_name
|
|
.strip_prefix(prefix)
|
|
.map(|suffix| suffix.starts_with('@'))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
|
|
prefixes
|
|
.iter()
|
|
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
|
|
}
|
|
|
|
fn canonicalize_plugin_name(plugin_name: &str) -> String {
|
|
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
|
|
if suffix.is_empty() || suffix.starts_with('@') {
|
|
return format!("oh-my-openagent{suffix}");
|
|
}
|
|
}
|
|
plugin_name.to_string()
|
|
}
|
|
|
|
pub fn get_opencode_dir() -> PathBuf {
|
|
if let Some(override_dir) = get_opencode_override_dir() {
|
|
return override_dir;
|
|
}
|
|
|
|
crate::config::get_home_dir()
|
|
.join(".config")
|
|
.join("opencode")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
pub fn read_opencode_config() -> Result<Value, AppError> {
|
|
let path = get_opencode_config_path();
|
|
|
|
if !path.exists() {
|
|
return Ok(json!({
|
|
"$schema": "https://opencode.ai/config.json"
|
|
}));
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
|
json5::from_str(&content).map_err(|e| {
|
|
AppError::Config(format!(
|
|
"Failed to parse OpenCode config: {}: {e}",
|
|
path.display()
|
|
))
|
|
})
|
|
}
|
|
|
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
|
let path = get_opencode_config_path();
|
|
write_json_file(&path, config)?;
|
|
|
|
log::debug!("OpenCode config written to {path:?}");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
|
let config = read_opencode_config()?;
|
|
Ok(config
|
|
.get("provider")
|
|
.and_then(|v| v.as_object())
|
|
.cloned()
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
|
let mut full_config = read_opencode_config()?;
|
|
|
|
if full_config.get("provider").is_none() {
|
|
full_config["provider"] = json!({});
|
|
}
|
|
|
|
if let Some(providers) = full_config
|
|
.get_mut("provider")
|
|
.and_then(|v| v.as_object_mut())
|
|
{
|
|
providers.insert(id.to_string(), config);
|
|
}
|
|
|
|
write_opencode_config(&full_config)
|
|
}
|
|
|
|
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
|
let mut config = read_opencode_config()?;
|
|
|
|
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
|
providers.remove(id);
|
|
}
|
|
|
|
write_opencode_config(&config)
|
|
}
|
|
|
|
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
|
let providers = get_providers()?;
|
|
let mut result = IndexMap::new();
|
|
|
|
for (id, value) in providers {
|
|
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
|
|
Ok(config) => {
|
|
result.insert(id, config);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Failed to parse provider '{id}': {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
|
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
|
set_provider(id, value)
|
|
}
|
|
|
|
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
|
let config = read_opencode_config()?;
|
|
Ok(config
|
|
.get("mcp")
|
|
.and_then(|v| v.as_object())
|
|
.cloned()
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
|
let mut full_config = read_opencode_config()?;
|
|
|
|
if full_config.get("mcp").is_none() {
|
|
full_config["mcp"] = json!({});
|
|
}
|
|
|
|
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
|
mcp.insert(id.to_string(), config);
|
|
}
|
|
|
|
write_opencode_config(&full_config)
|
|
}
|
|
|
|
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
|
let mut config = read_opencode_config()?;
|
|
|
|
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
|
mcp.remove(id);
|
|
}
|
|
|
|
write_opencode_config(&config)
|
|
}
|
|
|
|
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
|
let mut config = read_opencode_config()?;
|
|
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
|
|
|
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
|
|
|
match plugins {
|
|
Some(arr) => {
|
|
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
|
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
|
arr.retain(|v| {
|
|
v.as_str()
|
|
.map(|s| {
|
|
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
|
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
|
})
|
|
.unwrap_or(true)
|
|
});
|
|
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
|
{
|
|
arr.retain(|v| {
|
|
v.as_str()
|
|
.map(|s| {
|
|
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
|
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
|
})
|
|
.unwrap_or(true)
|
|
});
|
|
}
|
|
|
|
let already_exists = arr
|
|
.iter()
|
|
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
|
if !already_exists {
|
|
arr.push(Value::String(normalized_plugin_name));
|
|
}
|
|
}
|
|
None => {
|
|
config["plugin"] = json!([normalized_plugin_name]);
|
|
}
|
|
}
|
|
|
|
write_opencode_config(&config)
|
|
}
|
|
|
|
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
|
let mut config = read_opencode_config()?;
|
|
|
|
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
|
arr.retain(|v| {
|
|
v.as_str()
|
|
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
|
.unwrap_or(true)
|
|
});
|
|
|
|
if arr.is_empty() {
|
|
config.as_object_mut().map(|obj| obj.remove("plugin"));
|
|
}
|
|
}
|
|
|
|
write_opencode_config(&config)
|
|
}
|