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:
@nothingness0db nothingness0db
2026-06-04 22:26:24 +08:00
committed by GitHub
parent 2a24da517f
commit 0527002cca
13 changed files with 688 additions and 12 deletions
+35 -3
View File
@@ -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()?;