opencode-dashboard-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aggregate.py ADDED
@@ -0,0 +1,228 @@
1
+ """SQL + aggregation over the opencode storage, mirroring the proven logic in
2
+ ~/Downloads/export_token_usage.py.
3
+
4
+ Project/session rollups come from the `session` table's denormalized token/cost
5
+ columns; per-model breakdowns aggregate `message.data` JSON. All values are
6
+ returned as integers/floats, never null (missing = 0).
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ # token keys as emitted in API output -> session table column
13
+ TOKEN_COLS = ("input", "output", "reasoning", "cache_read", "cache_write")
14
+ SESS_COL = {
15
+ "input": "tokens_input",
16
+ "output": "tokens_output",
17
+ "reasoning": "tokens_reasoning",
18
+ "cache_read": "tokens_cache_read",
19
+ "cache_write": "tokens_cache_write",
20
+ }
21
+
22
+ # column order of the per-message TSV pull (must match MESSAGES_SQL select list)
23
+ _MSG_KEYS = [
24
+ "model_id", "provider", "mode", "message_count",
25
+ "tokens_input", "tokens_output", "tokens_reasoning",
26
+ "tokens_cache_read", "tokens_cache_write", "cost",
27
+ ]
28
+
29
+ # Effective grouping key: real projects (worktree != "/") group by project_id;
30
+ # the catch-all "global" project (worktree "/") holds sessions from many folders,
31
+ # so split it by session.directory — otherwise unrelated sessions get misattributed.
32
+ # Directory groups use hex(directory) so the id is URL-safe (a raw path contains
33
+ # "/" which would split the FastAPI route segment and 404).
34
+ EFF_SQL = """
35
+ CASE WHEN p.worktree IS NOT NULL AND p.worktree != '/' THEN s.project_id
36
+ ELSE 'dir:' || lower(hex(COALESCE(s.directory, ''))) END
37
+ """
38
+
39
+ # Join + effective-key mapping live in a derived table so `id`/`worktree` are
40
+ # unambiguous result columns (s.id and p.id would otherwise clash in GROUP BY).
41
+ ROLLUP_SQL = f"""
42
+ SELECT gid AS id,
43
+ MAX(worktree) AS worktree,
44
+ COUNT(*) AS session_count,
45
+ COUNT(DISTINCT CASE
46
+ WHEN eff.parent_id IS NULL
47
+ OR NOT EXISTS (SELECT 1 FROM session p2 WHERE p2.id = eff.parent_id)
48
+ THEN eff.sid END) AS main_session_count,
49
+ COUNT(DISTINCT gid) AS project_count,
50
+ COALESCE(SUM(cost), 0) AS cost,
51
+ COALESCE(SUM(tokens_input), 0) AS tokens_input,
52
+ COALESCE(SUM(tokens_output), 0) AS tokens_output,
53
+ COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning,
54
+ COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read,
55
+ COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write
56
+ FROM (
57
+ SELECT s.project_id, s.id AS sid, s.parent_id, s.cost, s.tokens_input,
58
+ s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
59
+ s.tokens_cache_write,
60
+ {EFF_SQL} AS gid,
61
+ CASE WHEN p.worktree IS NOT NULL AND p.worktree != '/' THEN p.worktree
62
+ ELSE s.directory END AS worktree
63
+ FROM session s
64
+ LEFT JOIN project p ON p.id = s.project_id
65
+ ) AS eff
66
+ """
67
+
68
+ SESSIONS_SQL = """
69
+ SELECT id, parent_id, project_id, title, agent, model, cost,
70
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
71
+ tokens_cache_write, time_created, time_updated
72
+ FROM session
73
+ """
74
+
75
+ PROJECT_SESSIONS_SQL = f"""
76
+ SELECT s.id, s.parent_id, s.project_id, s.title, s.agent, s.model, s.cost,
77
+ s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
78
+ s.tokens_cache_write, s.time_created, s.time_updated
79
+ FROM session s
80
+ LEFT JOIN project p ON p.id = s.project_id
81
+ WHERE {EFF_SQL} = ?
82
+ ORDER BY s.cost DESC, s.id ASC
83
+ """
84
+
85
+ MESSAGES_SQL = """
86
+ SELECT json_extract(data, '$.modelID') AS model_id,
87
+ json_extract(data, '$.providerID') AS provider,
88
+ json_extract(data, '$.mode') AS mode,
89
+ COUNT(*) AS message_count,
90
+ COALESCE(SUM(json_extract(data, '$.tokens.input')), 0) AS tokens_input,
91
+ COALESCE(SUM(json_extract(data, '$.tokens.output')), 0) AS tokens_output,
92
+ COALESCE(SUM(json_extract(data, '$.tokens.reasoning')), 0) AS tokens_reasoning,
93
+ COALESCE(SUM(json_extract(data, '$.tokens.cache.read')), 0) AS tokens_cache_read,
94
+ COALESCE(SUM(json_extract(data, '$.tokens.cache.write')), 0) AS tokens_cache_write,
95
+ COALESCE(SUM(json_extract(data, '$.cost')), 0) AS cost
96
+ FROM message
97
+ WHERE json_extract(data, '$.role') = 'assistant'
98
+ AND json_extract(data, '$.tokens') IS NOT NULL
99
+ """
100
+
101
+
102
+ def normalize_model(mid) -> str | None:
103
+ """Strip a provider prefix if present: deepseek/deepseek-v4-flash -> deepseek-v4-flash."""
104
+ if not mid:
105
+ return mid
106
+ return str(mid).rsplit("/", 1)[-1]
107
+
108
+
109
+ def session_model_id(raw) -> str | None:
110
+ """`session.model` is a JSON object string like
111
+ {"id":"deepseek-v4-flash","providerID":"deepseek",...} — extract the id.
112
+ Falls back to the raw string (older/plain values) with the prefix stripped."""
113
+ if not raw:
114
+ return None
115
+ s = str(raw).strip()
116
+ if s.startswith("{"):
117
+ try:
118
+ return normalize_model(json.loads(s).get("id") or None)
119
+ except ValueError:
120
+ pass
121
+ return normalize_model(s)
122
+
123
+
124
+ def tokens(row) -> dict:
125
+ """Build the API tokens object from a row carrying tokens_* columns."""
126
+ t = {k: int(row.get(SESS_COL[k]) or 0) for k in TOKEN_COLS}
127
+ return {
128
+ "input": t["input"],
129
+ "output": t["output"],
130
+ "reasoning": t["reasoning"],
131
+ "cacheRead": t["cache_read"],
132
+ "cacheWrite": t["cache_write"],
133
+ "total": sum(t.values()),
134
+ }
135
+
136
+
137
+ def _project(r) -> dict:
138
+ worktree = r["worktree"] or ""
139
+ name = Path(worktree).name or "(unknown)"
140
+ return {
141
+ "id": r["id"],
142
+ "name": name,
143
+ "worktree": worktree,
144
+ "sessionCount": int(r["session_count"] or 0),
145
+ "mainSessionCount": int(r["main_session_count"] or 0),
146
+ "tokens": tokens(r),
147
+ "cost": round(float(r["cost"] or 0), 6),
148
+ }
149
+
150
+
151
+ def _session(r) -> dict:
152
+ return {
153
+ "id": r["id"],
154
+ "parentId": r["parent_id"],
155
+ "projectId": r["project_id"],
156
+ "title": r["title"],
157
+ "agent": r["agent"],
158
+ "model": session_model_id(r["model"]),
159
+ "timeCreated": int(r["time_created"] or 0),
160
+ "timeUpdated": int(r["time_updated"] or 0),
161
+ "tokens": tokens(r),
162
+ "cost": round(float(r["cost"] or 0), 6),
163
+ }
164
+
165
+
166
+ def updated_at(runner) -> int:
167
+ """max time_updated across session + message (stream poll signal)."""
168
+ row = runner.query(
169
+ "SELECT MAX(m) AS m FROM ("
170
+ " SELECT MAX(time_updated) AS m FROM session"
171
+ " UNION ALL"
172
+ " SELECT MAX(time_updated) AS m FROM message)"
173
+ )[0]
174
+ return int(row["m"] or 0)
175
+
176
+
177
+ def overview(runner) -> dict:
178
+ r = runner.query(ROLLUP_SQL)[0]
179
+ return {
180
+ "projectCount": int(r["project_count"] or 0),
181
+ "sessionCount": int(r["session_count"] or 0),
182
+ "mainSessionCount": int(r["main_session_count"] or 0),
183
+ "tokens": tokens(r),
184
+ "cost": round(float(r["cost"] or 0), 6),
185
+ "updatedAt": updated_at(runner),
186
+ }
187
+
188
+
189
+ def projects(runner) -> list[dict]:
190
+ rows = runner.query(
191
+ ROLLUP_SQL + " GROUP BY id, worktree"
192
+ " ORDER BY cost DESC, id ASC"
193
+ )
194
+ return [_project(r) for r in rows]
195
+
196
+
197
+ def project_detail(runner, project_id) -> tuple[dict, list[dict]] | None:
198
+ rows = runner.query(
199
+ ROLLUP_SQL + " GROUP BY id, worktree HAVING id = ?",
200
+ (project_id,),
201
+ )
202
+ if not rows:
203
+ return None
204
+ sessions = runner.query(PROJECT_SESSIONS_SQL, (project_id,))
205
+ return _project(rows[0]), [_session(r) for r in sessions]
206
+
207
+
208
+ def session_detail(runner, session_id) -> tuple[dict, list[dict]] | None:
209
+ rows = runner.query(SESSIONS_SQL + " WHERE id = ?", (session_id,))
210
+ if not rows:
211
+ return None
212
+ msg_rows = runner.query_tsv(
213
+ MESSAGES_SQL + " AND session_id = ? GROUP BY model_id, provider, mode",
214
+ (session_id,),
215
+ )
216
+ models = []
217
+ for row in msg_rows:
218
+ d = dict(zip(_MSG_KEYS, row))
219
+ models.append({
220
+ "model": normalize_model(d["model_id"]),
221
+ "provider": d["provider"],
222
+ "mode": d["mode"],
223
+ "messageCount": int(d["message_count"] or 0),
224
+ "tokens": tokens(d),
225
+ "cost": round(float(d["cost"] or 0), 6),
226
+ })
227
+ models.sort(key=lambda m: (-m["cost"], m["model"] or ""))
228
+ return _session(rows[0]), models
app.py ADDED
@@ -0,0 +1,144 @@
1
+ """FastAPI app implementing the API.md contract over the opencode aggregator.
2
+
3
+ Run: uv run uvicorn app:app --reload
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import socket
10
+ import subprocess
11
+ from functools import lru_cache
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import StreamingResponse
16
+
17
+ import aggregate
18
+ from db import CliRunner
19
+
20
+ logger = logging.getLogger("dashboard")
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def opencode_version() -> str:
25
+ try:
26
+ return subprocess.run(
27
+ ["opencode", "--version"], capture_output=True, text=True, check=True
28
+ ).stdout.strip()
29
+ except (OSError, subprocess.CalledProcessError):
30
+ return "unknown" # e.g. CI without the opencode CLI; don't fail the request
31
+
32
+
33
+ def create_app(runner=None) -> FastAPI:
34
+ runner = runner or CliRunner()
35
+ app = FastAPI(title="opencode token dashboard")
36
+ # Loopback-only API; restrict origins so a random webpage can't exfiltrate
37
+ # local project/session data from the browser (the client runs from Vite).
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=[
41
+ "http://localhost:5173", "http://127.0.0.1:5173",
42
+ "http://localhost:4173", "http://127.0.0.1:4173",
43
+ ],
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+ def handle(fn):
49
+ try:
50
+ return fn()
51
+ except HTTPException:
52
+ raise
53
+ except Exception:
54
+ logger.exception("aggregation failed")
55
+ raise HTTPException(500, detail="aggregation failed")
56
+
57
+ @app.get("/health")
58
+ def health():
59
+ return {"status": "ok", "version": opencode_version()}
60
+
61
+ @app.get("/overview")
62
+ def overview():
63
+ def run():
64
+ data = aggregate.overview(runner)
65
+ data["host"] = socket.gethostname()
66
+ data["opencodeVersion"] = opencode_version()
67
+ return data
68
+
69
+ return handle(run)
70
+
71
+ @app.get("/projects")
72
+ def projects():
73
+ return handle(lambda: aggregate.projects(runner))
74
+
75
+ @app.get("/projects/{project_id}")
76
+ def project(project_id: str):
77
+ def run():
78
+ res = aggregate.project_detail(runner, project_id)
79
+ if res is None:
80
+ raise HTTPException(404, detail=f"project {project_id} not found")
81
+ proj, sessions = res
82
+ return {"project": proj, "sessions": sessions}
83
+
84
+ return handle(run)
85
+
86
+ @app.get("/sessions/{session_id}")
87
+ def session(session_id: str):
88
+ def run():
89
+ res = aggregate.session_detail(runner, session_id)
90
+ if res is None:
91
+ raise HTTPException(404, detail=f"session {session_id} not found")
92
+ sess, models = res
93
+ return {"session": sess, "models": models}
94
+
95
+ return handle(run)
96
+
97
+ @app.get("/stream")
98
+ async def stream():
99
+ q: asyncio.Queue = asyncio.Queue()
100
+ stop = asyncio.Event()
101
+
102
+ async def poll():
103
+ loop = asyncio.get_running_loop()
104
+ last = None
105
+ ticks = 0
106
+ while not stop.is_set():
107
+ try:
108
+ ts = await loop.run_in_executor(None, aggregate.updated_at, runner)
109
+ if ts and ts != last:
110
+ last = ts
111
+ await q.put({"type": "updated", "at": ts, "scope": "overview"})
112
+ else:
113
+ ticks += 1
114
+ if ticks % 3 == 0: # heartbeat every ~15s (poll = 5s)
115
+ await q.put({"type": "heartbeat"})
116
+ except Exception:
117
+ logger.exception("stream poll failed")
118
+ try:
119
+ await asyncio.wait_for(stop.wait(), 5)
120
+ except asyncio.TimeoutError:
121
+ pass
122
+
123
+ task = asyncio.create_task(poll())
124
+ await q.put({"type": "heartbeat"})
125
+
126
+ async def gen():
127
+ try:
128
+ while True:
129
+ payload = await q.get()
130
+ data = json.dumps(payload)
131
+ if payload["type"] == "updated":
132
+ yield f"event: update\ndata: {data}\n\n"
133
+ else:
134
+ yield f"data: {data}\n\n"
135
+ finally:
136
+ stop.set()
137
+ task.cancel()
138
+
139
+ return StreamingResponse(gen(), media_type="text/event-stream")
140
+
141
+ return app
142
+
143
+
144
+ app = create_app()
db.py ADDED
@@ -0,0 +1,65 @@
1
+ """DB access layer for the opencode storage.
2
+
3
+ Two interchangeable runners over the same SQL surface:
4
+
5
+ - `CliRunner`: shells out to `opencode db "<SQL>" --format json|tsv`. JSON mode
6
+ yields typed numbers; TSV is used for the big `message` pulls (JSON output
7
+ blows up on tens of thousands of rows). Never opens the sqlite file directly
8
+ (opencode keeps it in WAL mode).
9
+ - `SqliteRunner`: executes the same SQL on a `sqlite3.Connection` — used by
10
+ tests against an in-memory fixture replicating the opencode schema.
11
+ """
12
+
13
+ import json
14
+ import subprocess
15
+
16
+
17
+ def _inline(sql: str, params: tuple) -> str:
18
+ """Inline bound params into SQL as safely-quoted literals (CliRunner has no
19
+ parameter binding; ids come from URL paths)."""
20
+ for p in params:
21
+ if p is None:
22
+ rep = "NULL"
23
+ elif isinstance(p, (int, float)):
24
+ rep = repr(p)
25
+ else:
26
+ rep = "'" + str(p).replace("'", "''") + "'"
27
+ sql = sql.replace("?", rep, 1)
28
+ return sql
29
+
30
+
31
+ class CliRunner:
32
+ def __init__(self, executable: str = "opencode"):
33
+ self._cmd = [executable, "db"]
34
+
35
+ def query(self, sql: str, params: tuple = ()) -> list[dict]:
36
+ raw = subprocess.run(
37
+ [*self._cmd, _inline(sql, params), "--format", "json"],
38
+ capture_output=True, text=True, check=True,
39
+ ).stdout
40
+ return json.loads(raw) if raw.strip() else []
41
+
42
+ def query_tsv(self, sql: str, params: tuple = ()) -> list[list[str]]:
43
+ raw = subprocess.run(
44
+ [*self._cmd, _inline(sql, params), "--format", "tsv"],
45
+ capture_output=True, text=True, check=True,
46
+ ).stdout
47
+ lines = raw.strip().split("\n")
48
+ return [line.split("\t") for line in lines[1:]] # skip header row
49
+
50
+
51
+ class SqliteRunner:
52
+ def __init__(self, conn):
53
+ self._conn = conn
54
+
55
+ def query(self, sql: str, params: tuple = ()) -> list[dict]:
56
+ cur = self._conn.execute(sql, params)
57
+ cols = [d[0] for d in cur.description]
58
+ return [dict(zip(cols, row)) for row in cur.fetchall()]
59
+
60
+ def query_tsv(self, sql: str, params: tuple = ()) -> list[list[str]]:
61
+ cur = self._conn.execute(sql, params)
62
+ return [
63
+ ["" if v is None else v if isinstance(v, str) else str(v) for v in row]
64
+ for row in cur.fetchall()
65
+ ]
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencode-dashboard-server
3
+ Version: 0.1.0
4
+ Summary: FastAPI aggregator over opencode's SQLite storage
5
+ Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: fastapi<1,>=0.115
8
+ Requires-Dist: uvicorn<1,>=0.30
@@ -0,0 +1,7 @@
1
+ aggregate.py,sha256=Cs6gFXuvU0PlY5M00RZ6HFx7ixV_93OBKFs27fRiv0Q,8172
2
+ app.py,sha256=DGuHoA164G_gUTwjrPHrTRDbPMEZH2P-nE0mINuA5Tk,4479
3
+ db.py,sha256=eH_8vx1fIQYyEWZbOKxcNrVbUlL5UVZPDrE3oLnDsPI,2329
4
+ opencode_dashboard_server-0.1.0.dist-info/METADATA,sha256=6Q87rzjqQSYAxuZ5bj_JEViqRsCGXv788t6lh-kJeXM,286
5
+ opencode_dashboard_server-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
6
+ opencode_dashboard_server-0.1.0.dist-info/top_level.txt,sha256=zK4nqGlbKrYB71bxRP5ajiYktUMu3ipchoU34diKyv4,17
7
+ opencode_dashboard_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ aggregate
2
+ app
3
+ db