@miller-tech/uap 1.129.3 → 1.129.4
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.
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +47 -9
- package/tools/agents/scripts/project_telemetry.py +121 -4
- package/tools/agents/tests/test_project_telemetry_events.py +95 -0
- package/tools/agents/tests/test_stream_telemetry.py +42 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.129.
|
|
3
|
+
"version": "1.129.4",
|
|
4
4
|
"description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node dist/bin/cli.js",
|
|
22
22
|
"test": "vitest",
|
|
23
23
|
"test:ci": "vitest run",
|
|
24
|
-
"test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache",
|
|
24
|
+
"test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
Binary file
|
|
Binary file
|
|
@@ -536,18 +536,19 @@ except Exception: # pragma: no cover - optional middleware
|
|
|
536
536
|
_TOOLCALL_NORMALIZER_OK = False
|
|
537
537
|
|
|
538
538
|
|
|
539
|
-
def _record_project_telemetry(body, model, usage) -> None:
|
|
539
|
+
def _record_project_telemetry(body, model, usage, duration_ms: float = 0.0) -> None:
|
|
540
540
|
"""Fail-open per-project routing/cost telemetry: derive the request's project
|
|
541
|
-
dir and append a task_outcomes row
|
|
542
|
-
dashboards see the model calls this
|
|
543
|
-
tools/agents/scripts/project_telemetry.py. Never
|
|
541
|
+
dir and append a task_outcomes row (plus a dashboard live-feed event) to its
|
|
542
|
+
analytics/telemetry DBs so per-project dashboards see the model calls this
|
|
543
|
+
shared proxy makes. See tools/agents/scripts/project_telemetry.py. Never
|
|
544
|
+
raises."""
|
|
544
545
|
try:
|
|
545
546
|
import sys as _s
|
|
546
547
|
_d = os.path.dirname(os.path.abspath(__file__))
|
|
547
548
|
if _d not in _s.path:
|
|
548
549
|
_s.path.insert(0, _d)
|
|
549
550
|
import project_telemetry as _pt
|
|
550
|
-
_pt.record_from_request(body, model, usage)
|
|
551
|
+
_pt.record_from_request(body, model, usage, duration_ms=duration_ms)
|
|
551
552
|
except Exception:
|
|
552
553
|
pass
|
|
553
554
|
|
|
@@ -8930,6 +8931,7 @@ async def stream_anthropic_response(
|
|
|
8930
8931
|
- Proper upstream response closure on client disconnect
|
|
8931
8932
|
"""
|
|
8932
8933
|
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
|
|
8934
|
+
stream_started = time.monotonic()
|
|
8933
8935
|
|
|
8934
8936
|
# message_start
|
|
8935
8937
|
yield (
|
|
@@ -9257,16 +9259,40 @@ async def stream_anthropic_response(
|
|
|
9257
9259
|
upstream_input = int(upstream_usage.get("prompt_tokens") or 0)
|
|
9258
9260
|
if upstream_input > 0:
|
|
9259
9261
|
monitor.last_input_tokens = upstream_input
|
|
9262
|
+
final_output = int(upstream_usage.get("completion_tokens") or 0)
|
|
9263
|
+
if final_output <= 0:
|
|
9264
|
+
# Upstream sent no usable usage (older servers ignore stream_options):
|
|
9265
|
+
# estimate from everything actually generated — text, tool-call
|
|
9266
|
+
# arguments, and reasoning — at ~4 chars/token, never below the
|
|
9267
|
+
# per-delta chunk count.
|
|
9268
|
+
generated_chars = (
|
|
9269
|
+
sum(len(c) for c in text_chunks)
|
|
9270
|
+
# isinstance guard: the XML-recovery path stores a bare sentinel
|
|
9271
|
+
# (tool_calls_by_index["_xml_recovered"] = True) in this map.
|
|
9272
|
+
+ sum(
|
|
9273
|
+
len(tc.get("arguments", ""))
|
|
9274
|
+
for tc in tool_calls_by_index.values()
|
|
9275
|
+
if isinstance(tc, dict)
|
|
9276
|
+
)
|
|
9277
|
+
+ sum(len(c) for c in reasoning_chunks)
|
|
9278
|
+
)
|
|
9279
|
+
final_output = max(output_tokens, (generated_chars + 3) // 4)
|
|
9260
9280
|
usage_final = {
|
|
9261
9281
|
"input_tokens": upstream_input or getattr(monitor, "last_input_tokens", 0) or 0,
|
|
9262
|
-
"output_tokens":
|
|
9282
|
+
"output_tokens": final_output,
|
|
9263
9283
|
}
|
|
9264
9284
|
# After message_stop (never delays the client's final frame; a disconnect
|
|
9265
9285
|
# parked exactly on that yield skips recording, matching the non-stream
|
|
9266
9286
|
# "completed responses only" semantics) and off the event loop (a locked
|
|
9267
9287
|
# analytics DB must not stall token delivery for concurrent sessions).
|
|
9268
9288
|
try:
|
|
9269
|
-
await asyncio.to_thread(
|
|
9289
|
+
await asyncio.to_thread(
|
|
9290
|
+
_record_project_telemetry,
|
|
9291
|
+
anthropic_body,
|
|
9292
|
+
model,
|
|
9293
|
+
usage_final,
|
|
9294
|
+
(time.monotonic() - stream_started) * 1000.0,
|
|
9295
|
+
)
|
|
9270
9296
|
except Exception:
|
|
9271
9297
|
pass
|
|
9272
9298
|
|
|
@@ -9836,7 +9862,12 @@ async def messages(request: Request):
|
|
|
9836
9862
|
upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
|
|
9837
9863
|
if upstream_input > 0:
|
|
9838
9864
|
monitor.last_input_tokens = upstream_input
|
|
9839
|
-
|
|
9865
|
+
# Off the event loop: the recorder now writes TWO DBs (analytics +
|
|
9866
|
+
# dashboard telemetry) with 2s busy timeouts — a lock stall here
|
|
9867
|
+
# would block every concurrent session's turn.
|
|
9868
|
+
await asyncio.to_thread(
|
|
9869
|
+
_record_project_telemetry, body, model, anthropic_resp.get("usage", {})
|
|
9870
|
+
)
|
|
9840
9871
|
if PROXY_FORCE_NON_STREAM:
|
|
9841
9872
|
logger.info(
|
|
9842
9873
|
"FORCED NON-STREAM: served stream response via guarded non-stream path"
|
|
@@ -9877,6 +9908,10 @@ async def messages(request: Request):
|
|
|
9877
9908
|
|
|
9878
9909
|
if is_stream:
|
|
9879
9910
|
openai_body["stream"] = True
|
|
9911
|
+
# Without this, OpenAI-compat upstreams (llama-server included) omit
|
|
9912
|
+
# completion_tokens from the final stream chunk — the reason streamed
|
|
9913
|
+
# tool-use turns recorded tokensOut=0 in per-project telemetry.
|
|
9914
|
+
openai_body["stream_options"] = {"include_usage": True}
|
|
9880
9915
|
|
|
9881
9916
|
# Retry upstream connection with backoff to handle
|
|
9882
9917
|
# llama-server restarts gracefully instead of 500-ing to the client.
|
|
@@ -10276,7 +10311,10 @@ async def messages(request: Request):
|
|
|
10276
10311
|
if upstream_input > 0:
|
|
10277
10312
|
monitor.last_input_tokens = upstream_input
|
|
10278
10313
|
|
|
10279
|
-
|
|
10314
|
+
# Off the event loop — see the guarded-non-stream call site.
|
|
10315
|
+
await asyncio.to_thread(
|
|
10316
|
+
_record_project_telemetry, body, model, anthropic_resp.get("usage", {})
|
|
10317
|
+
)
|
|
10280
10318
|
return anthropic_resp
|
|
10281
10319
|
|
|
10282
10320
|
|
|
@@ -18,6 +18,7 @@ Design constraints (it runs in the hot path of a live proxy):
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import json
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
23
24
|
import sqlite3
|
|
@@ -171,19 +172,135 @@ def record_task_outcome(
|
|
|
171
172
|
return False
|
|
172
173
|
|
|
173
174
|
|
|
174
|
-
|
|
175
|
+
# ── dashboard live-feed (telemetry.db) ─────────────────────────────────────
|
|
176
|
+
# The Live Events / Performance panels read <project>/agents/data/memory/
|
|
177
|
+
# telemetry.db, which is otherwise written only by the TS producers
|
|
178
|
+
# (mcp-router/executor). Plain claude+proxy sessions never run those, so the
|
|
179
|
+
# panels stayed empty forever. The proxy is the one process that sees every
|
|
180
|
+
# turn — mirror the turn into the same store. Compression stats are NOT
|
|
181
|
+
# fabricated here: the proxy does no compression, that panel is honestly
|
|
182
|
+
# empty for plain sessions.
|
|
183
|
+
#
|
|
184
|
+
# DDL matches src/utils/telemetry-store.ts exactly (CREATE IF NOT EXISTS
|
|
185
|
+
# no-ops when the TS side created the tables first; the shape only matters
|
|
186
|
+
# when the proxy creates them for a fresh project).
|
|
187
|
+
_TELEMETRY_CREATE_SQL = """
|
|
188
|
+
CREATE TABLE IF NOT EXISTS perf_samples (
|
|
189
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
190
|
+
metric TEXT NOT NULL,
|
|
191
|
+
duration_ms REAL NOT NULL,
|
|
192
|
+
ts TEXT NOT NULL DEFAULT (datetime('now'))
|
|
193
|
+
);
|
|
194
|
+
CREATE INDEX IF NOT EXISTS idx_perf_samples_metric ON perf_samples(metric);
|
|
195
|
+
CREATE TABLE IF NOT EXISTS dashboard_events (
|
|
196
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
197
|
+
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
198
|
+
category TEXT NOT NULL,
|
|
199
|
+
type TEXT NOT NULL,
|
|
200
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
201
|
+
title TEXT NOT NULL,
|
|
202
|
+
detail TEXT,
|
|
203
|
+
metadata TEXT
|
|
204
|
+
);
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
# NOTE: the TS side trims events harder (MAX_PERSISTED_EVENTS = 500); this
|
|
208
|
+
# looser cap only matters for proxy-only projects where no TS producer runs.
|
|
209
|
+
_EVENTS_KEEP = 5000
|
|
210
|
+
_PERF_KEEP_PER_METRIC = 1000 # mirrors telemetry-store.ts PERF_SAMPLES_PER_METRIC
|
|
211
|
+
# Best-effort trim trigger: global across projects and not thread-safe, so a
|
|
212
|
+
# given DB trims roughly every 50*N-projects inserts — overshoot is bounded
|
|
213
|
+
# by the DELETEs themselves, precision is not needed here.
|
|
214
|
+
_trim_counter = 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def record_dashboard_turn(
|
|
218
|
+
project_dir: str,
|
|
219
|
+
model: str,
|
|
220
|
+
tokens_in: int,
|
|
221
|
+
tokens_out: int,
|
|
222
|
+
duration_ms: float = 0.0,
|
|
223
|
+
) -> bool:
|
|
224
|
+
"""Mirror one completed proxy turn into the project's telemetry.db so the
|
|
225
|
+
dashboard's Live Events and Performance panels move for plain
|
|
226
|
+
claude+proxy sessions. Never raises; a dropped event is strictly better
|
|
227
|
+
than a stalled turn."""
|
|
228
|
+
global _trim_counter
|
|
229
|
+
try:
|
|
230
|
+
db_dir = os.path.join(project_dir, "agents", "data", "memory")
|
|
231
|
+
if not os.path.isdir(db_dir):
|
|
232
|
+
return False
|
|
233
|
+
conn = sqlite3.connect(os.path.join(db_dir, "telemetry.db"), timeout=1.0)
|
|
234
|
+
try:
|
|
235
|
+
# Match the TS producers' settings so cross-process writes don't
|
|
236
|
+
# silently drop under lock contention (busy_timeout 2000: telemetry
|
|
237
|
+
# must never stall the hot path).
|
|
238
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
239
|
+
conn.execute("PRAGMA synchronous = NORMAL")
|
|
240
|
+
conn.execute("PRAGMA busy_timeout = 2000")
|
|
241
|
+
conn.executescript(_TELEMETRY_CREATE_SQL)
|
|
242
|
+
conn.execute(
|
|
243
|
+
"INSERT INTO dashboard_events (category, type, severity, title, metadata) "
|
|
244
|
+
"VALUES ('model', 'turn', 'info', ?, ?)",
|
|
245
|
+
(
|
|
246
|
+
f"{model}: {int(tokens_out or 0)} tok out / {int(tokens_in or 0)} in",
|
|
247
|
+
json.dumps(
|
|
248
|
+
{
|
|
249
|
+
"model": str(model or "unknown"),
|
|
250
|
+
"tokensIn": int(tokens_in or 0),
|
|
251
|
+
"tokensOut": int(tokens_out or 0),
|
|
252
|
+
"durationMs": round(float(duration_ms or 0), 1),
|
|
253
|
+
}
|
|
254
|
+
),
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
if duration_ms and duration_ms > 0:
|
|
258
|
+
conn.execute(
|
|
259
|
+
"INSERT INTO perf_samples (metric, duration_ms) VALUES ('proxy_turn_ms', ?)",
|
|
260
|
+
(float(duration_ms),),
|
|
261
|
+
)
|
|
262
|
+
_trim_counter += 1
|
|
263
|
+
if _trim_counter % 50 == 0:
|
|
264
|
+
conn.execute(
|
|
265
|
+
"DELETE FROM dashboard_events WHERE id NOT IN "
|
|
266
|
+
"(SELECT id FROM dashboard_events ORDER BY id DESC LIMIT ?)",
|
|
267
|
+
(_EVENTS_KEEP,),
|
|
268
|
+
)
|
|
269
|
+
conn.execute(
|
|
270
|
+
"DELETE FROM perf_samples WHERE metric = 'proxy_turn_ms' AND id NOT IN "
|
|
271
|
+
"(SELECT id FROM perf_samples WHERE metric = 'proxy_turn_ms' "
|
|
272
|
+
"ORDER BY id DESC LIMIT ?)",
|
|
273
|
+
(_PERF_KEEP_PER_METRIC,),
|
|
274
|
+
)
|
|
275
|
+
conn.commit()
|
|
276
|
+
finally:
|
|
277
|
+
conn.close()
|
|
278
|
+
return True
|
|
279
|
+
except Exception:
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def record_from_request(
|
|
284
|
+
body: dict, model: str, usage: dict, *, task_id: str = "", duration_ms: float = 0.0
|
|
285
|
+
) -> bool:
|
|
175
286
|
"""Proxy hot-path entry point: derive project + record. Never raises."""
|
|
176
287
|
try:
|
|
177
288
|
project_dir = derive_project_dir(body or {})
|
|
178
289
|
if not project_dir:
|
|
179
290
|
return False
|
|
180
291
|
u = usage or {}
|
|
181
|
-
|
|
292
|
+
tokens_in = int(u.get("input_tokens") or 0)
|
|
293
|
+
tokens_out = int(u.get("output_tokens") or 0)
|
|
294
|
+
wrote = record_task_outcome(
|
|
182
295
|
project_dir,
|
|
183
296
|
model,
|
|
184
|
-
|
|
185
|
-
|
|
297
|
+
tokens_in,
|
|
298
|
+
tokens_out,
|
|
299
|
+
duration_ms=int(duration_ms or 0),
|
|
186
300
|
task_id=task_id,
|
|
187
301
|
)
|
|
302
|
+
if wrote:
|
|
303
|
+
record_dashboard_turn(project_dir, model, tokens_in, tokens_out, duration_ms)
|
|
304
|
+
return wrote
|
|
188
305
|
except Exception:
|
|
189
306
|
return False
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""record_from_request must feed the dashboard live panels, not just analytics.
|
|
2
|
+
|
|
3
|
+
Live Events / Performance read <project>/agents/data/memory/telemetry.db,
|
|
4
|
+
which only the TS producers wrote — plain claude+proxy sessions left those
|
|
5
|
+
panels empty forever. The proxy now mirrors each completed turn there.
|
|
6
|
+
"""
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
pt_path = Path(__file__).resolve().parents[1] / "scripts" / "project_telemetry.py"
|
|
15
|
+
spec = importlib.util.spec_from_file_location("project_telemetry", pt_path)
|
|
16
|
+
pt = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(pt)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectTelemetryEventsTest(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="pt-events-")
|
|
23
|
+
self.root = Path(self._tmp.name)
|
|
24
|
+
# A recordable UAP project: marker + existing memory dir (the writer
|
|
25
|
+
# must never scaffold into arbitrary derived directories).
|
|
26
|
+
(self.root / ".git").mkdir()
|
|
27
|
+
(self.root / "agents" / "data" / "memory").mkdir(parents=True)
|
|
28
|
+
|
|
29
|
+
def tearDown(self):
|
|
30
|
+
self._tmp.cleanup()
|
|
31
|
+
|
|
32
|
+
def _body(self):
|
|
33
|
+
# derive_project_dir walks absolute paths echoed in the request.
|
|
34
|
+
return {"messages": [{"role": "user", "content": f"edit {self.root}/src/main.rs"}]}
|
|
35
|
+
|
|
36
|
+
def test_turn_writes_analytics_event_and_perf_sample(self):
|
|
37
|
+
ok = pt.record_from_request(
|
|
38
|
+
self._body(),
|
|
39
|
+
"qwen-test",
|
|
40
|
+
{"input_tokens": 1000, "output_tokens": 50},
|
|
41
|
+
duration_ms=1234.5,
|
|
42
|
+
)
|
|
43
|
+
self.assertTrue(ok)
|
|
44
|
+
mem = self.root / "agents" / "data" / "memory"
|
|
45
|
+
|
|
46
|
+
con = sqlite3.connect(mem / "model_analytics.db")
|
|
47
|
+
row = con.execute(
|
|
48
|
+
"SELECT modelId, tokensIn, tokensOut, durationMs FROM task_outcomes"
|
|
49
|
+
).fetchone()
|
|
50
|
+
con.close()
|
|
51
|
+
self.assertEqual(row, ("qwen-test", 1000, 50, 1234))
|
|
52
|
+
|
|
53
|
+
con = sqlite3.connect(mem / "telemetry.db")
|
|
54
|
+
ev = con.execute(
|
|
55
|
+
"SELECT category, type, severity, title, metadata FROM dashboard_events"
|
|
56
|
+
).fetchone()
|
|
57
|
+
perf = con.execute("SELECT metric, duration_ms FROM perf_samples").fetchone()
|
|
58
|
+
con.close()
|
|
59
|
+
self.assertEqual(ev[0:3], ("model", "turn", "info"))
|
|
60
|
+
self.assertIn("qwen-test", ev[3])
|
|
61
|
+
meta = json.loads(ev[4])
|
|
62
|
+
self.assertEqual(meta["tokensIn"], 1000)
|
|
63
|
+
self.assertEqual(meta["tokensOut"], 50)
|
|
64
|
+
self.assertEqual(perf, ("proxy_turn_ms", 1234.5))
|
|
65
|
+
|
|
66
|
+
def test_schema_matches_ts_producer_shape(self):
|
|
67
|
+
# The TS side (telemetry-store.ts) CREATE IF NOT EXISTS must no-op on a
|
|
68
|
+
# DB the proxy created first — the column shape has to match exactly.
|
|
69
|
+
pt.record_from_request(self._body(), "m", {"input_tokens": 1, "output_tokens": 1})
|
|
70
|
+
con = sqlite3.connect(self.root / "agents" / "data" / "memory" / "telemetry.db")
|
|
71
|
+
cols = [r[1] for r in con.execute("PRAGMA table_info(dashboard_events)")]
|
|
72
|
+
perf_cols = [r[1] for r in con.execute("PRAGMA table_info(perf_samples)")]
|
|
73
|
+
con.close()
|
|
74
|
+
self.assertEqual(cols, ["id", "ts", "category", "type", "severity", "title", "detail", "metadata"])
|
|
75
|
+
self.assertEqual(perf_cols, ["id", "metric", "duration_ms", "ts"])
|
|
76
|
+
|
|
77
|
+
def test_never_scaffolds_memory_dir(self):
|
|
78
|
+
bare = Path(self._tmp.name) / "bare"
|
|
79
|
+
(bare / ".git").mkdir(parents=True)
|
|
80
|
+
body = {"messages": [{"role": "user", "content": f"see {bare}/x.txt"}]}
|
|
81
|
+
self.assertFalse(pt.record_from_request(body, "m", {"input_tokens": 1, "output_tokens": 1}))
|
|
82
|
+
self.assertFalse((bare / "agents").exists())
|
|
83
|
+
|
|
84
|
+
def test_zero_duration_skips_perf_sample_but_keeps_event(self):
|
|
85
|
+
pt.record_from_request(self._body(), "m", {"input_tokens": 5, "output_tokens": 5})
|
|
86
|
+
con = sqlite3.connect(self.root / "agents" / "data" / "memory" / "telemetry.db")
|
|
87
|
+
events = con.execute("SELECT count(*) FROM dashboard_events").fetchone()[0]
|
|
88
|
+
perf = con.execute("SELECT count(*) FROM perf_samples").fetchone()[0]
|
|
89
|
+
con.close()
|
|
90
|
+
self.assertEqual(events, 1)
|
|
91
|
+
self.assertEqual(perf, 0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
unittest.main()
|
|
@@ -4,8 +4,11 @@ Regression for the frozen-dashboard incident (2026-07-08): the only
|
|
|
4
4
|
_record_project_telemetry call sites were on the non-stream / guarded
|
|
5
5
|
non-stream paths, so streaming sessions (the client default) wrote no
|
|
6
6
|
task_outcomes rows and per-project dashboards showed static data forever.
|
|
7
|
+
Follow-up coverage: real output tokens on tool-use turns (upstream usage via
|
|
8
|
+
stream_options.include_usage, char-estimate fallback) and turn duration.
|
|
7
9
|
"""
|
|
8
10
|
import asyncio
|
|
11
|
+
import json
|
|
9
12
|
import importlib.util
|
|
10
13
|
import unittest
|
|
11
14
|
from pathlib import Path
|
|
@@ -30,24 +33,32 @@ class FakeOpenAIStream:
|
|
|
30
33
|
pass
|
|
31
34
|
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
TEXT_TURN_LINES = [
|
|
34
37
|
'data: {"choices": [{"delta": {"content": "hello"}, "finish_reason": null}]}',
|
|
35
38
|
'data: {"choices": [{"delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 456, "completion_tokens": 5}}',
|
|
36
39
|
"data: [DONE]",
|
|
37
40
|
]
|
|
38
41
|
|
|
42
|
+
# A pure tool-call turn whose upstream sends NO usage (older servers ignore
|
|
43
|
+
# stream_options.include_usage): output must be estimated from the generated
|
|
44
|
+
# tool-call arguments, not recorded as 0.
|
|
45
|
+
TOOL_TURN_NO_USAGE_LINES = [
|
|
46
|
+
'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "Bash", "arguments": ""}}]}, "finish_reason": null}]}',
|
|
47
|
+
'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"command\\": \\"echo hello world this is a long command string\\"}"}}]}, "finish_reason": null}]}',
|
|
48
|
+
'data: {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}',
|
|
49
|
+
"data: [DONE]",
|
|
50
|
+
]
|
|
51
|
+
|
|
39
52
|
|
|
40
53
|
class StreamTelemetryTest(unittest.TestCase):
|
|
41
|
-
def _drive(self, body):
|
|
54
|
+
def _drive(self, body, lines=TEXT_TURN_LINES):
|
|
42
55
|
monitor = ap.SessionMonitor()
|
|
43
56
|
monitor.last_input_tokens = 123
|
|
44
57
|
recorded = []
|
|
45
58
|
saved = ap._record_project_telemetry
|
|
46
|
-
ap._record_project_telemetry = lambda b, m, u: recorded.append((b, m, u))
|
|
59
|
+
ap._record_project_telemetry = lambda b, m, u, d=0.0: recorded.append((b, m, u, d))
|
|
47
60
|
try:
|
|
48
|
-
gen = ap.stream_anthropic_response(
|
|
49
|
-
FakeOpenAIStream(OPENAI_LINES), "test-model", monitor, body
|
|
50
|
-
)
|
|
61
|
+
gen = ap.stream_anthropic_response(FakeOpenAIStream(lines), "test-model", monitor, body)
|
|
51
62
|
|
|
52
63
|
async def consume():
|
|
53
64
|
return [frame async for frame in gen]
|
|
@@ -62,13 +73,37 @@ class StreamTelemetryTest(unittest.TestCase):
|
|
|
62
73
|
frames, recorded = self._drive(body)
|
|
63
74
|
self.assertTrue(any("message_stop" in f for f in frames))
|
|
64
75
|
self.assertEqual(len(recorded), 1)
|
|
65
|
-
recorded_body, model, usage = recorded[0]
|
|
76
|
+
recorded_body, model, usage, duration_ms = recorded[0]
|
|
66
77
|
self.assertIs(recorded_body, body)
|
|
67
78
|
self.assertEqual(model, "test-model")
|
|
68
79
|
# Upstream's final usage chunk wins over the request-side estimate and
|
|
69
80
|
# the per-delta chunk counter (which misses tool-call turns).
|
|
70
81
|
self.assertEqual(usage.get("input_tokens"), 456)
|
|
71
82
|
self.assertEqual(usage.get("output_tokens"), 5)
|
|
83
|
+
self.assertGreaterEqual(duration_ms, 0)
|
|
84
|
+
|
|
85
|
+
def test_tool_turn_without_upstream_usage_estimates_output_tokens(self):
|
|
86
|
+
frames, recorded = self._drive({"messages": []}, TOOL_TURN_NO_USAGE_LINES)
|
|
87
|
+
self.assertEqual(len(recorded), 1)
|
|
88
|
+
_, _, usage, _ = recorded[0]
|
|
89
|
+
# ~60 chars of tool arguments -> well above zero at ~4 chars/token.
|
|
90
|
+
self.assertGreater(usage.get("output_tokens"), 5)
|
|
91
|
+
|
|
92
|
+
def test_xml_recovered_tool_turn_without_usage_still_records(self):
|
|
93
|
+
# The XML-recovery path plants a bare sentinel in tool_calls_by_index
|
|
94
|
+
# (_xml_recovered = True); the char-estimate fallback must not choke on
|
|
95
|
+
# it (regression: AttributeError after message_stop, telemetry lost).
|
|
96
|
+
xml = '<tool_call>{"name": "Bash", "arguments": {"command": "ls -la"}}</tool_call>'
|
|
97
|
+
lines = [
|
|
98
|
+
f'data: {json.dumps({"choices": [{"delta": {"content": xml}, "finish_reason": None}]})}',
|
|
99
|
+
'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}',
|
|
100
|
+
"data: [DONE]",
|
|
101
|
+
]
|
|
102
|
+
frames, recorded = self._drive({"messages": []}, lines)
|
|
103
|
+
self.assertTrue(any("tool_use" in f for f in frames)) # recovery actually fired
|
|
104
|
+
self.assertEqual(len(recorded), 1)
|
|
105
|
+
_, _, usage, _ = recorded[0]
|
|
106
|
+
self.assertGreater(usage.get("output_tokens"), 0)
|
|
72
107
|
|
|
73
108
|
def test_telemetry_recorded_after_client_visible_stream_end(self):
|
|
74
109
|
# The recorder must not delay or reorder client frames: message_stop is
|