tokenol 0.3.1__tar.gz → 0.3.2__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.
- {tokenol-0.3.1 → tokenol-0.3.2}/CHANGELOG.md +14 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/PKG-INFO +3 -1
- {tokenol-0.3.1 → tokenol-0.3.2}/README.md +2 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/pyproject.toml +1 -1
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/app.py +26 -16
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/streaming.py +6 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_app.py +88 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/.claude/settings.local.json +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/.claudesignore +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/.github/workflows/ci.yml +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/.gitignore +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/ASSUMPTIONS.md +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/METRICS.md +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/breakdown_bottom.jpg +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/breakdown_top.jpg +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/main.jpg +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/project.jpg +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/session_bottom.jpg +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/session_top.jpg +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr1.md +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr2.md +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr3.md +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/specs/2026-04-24-breakdown-tab-design.md +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/__init__.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/assumptions.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/cli.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/enums.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/__init__.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/builder.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/discovery.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/parser.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/schema.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/__init__.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/context.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/cost.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/history.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/patterns.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/rollups.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/thresholds.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/verdicts.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/windows.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/__init__.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/events.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/pricing.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/registry.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/report/__init__.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/report/text.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/__init__.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/prefs.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/session_detail.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/state.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/app.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/chart.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/components.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/day.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/day.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/index.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/model.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/model.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/project.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/project.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/session.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/session.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/styles.css +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/tool.html +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/tool.js +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/conftest.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/basic.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/dedup.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/gemini.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/interrupted.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/missing_ids.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/multi.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/sidechain.jsonl +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_cli.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_context_metrics.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_dedup.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_history_and_thresholds.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_metrics.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_parser.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_patterns.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_prefs.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_state.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_streaming.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_session_detail.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_verdicts.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_windows.py +0 -0
- {tokenol-0.3.1 → tokenol-0.3.2}/uv.lock +0 -0
|
@@ -4,6 +4,20 @@ All notable changes to tokenol are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.2] — 2026-04-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Dashboard fallback endpoints no longer freeze on stale turn counts.**
|
|
11
|
+
The 0.3.1 `/api/snapshot` fast-path stopped refreshing
|
|
12
|
+
`app.state.snapshot_result`, so every endpoint that fell back to it
|
|
13
|
+
(`/api/hourly`, `/api/daily`, `/api/models`, `/api/recent`, `/api/session/*`,
|
|
14
|
+
`/api/project/*`, `/api/breakdown/*`, `/api/search`, `/api/model/*`,
|
|
15
|
+
`/api/tool/*`) silently froze at first-page-load turn counts. Symptom: the
|
|
16
|
+
HIT% panel showed live turns but switching to $/KW, CTX, Cache Reuse, Output,
|
|
17
|
+
or Cost rendered stale numbers. Fix: `SnapshotBroadcaster` now exposes its
|
|
18
|
+
freshest `SnapshotResult`, and fallback endpoints prefer it over the
|
|
19
|
+
app-level cache.
|
|
20
|
+
|
|
7
21
|
## [0.3.1] — 2026-04-27
|
|
8
22
|
|
|
9
23
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenol
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure.
|
|
5
5
|
Project-URL: Homepage, https://github.com/farhanferoz/tokenol
|
|
6
6
|
Project-URL: Issues, https://github.com/farhanferoz/tokenol/issues
|
|
@@ -150,6 +150,8 @@ tokenol serve --open
|
|
|
150
150
|
|
|
151
151
|
The dashboard updates via SSE as Claude Code writes events to disk. The server gates rebuilds on JSONL file changes — when no files have changed, it idles at near-zero CPU and forces a refresh at most once a minute (so time-windowed panels like Recent Activity don't drift more than ~60 s from wall clock). Multiple browser tabs share a single producer, so opening more tabs does not multiply server cost.
|
|
152
152
|
|
|
153
|
+
If SSE delivery silently stalls (browser tab throttling, extension hooks, long-lived `EventSource` quirks), the client self-heals: it polls `/api/snapshot` every 30 s as a backstop, force-reconnects on tab-visibility return, and runs a 90 s staleness watchdog. `/api/snapshot` reuses the broadcaster's cached payload while an SSE group is live, so the backstop costs only a JSON serialize. Hover the live-status dot for a "last update Ns ago" indicator.
|
|
154
|
+
|
|
153
155
|
Main page layout (top to bottom):
|
|
154
156
|
|
|
155
157
|
| Panel | What it shows |
|
|
@@ -117,6 +117,8 @@ tokenol serve --open
|
|
|
117
117
|
|
|
118
118
|
The dashboard updates via SSE as Claude Code writes events to disk. The server gates rebuilds on JSONL file changes — when no files have changed, it idles at near-zero CPU and forces a refresh at most once a minute (so time-windowed panels like Recent Activity don't drift more than ~60 s from wall clock). Multiple browser tabs share a single producer, so opening more tabs does not multiply server cost.
|
|
119
119
|
|
|
120
|
+
If SSE delivery silently stalls (browser tab throttling, extension hooks, long-lived `EventSource` quirks), the client self-heals: it polls `/api/snapshot` every 30 s as a backstop, force-reconnects on tab-visibility return, and runs a 90 s staleness watchdog. `/api/snapshot` reuses the broadcaster's cached payload while an SSE group is live, so the backstop costs only a JSON serialize. Hover the live-status dot for a "last update Ns ago" indicator.
|
|
121
|
+
|
|
120
122
|
Main page layout (top to bottom):
|
|
121
123
|
|
|
122
124
|
| Panel | What it shows |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tokenol"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -113,6 +113,16 @@ def _build_and_cache_snapshot(request: Request, period: str = "today") -> Snapsh
|
|
|
113
113
|
return result
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
def _current_snapshot_result(request: Request) -> SnapshotResult:
|
|
117
|
+
"""Prefer the broadcaster's latest build; fall back to the app cache or a fresh build."""
|
|
118
|
+
broadcaster = getattr(request.app.state, "broadcaster", None)
|
|
119
|
+
if broadcaster is not None:
|
|
120
|
+
latest = broadcaster.latest_result()
|
|
121
|
+
if latest is not None:
|
|
122
|
+
return latest
|
|
123
|
+
return request.app.state.snapshot_result or _build_and_cache_snapshot(request)
|
|
124
|
+
|
|
125
|
+
|
|
116
126
|
def create_app(
|
|
117
127
|
config: ServerConfig | None = None,
|
|
118
128
|
prefs_path: Path | None = None,
|
|
@@ -193,7 +203,7 @@ def create_app(
|
|
|
193
203
|
|
|
194
204
|
@app.get("/api/session/{session_id}")
|
|
195
205
|
async def api_session_detail(session_id: str, request: Request):
|
|
196
|
-
result =
|
|
206
|
+
result = _current_snapshot_result(request)
|
|
197
207
|
session = next((s for s in result.sessions if s.session_id == session_id), None)
|
|
198
208
|
if session is None:
|
|
199
209
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
@@ -201,7 +211,7 @@ def create_app(
|
|
|
201
211
|
|
|
202
212
|
@app.get("/api/session/{session_id}/turn/{turn_idx}")
|
|
203
213
|
async def api_turn_detail(session_id: str, turn_idx: int, request: Request):
|
|
204
|
-
result =
|
|
214
|
+
result = _current_snapshot_result(request)
|
|
205
215
|
session = next((s for s in result.sessions if s.session_id == session_id), None)
|
|
206
216
|
if session is None:
|
|
207
217
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
@@ -218,7 +228,7 @@ def create_app(
|
|
|
218
228
|
raise HTTPException(status_code=400, detail="Invalid cwd encoding") from None
|
|
219
229
|
if range not in ("1d", "7d", "14d", "30d", "all"):
|
|
220
230
|
raise HTTPException(status_code=400, detail="Invalid range — use 1d, 7d, 14d, 30d, or all")
|
|
221
|
-
result =
|
|
231
|
+
result = _current_snapshot_result(request)
|
|
222
232
|
detail = await asyncio.get_running_loop().run_in_executor(
|
|
223
233
|
None,
|
|
224
234
|
lambda: build_project_detail(cwd, result.sessions, range_key=range),
|
|
@@ -235,7 +245,7 @@ def create_app(
|
|
|
235
245
|
raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD") from None
|
|
236
246
|
if parsed_date > date.today():
|
|
237
247
|
raise HTTPException(status_code=400, detail="Future dates not supported")
|
|
238
|
-
result =
|
|
248
|
+
result = _current_snapshot_result(request)
|
|
239
249
|
detail = await asyncio.get_running_loop().run_in_executor(
|
|
240
250
|
None,
|
|
241
251
|
lambda: build_day_detail(parsed_date, result.turns, result.sessions),
|
|
@@ -272,7 +282,7 @@ def create_app(
|
|
|
272
282
|
parsed = date.fromisoformat(target_date)
|
|
273
283
|
except ValueError:
|
|
274
284
|
raise HTTPException(status_code=400, detail="Invalid date — use YYYY-MM-DD") from None
|
|
275
|
-
result =
|
|
285
|
+
result = _current_snapshot_result(request)
|
|
276
286
|
return JSONResponse(build_hourly_panel(parsed, result.turns, result.sessions, metric, project, model))
|
|
277
287
|
|
|
278
288
|
@app.get("/api/daily")
|
|
@@ -289,7 +299,7 @@ def create_app(
|
|
|
289
299
|
raise HTTPException(status_code=400, detail=f"Unknown metric — valid: {sorted(VALID_METRICS)}")
|
|
290
300
|
if range not in ("7d", "30d", "90d", "all"):
|
|
291
301
|
raise HTTPException(status_code=400, detail="range must be 7d, 30d, 90d, or all")
|
|
292
|
-
result =
|
|
302
|
+
result = _current_snapshot_result(request)
|
|
293
303
|
# Fall back silently to the longest available window when the requested range
|
|
294
304
|
# exceeds the data we have — return 200 with a `note` so the UI can caption it.
|
|
295
305
|
# Returning 400 here forced clients to special-case "policy" failures even though
|
|
@@ -318,7 +328,7 @@ def create_app(
|
|
|
318
328
|
|
|
319
329
|
@app.get("/api/models")
|
|
320
330
|
async def api_models(request: Request, range: str = "today"):
|
|
321
|
-
result =
|
|
331
|
+
result = _current_snapshot_result(request)
|
|
322
332
|
since = range_since(range, date.today())
|
|
323
333
|
turns = [t for t in result.turns if t.timestamp.date() >= since] if since else result.turns
|
|
324
334
|
return JSONResponse(build_models_panel(turns, range))
|
|
@@ -327,7 +337,7 @@ def create_app(
|
|
|
327
337
|
async def api_recent(request: Request, window: str = "60m"):
|
|
328
338
|
if window not in _WINDOW_MINUTES:
|
|
329
339
|
raise HTTPException(status_code=400, detail=f"window must be one of: {list(_WINDOW_MINUTES)}")
|
|
330
|
-
result =
|
|
340
|
+
result = _current_snapshot_result(request)
|
|
331
341
|
now = datetime.now(tz=timezone.utc)
|
|
332
342
|
return JSONResponse(build_recent_activity_panel(
|
|
333
343
|
result.turns, result.sessions, now, _WINDOW_MINUTES[window]
|
|
@@ -335,7 +345,7 @@ def create_app(
|
|
|
335
345
|
|
|
336
346
|
@app.get("/api/model/{name}")
|
|
337
347
|
async def api_model_detail(name: str, request: Request):
|
|
338
|
-
result =
|
|
348
|
+
result = _current_snapshot_result(request)
|
|
339
349
|
detail = build_model_detail(name, result.turns, result.sessions)
|
|
340
350
|
if detail is None:
|
|
341
351
|
raise HTTPException(status_code=404, detail="Model not found")
|
|
@@ -343,7 +353,7 @@ def create_app(
|
|
|
343
353
|
|
|
344
354
|
@app.get("/api/tool/{name}")
|
|
345
355
|
async def api_tool_detail(name: str, request: Request):
|
|
346
|
-
result =
|
|
356
|
+
result = _current_snapshot_result(request)
|
|
347
357
|
detail = build_tool_detail(name, result.turns, result.sessions)
|
|
348
358
|
if detail is None:
|
|
349
359
|
raise HTTPException(status_code=404, detail="Tool not found")
|
|
@@ -353,7 +363,7 @@ def create_app(
|
|
|
353
363
|
async def api_search(request: Request, q: str = ""):
|
|
354
364
|
if not q.strip():
|
|
355
365
|
return JSONResponse({"hits": [], "query": q})
|
|
356
|
-
result =
|
|
366
|
+
result = _current_snapshot_result(request)
|
|
357
367
|
return JSONResponse(build_search_results(q, result.turns, result.sessions))
|
|
358
368
|
|
|
359
369
|
@app.get("/api/prefs")
|
|
@@ -402,7 +412,7 @@ def create_app(
|
|
|
402
412
|
@app.get("/api/breakdown/summary")
|
|
403
413
|
async def api_breakdown_summary(request: Request, range: str = "30d"):
|
|
404
414
|
_validate_breakdown_range(range)
|
|
405
|
-
result =
|
|
415
|
+
result = _current_snapshot_result(request)
|
|
406
416
|
since = range_since(range, date.today()) if range != "all" else None
|
|
407
417
|
if since is None:
|
|
408
418
|
turns = list(result.turns)
|
|
@@ -429,7 +439,7 @@ def create_app(
|
|
|
429
439
|
@app.get("/api/breakdown/daily-tokens")
|
|
430
440
|
async def api_breakdown_daily_tokens(request: Request, range: str = "30d"):
|
|
431
441
|
_validate_breakdown_range(range)
|
|
432
|
-
result =
|
|
442
|
+
result = _current_snapshot_result(request)
|
|
433
443
|
since = range_since(range, date.today()) if range != "all" else None
|
|
434
444
|
if since is None:
|
|
435
445
|
turns = list(result.turns)
|
|
@@ -456,7 +466,7 @@ def create_app(
|
|
|
456
466
|
@app.get("/api/breakdown/by-project")
|
|
457
467
|
async def api_breakdown_by_project(request: Request, range: str = "30d"):
|
|
458
468
|
_validate_breakdown_range(range)
|
|
459
|
-
result =
|
|
469
|
+
result = _current_snapshot_result(request)
|
|
460
470
|
since = range_since(range, date.today()) if range != "all" else None
|
|
461
471
|
|
|
462
472
|
cwd_by_sid = _grouped_cwd_by_sid(result.sessions)
|
|
@@ -484,7 +494,7 @@ def create_app(
|
|
|
484
494
|
@app.get("/api/breakdown/by-model")
|
|
485
495
|
async def api_breakdown_by_model(request: Request, range: str = "30d"):
|
|
486
496
|
_validate_breakdown_range(range)
|
|
487
|
-
result =
|
|
497
|
+
result = _current_snapshot_result(request)
|
|
488
498
|
since = range_since(range, date.today()) if range != "all" else None
|
|
489
499
|
|
|
490
500
|
buckets = _bucket_turns(
|
|
@@ -508,7 +518,7 @@ def create_app(
|
|
|
508
518
|
@app.get("/api/breakdown/tools")
|
|
509
519
|
async def api_breakdown_tools(request: Request, range: str = "30d"):
|
|
510
520
|
_validate_breakdown_range(range)
|
|
511
|
-
result =
|
|
521
|
+
result = _current_snapshot_result(request)
|
|
512
522
|
since = range_since(range, date.today()) if range != "all" else None
|
|
513
523
|
|
|
514
524
|
total: Counter[str] = Counter()
|
|
@@ -177,6 +177,8 @@ class SnapshotBroadcaster:
|
|
|
177
177
|
self._heartbeat_s = heartbeat_s
|
|
178
178
|
self._groups: dict[str, _Group] = {}
|
|
179
179
|
self._lock = asyncio.Lock()
|
|
180
|
+
# turns/sessions are period-agnostic, so any producer's freshest result serves all readers.
|
|
181
|
+
self._latest_result: SnapshotResult | None = None
|
|
180
182
|
|
|
181
183
|
def _compute_active_keys(self) -> frozenset[tuple[str, int, int]]:
|
|
182
184
|
return compute_active_keys(self._all_projects)
|
|
@@ -190,8 +192,12 @@ class SnapshotBroadcaster:
|
|
|
190
192
|
period=period,
|
|
191
193
|
thresholds=self._get_thresholds(),
|
|
192
194
|
)
|
|
195
|
+
self._latest_result = result
|
|
193
196
|
return result.payload
|
|
194
197
|
|
|
198
|
+
def latest_result(self) -> SnapshotResult | None:
|
|
199
|
+
return self._latest_result
|
|
200
|
+
|
|
195
201
|
async def subscribe(self, period: str) -> AsyncGenerator[str, None]:
|
|
196
202
|
sub = _Subscriber()
|
|
197
203
|
async with self._lock:
|
|
@@ -1052,3 +1052,91 @@ async def test_api_tool_detail_404_on_unknown(tmp_path: Path) -> None:
|
|
|
1052
1052
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
1053
1053
|
resp = await client.get("/api/tool/NoSuchTool")
|
|
1054
1054
|
assert resp.status_code == 404
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
@pytest.mark.asyncio
|
|
1058
|
+
async def test_api_hourly_reflects_broadcaster_freshness(tmp_path: Path) -> None:
|
|
1059
|
+
"""Regression: /api/hourly must track the broadcaster, not stale app.state.snapshot_result."""
|
|
1060
|
+
import asyncio
|
|
1061
|
+
from datetime import datetime, timezone
|
|
1062
|
+
|
|
1063
|
+
from httpx import ASGITransport, AsyncClient
|
|
1064
|
+
|
|
1065
|
+
today = datetime.now(tz=timezone.utc).date()
|
|
1066
|
+
target_date = today.isoformat()
|
|
1067
|
+
|
|
1068
|
+
def _turn_line(uuid: str, hour: int, minute: int) -> bytes:
|
|
1069
|
+
ts = f"{target_date}T{hour:02d}:{minute:02d}:00Z"
|
|
1070
|
+
return (
|
|
1071
|
+
b'{"type":"assistant","timestamp":"' + ts.encode() + b'",'
|
|
1072
|
+
b'"sessionId":"sess-001","requestId":"req-' + uuid.encode() + b'",'
|
|
1073
|
+
b'"uuid":"' + uuid.encode() + b'","isSidechain":false,'
|
|
1074
|
+
b'"model":"claude-opus-4-7",'
|
|
1075
|
+
b'"message":{"id":"msg-' + uuid.encode() + b'","role":"assistant",'
|
|
1076
|
+
b'"stop_reason":"end_turn",'
|
|
1077
|
+
b'"usage":{"input_tokens":10,"output_tokens":20,'
|
|
1078
|
+
b'"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}\n'
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
sess_path = tmp_path / "projects" / "sess-001.jsonl"
|
|
1082
|
+
sess_path.parent.mkdir(parents=True)
|
|
1083
|
+
sess_path.write_bytes(_turn_line("evt-001", 10, 0) + _turn_line("evt-002", 10, 5))
|
|
1084
|
+
|
|
1085
|
+
with _mock_dirs(tmp_path):
|
|
1086
|
+
app = create_app(ServerConfig())
|
|
1087
|
+
bc = app.state.broadcaster
|
|
1088
|
+
# Force the producer to rebuild on every mtime change with no heartbeat delay.
|
|
1089
|
+
bc._get_tick_seconds = lambda: 0
|
|
1090
|
+
bc._heartbeat_s = 0.0
|
|
1091
|
+
|
|
1092
|
+
agen = bc.subscribe("today").__aiter__()
|
|
1093
|
+
try:
|
|
1094
|
+
await asyncio.wait_for(agen.__anext__(), timeout=5.0)
|
|
1095
|
+
|
|
1096
|
+
async with AsyncClient(
|
|
1097
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
1098
|
+
) as client:
|
|
1099
|
+
resp = await client.get(
|
|
1100
|
+
f"/api/hourly/{target_date}?metric=cost_per_kw"
|
|
1101
|
+
)
|
|
1102
|
+
turns_before = sum(
|
|
1103
|
+
p["turns"] for s in resp.json()["series"] for p in s["points"]
|
|
1104
|
+
)
|
|
1105
|
+
assert turns_before == 2, (
|
|
1106
|
+
f"fixture has 2 assistant turns, /api/hourly saw {turns_before}"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
sess_path.write_bytes(sess_path.read_bytes() + _turn_line("evt-003", 10, 30))
|
|
1110
|
+
|
|
1111
|
+
# Poll /api/snapshot as the sync barrier — it exercises the same fast-path
|
|
1112
|
+
# the fix targets, without reaching into broadcaster internals.
|
|
1113
|
+
async with AsyncClient(
|
|
1114
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
1115
|
+
) as client:
|
|
1116
|
+
snapshot_turns = 0
|
|
1117
|
+
for _ in range(200):
|
|
1118
|
+
resp = await client.get("/api/snapshot?period=today")
|
|
1119
|
+
snapshot_turns = sum(
|
|
1120
|
+
s["turns"]
|
|
1121
|
+
for s in resp.json()["hourly_today"]["series"]
|
|
1122
|
+
)
|
|
1123
|
+
if snapshot_turns >= 3:
|
|
1124
|
+
break
|
|
1125
|
+
await asyncio.sleep(0.02)
|
|
1126
|
+
assert snapshot_turns == 3, (
|
|
1127
|
+
"broadcaster did not pick up appended turn within 4 s "
|
|
1128
|
+
f"(snapshot still reports {snapshot_turns} turns)"
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
resp = await client.get(
|
|
1132
|
+
f"/api/hourly/{target_date}?metric=cost_per_kw"
|
|
1133
|
+
)
|
|
1134
|
+
turns_after = sum(
|
|
1135
|
+
p["turns"] for s in resp.json()["series"] for p in s["points"]
|
|
1136
|
+
)
|
|
1137
|
+
assert turns_after == 3, (
|
|
1138
|
+
"/api/hourly returned stale turns "
|
|
1139
|
+
f"({turns_after}); expected 3 after appending one turn"
|
|
1140
|
+
)
|
|
1141
|
+
finally:
|
|
1142
|
+
await agen.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|