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.
@@ -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,2 @@
1
+ fastapi<1,>=0.115
2
+ uvicorn<1,>=0.30
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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