@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.
- package/addon/providers.py +58 -0
- package/addon/rulemetric_addon.py +73 -5
- package/addon/session_linker.py +64 -1
- package/addon/sse_parser.py +4 -0
- package/package.json +1 -1
package/addon/providers.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
641
|
-
#
|
|
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)
|
package/addon/session_linker.py
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/addon/sse_parser.py
CHANGED
|
@@ -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
|
|