@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.
Files changed (47) hide show
  1. package/Dockerfile +28 -0
  2. package/addon/context_classifier.py +375 -0
  3. package/addon/providers.py +483 -0
  4. package/addon/reporter.py +778 -0
  5. package/addon/requirements.txt +1 -0
  6. package/addon/rulemetric_addon.py +1288 -0
  7. package/addon/secret_redactor.py +130 -0
  8. package/addon/security_scanner.py +828 -0
  9. package/addon/session_linker.py +206 -0
  10. package/addon/sse_parser.py +364 -0
  11. package/dist/index.d.ts +10 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +8 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +135 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/base.d.ts +11 -0
  20. package/dist/providers/base.d.ts.map +1 -0
  21. package/dist/providers/base.js +43 -0
  22. package/dist/providers/base.js.map +1 -0
  23. package/dist/providers/bedrock.d.ts +8 -0
  24. package/dist/providers/bedrock.d.ts.map +1 -0
  25. package/dist/providers/bedrock.js +125 -0
  26. package/dist/providers/bedrock.js.map +1 -0
  27. package/dist/providers/gemini.d.ts +9 -0
  28. package/dist/providers/gemini.d.ts.map +1 -0
  29. package/dist/providers/gemini.js +140 -0
  30. package/dist/providers/gemini.js.map +1 -0
  31. package/dist/providers/index.d.ts +8 -0
  32. package/dist/providers/index.d.ts.map +1 -0
  33. package/dist/providers/index.js +116 -0
  34. package/dist/providers/index.js.map +1 -0
  35. package/dist/providers/openai.d.ts +9 -0
  36. package/dist/providers/openai.d.ts.map +1 -0
  37. package/dist/providers/openai.js +129 -0
  38. package/dist/providers/openai.js.map +1 -0
  39. package/dist/session-linker.d.ts +6 -0
  40. package/dist/session-linker.d.ts.map +1 -0
  41. package/dist/session-linker.js +68 -0
  42. package/dist/session-linker.js.map +1 -0
  43. package/dist/types.d.ts +41 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -0
  47. 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)