opencode-dashboard-server 0.1.0__tar.gz
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.
- opencode_dashboard_server-0.1.0/PKG-INFO +8 -0
- opencode_dashboard_server-0.1.0/aggregate.py +228 -0
- opencode_dashboard_server-0.1.0/app.py +144 -0
- opencode_dashboard_server-0.1.0/db.py +65 -0
- opencode_dashboard_server-0.1.0/opencode_dashboard_server.egg-info/PKG-INFO +8 -0
- opencode_dashboard_server-0.1.0/opencode_dashboard_server.egg-info/SOURCES.txt +11 -0
- opencode_dashboard_server-0.1.0/opencode_dashboard_server.egg-info/dependency_links.txt +1 -0
- opencode_dashboard_server-0.1.0/opencode_dashboard_server.egg-info/requires.txt +2 -0
- opencode_dashboard_server-0.1.0/opencode_dashboard_server.egg-info/top_level.txt +3 -0
- opencode_dashboard_server-0.1.0/pyproject.toml +29 -0
- opencode_dashboard_server-0.1.0/setup.cfg +4 -0
- opencode_dashboard_server-0.1.0/tests/test_aggregate.py +116 -0
- opencode_dashboard_server-0.1.0/tests/test_app.py +48 -0
|
@@ -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,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
|
|
@@ -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()
|
|
@@ -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,11 @@
|
|
|
1
|
+
aggregate.py
|
|
2
|
+
app.py
|
|
3
|
+
db.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
opencode_dashboard_server.egg-info/PKG-INFO
|
|
6
|
+
opencode_dashboard_server.egg-info/SOURCES.txt
|
|
7
|
+
opencode_dashboard_server.egg-info/dependency_links.txt
|
|
8
|
+
opencode_dashboard_server.egg-info/requires.txt
|
|
9
|
+
opencode_dashboard_server.egg-info/top_level.txt
|
|
10
|
+
tests/test_aggregate.py
|
|
11
|
+
tests/test_app.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "opencode-dashboard-server"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "FastAPI aggregator over opencode's SQLite storage"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.115,<1",
|
|
12
|
+
"uvicorn>=0.30,<1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/GCS-ZHN/opencode-dashboard"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
py-modules = ["app", "aggregate", "db"]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"httpx>=0.27,<1",
|
|
24
|
+
"pytest>=8,<10",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Assert-based tests for the aggregation logic, driven via SqliteRunner."""
|
|
2
|
+
|
|
3
|
+
from aggregate import (
|
|
4
|
+
normalize_model,
|
|
5
|
+
overview,
|
|
6
|
+
project_detail,
|
|
7
|
+
projects,
|
|
8
|
+
session_detail,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
ZERO_TOKENS = {"input": 0, "output": 0, "reasoning": 0,
|
|
12
|
+
"cacheRead": 0, "cacheWrite": 0, "total": 0}
|
|
13
|
+
|
|
14
|
+
DIR_DASH = "dir:" + b"/Users/test/projects/opencode-dashboard".hex()
|
|
15
|
+
DIR_TMP = "dir:" + b"/private/tmp".hex()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_overview_rollup(runner):
|
|
19
|
+
o = overview(runner)
|
|
20
|
+
assert o["projectCount"] == 5
|
|
21
|
+
assert o["sessionCount"] == 8
|
|
22
|
+
assert o["mainSessionCount"] == 7 # s3 is the only subagent
|
|
23
|
+
assert o["cost"] == 7.4
|
|
24
|
+
assert o["tokens"] == {"input": 4100, "output": 1980, "reasoning": 900,
|
|
25
|
+
"cacheRead": 350, "cacheWrite": 173, "total": 7503}
|
|
26
|
+
assert o["updatedAt"] == 190000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_projects_ordered_by_cost_desc(runner):
|
|
30
|
+
ps = projects(runner)
|
|
31
|
+
assert [p["id"] for p in ps] == [
|
|
32
|
+
"proj-ccc", "proj-aaa", "proj-bbb", DIR_DASH, DIR_TMP,
|
|
33
|
+
]
|
|
34
|
+
p1 = ps[1]
|
|
35
|
+
assert p1["name"] == "dash"
|
|
36
|
+
assert p1["worktree"] == "/Users/test/projects/dash"
|
|
37
|
+
assert p1["sessionCount"] == 3
|
|
38
|
+
assert p1["mainSessionCount"] == 2 # s1, s2 roots; s3 is s1's subagent
|
|
39
|
+
assert p1["cost"] == 2.7
|
|
40
|
+
assert p1["tokens"]["input"] == 1400
|
|
41
|
+
assert p1["tokens"]["total"] == 1400 + 650 + 270 + 115 + 57
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_global_catchall_sessions_split_by_directory(runner):
|
|
45
|
+
ps = {p["id"]: p for p in projects(runner)}
|
|
46
|
+
dash = ps[DIR_DASH]
|
|
47
|
+
tmp = ps[DIR_TMP]
|
|
48
|
+
assert dash["name"] == "opencode-dashboard"
|
|
49
|
+
assert dash["sessionCount"] == 1 # only s8, not the tmp/home sessions
|
|
50
|
+
assert dash["cost"] == 0.2
|
|
51
|
+
assert tmp["name"] == "tmp"
|
|
52
|
+
assert tmp["sessionCount"] == 1 # only s7
|
|
53
|
+
assert tmp["cost"] == 0.1
|
|
54
|
+
assert "global" not in ps # catch-all bucket itself is never shown as a project
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_global_dir_group_detail(runner):
|
|
58
|
+
proj, sessions = project_detail(runner, DIR_TMP)
|
|
59
|
+
assert proj["name"] == "tmp"
|
|
60
|
+
assert [s["id"] for s in sessions] == ["s7"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_project_detail_flat_sessions(runner):
|
|
64
|
+
proj, sessions = project_detail(runner, "proj-aaa")
|
|
65
|
+
assert proj["name"] == "dash"
|
|
66
|
+
assert [s["id"] for s in sessions] == ["s1", "s2", "s3"]
|
|
67
|
+
by_id = {s["id"]: s for s in sessions}
|
|
68
|
+
assert by_id["s1"]["parentId"] is None
|
|
69
|
+
assert by_id["s3"]["parentId"] == "s1"
|
|
70
|
+
assert by_id["s1"]["model"] == "deepseek-v4-flash"
|
|
71
|
+
assert by_id["s1"]["tokens"]["total"] == 1000 + 500 + 200 + 100 + 50
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_session_detail_model_breakdown(runner):
|
|
75
|
+
sess, models = session_detail(runner, "s4")
|
|
76
|
+
assert sess["cost"] == 0.9
|
|
77
|
+
assert [m["model"] for m in models] == ["deepseek-v4-flash", "claude-sonnet-4.5"]
|
|
78
|
+
flash = models[0]
|
|
79
|
+
assert flash["provider"] == "deepseek"
|
|
80
|
+
assert flash["mode"] == "build"
|
|
81
|
+
assert flash["messageCount"] == 2
|
|
82
|
+
assert flash["tokens"] == {"input": 200, "output": 100, "reasoning": 50,
|
|
83
|
+
"cacheRead": 20, "cacheWrite": 10, "total": 380}
|
|
84
|
+
assert flash["cost"] == 0.6
|
|
85
|
+
claude = models[1]
|
|
86
|
+
assert claude["messageCount"] == 1
|
|
87
|
+
assert claude["tokens"] == {"input": 200, "output": 100, "reasoning": 50,
|
|
88
|
+
"cacheRead": 0, "cacheWrite": 0, "total": 350}
|
|
89
|
+
assert claude["cost"] == 0.3
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_zero_token_session_is_zeros_not_null(runner):
|
|
93
|
+
sess, models = session_detail(runner, "s5")
|
|
94
|
+
assert sess["cost"] == 0.0
|
|
95
|
+
assert sess["tokens"] == ZERO_TOKENS
|
|
96
|
+
assert len(models) == 1
|
|
97
|
+
assert models[0]["tokens"] == ZERO_TOKENS
|
|
98
|
+
assert models[0]["cost"] == 0.0
|
|
99
|
+
assert models[0]["messageCount"] == 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_session_without_messages_has_empty_models(runner):
|
|
103
|
+
sess, models = session_detail(runner, "s6")
|
|
104
|
+
assert sess["model"] == "deepseek-v4-pro" # provider prefix stripped
|
|
105
|
+
assert models == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_normalize_model():
|
|
109
|
+
assert normalize_model("deepseek/deepseek-v4-flash") == "deepseek-v4-flash"
|
|
110
|
+
assert normalize_model("deepseek-v4-flash") == "deepseek-v4-flash"
|
|
111
|
+
assert normalize_model(None) is None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_unknown_ids_return_none(runner):
|
|
115
|
+
assert project_detail(runner, "nope") is None
|
|
116
|
+
assert session_detail(runner, "nope") is None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""HTTP-level smoke tests + the SQL-inlining boundary (CliRunner's only injection
|
|
2
|
+
surface). Driven via create_app(SqliteRunner) over the in-memory fixture, so no
|
|
3
|
+
`opencode db` CLI spawn happens."""
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
import app as appmod
|
|
10
|
+
from db import SqliteRunner, _inline
|
|
11
|
+
from tests.conftest import SCHEMA, seed
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_client() -> TestClient:
|
|
15
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
16
|
+
conn.executescript(SCHEMA)
|
|
17
|
+
seed(conn)
|
|
18
|
+
return TestClient(appmod.create_app(SqliteRunner(conn))), conn
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_inline_quotes_and_escapes():
|
|
22
|
+
sql = _inline("WHERE id = ? AND cost > ?", ("a'b\";\nDROP", 5))
|
|
23
|
+
assert sql == "WHERE id = 'a''b\";\nDROP' AND cost > 5"
|
|
24
|
+
assert 'DROP' in sql # payload stays inside one quoted literal
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_unknown_project_404():
|
|
28
|
+
c, _ = make_client()
|
|
29
|
+
r = c.get("/projects/nope")
|
|
30
|
+
assert r.status_code == 404
|
|
31
|
+
assert r.json() == {"detail": "project nope not found"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_overview_keys():
|
|
35
|
+
c, _ = make_client()
|
|
36
|
+
r = c.get("/overview")
|
|
37
|
+
assert r.status_code == 200
|
|
38
|
+
assert {"host", "opencodeVersion", "projectCount", "sessionCount",
|
|
39
|
+
"tokens", "cost", "updatedAt"} <= set(r.json())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_foreign_origin_not_allowed_by_cors():
|
|
43
|
+
c, _ = make_client()
|
|
44
|
+
r = c.options("/projects", headers={
|
|
45
|
+
"Origin": "https://evil.example",
|
|
46
|
+
"Access-Control-Request-Method": "GET",
|
|
47
|
+
})
|
|
48
|
+
assert "access-control-allow-origin" not in r.headers
|