@miller-tech/uap 1.109.1 → 1.111.0
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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +2 -1
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/dashboard.d.ts +1 -0
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +4 -1
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/dashboard/savings.d.ts.map +1 -1
- package/dist/dashboard/savings.js +13 -0
- package/dist/dashboard/savings.js.map +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +25 -4
- package/dist/dashboard/server.js.map +1 -1
- package/package.json +1 -1
- 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 +18 -0
- package/tools/agents/scripts/project_telemetry.py +183 -0
- package/tools/agents/tests/test_project_telemetry.py +107 -0
- package/web/dashboard.html +10 -0
|
@@ -401,6 +401,22 @@ except Exception: # pragma: no cover - optional middleware
|
|
|
401
401
|
_TOOLCALL_NORMALIZER_OK = False
|
|
402
402
|
|
|
403
403
|
|
|
404
|
+
def _record_project_telemetry(body, model, usage) -> None:
|
|
405
|
+
"""Fail-open per-project routing/cost telemetry: derive the request's project
|
|
406
|
+
dir and append a task_outcomes row to its analytics DB so per-project
|
|
407
|
+
dashboards see the model calls this shared proxy makes. See
|
|
408
|
+
tools/agents/scripts/project_telemetry.py. Never raises."""
|
|
409
|
+
try:
|
|
410
|
+
import sys as _s
|
|
411
|
+
_d = os.path.dirname(os.path.abspath(__file__))
|
|
412
|
+
if _d not in _s.path:
|
|
413
|
+
_s.path.insert(0, _d)
|
|
414
|
+
import project_telemetry as _pt
|
|
415
|
+
_pt.record_from_request(body, model, usage)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
|
|
404
420
|
def _toolcall_workdir_hint(messages: list, limit: int = 8000) -> str:
|
|
405
421
|
"""Recent text/tool_result content (capped) so derive_workdir can recover the
|
|
406
422
|
real workdir echoed in command outputs even when the model's own tool-call
|
|
@@ -9364,6 +9380,7 @@ async def messages(request: Request):
|
|
|
9364
9380
|
upstream_input = anthropic_resp.get("usage", {}).get("input_tokens", 0)
|
|
9365
9381
|
if upstream_input > 0:
|
|
9366
9382
|
monitor.last_input_tokens = upstream_input
|
|
9383
|
+
_record_project_telemetry(body, model, anthropic_resp.get("usage", {}))
|
|
9367
9384
|
if PROXY_FORCE_NON_STREAM:
|
|
9368
9385
|
logger.info(
|
|
9369
9386
|
"FORCED NON-STREAM: served stream response via guarded non-stream path"
|
|
@@ -9803,6 +9820,7 @@ async def messages(request: Request):
|
|
|
9803
9820
|
if upstream_input > 0:
|
|
9804
9821
|
monitor.last_input_tokens = upstream_input
|
|
9805
9822
|
|
|
9823
|
+
_record_project_telemetry(body, model, anthropic_resp.get("usage", {}))
|
|
9806
9824
|
return anthropic_resp
|
|
9807
9825
|
|
|
9808
9826
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Per-project routing/cost telemetry for the shared UAP anthropic proxy.
|
|
2
|
+
|
|
3
|
+
The proxy is a shared singleton that serves *every* project, so per-project
|
|
4
|
+
dashboards (which read ``<project>/agents/data/memory/model_analytics.db`` ->
|
|
5
|
+
``task_outcomes``) never see the model calls the proxy actually makes. This
|
|
6
|
+
module closes that gap: after each completed response the proxy calls
|
|
7
|
+
``record_from_request`` with the request body + model + usage; we derive the
|
|
8
|
+
project directory from absolute paths echoed in the request and append one
|
|
9
|
+
``task_outcomes`` row to that project's analytics DB.
|
|
10
|
+
|
|
11
|
+
Design constraints (it runs in the hot path of a live proxy):
|
|
12
|
+
* fully fail-open — never raise into the caller;
|
|
13
|
+
* bounded work — scans a capped slice of the request, does a few fs stat calls;
|
|
14
|
+
* schema-compatible with src/models/analytics.ts so the existing dashboard
|
|
15
|
+
reads it with no change. Local models are zero-cost, so ``cost=0`` and the
|
|
16
|
+
dashboard computes the counterfactual-vs-frontier saving itself.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sqlite3
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
|
|
26
|
+
# A UAP project root is identifiable by one of these child markers.
|
|
27
|
+
_PROJECT_MARKERS = ("agents/data/memory", ".uap", ".git")
|
|
28
|
+
_ABS_PATH_RE = re.compile(r"/(?:[\w.\-]+/){2,}[\w.\-]*")
|
|
29
|
+
# Bounds so a huge request can never stall the proxy hot path.
|
|
30
|
+
_MAX_SCAN_CHARS = 24_000
|
|
31
|
+
_MAX_CANDIDATE_PATHS = 40
|
|
32
|
+
_MAX_WALK_UP = 8
|
|
33
|
+
|
|
34
|
+
_CREATE_SQL = (
|
|
35
|
+
"CREATE TABLE IF NOT EXISTS task_outcomes ("
|
|
36
|
+
"modelId TEXT, taskType TEXT, complexity TEXT, success INTEGER, "
|
|
37
|
+
"durationMs INTEGER, tokensIn INTEGER, tokensOut INTEGER, cost REAL, "
|
|
38
|
+
"taskId TEXT, timestamp TEXT)"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iter_text(body: dict):
|
|
43
|
+
"""Yield string fragments from an Anthropic request body, capped in total."""
|
|
44
|
+
budget = _MAX_SCAN_CHARS
|
|
45
|
+
msgs = body.get("messages") or []
|
|
46
|
+
# Newest messages first — the current workdir shows up in recent tool output.
|
|
47
|
+
for msg in reversed(msgs):
|
|
48
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
49
|
+
if isinstance(content, str):
|
|
50
|
+
frag = content[:budget]
|
|
51
|
+
budget -= len(frag)
|
|
52
|
+
yield frag
|
|
53
|
+
elif isinstance(content, list):
|
|
54
|
+
for block in content:
|
|
55
|
+
if not isinstance(block, dict):
|
|
56
|
+
continue
|
|
57
|
+
for key in ("text", "content", "input"):
|
|
58
|
+
val = block.get(key)
|
|
59
|
+
if isinstance(val, str):
|
|
60
|
+
frag = val[:budget]
|
|
61
|
+
budget -= len(frag)
|
|
62
|
+
yield frag
|
|
63
|
+
elif isinstance(val, (dict, list)):
|
|
64
|
+
frag = str(val)[:budget]
|
|
65
|
+
budget -= len(frag)
|
|
66
|
+
yield frag
|
|
67
|
+
if budget <= 0:
|
|
68
|
+
return
|
|
69
|
+
if budget <= 0:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _project_root_for(path: str) -> str | None:
|
|
74
|
+
"""Walk up from ``path`` to the nearest dir carrying a UAP project marker."""
|
|
75
|
+
d = path if os.path.isdir(path) else os.path.dirname(path)
|
|
76
|
+
for _ in range(_MAX_WALK_UP):
|
|
77
|
+
if not d or d == "/":
|
|
78
|
+
break
|
|
79
|
+
for marker in _PROJECT_MARKERS:
|
|
80
|
+
if os.path.exists(os.path.join(d, marker)):
|
|
81
|
+
return d
|
|
82
|
+
d = os.path.dirname(d)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def derive_project_dir(body: dict) -> str | None:
|
|
87
|
+
"""Best-effort project directory for a request, or None. Never raises."""
|
|
88
|
+
try:
|
|
89
|
+
counts: dict[str, int] = {}
|
|
90
|
+
seen = 0
|
|
91
|
+
for frag in _iter_text(body):
|
|
92
|
+
for m in _ABS_PATH_RE.finditer(frag):
|
|
93
|
+
if seen >= _MAX_CANDIDATE_PATHS:
|
|
94
|
+
break
|
|
95
|
+
seen += 1
|
|
96
|
+
root = _project_root_for(m.group(0))
|
|
97
|
+
if root:
|
|
98
|
+
counts[root] = counts.get(root, 0) + 1
|
|
99
|
+
if seen >= _MAX_CANDIDATE_PATHS:
|
|
100
|
+
break
|
|
101
|
+
if not counts:
|
|
102
|
+
return None
|
|
103
|
+
# Most-referenced project root wins; tie-break to the deepest path.
|
|
104
|
+
return max(counts.items(), key=lambda kv: (kv[1], len(kv[0])))[0]
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _analytics_db_path(project_dir: str) -> str:
|
|
110
|
+
return os.path.join(project_dir, "agents", "data", "memory", "model_analytics.db")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def record_task_outcome(
|
|
114
|
+
project_dir: str,
|
|
115
|
+
model: str,
|
|
116
|
+
tokens_in: int,
|
|
117
|
+
tokens_out: int,
|
|
118
|
+
*,
|
|
119
|
+
duration_ms: int = 0,
|
|
120
|
+
cost: float = 0.0,
|
|
121
|
+
task_id: str = "",
|
|
122
|
+
task_type: str = "proxy",
|
|
123
|
+
complexity: str = "medium",
|
|
124
|
+
success: bool = True,
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Append one task_outcomes row to a project's analytics DB. Never raises.
|
|
127
|
+
|
|
128
|
+
Returns True on write, False if skipped (no dir, no tokens, or error).
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
if not project_dir:
|
|
132
|
+
return False
|
|
133
|
+
db_dir = os.path.join(project_dir, "agents", "data", "memory")
|
|
134
|
+
if not os.path.isdir(db_dir):
|
|
135
|
+
# Only record where the project already has a memory dir — do not
|
|
136
|
+
# scaffold analytics into arbitrary derived directories.
|
|
137
|
+
return False
|
|
138
|
+
if (tokens_in or 0) <= 0 and (tokens_out or 0) <= 0:
|
|
139
|
+
return False
|
|
140
|
+
conn = sqlite3.connect(_analytics_db_path(project_dir), timeout=1.0)
|
|
141
|
+
try:
|
|
142
|
+
conn.execute(_CREATE_SQL)
|
|
143
|
+
conn.execute(
|
|
144
|
+
"INSERT INTO task_outcomes (modelId, taskType, complexity, success, "
|
|
145
|
+
"durationMs, tokensIn, tokensOut, cost, taskId, timestamp) "
|
|
146
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
147
|
+
(
|
|
148
|
+
str(model or "unknown"),
|
|
149
|
+
task_type,
|
|
150
|
+
complexity,
|
|
151
|
+
1 if success else 0,
|
|
152
|
+
int(duration_ms or 0),
|
|
153
|
+
int(tokens_in or 0),
|
|
154
|
+
int(tokens_out or 0),
|
|
155
|
+
float(cost or 0.0),
|
|
156
|
+
str(task_id or ""),
|
|
157
|
+
datetime.now(timezone.utc).isoformat(),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
conn.commit()
|
|
161
|
+
finally:
|
|
162
|
+
conn.close()
|
|
163
|
+
return True
|
|
164
|
+
except Exception:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def record_from_request(body: dict, model: str, usage: dict, *, task_id: str = "") -> bool:
|
|
169
|
+
"""Proxy hot-path entry point: derive project + record. Never raises."""
|
|
170
|
+
try:
|
|
171
|
+
project_dir = derive_project_dir(body or {})
|
|
172
|
+
if not project_dir:
|
|
173
|
+
return False
|
|
174
|
+
u = usage or {}
|
|
175
|
+
return record_task_outcome(
|
|
176
|
+
project_dir,
|
|
177
|
+
model,
|
|
178
|
+
int(u.get("input_tokens") or 0),
|
|
179
|
+
int(u.get("output_tokens") or 0),
|
|
180
|
+
task_id=task_id,
|
|
181
|
+
)
|
|
182
|
+
except Exception:
|
|
183
|
+
return False
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for project_telemetry: per-project proxy routing/cost telemetry."""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load():
|
|
11
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "project_telemetry.py"
|
|
12
|
+
spec = importlib.util.spec_from_file_location("project_telemetry", p)
|
|
13
|
+
m = importlib.util.module_from_spec(spec)
|
|
14
|
+
spec.loader.exec_module(m)
|
|
15
|
+
return m
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
pt = _load()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _make_project(root: str) -> str:
|
|
22
|
+
os.makedirs(os.path.join(root, "agents", "data", "memory"), exist_ok=True)
|
|
23
|
+
return root
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeriveProjectDir(unittest.TestCase):
|
|
27
|
+
def test_finds_project_root_from_paths_in_messages(self):
|
|
28
|
+
with tempfile.TemporaryDirectory() as d:
|
|
29
|
+
proj = _make_project(os.path.join(d, "project-x"))
|
|
30
|
+
body = {"messages": [
|
|
31
|
+
{"role": "user", "content": "run the build"},
|
|
32
|
+
{"role": "assistant", "content": [
|
|
33
|
+
{"type": "tool_use", "input": {"file_path": f"{proj}/src/app.ts"}},
|
|
34
|
+
]},
|
|
35
|
+
{"role": "user", "content": [
|
|
36
|
+
{"type": "tool_result", "content": f"wrote {proj}/src/app.ts and {proj}/README.md"},
|
|
37
|
+
]},
|
|
38
|
+
]}
|
|
39
|
+
self.assertEqual(pt.derive_project_dir(body), proj)
|
|
40
|
+
|
|
41
|
+
def test_returns_none_when_no_project_paths(self):
|
|
42
|
+
self.assertIsNone(pt.derive_project_dir({"messages": [
|
|
43
|
+
{"role": "user", "content": "hello, no paths here"}]}))
|
|
44
|
+
|
|
45
|
+
def test_fail_open_on_garbage(self):
|
|
46
|
+
for bad in (None, {}, {"messages": None}, {"messages": [1, 2, 3]}, "nope"):
|
|
47
|
+
self.assertIsNone(pt.derive_project_dir(bad if isinstance(bad, dict) else {"messages": bad}))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RecordTaskOutcome(unittest.TestCase):
|
|
51
|
+
def _count_and_sums(self, proj):
|
|
52
|
+
db = os.path.join(proj, "agents", "data", "memory", "model_analytics.db")
|
|
53
|
+
c = sqlite3.connect(db)
|
|
54
|
+
row = c.execute("SELECT COUNT(*), SUM(tokensIn), SUM(tokensOut), SUM(cost) FROM task_outcomes").fetchone()
|
|
55
|
+
c.close()
|
|
56
|
+
return row
|
|
57
|
+
|
|
58
|
+
def test_writes_dashboard_compatible_row(self):
|
|
59
|
+
with tempfile.TemporaryDirectory() as d:
|
|
60
|
+
proj = _make_project(os.path.join(d, "p"))
|
|
61
|
+
ok = pt.record_task_outcome(proj, "qwen36-a3b", 1000, 500)
|
|
62
|
+
self.assertTrue(ok)
|
|
63
|
+
n, ti, to, cost = self._count_and_sums(proj)
|
|
64
|
+
self.assertEqual(n, 1)
|
|
65
|
+
self.assertEqual(ti, 1000)
|
|
66
|
+
self.assertEqual(to, 500)
|
|
67
|
+
self.assertEqual(cost, 0.0) # local model is zero-cost; dashboard computes counterfactual
|
|
68
|
+
|
|
69
|
+
def test_skips_when_no_memory_dir(self):
|
|
70
|
+
with tempfile.TemporaryDirectory() as d:
|
|
71
|
+
# no agents/data/memory created
|
|
72
|
+
self.assertFalse(pt.record_task_outcome(os.path.join(d, "bare"), "m", 100, 50))
|
|
73
|
+
|
|
74
|
+
def test_skips_when_zero_tokens(self):
|
|
75
|
+
with tempfile.TemporaryDirectory() as d:
|
|
76
|
+
proj = _make_project(os.path.join(d, "p"))
|
|
77
|
+
self.assertFalse(pt.record_task_outcome(proj, "m", 0, 0))
|
|
78
|
+
|
|
79
|
+
def test_appends_multiple_rows(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as d:
|
|
81
|
+
proj = _make_project(os.path.join(d, "p"))
|
|
82
|
+
pt.record_task_outcome(proj, "m", 10, 5)
|
|
83
|
+
pt.record_task_outcome(proj, "m", 20, 7)
|
|
84
|
+
n, ti, to, _ = self._count_and_sums(proj)
|
|
85
|
+
self.assertEqual((n, ti, to), (2, 30, 12))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RecordFromRequest(unittest.TestCase):
|
|
89
|
+
def test_end_to_end_derive_and_write(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as d:
|
|
91
|
+
proj = _make_project(os.path.join(d, "proj"))
|
|
92
|
+
body = {"messages": [{"role": "user", "content": [
|
|
93
|
+
{"type": "tool_result", "content": f"ls {proj}/src returned app.ts"}]}]}
|
|
94
|
+
ok = pt.record_from_request(body, "qwen36", {"input_tokens": 1234, "output_tokens": 88})
|
|
95
|
+
self.assertTrue(ok)
|
|
96
|
+
c = sqlite3.connect(os.path.join(proj, "agents", "data", "memory", "model_analytics.db"))
|
|
97
|
+
n = c.execute("SELECT COUNT(*) FROM task_outcomes WHERE modelId='qwen36'").fetchone()[0]
|
|
98
|
+
c.close()
|
|
99
|
+
self.assertEqual(n, 1)
|
|
100
|
+
|
|
101
|
+
def test_fail_open_returns_false_never_raises(self):
|
|
102
|
+
self.assertFalse(pt.record_from_request(None, "m", None))
|
|
103
|
+
self.assertFalse(pt.record_from_request({"messages": []}, "m", {"input_tokens": 5}))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|
package/web/dashboard.html
CHANGED
|
@@ -685,6 +685,16 @@
|
|
|
685
685
|
renderLiveEvents();
|
|
686
686
|
} catch {}
|
|
687
687
|
};
|
|
688
|
+
// Full live snapshot (cross-process) — used when the WebSocket is down so
|
|
689
|
+
// SSE alone keeps the dashboard live rather than falling to slow polling. (C)
|
|
690
|
+
eventSource.addEventListener('snapshot', (e) => {
|
|
691
|
+
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
692
|
+
try {
|
|
693
|
+
render(JSON.parse(e.data));
|
|
694
|
+
document.getElementById('ws-status').className = 'status-dot connected';
|
|
695
|
+
document.getElementById('refresh-info').textContent = 'Live (SSE) - ' + new Date().toLocaleTimeString();
|
|
696
|
+
} catch {}
|
|
697
|
+
});
|
|
688
698
|
eventSource.onerror = () => { eventSource.close(); setTimeout(connectSSE, 5000); };
|
|
689
699
|
} catch {}
|
|
690
700
|
}
|