@rulemetric/proxy 0.7.21 → 0.7.22

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.
@@ -52,6 +52,24 @@ def detect_provider(host: str, path: str) -> str | None:
52
52
  return "anthropic"
53
53
  if host == "generativelanguage.googleapis.com" and ":generateContent" in path:
54
54
  return "gemini"
55
+ # Antigravity / Gemini Code Assist. NOT generativelanguage — the IDE routes
56
+ # LLM traffic to the internal Cloud Code API at cloudcode-pa.googleapis.com
57
+ # (canary: daily-cloudcode-pa) with `/v1internal:<method>` paths. Confirmed
58
+ # live 2026-07-20: 17 intercepted `POST /v1internal:streamGenerateContent?alt=sse`
59
+ # calls, which also disproves the long-standing "Antigravity bypasses the
60
+ # proxy over QUIC" theory — the traffic arrives over TCP and is decrypted.
61
+ #
62
+ # Matching `:streamGenerateContent` only is deliberate: the other v1internal
63
+ # methods this host serves (loadCodeAssist, fetchUserInfo,
64
+ # fetchAvailableModels, cascadeNuxes, onboardUser) are metadata/heartbeat
65
+ # calls carrying no prompt or model usage, and they fire every 5 minutes.
66
+ # Matching them would buffer their bodies for nothing.
67
+ #
68
+ # Returning a provider here is load-bearing beyond routing: the addon streams
69
+ # (and therefore never buffers the body of) any flow `_flow_is_parsed()`
70
+ # rejects, so without this rule the request body is unreadable by design.
71
+ if "cloudcode-pa.googleapis.com" in host and ":streamGenerateContent" in path:
72
+ return "gemini_code_assist"
55
73
  if "bedrock-runtime" in host and "/converse" in path:
56
74
  return "bedrock"
57
75
  if host.endswith(".snowflakecomputing.com") and "/api/v2/cortex/inference:complete" in path:
@@ -385,6 +403,46 @@ def parse_gemini(body: dict[str, Any]) -> dict[str, Any]:
385
403
  return _finalize(snap)
386
404
 
387
405
 
406
+ def parse_gemini_code_assist(body: dict[str, Any]) -> dict[str, Any]:
407
+ """Parse an Antigravity / Gemini Code Assist ``streamGenerateContent`` body.
408
+
409
+ cloudcode-pa wraps a standard Gemini request in an envelope::
410
+
411
+ {"model": "gemini-3-flash-agent", "project": ..., "requestId": ...,
412
+ "requestType": ..., "userAgent": ...,
413
+ "request": {"contents": [...], "systemInstruction": {...},
414
+ "generationConfig": {...}, "tools": [...],
415
+ "sessionId": ..., "labels": ..., "toolConfig": ...}}
416
+
417
+ (envelope confirmed from a live intercepted request, 2026-07-20).
418
+
419
+ Two things make this more than a passthrough to :func:`parse_gemini`:
420
+
421
+ 1. The model id is on the OUTER envelope, not the inner body — parsing the
422
+ inner body alone yields ``model="unknown"`` and silently destroys every
423
+ per-model segmented verdict for this harness.
424
+ 2. The provider is reported as ``gemini_code_assist``, NOT ``gemini``. They
425
+ are different hosts, different envelopes and different harnesses;
426
+ collapsing them would merge Antigravity usage into direct Gemini API
427
+ usage in any provider-grouped read.
428
+ """
429
+ inner = body.get("request")
430
+ if not isinstance(inner, dict):
431
+ inner = {}
432
+
433
+ snap = parse_gemini(inner)
434
+ snap["provider"] = "gemini_code_assist"
435
+
436
+ # Outer envelope wins; fall back to the inner body, then parse_gemini's
437
+ # own "unknown" sentinel (which resolveModelId treats as no-model, so an
438
+ # unresolved model never becomes a fabricated segment).
439
+ model = body.get("model") or inner.get("model")
440
+ if model:
441
+ snap["model"] = model
442
+
443
+ return snap
444
+
445
+
388
446
  # ---------------------------------------------------------------------------
389
447
  # Bedrock
390
448
  # ---------------------------------------------------------------------------
@@ -13,7 +13,15 @@ from datetime import datetime, timezone
13
13
 
14
14
  from mitmproxy import http
15
15
 
16
- from providers import detect_provider, parse_anthropic, parse_openai, parse_gemini, parse_bedrock, parse_copilot_fim
16
+ from providers import (
17
+ detect_provider,
18
+ parse_anthropic,
19
+ parse_openai,
20
+ parse_gemini,
21
+ parse_gemini_code_assist,
22
+ parse_bedrock,
23
+ parse_copilot_fim,
24
+ )
17
25
  from reporter import report_snapshot, report_event
18
26
  from session_linker import find_active_session, find_session_by_external_id
19
27
  from sse_parser import parse_streaming_response
@@ -25,6 +33,7 @@ logger = logging.getLogger("rulemetric.addon")
25
33
  _NON_OPENAI_PARSERS = {
26
34
  "anthropic": parse_anthropic,
27
35
  "gemini": parse_gemini,
36
+ "gemini_code_assist": parse_gemini_code_assist,
28
37
  "bedrock": parse_bedrock,
29
38
  "github_copilot_fim": parse_copilot_fim,
30
39
  }
@@ -58,12 +67,14 @@ _UUID_RE_LITERAL = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
58
67
  _PROVIDERS_WITH_AUTO_BOOTSTRAP = frozenset({
59
68
  "github_copilot_chat", # vscode-sessionid header — stable per VS Code window
60
69
  "anthropic", # Claude Code session_id — unique per conversation
70
+ "gemini_code_assist", # request.sessionId — per-conversation (Antigravity)
61
71
  })
62
72
 
63
73
  # Maps internal provider name to the `tool` value used in the sessions table.
64
74
  _PROVIDER_TO_TOOL = {
65
75
  "github_copilot_chat": "vscode_copilot",
66
76
  "anthropic": "claude_code",
77
+ "gemini_code_assist": "antigravity",
67
78
  }
68
79
 
69
80
 
@@ -86,6 +97,8 @@ def _extract_wire_session_id(
86
97
  * ``github_copilot_chat``: ``vscode-sessionid`` HEADER (stable per VS
87
98
  Code window; one RuleMetric session per VS Code instance — see
88
99
  `_PROVIDERS_WITH_AUTO_BOOTSTRAP`).
100
+ * ``gemini_code_assist``: ``body.request.sessionId`` (Antigravity nests
101
+ its per-conversation id inside the Cloud Code envelope).
89
102
 
90
103
  OpenAI/Cursor put a stable user id in ``body.user``; whether it's a
91
104
  session id or a long-lived machine id is harness-dependent, so it's an
@@ -113,6 +126,26 @@ def _extract_wire_session_id(
113
126
  return sid
114
127
  return None
115
128
 
129
+ if provider == "gemini_code_assist":
130
+ # Antigravity nests its per-conversation id inside the Cloud Code
131
+ # envelope: {"model": ..., "request": {"sessionId": ..., ...}}.
132
+ # UUID-validated like the anthropic path — the id becomes a
133
+ # registry FILENAME in _ensure_session_for_wire_id, so an
134
+ # unvalidated value would be a path-traversal vector.
135
+ #
136
+ # UNVERIFIED: whether this equals the conversation id the
137
+ # Antigravity hook registers (_ensure-session.sh). If they differ,
138
+ # the wire id has no registry file and auto-bootstrap creates a
139
+ # SECOND session alongside the hook's. The probe logs both so the
140
+ # next live generation settles it — do not assume equivalence.
141
+ if not body:
142
+ return None
143
+ inner = body.get("request")
144
+ sid = inner.get("sessionId") if isinstance(inner, dict) else None
145
+ if isinstance(sid, str) and re.fullmatch(_UUID_RE_LITERAL, sid):
146
+ return sid
147
+ return None
148
+
116
149
  if provider == "github_copilot_chat" and headers is not None:
117
150
  # mitmproxy.http.Headers supports .get like a dict
118
151
  sid = headers.get("vscode-sessionid") if hasattr(headers, "get") else None
@@ -457,7 +490,7 @@ def _parse_response_content(body: dict, provider: str) -> dict:
457
490
  # Stop reason — Anthropic and Gemini have unique formats; everything else is OpenAI-compatible
458
491
  if provider == "anthropic":
459
492
  result["stop_reason"] = body.get("stop_reason")
460
- elif provider == "gemini":
493
+ elif provider in ("gemini", "gemini_code_assist"):
461
494
  candidates = body.get("candidates", [])
462
495
  if candidates:
463
496
  result["stop_reason"] = candidates[0].get("finishReason")
@@ -637,20 +670,55 @@ class RuleMetricAddon:
637
670
  # Deliberately placed BEFORE the `flow.request.stream` early-return:
638
671
  # generation calls are the ones most likely to be streamed, so a probe
639
672
  # after that check would silently skip the exact traffic we need.
640
- # Off unless RULEMETRIC_PROXY_DEBUG_CLOUDCODE=1; delete once the parser
641
- # lands.
673
+ # Off unless RULEMETRIC_PROXY_DEBUG_CLOUDCODE=1. The parser has landed,
674
+ # but this stays: snapshot ATTRIBUTION for this provider is still broken
675
+ # (the proxy posts to a session id the API 404s on), and this probe is
676
+ # the only view of the request envelope while that is worked out. Remove
677
+ # it once Antigravity snapshots persist end to end.
642
678
  if os.environ.get("RULEMETRIC_PROXY_DEBUG_CLOUDCODE") == "1":
643
679
  try:
644
680
  if "cloudcode-pa.googleapis.com" in flow.request.pretty_host:
645
681
  body_len = 0 if flow.request.stream else len(flow.request.raw_content or b"")
682
+ # Structure only — top-level keys and the model id, never
683
+ # prompt content. Enough to write the parser against the
684
+ # real Cloud Code envelope instead of assuming it matches
685
+ # generativelanguage's plain Gemini shape.
686
+ shape = "-"
687
+ if body_len:
688
+ try:
689
+ parsed = json.loads(flow.request.get_text() or "{}")
690
+ if isinstance(parsed, dict):
691
+ keys = sorted(parsed.keys())
692
+ model = parsed.get("model")
693
+ inner = parsed.get("request")
694
+ inner_keys = (
695
+ sorted(inner.keys()) if isinstance(inner, dict) else None
696
+ )
697
+ # sessionId is logged because attribution now
698
+ # keys on it (_extract_wire_session_id). It must
699
+ # be compared against the conversation id the
700
+ # Antigravity hook registers in
701
+ # $TMPDIR/rulemetric/ — if they differ, the
702
+ # proxy bootstraps a second session instead of
703
+ # joining the hook's. An id, not prompt content.
704
+ inner_sid = (
705
+ inner.get("sessionId") if isinstance(inner, dict) else None
706
+ )
707
+ shape = (
708
+ f"keys={keys} model={model!r} "
709
+ f"request.keys={inner_keys} sessionId={inner_sid!r}"
710
+ )
711
+ except Exception as exc:
712
+ shape = f"<unparsed: {exc}>"
646
713
  logger.warning(
647
- "[cloudcode-probe] %s https://%s%s streamed=%s body_bytes=%d ctype=%s",
714
+ "[cloudcode-probe] %s https://%s%s streamed=%s body_bytes=%d ctype=%s %s",
648
715
  flow.request.method,
649
716
  flow.request.pretty_host,
650
717
  flow.request.path,
651
718
  bool(flow.request.stream),
652
719
  body_len,
653
720
  flow.request.headers.get("content-type", "-"),
721
+ shape,
654
722
  )
655
723
  except Exception as exc: # never let a probe break capture
656
724
  logger.warning("[cloudcode-probe] failed: %s", exc)
@@ -37,6 +37,11 @@ _PROVIDER_TO_TOOLS: dict[str, list[str]] = {
37
37
  "github_copilot_chat": ["vscode_copilot"],
38
38
  "github_copilot_fim": ["vscode_copilot"],
39
39
  "azure_openai": ["cursor", "vscode_copilot"],
40
+ # Antigravity / Gemini Code Assist (cloudcode-pa). Without this the linker
41
+ # falls through to "most recent session", which on a dev machine is the
42
+ # Claude Code session driving the terminal — silently recording Antigravity
43
+ # generations against Claude Code and corrupting per-harness comparisons.
44
+ "gemini_code_assist": ["antigravity"],
40
45
  }
41
46
 
42
47
 
@@ -45,7 +50,32 @@ def _parse_session_file(f: Path, now: float) -> dict[str, str] | None:
45
50
 
46
51
  Returned dict has keys: session_id, rw_session_id, tool_type.
47
52
  """
48
- SKIP_SUFFIXES = (".log", ".seq", ".ctxhash", ".pid", ".lock")
53
+ # Companion files that share the registry directory with real session keys.
54
+ # A session key file is named for the harness session id and CONTAINS the DB
55
+ # session id; every entry below is a sidecar written next to it.
56
+ #
57
+ # This list is load-bearing, not cosmetic. `.injected-memories` (written by
58
+ # session-start.sh and REWRITTEN by user-prompt.sh on every prompt) holds one
59
+ # MEMORY uuid per line. Memory ids and session ids are both bare uuids, so a
60
+ # marker parsed as a session file hands back a memory id — and because the
61
+ # marker is rewritten per prompt it is usually the NEWEST file in the
62
+ # directory, which is exactly what the mtime-descending scan below prefers.
63
+ # Result: every snapshot for any provider without a wire session id was
64
+ # posted against a memory id and rejected 404. Observed live 2026-07-20 with
65
+ # Antigravity (memory dbed4f3d-88ab-4abb-a7e1-71702e8667b6), silently broken
66
+ # since memory injection shipped.
67
+ #
68
+ # When adding a new sidecar anywhere (hooks, proxy, CLI), add its suffix
69
+ # here in the same change.
70
+ SKIP_SUFFIXES = (
71
+ ".log",
72
+ ".seq",
73
+ ".ctxhash",
74
+ ".pid",
75
+ ".lock",
76
+ ".sesslock",
77
+ ".injected-memories",
78
+ )
49
79
 
50
80
  if not f.is_file():
51
81
  return None
@@ -198,6 +228,39 @@ def find_active_session(provider: str | None = None) -> dict[str, str] | None:
198
228
  )
199
229
  return candidate
200
230
 
231
+ # No session of the expected harness. Do NOT hand this traffic to a
232
+ # session belonging to a DIFFERENT, positively-identified harness —
233
+ # that silently files (e.g.) Antigravity generations into a Claude
234
+ # Code session and corrupts the per-harness comparison this pipeline
235
+ # exists to make. Observed live 2026-07-20: 2 of 6 Antigravity
236
+ # snapshots landed on an active Claude Code session because the
237
+ # Antigravity registry file predated the tool_type line and so could
238
+ # never match above, leaving a bare mtime race between two live
239
+ # harnesses.
240
+ #
241
+ # This mirrors the wire-id path's existing policy in
242
+ # rulemetric_addon.py ("drop rather than risk cross-attribution");
243
+ # that guard only covered providers that expose a wire id.
244
+ #
245
+ # Blank tool_type stays eligible: legacy/pre-fix registry files
246
+ # carry no harness line, and dropping them would lose capture for
247
+ # every session created before _ensure-session.sh started writing
248
+ # line 2. Unknown is ambiguous; a known-foreign harness is not.
249
+ foreign_tools = {
250
+ t for tools in _PROVIDER_TO_TOOLS.values() for t in tools
251
+ } - set(preferred_tools)
252
+ filtered = [
253
+ c for c in candidates if c.get("tool_type") not in foreign_tools
254
+ ]
255
+ if not filtered:
256
+ logger.warning(
257
+ "No session for provider %s (%d candidate(s), all belonging to "
258
+ "other harnesses) — dropping rather than cross-attributing",
259
+ provider, len(candidates),
260
+ )
261
+ return None
262
+ candidates = filtered
263
+
201
264
  # Fall back to most recent session (candidates are already sorted by mtime desc)
202
265
  logger.debug(
203
266
  "Using most recent session %s (tool=%s) — no provider-specific match",
@@ -320,6 +320,10 @@ def aggregate_copilot_fim_sse(events: list[dict[str, Any]]) -> dict[str, Any]:
320
320
  _NON_OPENAI_AGGREGATORS = {
321
321
  "anthropic": aggregate_anthropic_sse,
322
322
  "gemini": aggregate_gemini_sse,
323
+ # Antigravity/Code Assist responses are Gemini SSE (usageMetadata +
324
+ # candidates[].finishReason) — same wire shape, different envelope on the
325
+ # REQUEST side only.
326
+ "gemini_code_assist": aggregate_gemini_sse,
323
327
  "github_copilot_fim": aggregate_copilot_fim_sse,
324
328
  }
325
329
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/proxy",
3
- "version": "0.7.21",
3
+ "version": "0.7.22",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"