@rulemetric/proxy 0.2.2
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/Dockerfile +28 -0
- package/addon/context_classifier.py +375 -0
- package/addon/providers.py +483 -0
- package/addon/reporter.py +778 -0
- package/addon/requirements.txt +1 -0
- package/addon/rulemetric_addon.py +1288 -0
- package/addon/secret_redactor.py +130 -0
- package/addon/security_scanner.py +828 -0
- package/addon/session_linker.py +206 -0
- package/addon/sse_parser.py +364 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/anthropic.d.ts +8 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +135 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +43 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +125 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/gemini.d.ts +9 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +140 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +116 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +129 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/session-linker.d.ts +6 -0
- package/dist/session-linker.d.ts.map +1 -0
- package/dist/session-linker.js +68 -0
- package/dist/session-linker.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
"""mitmproxy addon that captures LLM API requests AND responses, reporting them to RuleMetric."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from mitmproxy import http
|
|
15
|
+
|
|
16
|
+
from providers import detect_provider, parse_anthropic, parse_openai, parse_gemini, parse_bedrock, parse_copilot_fim
|
|
17
|
+
from reporter import report_snapshot, report_event
|
|
18
|
+
from session_linker import find_active_session, find_session_by_external_id
|
|
19
|
+
from sse_parser import parse_streaming_response
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("rulemetric.addon")
|
|
22
|
+
|
|
23
|
+
# Parsers for non-OpenAI formats. Any provider not listed here
|
|
24
|
+
# is assumed to be OpenAI-compatible and uses parse_openai.
|
|
25
|
+
_NON_OPENAI_PARSERS = {
|
|
26
|
+
"anthropic": parse_anthropic,
|
|
27
|
+
"gemini": parse_gemini,
|
|
28
|
+
"bedrock": parse_bedrock,
|
|
29
|
+
"github_copilot_fim": parse_copilot_fim,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_parser(provider: str):
|
|
34
|
+
"""Return the parser for a provider, defaulting to parse_openai."""
|
|
35
|
+
return _NON_OPENAI_PARSERS.get(provider, parse_openai)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_UUID_RE_LITERAL = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
39
|
+
|
|
40
|
+
# Providers whose wire session id is strong enough (unique per conversation,
|
|
41
|
+
# no aliasing risk) that the proxy is allowed to AUTO-CREATE a session row
|
|
42
|
+
# on first sight, when no registry file exists.
|
|
43
|
+
#
|
|
44
|
+
# This is a narrow relaxation of the "never auto-create" rule from commit
|
|
45
|
+
# 7aebad7. The rule existed because pre-wire-first attribution, orphan
|
|
46
|
+
# sessions became magnets that swallowed unrelated traffic. With wire-first,
|
|
47
|
+
# each provider's id below is per-conversation, so magnet behavior is
|
|
48
|
+
# impossible — every new value lands on its own session row.
|
|
49
|
+
#
|
|
50
|
+
# Add a provider here ONLY after confirming its wire id is per-conversation
|
|
51
|
+
# (not per-machine, per-account, etc.). Cursor is explicitly NOT here because
|
|
52
|
+
# its hooks write the registry file. Anthropic was added 2026-06-11: `hooks
|
|
53
|
+
# install` stopped writing Claude Code hooks (the registry writer), and the
|
|
54
|
+
# anthropic wire id is the per-conversation session_id from
|
|
55
|
+
# body.metadata.user_id — it satisfies the per-conversation rule, so the
|
|
56
|
+
# proxy bootstraps the session row itself. If a hook DID already register
|
|
57
|
+
# the wire id, the registry-file fast path wins and no duplicate is created.
|
|
58
|
+
_PROVIDERS_WITH_AUTO_BOOTSTRAP = frozenset({
|
|
59
|
+
"github_copilot_chat", # vscode-sessionid header — stable per VS Code window
|
|
60
|
+
"anthropic", # Claude Code session_id — unique per conversation
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
# Maps internal provider name to the `tool` value used in the sessions table.
|
|
64
|
+
_PROVIDER_TO_TOOL = {
|
|
65
|
+
"github_copilot_chat": "vscode_copilot",
|
|
66
|
+
"anthropic": "claude_code",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _extract_wire_session_id(
|
|
71
|
+
provider: str,
|
|
72
|
+
body: dict | None,
|
|
73
|
+
headers: object | None = None,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""Pull the harness session id off the wire if the provider embeds one.
|
|
76
|
+
|
|
77
|
+
Race-free attribution: every request from a given Claude Code / Cursor /
|
|
78
|
+
Copilot instance carries that instance's own session id. The proxy reads
|
|
79
|
+
it from the wire instead of guessing via the filesystem registry's
|
|
80
|
+
mtimes, so multiple concurrent same-tool instances never
|
|
81
|
+
cross-contaminate.
|
|
82
|
+
|
|
83
|
+
Per-provider sources:
|
|
84
|
+
* ``anthropic``: ``body.metadata.user_id`` (Claude Code packs a JSON
|
|
85
|
+
blob ``{device_id, account_uuid, session_id}`` into the field).
|
|
86
|
+
* ``github_copilot_chat``: ``vscode-sessionid`` HEADER (stable per VS
|
|
87
|
+
Code window; one RuleMetric session per VS Code instance — see
|
|
88
|
+
`_PROVIDERS_WITH_AUTO_BOOTSTRAP`).
|
|
89
|
+
|
|
90
|
+
OpenAI/Cursor put a stable user id in ``body.user``; whether it's a
|
|
91
|
+
session id or a long-lived machine id is harness-dependent, so it's an
|
|
92
|
+
opt-in fallback only and not implemented here.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
if provider == "anthropic":
|
|
96
|
+
if not body:
|
|
97
|
+
return None
|
|
98
|
+
md = body.get("metadata") or {}
|
|
99
|
+
raw = md.get("user_id")
|
|
100
|
+
if not raw:
|
|
101
|
+
return None
|
|
102
|
+
# Claude Code packs JSON into user_id. Older builds may send a bare
|
|
103
|
+
# uuid; tolerate both.
|
|
104
|
+
if isinstance(raw, str) and raw.startswith("{"):
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(raw)
|
|
107
|
+
sid = parsed.get("session_id") if isinstance(parsed, dict) else None
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
sid = None
|
|
110
|
+
else:
|
|
111
|
+
sid = raw if isinstance(raw, str) else None
|
|
112
|
+
if isinstance(sid, str) and re.fullmatch(_UUID_RE_LITERAL, sid):
|
|
113
|
+
return sid
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
if provider == "github_copilot_chat" and headers is not None:
|
|
117
|
+
# mitmproxy.http.Headers supports .get like a dict
|
|
118
|
+
sid = headers.get("vscode-sessionid") if hasattr(headers, "get") else None
|
|
119
|
+
if isinstance(sid, str) and sid:
|
|
120
|
+
return sid
|
|
121
|
+
return None
|
|
122
|
+
except (AttributeError, TypeError):
|
|
123
|
+
# Defensive — body / headers shape may differ across SDK versions
|
|
124
|
+
return None
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ensure_session_for_wire_id(
|
|
129
|
+
provider: str,
|
|
130
|
+
wire_id: str,
|
|
131
|
+
project_path: str | None = None,
|
|
132
|
+
) -> str | None:
|
|
133
|
+
"""Auto-create a RuleMetric session for an allowlisted wire-id provider.
|
|
134
|
+
|
|
135
|
+
Returns the DB session id on success, or None if the provider isn't
|
|
136
|
+
allowlisted or the create call fails.
|
|
137
|
+
|
|
138
|
+
On success, writes the registry file at $TMPDIR/rulemetric/<wire_id> so
|
|
139
|
+
subsequent requests with the same wire id hit the registry-file fast
|
|
140
|
+
path instead of re-bootstrapping.
|
|
141
|
+
"""
|
|
142
|
+
if provider not in _PROVIDERS_WITH_AUTO_BOOTSTRAP:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# wire_id becomes a filename below. The anthropic path is UUID-validated
|
|
146
|
+
# upstream, but copilot's vscode-sessionid header is attacker-influenced
|
|
147
|
+
# and only checked for non-emptiness — enforce the registry charset here
|
|
148
|
+
# (same rule as session_linker._EXTERNAL_ID_RE) before any write.
|
|
149
|
+
if not re.fullmatch(r"[A-Za-z0-9_.\-]{8,128}", wire_id):
|
|
150
|
+
logger.warning(
|
|
151
|
+
"auto-bootstrap rejected wire id failing charset validation (provider %s): %r",
|
|
152
|
+
provider, wire_id[:32],
|
|
153
|
+
)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Re-check the registry: a concurrent request may have just bootstrapped
|
|
157
|
+
info = find_session_by_external_id(wire_id)
|
|
158
|
+
if info and info.get("session_id"):
|
|
159
|
+
return info["session_id"]
|
|
160
|
+
|
|
161
|
+
# Lazy-imported to avoid widening the addon's import surface at startup
|
|
162
|
+
import urllib.error
|
|
163
|
+
import urllib.request
|
|
164
|
+
from reporter import _get_api_url, _get_auth_headers
|
|
165
|
+
from session_linker import _get_session_dir
|
|
166
|
+
|
|
167
|
+
tool = _PROVIDER_TO_TOOL.get(provider, provider)
|
|
168
|
+
# Schema: apps/api/src/routes/sessions.ts:createSessionSchema. Optional
|
|
169
|
+
# fields are `z.string().optional()` — they reject null. Only include
|
|
170
|
+
# fields we actually have a value for.
|
|
171
|
+
payload_dict: dict[str, object] = {
|
|
172
|
+
"tool": tool,
|
|
173
|
+
"externalSessionId": wire_id,
|
|
174
|
+
"metadata": {
|
|
175
|
+
"autoBootstrapped": True,
|
|
176
|
+
"viaProxy": True,
|
|
177
|
+
"provider": provider,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
if project_path:
|
|
181
|
+
payload_dict["projectPath"] = project_path
|
|
182
|
+
payload = json.dumps(payload_dict).encode("utf-8")
|
|
183
|
+
|
|
184
|
+
req = urllib.request.Request(
|
|
185
|
+
f"{_get_api_url()}/api/sessions",
|
|
186
|
+
data=payload,
|
|
187
|
+
headers=_get_auth_headers(),
|
|
188
|
+
method="POST",
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
192
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
193
|
+
except urllib.error.URLError as exc:
|
|
194
|
+
logger.warning("auto-bootstrap session POST failed for %s: %s", provider, exc)
|
|
195
|
+
return None
|
|
196
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
197
|
+
logger.warning("auto-bootstrap session response unparseable: %s", exc)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
session_id = data.get("id") if isinstance(data, dict) else None
|
|
201
|
+
if not session_id:
|
|
202
|
+
logger.warning("auto-bootstrap response missing 'id': %s", str(data)[:200])
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# Write registry file so subsequent same-wire-id requests find it without
|
|
206
|
+
# re-POSTing.
|
|
207
|
+
try:
|
|
208
|
+
sdir = _get_session_dir()
|
|
209
|
+
sdir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
(sdir / wire_id).write_text(f"{session_id}\n{tool}\n")
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
logger.warning("registry file write failed for %s: %s", wire_id[:16], exc)
|
|
213
|
+
# Not fatal — return the session id anyway; next request will retry
|
|
214
|
+
|
|
215
|
+
logger.info(
|
|
216
|
+
"Auto-bootstrapped %s session %s for wire id %s",
|
|
217
|
+
tool, session_id[:8], wire_id[:16],
|
|
218
|
+
)
|
|
219
|
+
return session_id
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Wire ids with a bootstrap thread currently in flight — prevents a burst of
|
|
223
|
+
# same-session requests from POSTing duplicate session rows before the first
|
|
224
|
+
# thread writes the registry file.
|
|
225
|
+
_PENDING_BOOTSTRAPS: set[str] = set()
|
|
226
|
+
_PENDING_LOCK = threading.Lock()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Last-seen plan tier from oauth/account/settings (dict so tests can reset).
|
|
230
|
+
# Claude Code polls that endpoint ~every few minutes, so this is warm within
|
|
231
|
+
# moments of proxy start while Claude Code is in use.
|
|
232
|
+
_CACHED_PLAN: dict[str, str | None] = {"plan": None}
|
|
233
|
+
|
|
234
|
+
_TIER_MAP = (
|
|
235
|
+
("max_20x", "max_20x"),
|
|
236
|
+
("max_5x", "max_5x"),
|
|
237
|
+
("20x", "max_20x"),
|
|
238
|
+
("5x", "max_5x"),
|
|
239
|
+
("pro", "pro"),
|
|
240
|
+
("free", "free"),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _extract_plan_tier(body: object) -> str | None:
|
|
245
|
+
"""Find a rate-limit-tier string anywhere in a JSON body and normalize it.
|
|
246
|
+
|
|
247
|
+
Looks for keys containing 'rate_limit_tier'/'ratelimittier' (any case,
|
|
248
|
+
any nesting) — e.g. organizationRateLimitTier: 'default_claude_max_5x'.
|
|
249
|
+
Returns one of max_20x/max_5x/pro/free, or None."""
|
|
250
|
+
if isinstance(body, dict):
|
|
251
|
+
for key, value in body.items():
|
|
252
|
+
normalized_key = key.replace("_", "").lower()
|
|
253
|
+
if "ratelimittier" in normalized_key and isinstance(value, str):
|
|
254
|
+
tier_value = value.lower()
|
|
255
|
+
for marker, plan in _TIER_MAP:
|
|
256
|
+
if marker in tier_value:
|
|
257
|
+
return plan
|
|
258
|
+
found = _extract_plan_tier(value)
|
|
259
|
+
if found:
|
|
260
|
+
return found
|
|
261
|
+
elif isinstance(body, list):
|
|
262
|
+
for item in body:
|
|
263
|
+
found = _extract_plan_tier(item)
|
|
264
|
+
if found:
|
|
265
|
+
return found
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _is_headless_sdk_call(body: dict | None) -> bool:
|
|
270
|
+
"""True when the request comes from headless `claude -p` (SDK entrypoint).
|
|
271
|
+
|
|
272
|
+
Claude Code embeds a billing pseudo-header in the first system block:
|
|
273
|
+
`cc_entrypoint=cli` for interactive sessions, `cc_entrypoint=sdk-cli` for
|
|
274
|
+
`claude -p`. The eval worker / llm-client fires headless calls every few
|
|
275
|
+
seconds, each with a fresh wire session id — bootstrapping those floods
|
|
276
|
+
/sessions with one-snapshot rows. Only the known-headless marker is
|
|
277
|
+
excluded; absent/unknown markers still capture (don't break on new CC
|
|
278
|
+
versions)."""
|
|
279
|
+
if not body:
|
|
280
|
+
return False
|
|
281
|
+
system = body.get("system")
|
|
282
|
+
blocks: list = system if isinstance(system, list) else [system]
|
|
283
|
+
for block in blocks[:2]: # billing header is in the leading block
|
|
284
|
+
text = block.get("text") if isinstance(block, dict) else block
|
|
285
|
+
if isinstance(text, str) and "cc_entrypoint=sdk-cli" in text:
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _bootstrap_session_async(provider: str, wire_id: str) -> None:
|
|
291
|
+
"""Run _ensure_session_for_wire_id in a daemon thread (single-flight per wire id)."""
|
|
292
|
+
with _PENDING_LOCK:
|
|
293
|
+
if wire_id in _PENDING_BOOTSTRAPS:
|
|
294
|
+
return
|
|
295
|
+
_PENDING_BOOTSTRAPS.add(wire_id)
|
|
296
|
+
|
|
297
|
+
def run() -> None:
|
|
298
|
+
try:
|
|
299
|
+
_ensure_session_for_wire_id(provider, wire_id)
|
|
300
|
+
except Exception as exc: # never let a bootstrap error kill the thread silently
|
|
301
|
+
logger.warning("background bootstrap failed for %s: %s", wire_id[:16], exc)
|
|
302
|
+
finally:
|
|
303
|
+
with _PENDING_LOCK:
|
|
304
|
+
_PENDING_BOOTSTRAPS.discard(wire_id)
|
|
305
|
+
|
|
306
|
+
threading.Thread(target=run, daemon=True, name="rulemetric-bootstrap").start()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _resolve_session_id(
|
|
310
|
+
provider: str,
|
|
311
|
+
body: dict | None = None,
|
|
312
|
+
headers: object | None = None,
|
|
313
|
+
) -> str | None:
|
|
314
|
+
"""Return the RuleMetric DB session id this request should be attributed to.
|
|
315
|
+
|
|
316
|
+
Resolution order:
|
|
317
|
+
1. Wire session id from the request body/headers (race-free, per-request)
|
|
318
|
+
→ look up by filename in $TMPDIR/rulemetric/.
|
|
319
|
+
2. If the wire id has no registry file AND the provider is on the
|
|
320
|
+
auto-bootstrap allowlist, create the session row on demand and
|
|
321
|
+
write the registry file (allowlisted providers only — see
|
|
322
|
+
`_PROVIDERS_WITH_AUTO_BOOTSTRAP`).
|
|
323
|
+
3. Filesystem registry's currently-active session for the provider
|
|
324
|
+
(legacy path; relied on for harnesses that don't expose a wire id).
|
|
325
|
+
|
|
326
|
+
Callers MUST drop the snapshot on None.
|
|
327
|
+
"""
|
|
328
|
+
wire_id = _extract_wire_session_id(provider, body, headers)
|
|
329
|
+
if wire_id:
|
|
330
|
+
info = find_session_by_external_id(wire_id)
|
|
331
|
+
if info and info.get("session_id"):
|
|
332
|
+
return info["session_id"]
|
|
333
|
+
# No registry file. Kick off auto-bootstrap in the background
|
|
334
|
+
# (no-op for non-allowlisted providers) and drop THIS snapshot —
|
|
335
|
+
# blocking the mitmproxy event loop on a 5s HTTP POST would stall
|
|
336
|
+
# every proxied request. Claude Code resends full context items on
|
|
337
|
+
# each turn, so attribution starts one snapshot later.
|
|
338
|
+
if provider in _PROVIDERS_WITH_AUTO_BOOTSTRAP:
|
|
339
|
+
if _is_headless_sdk_call(body):
|
|
340
|
+
logger.debug(
|
|
341
|
+
"Headless sdk-cli call (wire id %s) — not bootstrapping a session",
|
|
342
|
+
wire_id[:8],
|
|
343
|
+
)
|
|
344
|
+
return None
|
|
345
|
+
_bootstrap_session_async(provider, wire_id)
|
|
346
|
+
return None
|
|
347
|
+
# Wire id present, no registry file, no auto-bootstrap → drop rather
|
|
348
|
+
# than fall back. With concurrent same-tool sessions running, the
|
|
349
|
+
# linker fallback would attribute to whoever else is active.
|
|
350
|
+
logger.warning(
|
|
351
|
+
"Wire session id %s (provider %s) has no registry file — "
|
|
352
|
+
"dropping snapshot rather than risk cross-attribution",
|
|
353
|
+
wire_id[:8], provider,
|
|
354
|
+
)
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
active = find_active_session(provider)
|
|
358
|
+
if active and active.get("session_id"):
|
|
359
|
+
return active["session_id"]
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
# Header names safe to capture (never capture auth keys)
|
|
363
|
+
SAFE_REQUEST_HEADERS = (
|
|
364
|
+
# Anthropic
|
|
365
|
+
"anthropic-version",
|
|
366
|
+
"anthropic-beta",
|
|
367
|
+
# OpenAI / Stainless SDK
|
|
368
|
+
"openai-organization",
|
|
369
|
+
"x-request-id",
|
|
370
|
+
"x-stainless-arch",
|
|
371
|
+
"x-stainless-os",
|
|
372
|
+
"x-stainless-runtime",
|
|
373
|
+
"x-stainless-runtime-version",
|
|
374
|
+
"x-stainless-lang",
|
|
375
|
+
"x-stainless-package-version",
|
|
376
|
+
# Snowflake Cortex
|
|
377
|
+
"x-snowflake-authorization-token-type",
|
|
378
|
+
# GitHub Copilot
|
|
379
|
+
"editor-version",
|
|
380
|
+
"editor-plugin-version",
|
|
381
|
+
"x-github-api-version",
|
|
382
|
+
"copilot-integration-id",
|
|
383
|
+
# Groq
|
|
384
|
+
"x-groq-api-version",
|
|
385
|
+
# Generic
|
|
386
|
+
"x-api-version",
|
|
387
|
+
"x-model-id",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
SAFE_RESPONSE_HEADERS = (
|
|
391
|
+
"request-id",
|
|
392
|
+
"x-request-id",
|
|
393
|
+
# Anthropic rate limits
|
|
394
|
+
"anthropic-ratelimit-requests-limit",
|
|
395
|
+
"anthropic-ratelimit-requests-remaining",
|
|
396
|
+
"anthropic-ratelimit-tokens-limit",
|
|
397
|
+
"anthropic-ratelimit-tokens-remaining",
|
|
398
|
+
# OpenAI-style rate limits (used by Groq, Together, Fireworks, etc.)
|
|
399
|
+
"x-ratelimit-limit-requests",
|
|
400
|
+
"x-ratelimit-remaining-requests",
|
|
401
|
+
"x-ratelimit-limit-tokens",
|
|
402
|
+
"x-ratelimit-remaining-tokens",
|
|
403
|
+
"x-ratelimit-reset-requests",
|
|
404
|
+
"x-ratelimit-reset-tokens",
|
|
405
|
+
# Timing
|
|
406
|
+
"server-timing",
|
|
407
|
+
"x-response-time",
|
|
408
|
+
# Infrastructure
|
|
409
|
+
"cf-ray",
|
|
410
|
+
# Groq
|
|
411
|
+
"x-groq-id",
|
|
412
|
+
# OpenRouter
|
|
413
|
+
"x-openrouter-id",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _parse_response_usage(body: dict) -> dict | None:
|
|
418
|
+
"""Extract usage stats from a response body.
|
|
419
|
+
|
|
420
|
+
Normalises both Anthropic naming (``input_tokens``) and OpenAI naming
|
|
421
|
+
(``prompt_tokens``). Also extracts ``prompt_tokens_details.cached_tokens``
|
|
422
|
+
which some OpenAI-compatible providers return.
|
|
423
|
+
"""
|
|
424
|
+
usage = body.get("usage")
|
|
425
|
+
if not usage or not isinstance(usage, dict):
|
|
426
|
+
return None
|
|
427
|
+
result = {
|
|
428
|
+
"input_tokens": usage.get("input_tokens") or usage.get("prompt_tokens"),
|
|
429
|
+
"output_tokens": usage.get("output_tokens") or usage.get("completion_tokens"),
|
|
430
|
+
"cache_creation_input_tokens": usage.get("cache_creation_input_tokens"),
|
|
431
|
+
"cache_read_input_tokens": usage.get("cache_read_input_tokens"),
|
|
432
|
+
"total_tokens": usage.get("total_tokens"),
|
|
433
|
+
}
|
|
434
|
+
# Granular cache TTL breakdown (Anthropic ephemeral 5m vs 1h pricing tiers).
|
|
435
|
+
# Prefer the nested `cache_creation` object when present; fall back to treating
|
|
436
|
+
# the flat sum as 5m (older response shape). Absent for non-Anthropic providers.
|
|
437
|
+
cache_creation_obj = usage.get("cache_creation")
|
|
438
|
+
if isinstance(cache_creation_obj, dict):
|
|
439
|
+
result["cache_creation_5m"] = cache_creation_obj.get("ephemeral_5m_input_tokens", 0) or 0
|
|
440
|
+
result["cache_creation_1h"] = cache_creation_obj.get("ephemeral_1h_input_tokens", 0) or 0
|
|
441
|
+
elif usage.get("cache_creation_input_tokens") is not None:
|
|
442
|
+
result["cache_creation_5m"] = usage.get("cache_creation_input_tokens") or 0
|
|
443
|
+
result["cache_creation_1h"] = 0
|
|
444
|
+
# OpenAI-style prompt_tokens_details (cached_tokens)
|
|
445
|
+
details = usage.get("prompt_tokens_details")
|
|
446
|
+
if isinstance(details, dict) and details.get("cached_tokens"):
|
|
447
|
+
result["cache_read_input_tokens"] = (
|
|
448
|
+
result.get("cache_read_input_tokens") or details["cached_tokens"]
|
|
449
|
+
)
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_response_content(body: dict, provider: str) -> dict:
|
|
454
|
+
"""Extract key fields from a response body."""
|
|
455
|
+
result: dict = {}
|
|
456
|
+
|
|
457
|
+
# Stop reason — Anthropic and Gemini have unique formats; everything else is OpenAI-compatible
|
|
458
|
+
if provider == "anthropic":
|
|
459
|
+
result["stop_reason"] = body.get("stop_reason")
|
|
460
|
+
elif provider == "gemini":
|
|
461
|
+
candidates = body.get("candidates", [])
|
|
462
|
+
if candidates:
|
|
463
|
+
result["stop_reason"] = candidates[0].get("finishReason")
|
|
464
|
+
else:
|
|
465
|
+
# OpenAI-compatible format (covers openai, groq, together, fireworks, deepseek, etc.)
|
|
466
|
+
choices = body.get("choices", [])
|
|
467
|
+
if choices:
|
|
468
|
+
result["stop_reason"] = choices[0].get("finish_reason")
|
|
469
|
+
|
|
470
|
+
# Model actually used (may differ from requested)
|
|
471
|
+
result["model_used"] = body.get("model") or body.get("modelId")
|
|
472
|
+
|
|
473
|
+
# Anthropic request ID
|
|
474
|
+
result["request_id"] = body.get("id")
|
|
475
|
+
|
|
476
|
+
# Response content blocks (for seeing tool_use calls, thinking blocks, etc.)
|
|
477
|
+
if provider == "anthropic":
|
|
478
|
+
content = body.get("content") or []
|
|
479
|
+
# Summarize content blocks by type
|
|
480
|
+
block_types = {}
|
|
481
|
+
for block in content:
|
|
482
|
+
btype = block.get("type", "unknown")
|
|
483
|
+
block_types[btype] = block_types.get(btype, 0) + 1
|
|
484
|
+
result["content_block_types"] = block_types
|
|
485
|
+
|
|
486
|
+
# Extract thinking blocks
|
|
487
|
+
thinking_blocks = [b for b in content if b.get("type") == "thinking"]
|
|
488
|
+
if thinking_blocks:
|
|
489
|
+
result["thinking_token_count"] = sum(
|
|
490
|
+
len(b.get("thinking", "")) // 4 for b in thinking_blocks
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Extract text content for the timeline
|
|
494
|
+
text_blocks = [b.get("text", "") for b in content if b.get("type") == "text"]
|
|
495
|
+
if text_blocks:
|
|
496
|
+
result["text_content"] = "\n".join(text_blocks)
|
|
497
|
+
|
|
498
|
+
elif provider in ("openai", "github_copilot_chat"):
|
|
499
|
+
choices = body.get("choices") or []
|
|
500
|
+
if choices and isinstance(choices[0], dict):
|
|
501
|
+
message = choices[0].get("message") or {}
|
|
502
|
+
text = message.get("content") if isinstance(message, dict) else None
|
|
503
|
+
if text:
|
|
504
|
+
result["text_content"] = text
|
|
505
|
+
|
|
506
|
+
return result
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _promote_actual_model(snapshot: dict) -> None:
|
|
510
|
+
"""Prefer the response-reported model as the snapshot's primary model.
|
|
511
|
+
|
|
512
|
+
Keep the originally requested model in api_metadata when it differs so
|
|
513
|
+
the UI and downstream consumers can show the actual model while still
|
|
514
|
+
preserving what the client asked for.
|
|
515
|
+
"""
|
|
516
|
+
response = snapshot.get("response")
|
|
517
|
+
if not isinstance(response, dict):
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
model_used = response.get("model_used")
|
|
521
|
+
if not isinstance(model_used, str) or not model_used:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
requested_model = snapshot.get("model")
|
|
525
|
+
if isinstance(requested_model, str) and requested_model and requested_model != model_used:
|
|
526
|
+
api_metadata = snapshot.get("api_metadata")
|
|
527
|
+
if isinstance(api_metadata, dict):
|
|
528
|
+
api_metadata.setdefault("requested_model", requested_model)
|
|
529
|
+
else:
|
|
530
|
+
snapshot["api_metadata"] = {"requested_model": requested_model}
|
|
531
|
+
|
|
532
|
+
snapshot["model"] = model_used
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class RuleMetricAddon:
|
|
536
|
+
"""mitmproxy addon that intercepts LLM API requests and responses."""
|
|
537
|
+
|
|
538
|
+
_PENDING_MAX_AGE = 300 # evict pending entries older than 5 minutes
|
|
539
|
+
|
|
540
|
+
def __init__(self) -> None:
|
|
541
|
+
self.project_path = os.environ.get("RULEMETRIC_PROJECT_PATH", "")
|
|
542
|
+
self._snapshot_count = 0
|
|
543
|
+
self._error_count = 0
|
|
544
|
+
# Store pending snapshots keyed by flow id, so response() can enrich them
|
|
545
|
+
self._pending: dict[str, dict] = {}
|
|
546
|
+
# Belt-and-suspenders for hot-reload: mitmproxy reloads the addon
|
|
547
|
+
# script when this file changes, but it does NOT reload subordinate
|
|
548
|
+
# imports (`reporter`, `providers`, `sse_parser`, etc.) — those stay
|
|
549
|
+
# cached in sys.modules from the first load. Without this, edits to
|
|
550
|
+
# `reporter.py` are invisible to the addon, and worse, removed/renamed
|
|
551
|
+
# symbols cause ImportError on the next addon reload (silent failure
|
|
552
|
+
# — mitmproxy keeps proxying traffic but the addon is dead). We hit
|
|
553
|
+
# this on 2026-05-04 when `start_drainer` was added to reporter.py.
|
|
554
|
+
# Force-reload the modules we depend on so a hot-reload picks up the
|
|
555
|
+
# latest code from disk, not the cached version.
|
|
556
|
+
import importlib
|
|
557
|
+
for mod_name in ('reporter', 'providers', 'sse_parser', 'secret_redactor', 'context_classifier'):
|
|
558
|
+
mod = sys.modules.get(mod_name)
|
|
559
|
+
if mod is not None:
|
|
560
|
+
importlib.reload(mod)
|
|
561
|
+
# Spool drainer: replays events that exhausted retries during API outages.
|
|
562
|
+
# Idempotent — safe to call from __init__ even when the addon is
|
|
563
|
+
# reloaded. Started in __init__ rather than `load()` so it runs in
|
|
564
|
+
# mitmdump and unit tests alike.
|
|
565
|
+
from reporter import start_drainer
|
|
566
|
+
start_drainer()
|
|
567
|
+
# Drift detection: write our version to a file the gateway watches.
|
|
568
|
+
# When the CLI is upgraded, the new `proxy start` injects a new
|
|
569
|
+
# RULEMETRIC_CLI_VERSION; the gateway compares it to its own bundled
|
|
570
|
+
# version and restarts mitmproxy on drift. Without this, a fresh CLI
|
|
571
|
+
# install would not restart the long-running proxy that's still
|
|
572
|
+
# loading old code.
|
|
573
|
+
cli_version = os.environ.get("RULEMETRIC_CLI_VERSION", "unknown")
|
|
574
|
+
try:
|
|
575
|
+
config_dir = os.path.expanduser(
|
|
576
|
+
os.environ.get("RULEMETRIC_CONFIG_DIR") or "~/.config/rulemetric"
|
|
577
|
+
)
|
|
578
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
579
|
+
version_path = os.path.join(config_dir, "proxy-addon.version")
|
|
580
|
+
with open(version_path, "w") as f:
|
|
581
|
+
f.write(cli_version + "\n")
|
|
582
|
+
except OSError as exc:
|
|
583
|
+
logger.warning("Failed to write proxy-addon.version: %s", exc)
|
|
584
|
+
logger.info(
|
|
585
|
+
"RuleMetric addon initialized (project: %s, version: %s)",
|
|
586
|
+
self.project_path,
|
|
587
|
+
cli_version,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# ─── Memory guard: stream bodies the addon will never parse ──────────
|
|
591
|
+
#
|
|
592
|
+
# mitmproxy buffers full request/response bodies in memory unless
|
|
593
|
+
# `.stream = True` is set at headers time. Through this proxy flows ALL
|
|
594
|
+
# of the user's dev traffic (VS Code CDN downloads, Datadog intake,
|
|
595
|
+
# telemetry, extension updates) — buffering it ballooned mitmdump to
|
|
596
|
+
# ~1 GB within a minute and macOS jetsam SIGKILLed it every ~2 min
|
|
597
|
+
# (last exit reason OS_REASON_JETSAM, 200+ restarts/day), which is why
|
|
598
|
+
# the /usage dashboard went stale: the proxy was dead more often than
|
|
599
|
+
# alive and the gateway routed direct. Only flows we actually parse
|
|
600
|
+
# (detected LLM providers + the oauth/usage poll) may be buffered.
|
|
601
|
+
|
|
602
|
+
def _is_oauth_usage_flow(self, flow: http.HTTPFlow) -> bool:
|
|
603
|
+
return (
|
|
604
|
+
flow.request.method == "GET"
|
|
605
|
+
and flow.request.pretty_host == self._OAUTH_USAGE_HOST
|
|
606
|
+
and flow.request.path.split("?", 1)[0] == self._OAUTH_USAGE_PATH
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def _flow_is_parsed(self, flow: http.HTTPFlow) -> bool:
|
|
610
|
+
"""True when request/response bodies are needed by this addon."""
|
|
611
|
+
if detect_provider(flow.request.pretty_host, flow.request.path) is not None:
|
|
612
|
+
return True
|
|
613
|
+
return self._is_oauth_usage_flow(flow)
|
|
614
|
+
|
|
615
|
+
def requestheaders(self, flow: http.HTTPFlow) -> None:
|
|
616
|
+
if not self._flow_is_parsed(flow):
|
|
617
|
+
flow.request.stream = True
|
|
618
|
+
|
|
619
|
+
def responseheaders(self, flow: http.HTTPFlow) -> None:
|
|
620
|
+
if not self._flow_is_parsed(flow):
|
|
621
|
+
flow.response.stream = True
|
|
622
|
+
|
|
623
|
+
def request(self, flow: http.HTTPFlow) -> None:
|
|
624
|
+
"""Called for each HTTP request passing through the proxy."""
|
|
625
|
+
# Streamed flows have no buffered body to read — and by construction
|
|
626
|
+
# they're flows we don't parse anyway.
|
|
627
|
+
if flow.request.stream:
|
|
628
|
+
return
|
|
629
|
+
# Skip internal worker calls (insights, evals) — not user sessions
|
|
630
|
+
if flow.request.headers.get("X-RuleMetric-Internal"):
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
host = flow.request.pretty_host
|
|
634
|
+
path = flow.request.path
|
|
635
|
+
|
|
636
|
+
provider = detect_provider(host, path)
|
|
637
|
+
if provider is None:
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
if flow.request.method != "POST":
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
body_text = flow.request.get_text()
|
|
644
|
+
if not body_text:
|
|
645
|
+
logger.warning("Empty request body for %s %s (provider: %s)", flow.request.method, path, provider)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
body = json.loads(body_text)
|
|
650
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
651
|
+
logger.warning("Failed to parse %s request body as JSON: %s (body length: %d)", provider, exc, len(body_text))
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
parser = _get_parser(provider)
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
snapshot = parser(body)
|
|
658
|
+
except Exception as exc:
|
|
659
|
+
logger.error("Parser crashed for provider %s: %s", provider, exc, exc_info=True)
|
|
660
|
+
self._error_count += 1
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
if snapshot is None:
|
|
664
|
+
logger.warning("Parser returned None for %s request (model: %s)", provider, body.get("model", "unknown"))
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
snapshot["provider"] = provider
|
|
668
|
+
snapshot["captured_at"] = datetime.now(timezone.utc).isoformat()
|
|
669
|
+
snapshot["project_path"] = self.project_path
|
|
670
|
+
|
|
671
|
+
# Capture safe request headers
|
|
672
|
+
headers_to_capture = {}
|
|
673
|
+
for header_name in SAFE_REQUEST_HEADERS:
|
|
674
|
+
val = flow.request.headers.get(header_name)
|
|
675
|
+
if val:
|
|
676
|
+
headers_to_capture[header_name] = val
|
|
677
|
+
if headers_to_capture:
|
|
678
|
+
snapshot["request_headers"] = headers_to_capture
|
|
679
|
+
|
|
680
|
+
# Parse anthropic-beta into feature list
|
|
681
|
+
beta_header = flow.request.headers.get("anthropic-beta")
|
|
682
|
+
if beta_header:
|
|
683
|
+
snapshot["beta_features"] = [f.strip() for f in beta_header.split(",") if f.strip()]
|
|
684
|
+
|
|
685
|
+
# Copilot Chat fires multiple LLM calls per user turn (main chat +
|
|
686
|
+
# title-gen + intent classification) — record the per-turn
|
|
687
|
+
# `x-interaction-id` so the UI can group snapshots within a session.
|
|
688
|
+
# Stored as snapshot metadata, NOT used for session attribution.
|
|
689
|
+
interaction_id = flow.request.headers.get("x-interaction-id")
|
|
690
|
+
if interaction_id:
|
|
691
|
+
snapshot["interaction_id"] = interaction_id
|
|
692
|
+
|
|
693
|
+
session_id = _resolve_session_id(provider, body, flow.request.headers)
|
|
694
|
+
wire_id = None
|
|
695
|
+
if not session_id:
|
|
696
|
+
# Auto-bootstrap may be in flight for this wire id (the resolver
|
|
697
|
+
# kicked it off in a background thread). Don't drop the snapshot:
|
|
698
|
+
# park it with session_id=None and retry resolution at response
|
|
699
|
+
# time — the model's multi-second response gives the bootstrap
|
|
700
|
+
# ample time to write the registry file. Without this, agents
|
|
701
|
+
# that make a single LLM call (subagents, scheduled wake-ups)
|
|
702
|
+
# produce sessions with zero snapshots.
|
|
703
|
+
wire_id = _extract_wire_session_id(provider, body, flow.request.headers)
|
|
704
|
+
if wire_id and _is_headless_sdk_call(body):
|
|
705
|
+
# Headless `claude -p` traffic is deliberately not captured
|
|
706
|
+
# as sessions (see _is_headless_sdk_call) — skip quietly.
|
|
707
|
+
return
|
|
708
|
+
if not (wire_id and provider in _PROVIDERS_WITH_AUTO_BOOTSTRAP):
|
|
709
|
+
logger.warning(
|
|
710
|
+
"No active hook session for %s traffic — snapshot dropped "
|
|
711
|
+
"(hook file missing or stale; expected at $TMPDIR/rulemetric/)",
|
|
712
|
+
provider,
|
|
713
|
+
)
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
snapshot["session_id"] = session_id
|
|
717
|
+
|
|
718
|
+
# Sequence is assigned server-side (atomic, survives proxy restarts)
|
|
719
|
+
self._snapshot_count += 1
|
|
720
|
+
logger.info(
|
|
721
|
+
"Captured %s request #%d: model=%s, messages=%d, tools=%d, session=%s",
|
|
722
|
+
provider,
|
|
723
|
+
self._snapshot_count,
|
|
724
|
+
snapshot.get("model", "unknown"),
|
|
725
|
+
snapshot.get("message_count", 0),
|
|
726
|
+
snapshot.get("tool_count", 0),
|
|
727
|
+
session_id,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# Store snapshot pending response enrichment
|
|
731
|
+
self._pending[flow.id] = {
|
|
732
|
+
"snapshot": snapshot,
|
|
733
|
+
"session_id": session_id,
|
|
734
|
+
"wire_id": wire_id,
|
|
735
|
+
"provider": provider,
|
|
736
|
+
"created_at": time.monotonic(),
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
# Evict stale entries to prevent memory leaks from aborted requests
|
|
740
|
+
self._evict_stale_pending()
|
|
741
|
+
|
|
742
|
+
def response(self, flow: http.HTTPFlow) -> None:
|
|
743
|
+
"""Called when the response is received — enrich snapshot with usage/stop_reason."""
|
|
744
|
+
# Phase 3a: intercept Claude Code's `/api/oauth/usage` polls so the
|
|
745
|
+
# research pipeline can observe rate-limit utilization without
|
|
746
|
+
# touching the request flow. This is a side channel — it runs even
|
|
747
|
+
# when no _pending snapshot exists for this flow.
|
|
748
|
+
try:
|
|
749
|
+
self._maybe_report_oauth_usage(flow)
|
|
750
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
751
|
+
logger.debug("oauth_usage observe failed: %s", exc)
|
|
752
|
+
|
|
753
|
+
# Phase 3b (Strategy C): parse rate-limit response headers for any
|
|
754
|
+
# adapter that has registered a `headers` handler (e.g. Codex).
|
|
755
|
+
# No-ops when no adapter matches the host or headers are absent.
|
|
756
|
+
try:
|
|
757
|
+
self._maybe_report_live_headers(flow)
|
|
758
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
759
|
+
logger.debug("live_headers observe failed: %s", exc)
|
|
760
|
+
|
|
761
|
+
pending = self._pending.pop(flow.id, None)
|
|
762
|
+
if pending is None:
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
snapshot = pending["snapshot"]
|
|
766
|
+
session_id = pending["session_id"]
|
|
767
|
+
provider = pending["provider"]
|
|
768
|
+
|
|
769
|
+
if not session_id:
|
|
770
|
+
# The request was parked while auto-bootstrap ran (see request()).
|
|
771
|
+
# By response time the bootstrap thread has normally written the
|
|
772
|
+
# registry file — resolve now or drop.
|
|
773
|
+
wire_id = pending.get("wire_id")
|
|
774
|
+
info = find_session_by_external_id(wire_id) if wire_id else None
|
|
775
|
+
session_id = info.get("session_id") if info else None
|
|
776
|
+
if not session_id:
|
|
777
|
+
logger.warning(
|
|
778
|
+
"Bootstrap for wire id %s not done by response time — snapshot dropped",
|
|
779
|
+
(wire_id or "?")[:16],
|
|
780
|
+
)
|
|
781
|
+
return
|
|
782
|
+
snapshot["session_id"] = session_id
|
|
783
|
+
|
|
784
|
+
# Parse response body — try SSE streaming first, fall back to JSON
|
|
785
|
+
resp_text = flow.response.get_text() if flow.response else None
|
|
786
|
+
content_type = flow.response.headers.get("content-type", "") if flow.response else ""
|
|
787
|
+
|
|
788
|
+
if resp_text:
|
|
789
|
+
# Try SSE streaming parse (Claude Code uses stream: true)
|
|
790
|
+
streaming_result = parse_streaming_response(resp_text, provider, content_type)
|
|
791
|
+
if streaming_result:
|
|
792
|
+
usage = streaming_result.get("usage")
|
|
793
|
+
if usage:
|
|
794
|
+
snapshot["usage"] = usage
|
|
795
|
+
logger.info(
|
|
796
|
+
"Streaming response usage: input=%s, output=%s, cache_read=%s, cache_write=%s",
|
|
797
|
+
usage.get("input_tokens"),
|
|
798
|
+
usage.get("output_tokens"),
|
|
799
|
+
usage.get("cache_read_input_tokens"),
|
|
800
|
+
usage.get("cache_creation_input_tokens"),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
resp_info = {
|
|
804
|
+
k: v for k, v in streaming_result.items()
|
|
805
|
+
if k in ("stop_reason", "model_used", "request_id", "content_block_types", "streaming", "text_content")
|
|
806
|
+
and v is not None
|
|
807
|
+
}
|
|
808
|
+
if resp_info:
|
|
809
|
+
snapshot["response"] = resp_info
|
|
810
|
+
else:
|
|
811
|
+
# Fallback: non-streaming JSON response
|
|
812
|
+
try:
|
|
813
|
+
resp_body = json.loads(resp_text)
|
|
814
|
+
|
|
815
|
+
usage = _parse_response_usage(resp_body)
|
|
816
|
+
if usage:
|
|
817
|
+
snapshot["usage"] = usage
|
|
818
|
+
logger.info(
|
|
819
|
+
"Response usage: input=%s, output=%s, cache_read=%s, cache_write=%s",
|
|
820
|
+
usage.get("input_tokens"),
|
|
821
|
+
usage.get("output_tokens"),
|
|
822
|
+
usage.get("cache_read_input_tokens"),
|
|
823
|
+
usage.get("cache_creation_input_tokens"),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
resp_info = _parse_response_content(resp_body, provider)
|
|
827
|
+
if resp_info:
|
|
828
|
+
snapshot["response"] = resp_info
|
|
829
|
+
|
|
830
|
+
except (json.JSONDecodeError, ValueError):
|
|
831
|
+
logger.debug("Could not parse response body as JSON or SSE")
|
|
832
|
+
|
|
833
|
+
_promote_actual_model(snapshot)
|
|
834
|
+
|
|
835
|
+
# Capture safe response headers
|
|
836
|
+
if flow.response:
|
|
837
|
+
resp_headers = {}
|
|
838
|
+
for header_name in SAFE_RESPONSE_HEADERS:
|
|
839
|
+
val = flow.response.headers.get(header_name)
|
|
840
|
+
if val:
|
|
841
|
+
resp_headers[header_name] = val
|
|
842
|
+
if resp_headers:
|
|
843
|
+
snapshot["response_headers"] = resp_headers
|
|
844
|
+
|
|
845
|
+
snapshot["response_status"] = flow.response.status_code
|
|
846
|
+
|
|
847
|
+
# Now report the enriched snapshot
|
|
848
|
+
report_snapshot(session_id, snapshot)
|
|
849
|
+
|
|
850
|
+
# Also post an assistant_response event for the timeline
|
|
851
|
+
event_metadata: dict = {}
|
|
852
|
+
if snapshot.get("usage"):
|
|
853
|
+
event_metadata["usage"] = snapshot["usage"]
|
|
854
|
+
if snapshot.get("response"):
|
|
855
|
+
resp = snapshot["response"]
|
|
856
|
+
if resp.get("stop_reason"):
|
|
857
|
+
event_metadata["stop_reason"] = resp["stop_reason"]
|
|
858
|
+
if resp.get("content_block_types"):
|
|
859
|
+
event_metadata["content_block_types"] = resp["content_block_types"]
|
|
860
|
+
if resp.get("model_used"):
|
|
861
|
+
event_metadata["model"] = resp["model_used"]
|
|
862
|
+
if resp.get("request_id"):
|
|
863
|
+
event_metadata["request_id"] = resp["request_id"]
|
|
864
|
+
if resp.get("streaming") is not None:
|
|
865
|
+
event_metadata["streaming"] = resp["streaming"]
|
|
866
|
+
|
|
867
|
+
# Extract response latency from server-timing header
|
|
868
|
+
if flow.response:
|
|
869
|
+
server_timing = flow.response.headers.get("server-timing", "")
|
|
870
|
+
if "dur=" in server_timing:
|
|
871
|
+
try:
|
|
872
|
+
dur_str = server_timing.split("dur=")[1].split(",")[0].split(";")[0]
|
|
873
|
+
event_metadata["response_time_ms"] = int(float(dur_str))
|
|
874
|
+
except (ValueError, IndexError):
|
|
875
|
+
pass
|
|
876
|
+
|
|
877
|
+
if snapshot.get("model"):
|
|
878
|
+
event_metadata.setdefault("model", snapshot["model"])
|
|
879
|
+
|
|
880
|
+
# Extract assistant text content for the timeline
|
|
881
|
+
assistant_text = None
|
|
882
|
+
response = snapshot.get("response")
|
|
883
|
+
if isinstance(response, dict):
|
|
884
|
+
assistant_text = response.get("text_content") or None
|
|
885
|
+
|
|
886
|
+
# Build the event — omit sequence so the API auto-assigns the next
|
|
887
|
+
# number from the shared counter (avoids collision with hook sequences)
|
|
888
|
+
assistant_event: dict = {
|
|
889
|
+
"eventType": "assistant_response",
|
|
890
|
+
"timestamp": snapshot.get("captured_at", datetime.now(timezone.utc).isoformat()),
|
|
891
|
+
"metadata": event_metadata,
|
|
892
|
+
}
|
|
893
|
+
if assistant_text:
|
|
894
|
+
assistant_event["content"] = assistant_text
|
|
895
|
+
# Phase 1E: top-level model + tokenUsage so the API writes them to
|
|
896
|
+
# session_events.model / session_events.token_usage (dedicated columns
|
|
897
|
+
# used by the insights cost rollup, instead of relying on the per-event
|
|
898
|
+
# metadata blob).
|
|
899
|
+
if snapshot.get("model"):
|
|
900
|
+
assistant_event["model"] = snapshot["model"]
|
|
901
|
+
if snapshot.get("usage"):
|
|
902
|
+
assistant_event["tokenUsage"] = snapshot["usage"]
|
|
903
|
+
|
|
904
|
+
report_event(session_id, assistant_event)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# ─── Phase 3a: oauth/usage observer ──────────────────────────────────
|
|
908
|
+
#
|
|
909
|
+
# Claude Code calls `GET https://api.anthropic.com/api/oauth/usage`
|
|
910
|
+
# (internal name `fetchUtilization`) periodically. The response shape is:
|
|
911
|
+
#
|
|
912
|
+
# {"rate_limits": {
|
|
913
|
+
# "five_hour": {"used_percentage": <0-100>, "resets_at": <unix>},
|
|
914
|
+
# "seven_day": {"used_percentage": <0-100>, "resets_at": <unix>},
|
|
915
|
+
# "seven_day_opus": {"used_percentage": <0-100>, "resets_at": <unix>}
|
|
916
|
+
# }}
|
|
917
|
+
#
|
|
918
|
+
# `seven_day` is the combined weekly window (presented as the Sonnet ring
|
|
919
|
+
# in /usage); `seven_day_opus` is the Opus-only sub-limit. Older Claude
|
|
920
|
+
# Code builds and Pro-tier accounts may omit `seven_day_opus`.
|
|
921
|
+
#
|
|
922
|
+
# We forward each window present in `rate_limits` to the research
|
|
923
|
+
# `observe` endpoint. Fire-and-forget — never blocks Claude Code's
|
|
924
|
+
# response — and silently no-ops on any parse failure / missing key.
|
|
925
|
+
|
|
926
|
+
_OAUTH_USAGE_HOST = "api.anthropic.com"
|
|
927
|
+
_OAUTH_USAGE_PATH = "/api/oauth/usage"
|
|
928
|
+
_OAUTH_USAGE_WINDOWS = (
|
|
929
|
+
("five_hour", "five_hour_percent"),
|
|
930
|
+
("seven_day", "seven_day_percent"),
|
|
931
|
+
("seven_day_opus", "seven_day_opus_percent"),
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
def _maybe_report_oauth_usage(self, flow: http.HTTPFlow) -> None:
|
|
935
|
+
"""If the flow is a `GET /api/oauth/usage` response, post observations."""
|
|
936
|
+
request = flow.request
|
|
937
|
+
if request.method != "GET":
|
|
938
|
+
return
|
|
939
|
+
if request.pretty_host != self._OAUTH_USAGE_HOST:
|
|
940
|
+
return
|
|
941
|
+
# Match exact path (strip query string just in case)
|
|
942
|
+
path = request.path.split("?", 1)[0]
|
|
943
|
+
|
|
944
|
+
# Side channel: oauth/account/settings carries the rate-limit tier
|
|
945
|
+
# (e.g. default_claude_max_5x) that the usage body does NOT — cache
|
|
946
|
+
# it so usage observations get a real plan instead of 'unknown'.
|
|
947
|
+
if path == "/api/oauth/account/settings" and flow.response is not None \
|
|
948
|
+
and flow.response.status_code == 200:
|
|
949
|
+
try:
|
|
950
|
+
tier = _extract_plan_tier(json.loads(flow.response.get_text() or "null"))
|
|
951
|
+
except (json.JSONDecodeError, ValueError):
|
|
952
|
+
tier = None
|
|
953
|
+
if tier and tier != _CACHED_PLAN["plan"]:
|
|
954
|
+
_CACHED_PLAN["plan"] = tier
|
|
955
|
+
logger.info("detected plan tier from account/settings: %s", tier)
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
if path != self._OAUTH_USAGE_PATH:
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
response = flow.response
|
|
962
|
+
if response is None or response.status_code != 200:
|
|
963
|
+
return
|
|
964
|
+
|
|
965
|
+
body_text = response.get_text()
|
|
966
|
+
if not body_text:
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
try:
|
|
970
|
+
body = json.loads(body_text)
|
|
971
|
+
except (json.JSONDecodeError, ValueError):
|
|
972
|
+
return
|
|
973
|
+
|
|
974
|
+
if not isinstance(body, dict):
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
# Two wire shapes: pre-June-2026 wrapped the windows in a
|
|
978
|
+
# `rate_limits` object; newer builds put `five_hour` / `seven_day` /
|
|
979
|
+
# `seven_day_opus` (plus extra windows we ignore) at the top level.
|
|
980
|
+
rate_limits = body.get("rate_limits")
|
|
981
|
+
if not isinstance(rate_limits, dict) or not rate_limits:
|
|
982
|
+
if any(k in body for k, _lt in self._OAUTH_USAGE_WINDOWS):
|
|
983
|
+
rate_limits = body
|
|
984
|
+
else:
|
|
985
|
+
# Loud on purpose: a silent schema change here killed usage
|
|
986
|
+
# tracking for a week once (2026-06-03 → 06-10).
|
|
987
|
+
logger.warning(
|
|
988
|
+
"oauth/usage response had no known windows — schema "
|
|
989
|
+
"drift? keys=%s", sorted(body.keys()),
|
|
990
|
+
)
|
|
991
|
+
return
|
|
992
|
+
|
|
993
|
+
# Best-effort session attribution — the oauth/usage call doesn't
|
|
994
|
+
# carry a session id, so fall back to the linker's current Claude
|
|
995
|
+
# Code session if any. Source evidence still records the raw body
|
|
996
|
+
# either way.
|
|
997
|
+
session_id: str | None = None
|
|
998
|
+
try:
|
|
999
|
+
active = find_active_session("anthropic")
|
|
1000
|
+
if active and isinstance(active, dict):
|
|
1001
|
+
session_id = active.get("session_id")
|
|
1002
|
+
except Exception: # pragma: no cover — defensive
|
|
1003
|
+
session_id = None
|
|
1004
|
+
|
|
1005
|
+
observed: list[tuple[str, float]] = []
|
|
1006
|
+
for wire_key, limit_type in self._OAUTH_USAGE_WINDOWS:
|
|
1007
|
+
window = rate_limits.get(wire_key)
|
|
1008
|
+
if not isinstance(window, dict):
|
|
1009
|
+
continue
|
|
1010
|
+
# `used_percentage` pre-June-2026; renamed `utilization` after.
|
|
1011
|
+
pct = window.get("used_percentage")
|
|
1012
|
+
if pct is None:
|
|
1013
|
+
pct = window.get("utilization")
|
|
1014
|
+
resets_at_raw = window.get("resets_at")
|
|
1015
|
+
if pct is None or resets_at_raw is None:
|
|
1016
|
+
continue
|
|
1017
|
+
try:
|
|
1018
|
+
value = float(pct)
|
|
1019
|
+
except (TypeError, ValueError):
|
|
1020
|
+
continue
|
|
1021
|
+
# Unix epoch pre-June-2026; ISO-8601 string after.
|
|
1022
|
+
try:
|
|
1023
|
+
if isinstance(resets_at_raw, str) and not resets_at_raw.isdigit():
|
|
1024
|
+
resets_at_iso = datetime.fromisoformat(
|
|
1025
|
+
resets_at_raw.replace("Z", "+00:00")
|
|
1026
|
+
).isoformat()
|
|
1027
|
+
else:
|
|
1028
|
+
resets_at_iso = datetime.fromtimestamp(
|
|
1029
|
+
int(resets_at_raw), tz=timezone.utc
|
|
1030
|
+
).isoformat()
|
|
1031
|
+
except (TypeError, ValueError, OSError, OverflowError):
|
|
1032
|
+
logger.warning(
|
|
1033
|
+
"oauth/usage %s: unparseable resets_at=%r", wire_key,
|
|
1034
|
+
resets_at_raw,
|
|
1035
|
+
)
|
|
1036
|
+
continue
|
|
1037
|
+
|
|
1038
|
+
observation = {
|
|
1039
|
+
"provider": "anthropic",
|
|
1040
|
+
"plan": _CACHED_PLAN["plan"] or "unknown",
|
|
1041
|
+
"limitType": limit_type,
|
|
1042
|
+
"value": value,
|
|
1043
|
+
"unit": "percentage",
|
|
1044
|
+
"comparator": "=",
|
|
1045
|
+
"resetsAt": resets_at_iso,
|
|
1046
|
+
"sourceType": "oauth_usage",
|
|
1047
|
+
"sourceUrl": "https://api.anthropic.com/api/oauth/usage",
|
|
1048
|
+
"sourceEvidence": {
|
|
1049
|
+
"rawResponse": body,
|
|
1050
|
+
"capturedSessionId": session_id,
|
|
1051
|
+
},
|
|
1052
|
+
"confidence": 1.0,
|
|
1053
|
+
}
|
|
1054
|
+
self._post_observation_async(observation)
|
|
1055
|
+
observed.append((limit_type, value))
|
|
1056
|
+
|
|
1057
|
+
if observed:
|
|
1058
|
+
parts = ", ".join(f"{lt}={v}%" for lt, v in observed)
|
|
1059
|
+
logger.info("observed oauth_usage: %s", parts)
|
|
1060
|
+
else:
|
|
1061
|
+
sample = next(
|
|
1062
|
+
(v for k, v in rate_limits.items() if isinstance(v, dict)), {},
|
|
1063
|
+
)
|
|
1064
|
+
logger.warning(
|
|
1065
|
+
"oauth/usage windows present but none parsed — keys=%s, "
|
|
1066
|
+
"sample window fields=%s",
|
|
1067
|
+
sorted(rate_limits.keys()), sorted(sample.keys()),
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
# ─── Phase 3b: Strategy-C response-header observer ───────────────────
|
|
1071
|
+
#
|
|
1072
|
+
# For providers that surface rate-limit state in response headers
|
|
1073
|
+
# (e.g. Codex: x-ratelimit-remaining-requests, x-ratelimit-reset-requests)
|
|
1074
|
+
# rather than a dedicated poll endpoint, we harvest the headers off every
|
|
1075
|
+
# captured model-call response. Zero extra network traffic — headers are
|
|
1076
|
+
# already in hand.
|
|
1077
|
+
#
|
|
1078
|
+
# The matching logic is intentionally kept in the TypeScript adapter
|
|
1079
|
+
# (`codex.ts liveSource.headers`) so the Python addon stays provider-
|
|
1080
|
+
# agnostic. We call a thin JSON-over-HTTP helper that loads the adapter
|
|
1081
|
+
# registry and runs `matchLiveHeaders`. That helper lives at
|
|
1082
|
+
# GET /api/internal/research/match-headers?host=<host> and returns the
|
|
1083
|
+
# parsed observations (empty array when no adapter matches).
|
|
1084
|
+
#
|
|
1085
|
+
# NOTE: This method is a no-op until the Codex spike (§2.1 of the plan)
|
|
1086
|
+
# fills in the real header names and host in codex.ts. The
|
|
1087
|
+
# matchLiveHeaders() function returns undefined for any host until the
|
|
1088
|
+
# SPIKE_REQUIRED placeholders are replaced.
|
|
1089
|
+
|
|
1090
|
+
def _maybe_report_live_headers(self, flow: http.HTTPFlow) -> None:
|
|
1091
|
+
"""Parse rate-limit headers for Strategy-C providers (e.g. Codex).
|
|
1092
|
+
|
|
1093
|
+
Dispatches to a background thread to avoid blocking the mitmproxy
|
|
1094
|
+
event loop — urlopen on the API can take tens of ms.
|
|
1095
|
+
"""
|
|
1096
|
+
if flow.response is None:
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
host = flow.request.pretty_host
|
|
1100
|
+
response_headers = dict(flow.response.headers)
|
|
1101
|
+
|
|
1102
|
+
# Quick pre-filter: only proceed if any x-ratelimit-* header is present.
|
|
1103
|
+
if not any(k.lower().startswith("x-ratelimit-") for k in response_headers):
|
|
1104
|
+
return
|
|
1105
|
+
|
|
1106
|
+
# Best-effort session attribution.
|
|
1107
|
+
session_id: str | None = None
|
|
1108
|
+
try:
|
|
1109
|
+
active = find_active_session(host)
|
|
1110
|
+
if active and isinstance(active, dict):
|
|
1111
|
+
session_id = active.get("session_id")
|
|
1112
|
+
except Exception: # pragma: no cover — defensive
|
|
1113
|
+
pass
|
|
1114
|
+
|
|
1115
|
+
payload = {
|
|
1116
|
+
"host": host,
|
|
1117
|
+
"path": flow.request.path,
|
|
1118
|
+
"responseHeaders": response_headers,
|
|
1119
|
+
"observedAt": datetime.now(timezone.utc).isoformat(),
|
|
1120
|
+
"plan": _CACHED_PLAN.get("plan") or "unknown",
|
|
1121
|
+
"userId": session_id,
|
|
1122
|
+
}
|
|
1123
|
+
threading.Thread(
|
|
1124
|
+
target=self._post_match_headers,
|
|
1125
|
+
args=(payload,),
|
|
1126
|
+
daemon=True,
|
|
1127
|
+
name="rulemetric-live-headers",
|
|
1128
|
+
).start()
|
|
1129
|
+
|
|
1130
|
+
@staticmethod
|
|
1131
|
+
def _post_match_headers(payload: dict) -> None:
|
|
1132
|
+
"""Synchronous POST to match-headers — called from a background thread."""
|
|
1133
|
+
import urllib.error
|
|
1134
|
+
import urllib.request
|
|
1135
|
+
from reporter import _get_api_url, _get_auth_headers
|
|
1136
|
+
|
|
1137
|
+
url = f"{_get_api_url()}/api/internal/research/match-headers"
|
|
1138
|
+
headers = {
|
|
1139
|
+
**_get_auth_headers(),
|
|
1140
|
+
"Content-Type": "application/json",
|
|
1141
|
+
"X-RuleMetric-Internal": "1",
|
|
1142
|
+
}
|
|
1143
|
+
req = urllib.request.Request(
|
|
1144
|
+
url, data=json.dumps(payload).encode(), headers=headers, method="POST"
|
|
1145
|
+
)
|
|
1146
|
+
try:
|
|
1147
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
1148
|
+
result = json.loads(resp.read())
|
|
1149
|
+
observations = result.get("observations", [])
|
|
1150
|
+
if observations:
|
|
1151
|
+
parts = [f"{o['limitType']}={o['value']}" for o in observations]
|
|
1152
|
+
logger.info("live_headers from %s: %s", payload.get("host"), ", ".join(parts))
|
|
1153
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
1154
|
+
pass # fire-and-forget
|
|
1155
|
+
|
|
1156
|
+
def _post_observation_async(self, observation: dict) -> None:
|
|
1157
|
+
"""Fire-and-forget POST to the research observe endpoint."""
|
|
1158
|
+
import threading
|
|
1159
|
+
threading.Thread(
|
|
1160
|
+
target=self._post_observation,
|
|
1161
|
+
args=(observation,),
|
|
1162
|
+
daemon=True,
|
|
1163
|
+
name="rulemetric-observe",
|
|
1164
|
+
).start()
|
|
1165
|
+
|
|
1166
|
+
@staticmethod
|
|
1167
|
+
def _post_observation(observation: dict) -> None:
|
|
1168
|
+
"""Synchronous POST — called from a background thread."""
|
|
1169
|
+
import urllib.error
|
|
1170
|
+
import urllib.request
|
|
1171
|
+
from reporter import _get_api_url, _get_auth_headers
|
|
1172
|
+
|
|
1173
|
+
url = f"{_get_api_url()}/api/internal/research/observe"
|
|
1174
|
+
headers = _get_auth_headers()
|
|
1175
|
+
# Mark internal so the addon's own request() handler skips it on the
|
|
1176
|
+
# ingress side (X-RuleMetric-Internal short-circuits the proxy).
|
|
1177
|
+
headers["X-RuleMetric-Internal"] = "1"
|
|
1178
|
+
data = json.dumps(observation).encode("utf-8")
|
|
1179
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
1180
|
+
try:
|
|
1181
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
1182
|
+
if resp.status >= 300:
|
|
1183
|
+
logger.debug(
|
|
1184
|
+
"observe POST returned HTTP %d", resp.status,
|
|
1185
|
+
)
|
|
1186
|
+
except urllib.error.URLError as exc:
|
|
1187
|
+
logger.debug("observe POST failed: %s", exc)
|
|
1188
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
1189
|
+
logger.debug("observe POST unexpected error: %s", exc)
|
|
1190
|
+
|
|
1191
|
+
# ─── Phase 3c: Strategy-E WebSocket frame observer ───────────────────
|
|
1192
|
+
#
|
|
1193
|
+
# Codex CLI uses a WebSocket connection to chatgpt.com/backend-api/codex/responses.
|
|
1194
|
+
# The server sends a `codex.rate_limits` frame (JSON) carrying primary/secondary
|
|
1195
|
+
# quota usage as percentages. We POST the raw frame text to match-stream-frame
|
|
1196
|
+
# so the TypeScript adapter registry can parse it. Zero extra network traffic
|
|
1197
|
+
# on top of what Codex already receives.
|
|
1198
|
+
|
|
1199
|
+
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
|
1200
|
+
"""Called for each WebSocket message frame passing through the proxy."""
|
|
1201
|
+
if not hasattr(flow, 'websocket') or flow.websocket is None:
|
|
1202
|
+
return
|
|
1203
|
+
message = flow.websocket.messages[-1] if flow.websocket.messages else None
|
|
1204
|
+
if message is None or message.from_client:
|
|
1205
|
+
return # only capture server→client frames (from_client is False for those)
|
|
1206
|
+
|
|
1207
|
+
try:
|
|
1208
|
+
frame_text = message.content.decode("utf-8", errors="replace")
|
|
1209
|
+
except (AttributeError, UnicodeDecodeError):
|
|
1210
|
+
return
|
|
1211
|
+
|
|
1212
|
+
# Quick pre-filter: only forward frames that look like rate_limits events.
|
|
1213
|
+
if "rate_limits" not in frame_text:
|
|
1214
|
+
return
|
|
1215
|
+
|
|
1216
|
+
host = flow.request.pretty_host
|
|
1217
|
+
payload = {
|
|
1218
|
+
"host": host,
|
|
1219
|
+
"path": flow.request.path,
|
|
1220
|
+
"frameText": frame_text,
|
|
1221
|
+
"observedAt": datetime.now(timezone.utc).isoformat(),
|
|
1222
|
+
"plan": _CACHED_PLAN.get("plan") or "unknown",
|
|
1223
|
+
}
|
|
1224
|
+
threading.Thread(
|
|
1225
|
+
target=self._post_match_stream_frame,
|
|
1226
|
+
args=(payload,),
|
|
1227
|
+
daemon=True,
|
|
1228
|
+
name="rulemetric-stream-frame",
|
|
1229
|
+
).start()
|
|
1230
|
+
|
|
1231
|
+
@staticmethod
|
|
1232
|
+
def _post_match_stream_frame(payload: dict) -> None:
|
|
1233
|
+
"""Synchronous POST to match-stream-frame — called from a background thread."""
|
|
1234
|
+
import urllib.error
|
|
1235
|
+
import urllib.request
|
|
1236
|
+
from reporter import _get_api_url, _get_auth_headers
|
|
1237
|
+
|
|
1238
|
+
url = f"{_get_api_url()}/api/internal/research/match-stream-frame"
|
|
1239
|
+
headers = {
|
|
1240
|
+
**_get_auth_headers(),
|
|
1241
|
+
"Content-Type": "application/json",
|
|
1242
|
+
"X-RuleMetric-Internal": "1",
|
|
1243
|
+
}
|
|
1244
|
+
req = urllib.request.Request(
|
|
1245
|
+
url, data=json.dumps(payload).encode(), headers=headers, method="POST"
|
|
1246
|
+
)
|
|
1247
|
+
try:
|
|
1248
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
1249
|
+
result = json.loads(resp.read())
|
|
1250
|
+
observations = result.get("observations", [])
|
|
1251
|
+
if observations:
|
|
1252
|
+
parts = [f"{o['limitType']}={o['value']}" for o in observations]
|
|
1253
|
+
logger.info("stream_frame from %s: %s", payload.get("host"), ", ".join(parts))
|
|
1254
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
1255
|
+
pass # fire-and-forget
|
|
1256
|
+
|
|
1257
|
+
def error(self, flow: http.HTTPFlow) -> None:
|
|
1258
|
+
"""Called when a flow errors (timeout, connection refused, etc.) — clean up pending."""
|
|
1259
|
+
pending = self._pending.pop(flow.id, None)
|
|
1260
|
+
if pending:
|
|
1261
|
+
logger.warning(
|
|
1262
|
+
"Flow errored — dropping pending snapshot (provider=%s, session=%s)",
|
|
1263
|
+
pending.get("provider", "unknown"),
|
|
1264
|
+
pending.get("session_id", "NONE"),
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
def _evict_stale_pending(self) -> None:
|
|
1268
|
+
"""Remove pending entries older than _PENDING_MAX_AGE seconds."""
|
|
1269
|
+
now = time.monotonic()
|
|
1270
|
+
stale = [fid for fid, p in self._pending.items()
|
|
1271
|
+
if now - p.get("created_at", now) > self._PENDING_MAX_AGE]
|
|
1272
|
+
for fid in stale:
|
|
1273
|
+
logger.warning("Evicting stale pending entry (flow_id=%s)", fid)
|
|
1274
|
+
self._pending.pop(fid, None)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
addons = [RuleMetricAddon()]
|
|
1278
|
+
|
|
1279
|
+
# Optionally prepend the security scanner addon (runs BEFORE traffic capture).
|
|
1280
|
+
# Fails gracefully if llm_guard is not installed or scanner is disabled.
|
|
1281
|
+
try:
|
|
1282
|
+
import os as _os
|
|
1283
|
+
if _os.environ.get("RULEMETRIC_SCANNER_MODE", "off") != "off":
|
|
1284
|
+
from security_scanner import SecurityScannerAddon
|
|
1285
|
+
addons.insert(0, SecurityScannerAddon())
|
|
1286
|
+
logger.info("SecurityScannerAddon loaded (mode=%s)", _os.environ.get("RULEMETRIC_SCANNER_MODE"))
|
|
1287
|
+
except Exception as _exc:
|
|
1288
|
+
logger.debug("SecurityScannerAddon not loaded: %s", _exc)
|