opencode-dashboard-server 0.3.0__tar.gz → 0.4.1__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.
Files changed (17) hide show
  1. opencode_dashboard_server-0.4.1/LICENSE.md +21 -0
  2. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/PKG-INFO +4 -1
  3. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/aggregate.py +42 -11
  4. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/app.py +4 -0
  5. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/opencode_dashboard_server.egg-info/PKG-INFO +4 -1
  6. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/opencode_dashboard_server.egg-info/SOURCES.txt +1 -0
  7. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/pyproject.toml +3 -1
  8. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/tests/test_aggregate.py +36 -1
  9. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/tests/test_app.py +10 -0
  10. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/README.md +0 -0
  11. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/cli.py +0 -0
  12. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/db.py +0 -0
  13. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/opencode_dashboard_server.egg-info/dependency_links.txt +0 -0
  14. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/opencode_dashboard_server.egg-info/entry_points.txt +0 -0
  15. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/opencode_dashboard_server.egg-info/requires.txt +0 -0
  16. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/opencode_dashboard_server.egg-info/top_level.txt +0 -0
  17. {opencode_dashboard_server-0.3.0 → opencode_dashboard_server-0.4.1}/setup.cfg +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GCS-ZHN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-dashboard-server
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: FastAPI aggregator over opencode's SQLite storage
5
+ License-Expression: MIT
5
6
  Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
6
7
  Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
7
8
  Project-URL: Issues, https://github.com/GCS-ZHN/opencode-dashboard/issues
8
9
  Requires-Python: >=3.10
9
10
  Description-Content-Type: text/markdown
11
+ License-File: LICENSE.md
10
12
  Requires-Dist: fastapi<1,>=0.115
11
13
  Requires-Dist: uvicorn<1,>=0.30
12
14
  Requires-Dist: platformdirs<5,>=4
13
15
  Requires-Dist: PyYAML<7,>=6
16
+ Dynamic: license-file
14
17
 
15
18
  # opencode-dashboard-server
16
19
 
@@ -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
@@ -89,6 +89,10 @@ def create_app(runner=None, cors_origins=None, poll_seconds=None, opencode_bin=N
89
89
  def projects():
90
90
  return handle(lambda: aggregate.projects(runner))
91
91
 
92
+ @app.get("/models")
93
+ def models():
94
+ return handle(lambda: aggregate.models(runner))
95
+
92
96
  @app.get("/projects/{project_id}")
93
97
  def project(project_id: str):
94
98
  def run():
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-dashboard-server
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: FastAPI aggregator over opencode's SQLite storage
5
+ License-Expression: MIT
5
6
  Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
6
7
  Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
7
8
  Project-URL: Issues, https://github.com/GCS-ZHN/opencode-dashboard/issues
8
9
  Requires-Python: >=3.10
9
10
  Description-Content-Type: text/markdown
11
+ License-File: LICENSE.md
10
12
  Requires-Dist: fastapi<1,>=0.115
11
13
  Requires-Dist: uvicorn<1,>=0.30
12
14
  Requires-Dist: platformdirs<5,>=4
13
15
  Requires-Dist: PyYAML<7,>=6
16
+ Dynamic: license-file
14
17
 
15
18
  # opencode-dashboard-server
16
19
 
@@ -4,9 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-dashboard-server"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  description = "FastAPI aggregator over opencode's SQLite storage"
9
9
  readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE.md"]
10
12
  requires-python = ">=3.10"
11
13
  dependencies = [
12
14
  "fastapi>=0.115,<1",
@@ -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
- assert [m["model"] for m in models] == ["deepseek-v4-flash", "claude-sonnet-4.5"]
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={