From 8776bdefb81a91ec9ffe5e28ebcb90f318b7883f Mon Sep 17 00:00:00 2001 From: manuaudio Date: Wed, 30 Sep 2026 10:12:04 -0700 Subject: [PATCH 1/2] Count Claude usage from opencode v2 sessions Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-claude | 32 ++++++++-- .../agent-usage-claude-scanner-test.sh | 58 ++++++++++++++++++- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude index 5b3634aef1c..3ccf2202152 100755 --- a/bin/omarchy-agent-usage-claude +++ b/bin/omarchy-agent-usage-claude @@ -473,16 +473,36 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None: return None try: conn.execute("PRAGMA query_only = ON") - for session_id, raw in conn.execute("SELECT session_id, data FROM message"): + tables = {name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + # opencode v1 keeps messages in `message`, with the role, provider and model + # in each row's data. v2 writes `session_message` instead: the role moves to + # the `type` column and the provider and model nest under data.model. A + # database can hold either or both. + queries = [] + if "message" in tables: + queries.append(("opencode:", "SELECT session_id, data, NULL FROM message")) + if "session_message" in tables: + queries.append(("opencode-v2:", "SELECT session_id, data, type FROM session_message")) + rows = ((prefix, row) for prefix, query in queries for row in conn.execute(query)) + for session_prefix, (session_id, raw, row_type) in rows: # One malformed row must not abort the scan, so every shape assumption # lives inside the try. try: entry = json.loads(raw) + if not isinstance(entry, dict): + continue + if row_type is None: + role = entry.get("role") + provider = entry.get("providerID") + model_id = entry.get("modelID") + else: + role = row_type + model_info = entry.get("model") or {} + provider = model_info.get("providerID") + model_id = model_info.get("id") # Exact match: opencode provider ids are free-form, and a custom # "anthropic-proxy" gateway is not this subscription. - if not isinstance(entry, dict) or entry.get("role") != "assistant": - continue - if str(entry.get("providerID") or "") != "anthropic": + if role != "assistant" or str(provider or "") != "anthropic": continue tokens = entry.get("tokens") or {} cache = tokens.get("cache") or {} @@ -497,10 +517,10 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None: created = number((entry.get("time") or {}).get("created")) day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today - model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1] + model = str(model_id or "claude").rstrip("/").split("/")[-1] except Exception: continue - session_key = "opencode:" + str(session_id) + session_key = session_prefix + str(session_id) sessions.add(session_key) active_days.add(day) prompts += 1 diff --git a/test/shell.d/agent-usage-claude-scanner-test.sh b/test/shell.d/agent-usage-claude-scanner-test.sh index e1407f3cf2e..c84c3f44542 100644 --- a/test/shell.d/agent-usage-claude-scanner-test.sh +++ b/test/shell.d/agent-usage-claude-scanner-test.sh @@ -103,10 +103,66 @@ pass "Claude collector counts Anthropic usage, reasoning included, from opencode fail "Claude collector ignores prefix-colliding providers, user messages, and malformed rows" "$result" pass "Claude collector ignores prefix-colliding providers, user messages, and malformed rows" +# opencode v2 writes session_message instead of message: the role is its own +# column and the provider and model nest under data.model. An upgraded +# database keeps its v1 rows, so both tables count. +OPENCODE_V2_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME" "$HISTORY_HOME" "$OPENCODE_HOME" "$OPENCODE_V2_HOME"' EXIT + +python3 - "$OPENCODE_V2_HOME/.local/share/opencode/opencode.db" <<'PY' +import json +import sqlite3 +import sys +import time +from pathlib import Path + +db = Path(sys.argv[1]) +db.parent.mkdir(parents=True, exist_ok=True) +conn = sqlite3.connect(db) +now_ms = int(time.time() * 1000) +conn.execute("CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)") +conn.execute("INSERT INTO message VALUES ('msg_1', 'ses_1', ?, ?, ?)", (now_ms, now_ms, json.dumps({ + "role": "assistant", "providerID": "anthropic", "modelID": "claude-opus-5", + "tokens": {"input": 1, "output": 1, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "time": {"created": now_ms}, +}))) +conn.execute("""CREATE TABLE session_message ( + id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, + seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)""") + +def v2_message(id, provider, model, type="assistant", input=0, output=0, reasoning=0, read=0, write=0): + return (id, "ses_v2", type, 1, now_ms, now_ms, json.dumps({ + "model": {"id": model, "providerID": provider, "variant": "xhigh"}, + "tokens": {"input": input, "output": output, "reasoning": reasoning, "cache": {"read": read, "write": write}}, + "time": {"created": now_ms, "completed": now_ms}, + })) + +conn.executemany("INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)", [ + v2_message("v_1", "anthropic", "claude-opus-5-5", input=4, output=96, reasoning=775, write=50180), + v2_message("v_2", "anthropic", "claude-opus-5-5", type="user", input=999), + v2_message("v_3", "anthropic-proxy", "claude-opus-5-5", input=999), + v2_message("v_4", "openai", "gpt-5.6", input=999), +]) +conn.execute("INSERT INTO session_message VALUES ('v_5', 'ses_v2', 'assistant', 1, ?, ?, 'not json')", (now_ms, now_ms)) +conn.commit() +conn.close() +PY + +result=$(HOME="$OPENCODE_V2_HOME" XDG_CACHE_HOME="$OPENCODE_V2_HOME/.cache" XDG_DATA_HOME="$OPENCODE_V2_HOME/.local/share" \ + "$ROOT/bin/omarchy-agent-usage-claude" --force) + +[[ $(jq -r '(.todayTotalTokens|tostring) + "/" + (.todaySessions|tostring)' <<<"$result") == "51057/2" ]] || + fail "Claude collector counts Anthropic usage from opencode v2 sessions" "$result" +pass "Claude collector counts Anthropic usage from opencode v2 sessions" + +[[ $(jq -c '.modelUsage["claude-opus-5-5"]' <<<"$result") == '{"cacheCreationInputTokens":50180,"cacheReadInputTokens":0,"inputTokens":4,"outputTokens":871}' ]] || + fail "Claude collector reads the model from opencode v2's nested model" "$result" +pass "Claude collector reads the model from opencode v2's nested model" + # Pi and omp can both spend a Claude subscription without writing native # Claude Code transcripts. Their compatible JSONL sessions must be included. PI_HOME=$(mktemp -d) -trap 'rm -rf "$TEST_HOME" "$HISTORY_HOME" "$OPENCODE_HOME" "$PI_HOME"' EXIT +trap 'rm -rf "$TEST_HOME" "$HISTORY_HOME" "$OPENCODE_HOME" "$OPENCODE_V2_HOME" "$PI_HOME"' EXIT mkdir -p "$PI_HOME/.pi/agent/sessions/project" "$PI_HOME/.omp/agent/sessions/project" cat >"$PI_HOME/.pi/agent/sessions/project/pi.jsonl" < Date: Wed, 30 Sep 2026 10:17:22 -0700 Subject: [PATCH 2/2] Cover a v2-only opencode database in the Claude collector test Co-Authored-By: Claude Opus 5.5 --- test/shell.d/agent-usage-claude-scanner-test.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/shell.d/agent-usage-claude-scanner-test.sh b/test/shell.d/agent-usage-claude-scanner-test.sh index c84c3f44542..701c8df5cd8 100644 --- a/test/shell.d/agent-usage-claude-scanner-test.sh +++ b/test/shell.d/agent-usage-claude-scanner-test.sh @@ -159,6 +159,17 @@ pass "Claude collector counts Anthropic usage from opencode v2 sessions" fail "Claude collector reads the model from opencode v2's nested model" "$result" pass "Claude collector reads the model from opencode v2's nested model" +# A fresh v2 install has no legacy message table at all. +python3 -c 'import sqlite3, sys; c = sqlite3.connect(sys.argv[1]); c.execute("DROP TABLE message"); c.commit()' \ + "$OPENCODE_V2_HOME/.local/share/opencode/opencode.db" + +result=$(HOME="$OPENCODE_V2_HOME" XDG_CACHE_HOME="$OPENCODE_V2_HOME/.cache" XDG_DATA_HOME="$OPENCODE_V2_HOME/.local/share" \ + "$ROOT/bin/omarchy-agent-usage-claude" --force) + +[[ $(jq -r '(.todayTotalTokens|tostring) + "/" + (.todaySessions|tostring)' <<<"$result") == "51055/1" ]] || + fail "Claude collector counts a v2-only opencode database" "$result" +pass "Claude collector counts a v2-only opencode database" + # Pi and omp can both spend a Claude subscription without writing native # Claude Code transcripts. Their compatible JSONL sessions must be included. PI_HOME=$(mktemp -d)