opencode-dashboard-server 0.2.0__tar.gz → 0.4.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.2.0 → opencode_dashboard_server-0.4.0}/PKG-INFO +22 -3
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/README.md +19 -2
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/aggregate.py +42 -11
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/app.py +22 -13
- opencode_dashboard_server-0.4.0/cli.py +126 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/opencode_dashboard_server.egg-info/PKG-INFO +22 -3
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/opencode_dashboard_server.egg-info/SOURCES.txt +2 -0
- opencode_dashboard_server-0.4.0/opencode_dashboard_server.egg-info/entry_points.txt +2 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/opencode_dashboard_server.egg-info/requires.txt +2 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/opencode_dashboard_server.egg-info/top_level.txt +1 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/pyproject.toml +7 -2
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/tests/test_aggregate.py +36 -1
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/tests/test_app.py +22 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/db.py +0 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/opencode_dashboard_server.egg-info/dependency_links.txt +0 -0
- {opencode_dashboard_server-0.2.0 → opencode_dashboard_server-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-dashboard-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: FastAPI aggregator over opencode's SQLite storage
|
|
5
5
|
Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
|
|
6
6
|
Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
|
|
@@ -9,6 +9,8 @@ Requires-Python: >=3.10
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: fastapi<1,>=0.115
|
|
11
11
|
Requires-Dist: uvicorn<1,>=0.30
|
|
12
|
+
Requires-Dist: platformdirs<5,>=4
|
|
13
|
+
Requires-Dist: PyYAML<7,>=6
|
|
12
14
|
|
|
13
15
|
# opencode-dashboard-server
|
|
14
16
|
|
|
@@ -20,6 +22,8 @@ API for the dashboard front end.
|
|
|
20
22
|
|
|
21
23
|
```bash
|
|
22
24
|
pip install opencode-dashboard-server
|
|
25
|
+
# or with uv:
|
|
26
|
+
uv tool install opencode-dashboard-server
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
Requires:
|
|
@@ -30,14 +34,29 @@ Requires:
|
|
|
30
34
|
|
|
31
35
|
## Quick start
|
|
32
36
|
|
|
37
|
+
Configure once (interactive; writes `~/.config/opencode-dashboard/server.yaml`), then serve:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
opencode-dashboard-server configure
|
|
41
|
+
opencode-dashboard-server serve
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`configure` walks you through `port` (default `8791`), `host` (default `0.0.0.0`),
|
|
45
|
+
`cors_origins` (comma-separated; empty = built-in loopback whitelist), `poll_seconds`
|
|
46
|
+
(default `5`), and `opencode_bin` (default `opencode`). Empty input keeps the default.
|
|
47
|
+
|
|
48
|
+
`serve` accepts overrides: `--port N`, `--host H`, `--config PATH` (instead of the XDG file).
|
|
49
|
+
Precedence for port/host: CLI flag > env `PORT`/`HOST` > config file > default.
|
|
50
|
+
|
|
33
51
|
Run one aggregator per opencode host:
|
|
34
52
|
|
|
35
53
|
```bash
|
|
36
54
|
uvicorn app:app --port 8791
|
|
37
55
|
```
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
`dashboard.
|
|
57
|
+
or directly with uvicorn (still works — reads env/defaults only). Run on more hosts with
|
|
58
|
+
different ports (`8792`, `8793`, …) and list each in the front end's config (`opencode-dashboard configure`).
|
|
59
|
+
Environment switches (used when no config file value is set):
|
|
41
60
|
|
|
42
61
|
- `DASHBOARD_CORS_ORIGINS` — comma-separated CORS allow-list (defaults to the loopback dev origins `localhost/127.0.0.1:5173` and `:4173`; tighten or widen for your deployment).
|
|
43
62
|
- `DASHBOARD_POLL_SECONDS` — SSE poll interval in seconds (default `5`).
|
|
@@ -8,6 +8,8 @@ API for the dashboard front end.
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pip install opencode-dashboard-server
|
|
11
|
+
# or with uv:
|
|
12
|
+
uv tool install opencode-dashboard-server
|
|
11
13
|
```
|
|
12
14
|
|
|
13
15
|
Requires:
|
|
@@ -18,14 +20,29 @@ Requires:
|
|
|
18
20
|
|
|
19
21
|
## Quick start
|
|
20
22
|
|
|
23
|
+
Configure once (interactive; writes `~/.config/opencode-dashboard/server.yaml`), then serve:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
opencode-dashboard-server configure
|
|
27
|
+
opencode-dashboard-server serve
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`configure` walks you through `port` (default `8791`), `host` (default `0.0.0.0`),
|
|
31
|
+
`cors_origins` (comma-separated; empty = built-in loopback whitelist), `poll_seconds`
|
|
32
|
+
(default `5`), and `opencode_bin` (default `opencode`). Empty input keeps the default.
|
|
33
|
+
|
|
34
|
+
`serve` accepts overrides: `--port N`, `--host H`, `--config PATH` (instead of the XDG file).
|
|
35
|
+
Precedence for port/host: CLI flag > env `PORT`/`HOST` > config file > default.
|
|
36
|
+
|
|
21
37
|
Run one aggregator per opencode host:
|
|
22
38
|
|
|
23
39
|
```bash
|
|
24
40
|
uvicorn app:app --port 8791
|
|
25
41
|
```
|
|
26
42
|
|
|
27
|
-
|
|
28
|
-
`dashboard.
|
|
43
|
+
or directly with uvicorn (still works — reads env/defaults only). Run on more hosts with
|
|
44
|
+
different ports (`8792`, `8793`, …) and list each in the front end's config (`opencode-dashboard configure`).
|
|
45
|
+
Environment switches (used when no config file value is set):
|
|
29
46
|
|
|
30
47
|
- `DASHBOARD_CORS_ORIGINS` — comma-separated CORS allow-list (defaults to the loopback dev origins `localhost/127.0.0.1:5173` and `:4173`; tighten or widen for your deployment).
|
|
31
48
|
- `DASHBOARD_POLL_SECONDS` — SSE poll interval in seconds (default `5`).
|
|
@@ -205,6 +205,17 @@ def project_detail(runner, project_id) -> tuple[dict, list[dict]] | None:
|
|
|
205
205
|
return _project(rows[0]), [_session(r) for r in sessions]
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def _model_entry(d: dict) -> dict:
|
|
209
|
+
return {
|
|
210
|
+
"model": normalize_model(d["model_id"]),
|
|
211
|
+
"provider": d["provider"],
|
|
212
|
+
"mode": d["mode"],
|
|
213
|
+
"messageCount": int(d["message_count"] or 0),
|
|
214
|
+
"tokens": tokens(d),
|
|
215
|
+
"cost": round(float(d["cost"] or 0), 6),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
208
219
|
def session_detail(runner, session_id) -> tuple[dict, list[dict]] | None:
|
|
209
220
|
rows = runner.query(SESSIONS_SQL + " WHERE id = ?", (session_id,))
|
|
210
221
|
if not rows:
|
|
@@ -213,16 +224,36 @@ def session_detail(runner, session_id) -> tuple[dict, list[dict]] | None:
|
|
|
213
224
|
MESSAGES_SQL + " AND session_id = ? GROUP BY model_id, provider, mode",
|
|
214
225
|
(session_id,),
|
|
215
226
|
)
|
|
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 = [_model_entry(dict(zip(_MSG_KEYS, row))) for row in msg_rows]
|
|
227
228
|
models.sort(key=lambda m: (-m["cost"], m["model"] or ""))
|
|
228
229
|
return _session(rows[0]), models
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def models(runner) -> list[dict]:
|
|
233
|
+
"""Whole-host per-model rollup from message.data (same shape as the
|
|
234
|
+
per-session model breakdown, but aggregated across all sessions).
|
|
235
|
+
|
|
236
|
+
Rows are grouped by (model_id, provider, mode) so provider/mode labels are
|
|
237
|
+
accurate, then merged by display name so prefixed/unprefixed ids (e.g.
|
|
238
|
+
deepseek/deepseek-v4-flash vs deepseek-v4-flash) don't create duplicate
|
|
239
|
+
slices. On merge, the higher-cost row keeps its provider/mode label.
|
|
240
|
+
"""
|
|
241
|
+
msg_rows = runner.query_tsv(MESSAGES_SQL + " GROUP BY model_id, provider, mode")
|
|
242
|
+
merged: dict[str, dict] = {}
|
|
243
|
+
for row in msg_rows:
|
|
244
|
+
e = _model_entry(dict(zip(_MSG_KEYS, row)))
|
|
245
|
+
key = e["model"] or ""
|
|
246
|
+
cur = merged.get(key)
|
|
247
|
+
if cur is None:
|
|
248
|
+
merged[key] = e
|
|
249
|
+
else:
|
|
250
|
+
if e["cost"] > cur["cost"]:
|
|
251
|
+
cur["provider"] = e["provider"]
|
|
252
|
+
cur["mode"] = e["mode"]
|
|
253
|
+
cur["messageCount"] += e["messageCount"]
|
|
254
|
+
for k in ("input", "output", "reasoning", "cacheRead", "cacheWrite", "total"):
|
|
255
|
+
cur["tokens"][k] += e["tokens"][k]
|
|
256
|
+
cur["cost"] = round(cur["cost"] + e["cost"], 6)
|
|
257
|
+
ms = list(merged.values())
|
|
258
|
+
ms.sort(key=lambda m: (-m["cost"], m["model"] or ""))
|
|
259
|
+
return ms
|
|
@@ -22,7 +22,7 @@ logger = logging.getLogger("dashboard")
|
|
|
22
22
|
|
|
23
23
|
# Comma-separated CORS allow-list; defaults to the loopback dev origins. Tight
|
|
24
24
|
# by default so a random webpage can't exfiltrate local project/session data.
|
|
25
|
-
def
|
|
25
|
+
def default_cors_origins() -> list[str]:
|
|
26
26
|
env = os.environ.get("DASHBOARD_CORS_ORIGINS", "").strip()
|
|
27
27
|
if env:
|
|
28
28
|
return [o.strip() for o in env.split(",") if o.strip()]
|
|
@@ -32,27 +32,32 @@ def cors_origins() -> list[str]:
|
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@lru_cache(maxsize=1)
|
|
39
|
-
def opencode_version() -> str:
|
|
35
|
+
@lru_cache(maxsize=8)
|
|
36
|
+
def opencode_version(executable: str = "opencode") -> str:
|
|
40
37
|
try:
|
|
41
38
|
return subprocess.run(
|
|
42
|
-
[
|
|
39
|
+
[executable, "--version"], capture_output=True, text=True, check=True
|
|
43
40
|
).stdout.strip()
|
|
44
41
|
except (OSError, subprocess.CalledProcessError):
|
|
45
42
|
return "unknown" # e.g. CI without the opencode CLI; don't fail the request
|
|
46
43
|
|
|
47
44
|
|
|
48
|
-
def create_app(runner=None) -> FastAPI:
|
|
49
|
-
|
|
45
|
+
def create_app(runner=None, cors_origins=None, poll_seconds=None, opencode_bin=None) -> FastAPI:
|
|
46
|
+
bin = opencode_bin or os.environ.get("OPENCODE_BIN") or "opencode"
|
|
47
|
+
if runner is None:
|
|
48
|
+
runner = CliRunner(executable=bin)
|
|
49
|
+
origins = cors_origins if cors_origins is not None else default_cors_origins()
|
|
50
|
+
if poll_seconds is None:
|
|
51
|
+
try:
|
|
52
|
+
poll_seconds = float(os.environ.get("DASHBOARD_POLL_SECONDS", "5"))
|
|
53
|
+
except ValueError:
|
|
54
|
+
poll_seconds = 5.0
|
|
50
55
|
app = FastAPI(title="opencode token dashboard")
|
|
51
56
|
# Loopback-only API; restrict origins so a random webpage can't exfiltrate
|
|
52
57
|
# local project/session data from the browser (the client runs from Vite).
|
|
53
58
|
app.add_middleware(
|
|
54
59
|
CORSMiddleware,
|
|
55
|
-
allow_origins=
|
|
60
|
+
allow_origins=origins,
|
|
56
61
|
allow_methods=["*"],
|
|
57
62
|
allow_headers=["*"],
|
|
58
63
|
)
|
|
@@ -68,14 +73,14 @@ def create_app(runner=None) -> FastAPI:
|
|
|
68
73
|
|
|
69
74
|
@app.get("/health")
|
|
70
75
|
def health():
|
|
71
|
-
return {"status": "ok", "version": opencode_version()}
|
|
76
|
+
return {"status": "ok", "version": opencode_version(bin)}
|
|
72
77
|
|
|
73
78
|
@app.get("/overview")
|
|
74
79
|
def overview():
|
|
75
80
|
def run():
|
|
76
81
|
data = aggregate.overview(runner)
|
|
77
82
|
data["host"] = socket.gethostname()
|
|
78
|
-
data["opencodeVersion"] = opencode_version()
|
|
83
|
+
data["opencodeVersion"] = opencode_version(bin)
|
|
79
84
|
return data
|
|
80
85
|
|
|
81
86
|
return handle(run)
|
|
@@ -84,6 +89,10 @@ def create_app(runner=None) -> FastAPI:
|
|
|
84
89
|
def projects():
|
|
85
90
|
return handle(lambda: aggregate.projects(runner))
|
|
86
91
|
|
|
92
|
+
@app.get("/models")
|
|
93
|
+
def models():
|
|
94
|
+
return handle(lambda: aggregate.models(runner))
|
|
95
|
+
|
|
87
96
|
@app.get("/projects/{project_id}")
|
|
88
97
|
def project(project_id: str):
|
|
89
98
|
def run():
|
|
@@ -128,7 +137,7 @@ def create_app(runner=None) -> FastAPI:
|
|
|
128
137
|
except Exception:
|
|
129
138
|
logger.exception("stream poll failed")
|
|
130
139
|
try:
|
|
131
|
-
await asyncio.wait_for(stop.wait(),
|
|
140
|
+
await asyncio.wait_for(stop.wait(), poll_seconds)
|
|
132
141
|
except asyncio.TimeoutError:
|
|
133
142
|
pass
|
|
134
143
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""opencode-dashboard-server CLI: interactive configure + serve.
|
|
2
|
+
|
|
3
|
+
Run: uv tool install opencode-dashboard-server
|
|
4
|
+
opencode-dashboard-server configure
|
|
5
|
+
opencode-dashboard-server serve [--port N] [--host H] [--config PATH]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from platformdirs import user_config_path
|
|
15
|
+
|
|
16
|
+
from app import create_app
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def config_path() -> Path:
|
|
20
|
+
# Force XDG semantics so the backend shares ~/.config/opencode-dashboard
|
|
21
|
+
# with the front end; platformdirs would otherwise use ~/Library/Application
|
|
22
|
+
# Support on macOS. setdefault keeps a user-set XDG_CONFIG_HOME intact.
|
|
23
|
+
os.environ.setdefault("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
24
|
+
return user_config_path("opencode-dashboard") / "server.yaml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config(path: Path) -> dict:
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return {}
|
|
30
|
+
return yaml.safe_load(path.read_text()) or {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _prompt(label: str, current, default) -> str:
|
|
34
|
+
hint = current if current != "" else default
|
|
35
|
+
try:
|
|
36
|
+
value = input(f"{label} [{hint}]: ").strip()
|
|
37
|
+
except EOFError:
|
|
38
|
+
return hint
|
|
39
|
+
return value if value else hint
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prompt_num(label: str, current, default, conv):
|
|
43
|
+
while True:
|
|
44
|
+
try:
|
|
45
|
+
return conv(_prompt(label, current, default))
|
|
46
|
+
except ValueError:
|
|
47
|
+
print(f"Invalid {label}; enter a number.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_configure(args) -> None:
|
|
51
|
+
path = config_path()
|
|
52
|
+
existing = load_config(path)
|
|
53
|
+
cur = {
|
|
54
|
+
"port": existing.get("port", 8791),
|
|
55
|
+
"host": existing.get("host", "0.0.0.0"),
|
|
56
|
+
"cors_origins": existing.get("cors_origins", ""),
|
|
57
|
+
"poll_seconds": existing.get("poll_seconds", 5),
|
|
58
|
+
"opencode_bin": existing.get("opencode_bin", "opencode"),
|
|
59
|
+
}
|
|
60
|
+
if existing:
|
|
61
|
+
print(f"Current config at {path}:")
|
|
62
|
+
print(yaml.safe_dump(cur, sort_keys=False).rstrip())
|
|
63
|
+
print()
|
|
64
|
+
cur["port"] = _prompt_num("port", cur["port"], 8791, int)
|
|
65
|
+
cur["host"] = _prompt("host", cur["host"], "0.0.0.0")
|
|
66
|
+
cur["cors_origins"] = _prompt(
|
|
67
|
+
"cors_origins (comma-separated; empty = built-in loopback whitelist)",
|
|
68
|
+
cur["cors_origins"], "",
|
|
69
|
+
)
|
|
70
|
+
cur["poll_seconds"] = _prompt_num("poll_seconds", cur["poll_seconds"], 5, float)
|
|
71
|
+
cur["opencode_bin"] = _prompt("opencode_bin", cur["opencode_bin"], "opencode")
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_text(yaml.safe_dump(cur, sort_keys=False))
|
|
74
|
+
print(f"Wrote {path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_serve(args) -> None:
|
|
78
|
+
path = args.config or config_path()
|
|
79
|
+
cfg = load_config(path)
|
|
80
|
+
try:
|
|
81
|
+
port = args.port or int(os.environ.get("PORT") or cfg.get("port") or 8791)
|
|
82
|
+
except ValueError:
|
|
83
|
+
print(f"invalid port: {os.environ.get('PORT') or cfg.get('port')!r}")
|
|
84
|
+
sys.exit(2)
|
|
85
|
+
host = args.host or os.environ.get("HOST") or cfg.get("host") or "0.0.0.0"
|
|
86
|
+
|
|
87
|
+
import uvicorn
|
|
88
|
+
|
|
89
|
+
# cors_origins is stored comma-separated; create_app expects a list.
|
|
90
|
+
cors = [o.strip() for o in str(cfg.get("cors_origins", "")).split(",") if o.strip()]
|
|
91
|
+
|
|
92
|
+
uvicorn.run(
|
|
93
|
+
create_app(
|
|
94
|
+
cors_origins=cors or None,
|
|
95
|
+
poll_seconds=cfg.get("poll_seconds"),
|
|
96
|
+
opencode_bin=cfg.get("opencode_bin") or None,
|
|
97
|
+
),
|
|
98
|
+
host=host,
|
|
99
|
+
port=port,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv=None) -> int:
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="opencode-dashboard-server",
|
|
106
|
+
description="opencode token dashboard aggregation backend",
|
|
107
|
+
)
|
|
108
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
109
|
+
sub.add_parser("configure", help="interactively write the XDG config file")
|
|
110
|
+
serve = sub.add_parser("serve", help="run the server (FastAPI + uvicorn)")
|
|
111
|
+
serve.add_argument("--config", metavar="PATH", help="config file (default: XDG server.yaml)")
|
|
112
|
+
serve.add_argument("--port", type=int, help="listen port")
|
|
113
|
+
serve.add_argument("--host", help="bind host")
|
|
114
|
+
args = parser.parse_args(argv)
|
|
115
|
+
if args.cmd == "configure":
|
|
116
|
+
cmd_configure(args)
|
|
117
|
+
elif args.cmd == "serve":
|
|
118
|
+
cmd_serve(args)
|
|
119
|
+
else:
|
|
120
|
+
parser.print_usage()
|
|
121
|
+
return 2
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-dashboard-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: FastAPI aggregator over opencode's SQLite storage
|
|
5
5
|
Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
|
|
6
6
|
Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
|
|
@@ -9,6 +9,8 @@ Requires-Python: >=3.10
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: fastapi<1,>=0.115
|
|
11
11
|
Requires-Dist: uvicorn<1,>=0.30
|
|
12
|
+
Requires-Dist: platformdirs<5,>=4
|
|
13
|
+
Requires-Dist: PyYAML<7,>=6
|
|
12
14
|
|
|
13
15
|
# opencode-dashboard-server
|
|
14
16
|
|
|
@@ -20,6 +22,8 @@ API for the dashboard front end.
|
|
|
20
22
|
|
|
21
23
|
```bash
|
|
22
24
|
pip install opencode-dashboard-server
|
|
25
|
+
# or with uv:
|
|
26
|
+
uv tool install opencode-dashboard-server
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
Requires:
|
|
@@ -30,14 +34,29 @@ Requires:
|
|
|
30
34
|
|
|
31
35
|
## Quick start
|
|
32
36
|
|
|
37
|
+
Configure once (interactive; writes `~/.config/opencode-dashboard/server.yaml`), then serve:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
opencode-dashboard-server configure
|
|
41
|
+
opencode-dashboard-server serve
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`configure` walks you through `port` (default `8791`), `host` (default `0.0.0.0`),
|
|
45
|
+
`cors_origins` (comma-separated; empty = built-in loopback whitelist), `poll_seconds`
|
|
46
|
+
(default `5`), and `opencode_bin` (default `opencode`). Empty input keeps the default.
|
|
47
|
+
|
|
48
|
+
`serve` accepts overrides: `--port N`, `--host H`, `--config PATH` (instead of the XDG file).
|
|
49
|
+
Precedence for port/host: CLI flag > env `PORT`/`HOST` > config file > default.
|
|
50
|
+
|
|
33
51
|
Run one aggregator per opencode host:
|
|
34
52
|
|
|
35
53
|
```bash
|
|
36
54
|
uvicorn app:app --port 8791
|
|
37
55
|
```
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
`dashboard.
|
|
57
|
+
or directly with uvicorn (still works — reads env/defaults only). Run on more hosts with
|
|
58
|
+
different ports (`8792`, `8793`, …) and list each in the front end's config (`opencode-dashboard configure`).
|
|
59
|
+
Environment switches (used when no config file value is set):
|
|
41
60
|
|
|
42
61
|
- `DASHBOARD_CORS_ORIGINS` — comma-separated CORS allow-list (defaults to the loopback dev origins `localhost/127.0.0.1:5173` and `:4173`; tighten or widen for your deployment).
|
|
43
62
|
- `DASHBOARD_POLL_SECONDS` — SSE poll interval in seconds (default `5`).
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
aggregate.py
|
|
3
3
|
app.py
|
|
4
|
+
cli.py
|
|
4
5
|
db.py
|
|
5
6
|
pyproject.toml
|
|
6
7
|
opencode_dashboard_server.egg-info/PKG-INFO
|
|
7
8
|
opencode_dashboard_server.egg-info/SOURCES.txt
|
|
8
9
|
opencode_dashboard_server.egg-info/dependency_links.txt
|
|
10
|
+
opencode_dashboard_server.egg-info/entry_points.txt
|
|
9
11
|
opencode_dashboard_server.egg-info/requires.txt
|
|
10
12
|
opencode_dashboard_server.egg-info/top_level.txt
|
|
11
13
|
tests/test_aggregate.py
|
|
@@ -4,13 +4,15 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-dashboard-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "FastAPI aggregator over opencode's SQLite storage"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"fastapi>=0.115,<1",
|
|
13
13
|
"uvicorn>=0.30,<1",
|
|
14
|
+
"platformdirs>=4,<5",
|
|
15
|
+
"PyYAML>=6,<7",
|
|
14
16
|
]
|
|
15
17
|
|
|
16
18
|
[project.urls]
|
|
@@ -18,8 +20,11 @@ Homepage = "https://github.com/GCS-ZHN/opencode-dashboard"
|
|
|
18
20
|
Repository = "https://github.com/GCS-ZHN/opencode-dashboard"
|
|
19
21
|
Issues = "https://github.com/GCS-ZHN/opencode-dashboard/issues"
|
|
20
22
|
|
|
23
|
+
[project.scripts]
|
|
24
|
+
opencode-dashboard-server = "cli:main"
|
|
25
|
+
|
|
21
26
|
[tool.setuptools]
|
|
22
|
-
py-modules = ["app", "aggregate", "db"]
|
|
27
|
+
py-modules = ["app", "aggregate", "db", "cli"]
|
|
23
28
|
|
|
24
29
|
[dependency-groups]
|
|
25
30
|
dev = [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Assert-based tests for the aggregation logic, driven via SqliteRunner."""
|
|
2
2
|
|
|
3
3
|
from aggregate import (
|
|
4
|
+
models,
|
|
4
5
|
normalize_model,
|
|
5
6
|
overview,
|
|
6
7
|
project_detail,
|
|
@@ -74,7 +75,10 @@ def test_project_detail_flat_sessions(runner):
|
|
|
74
75
|
def test_session_detail_model_breakdown(runner):
|
|
75
76
|
sess, models = session_detail(runner, "s4")
|
|
76
77
|
assert sess["cost"] == 0.9
|
|
77
|
-
|
|
78
|
+
# per-session breakdown keeps (model, provider, mode) granularity, so the
|
|
79
|
+
# deepseek/build + openrouter/plan pair for the same model id stays split
|
|
80
|
+
assert [m["model"] for m in models] == ["deepseek-v4-flash", "claude-sonnet-4.5", "deepseek-v4-flash"]
|
|
81
|
+
assert [m["provider"] for m in models] == ["deepseek", "opencode", "openrouter"]
|
|
78
82
|
flash = models[0]
|
|
79
83
|
assert flash["provider"] == "deepseek"
|
|
80
84
|
assert flash["mode"] == "build"
|
|
@@ -87,6 +91,13 @@ def test_session_detail_model_breakdown(runner):
|
|
|
87
91
|
assert claude["tokens"] == {"input": 200, "output": 100, "reasoning": 50,
|
|
88
92
|
"cacheRead": 0, "cacheWrite": 0, "total": 350}
|
|
89
93
|
assert claude["cost"] == 0.3
|
|
94
|
+
flash2 = models[2]
|
|
95
|
+
assert flash2["provider"] == "openrouter"
|
|
96
|
+
assert flash2["mode"] == "plan"
|
|
97
|
+
assert flash2["messageCount"] == 1
|
|
98
|
+
assert flash2["tokens"] == {"input": 50, "output": 20, "reasoning": 10,
|
|
99
|
+
"cacheRead": 5, "cacheWrite": 2, "total": 87}
|
|
100
|
+
assert flash2["cost"] == 0.1
|
|
90
101
|
|
|
91
102
|
|
|
92
103
|
def test_zero_token_session_is_zeros_not_null(runner):
|
|
@@ -114,3 +125,27 @@ def test_normalize_model():
|
|
|
114
125
|
def test_unknown_ids_return_none(runner):
|
|
115
126
|
assert project_detail(runner, "nope") is None
|
|
116
127
|
assert session_detail(runner, "nope") is None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_models_hostwide_rollup(runner):
|
|
131
|
+
ms = models(runner)
|
|
132
|
+
# ordered by cost desc (tie by model name); user message in m5 excluded
|
|
133
|
+
assert [m["model"] for m in ms] == ["deepseek-v4-flash", "claude-sonnet-4.5", "kimi-k3"]
|
|
134
|
+
flash = ms[0]
|
|
135
|
+
# m1+m2 (deepseek/build) and m6 (openrouter/plan) share a model id; the
|
|
136
|
+
# host-wide rollup merges them into one entry, keeping the higher-cost label
|
|
137
|
+
assert flash["provider"] == "deepseek"
|
|
138
|
+
assert flash["mode"] == "build"
|
|
139
|
+
assert flash["messageCount"] == 3 # m1 + m2 + m6, not the m5 user message
|
|
140
|
+
assert flash["tokens"] == {"input": 250, "output": 120, "reasoning": 60,
|
|
141
|
+
"cacheRead": 25, "cacheWrite": 12, "total": 467}
|
|
142
|
+
assert flash["cost"] == 0.7
|
|
143
|
+
claude = ms[1]
|
|
144
|
+
assert claude["provider"] == "opencode"
|
|
145
|
+
assert claude["messageCount"] == 1
|
|
146
|
+
assert claude["tokens"]["total"] == 350
|
|
147
|
+
assert claude["cost"] == 0.3
|
|
148
|
+
kimi = ms[2]
|
|
149
|
+
assert kimi["messageCount"] == 1 # only m4; the m5 user message is excluded
|
|
150
|
+
assert kimi["tokens"] == ZERO_TOKENS
|
|
151
|
+
assert kimi["cost"] == 0.0
|
|
@@ -39,6 +39,16 @@ def test_overview_keys():
|
|
|
39
39
|
"tokens", "cost", "updatedAt"} <= set(r.json())
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def test_models_keys():
|
|
43
|
+
c, _ = make_client()
|
|
44
|
+
r = c.get("/models")
|
|
45
|
+
assert r.status_code == 200
|
|
46
|
+
rows = r.json()
|
|
47
|
+
assert rows # fixture seeds assistant token-bearing messages
|
|
48
|
+
assert {"model", "provider", "mode", "messageCount", "tokens", "cost"} <= set(rows[0])
|
|
49
|
+
assert rows == sorted(rows, key=lambda m: (-m["cost"], m["model"] or ""))
|
|
50
|
+
|
|
51
|
+
|
|
42
52
|
def test_foreign_origin_not_allowed_by_cors():
|
|
43
53
|
c, _ = make_client()
|
|
44
54
|
r = c.options("/projects", headers={
|
|
@@ -46,3 +56,15 @@ def test_foreign_origin_not_allowed_by_cors():
|
|
|
46
56
|
"Access-Control-Request-Method": "GET",
|
|
47
57
|
})
|
|
48
58
|
assert "access-control-allow-origin" not in r.headers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_cors_origins_injection():
|
|
62
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
63
|
+
conn.executescript(SCHEMA)
|
|
64
|
+
seed(conn)
|
|
65
|
+
app = appmod.create_app(SqliteRunner(conn), cors_origins=["http://a.com"])
|
|
66
|
+
c = TestClient(app)
|
|
67
|
+
ok = c.options("/projects", headers={"Origin": "http://a.com", "Access-Control-Request-Method": "GET"})
|
|
68
|
+
assert ok.headers.get("access-control-allow-origin") == "http://a.com"
|
|
69
|
+
nope = c.options("/projects", headers={"Origin": "http://a.co", "Access-Control-Request-Method": "GET"})
|
|
70
|
+
assert "access-control-allow-origin" not in nope.headers
|
|
File without changes
|
|
File without changes
|
|
File without changes
|