@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,483 @@
1
+ """Provider detection and request body parsing for mitmproxy addon.
2
+
3
+ Mirrors the TypeScript provider adapters. Uses only Python stdlib.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+ from context_classifier import classify_context
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Provider detection
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _KNOWN_OPENAI_COMPAT_HOSTS: dict[str, str] = {
20
+ "api.openai.com": "openai",
21
+ "api.groq.com": "groq",
22
+ "api.together.xyz": "together",
23
+ "api.fireworks.ai": "fireworks",
24
+ "api.deepseek.com": "deepseek",
25
+ "api.perplexity.ai": "perplexity",
26
+ "api.cerebras.ai": "cerebras",
27
+ "api.sambanova.ai": "sambanova",
28
+ "api.x.ai": "xai",
29
+ "api.mistral.ai": "mistral",
30
+ "integrate.api.nvidia.com": "nvidia_nim",
31
+ "api.ai21.com": "ai21",
32
+ "api.cohere.com": "cohere",
33
+ "openrouter.ai": "openrouter",
34
+ "openai-proxy.replicate.com": "replicate",
35
+ "api.githubcopilot.com": "github_copilot_chat",
36
+ "api.individual.githubcopilot.com": "github_copilot_chat",
37
+ "api.business.githubcopilot.com": "github_copilot_chat",
38
+ "api.enterprise.githubcopilot.com": "github_copilot_chat",
39
+ }
40
+
41
+
42
+ def detect_provider(host: str, path: str) -> str | None:
43
+ """Return a canonical provider name based on the request host and path.
44
+
45
+ Specific providers with non-OpenAI formats are matched first.
46
+ Then known OpenAI-compatible hosts are checked.
47
+ Finally, any request to a ``chat/completions`` path is treated as
48
+ a generic OpenAI-compatible provider, with the name derived from the host.
49
+ """
50
+ # --- Non-OpenAI formats (must match first) ---
51
+ if host == "api.anthropic.com" and "/v1/messages" in path:
52
+ return "anthropic"
53
+ if host == "generativelanguage.googleapis.com" and ":generateContent" in path:
54
+ return "gemini"
55
+ if "bedrock-runtime" in host and "/converse" in path:
56
+ return "bedrock"
57
+ if host.endswith(".snowflakecomputing.com") and "/api/v2/cortex/inference:complete" in path:
58
+ return "cortex"
59
+
60
+ # --- GitHub Copilot FIM completions (non-chat format) ---
61
+ if host == "copilot-proxy.githubusercontent.com":
62
+ if "/v1/engines/" in path and "/completions" in path:
63
+ return "github_copilot_fim"
64
+ if "/chat/completions" in path:
65
+ return "github_copilot_chat"
66
+ return None # telemetry or other traffic — skip
67
+
68
+ # --- Azure OpenAI (host-based, before generic catch-all) ---
69
+ if host.endswith(".openai.azure.com") and "/chat/completions" in path:
70
+ return "azure_openai"
71
+
72
+ # --- Azure AI Model Catalog ---
73
+ if host.endswith(".models.ai.azure.com") and "/chat/completions" in path:
74
+ return "azure_ai"
75
+
76
+ # --- Cloudflare Workers AI (OpenAI-compat mode) ---
77
+ if host == "api.cloudflare.com" and "/ai/v1/chat/completions" in path:
78
+ return "cloudflare"
79
+
80
+ # --- Databricks Foundation Model API ---
81
+ if host.endswith(".cloud.databricks.com") and "/invocations" in path:
82
+ return "databricks"
83
+
84
+ # NOTE: VS Code Copilot also POSTs to `/models/session` and
85
+ # `/agents/session` on api.githubcopilot.com — these are session-init /
86
+ # model-list polling endpoints, NOT chat completions. Routing them
87
+ # through parse_openai produces empty `messages: []` snapshots and 0-len
88
+ # assistant_response events that pollute the dashboard. Detection for
89
+ # those endpoints is intentionally removed; only `/chat/completions`
90
+ # gets captured (handled by the _KNOWN_OPENAI_COMPAT_HOSTS block below).
91
+
92
+ # --- Known OpenAI-compatible hosts ---
93
+ for known_host, provider_name in _KNOWN_OPENAI_COMPAT_HOSTS.items():
94
+ if host == known_host or host.endswith("." + known_host):
95
+ if "chat/completions" in path or "/completions" in path:
96
+ return provider_name
97
+
98
+ # --- Ollama native API ---
99
+ if "localhost" in host and "/api/chat" in path:
100
+ return "ollama"
101
+
102
+ # --- Ollama OpenAI-compat endpoint ---
103
+ if "localhost" in host and "/v1/chat/completions" in path:
104
+ return "ollama"
105
+
106
+ # --- Generic OpenAI-compatible catch-all ---
107
+ if "chat/completions" in path:
108
+ return _provider_from_host(host)
109
+
110
+ return None
111
+
112
+
113
+ def _provider_from_host(host: str) -> str:
114
+ """Derive a short provider name from a hostname.
115
+
116
+ Examples:
117
+ api.foo.com → foo
118
+ foo.example.com → foo
119
+ localhost:1234 → localhost
120
+ """
121
+ # Strip port
122
+ h = host.split(":")[0]
123
+ parts = h.split(".")
124
+ if len(parts) >= 2:
125
+ # Use the second-to-last part (e.g., "foo" from "api.foo.com")
126
+ # Unless it's a known TLD part like "co" in "api.foo.co.uk"
127
+ return parts[-2] if parts[-2] not in ("co", "com", "net", "org", "io", "ai") else parts[-3] if len(parts) >= 3 else parts[0]
128
+ return parts[0]
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # System-reminder extraction & hashing
133
+ # ---------------------------------------------------------------------------
134
+
135
+ _SYSTEM_REMINDER_RE = re.compile(
136
+ r"<system-reminder>(.*?)</system-reminder>", re.DOTALL
137
+ )
138
+
139
+
140
+ def extract_system_reminders(text: str) -> list[str]:
141
+ """Extract all fully-closed <system-reminder> blocks from *text*."""
142
+ return _SYSTEM_REMINDER_RE.findall(text)
143
+
144
+
145
+ def compute_system_hash(text: str) -> str:
146
+ """Return the SHA-256 hex digest of *text*."""
147
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Helpers
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def _estimate_tokens(text: str) -> int:
155
+ """Rough token estimate (~4 chars per token)."""
156
+ return max(1, len(text) // 4)
157
+
158
+
159
+ def _flatten_text(parts: Any) -> str:
160
+ """Collapse a string-or-list-of-blocks into a single string."""
161
+ if isinstance(parts, str):
162
+ return parts
163
+ if isinstance(parts, list):
164
+ return "\n".join(
165
+ block.get("text", "") if isinstance(block, dict) else str(block)
166
+ for block in parts
167
+ )
168
+ return str(parts) if parts else ""
169
+
170
+
171
+ def _base_snapshot() -> dict[str, Any]:
172
+ return {
173
+ "provider": "unknown",
174
+ "model": "unknown",
175
+ "system_prompt": "",
176
+ "system_blocks": [],
177
+ "messages": [],
178
+ "messages_raw": [], # Full raw message objects from the API request
179
+ "tools": [],
180
+ "max_tokens": None,
181
+ "temperature": None,
182
+ "top_p": None,
183
+ "top_k": None,
184
+ "stop_sequences": [],
185
+ "stream": False,
186
+ "tool_choice": None,
187
+ "thinking": None,
188
+ "injected_instructions": [],
189
+ "system_hash": "",
190
+ "request_hash": "", # Hash of the entire request (system + messages + tools)
191
+ "message_count": 0,
192
+ "tool_count": 0,
193
+ "estimated_tokens": 0,
194
+ }
195
+
196
+
197
+ def _finalize(snapshot: dict[str, Any]) -> dict[str, Any]:
198
+ """Compute derived fields on a snapshot dict."""
199
+ snapshot["message_count"] = len(snapshot["messages"])
200
+ snapshot["tool_count"] = len(snapshot["tools"])
201
+
202
+ blob = json.dumps(
203
+ {
204
+ "system_prompt": snapshot["system_prompt"],
205
+ "messages": snapshot["messages"],
206
+ "tools": snapshot["tools"],
207
+ },
208
+ separators=(",", ":"),
209
+ )
210
+ snapshot["estimated_tokens"] = _estimate_tokens(blob)
211
+
212
+ if snapshot["system_prompt"]:
213
+ snapshot["system_hash"] = compute_system_hash(snapshot["system_prompt"])
214
+
215
+ # Hash the entire request for per-turn dedup
216
+ full_blob = json.dumps(
217
+ {
218
+ "system_prompt": snapshot["system_prompt"],
219
+ "messages_raw": snapshot["messages_raw"],
220
+ "tools": snapshot["tools"],
221
+ },
222
+ separators=(",", ":"),
223
+ sort_keys=True,
224
+ )
225
+ snapshot["request_hash"] = compute_system_hash(full_blob)
226
+
227
+ # Extract system-reminders from ALL messages (not just system prompt)
228
+ all_injected = []
229
+ if snapshot["system_prompt"]:
230
+ all_injected.extend(extract_system_reminders(snapshot["system_prompt"]))
231
+ for msg in snapshot["messages"]:
232
+ content = msg.get("content", "")
233
+ if content:
234
+ all_injected.extend(extract_system_reminders(content))
235
+ if all_injected:
236
+ snapshot["injected_instructions"] = all_injected
237
+
238
+ # Classify all context items
239
+ snapshot["context_items"] = classify_context(snapshot)
240
+
241
+ return snapshot
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Anthropic
246
+ # ---------------------------------------------------------------------------
247
+
248
+ def parse_anthropic(body: dict[str, Any]) -> dict[str, Any]:
249
+ """Parse an Anthropic ``/v1/messages`` request body."""
250
+ snap = _base_snapshot()
251
+ snap["provider"] = "anthropic"
252
+ snap["model"] = body.get("model", "unknown")
253
+ snap["max_tokens"] = body.get("max_tokens")
254
+ snap["temperature"] = body.get("temperature")
255
+ snap["top_p"] = body.get("top_p")
256
+ snap["top_k"] = body.get("top_k")
257
+ snap["stop_sequences"] = body.get("stop_sequences", [])
258
+ snap["stream"] = body.get("stream", False)
259
+ snap["tool_choice"] = body.get("tool_choice")
260
+ snap["thinking"] = body.get("thinking")
261
+ # Metadata (user_id, etc.)
262
+ snap["api_metadata"] = body.get("metadata")
263
+
264
+ # System — string or array of content blocks
265
+ raw_system = body.get("system", "")
266
+ if isinstance(raw_system, list):
267
+ snap["system_blocks"] = raw_system
268
+ snap["system_prompt"] = _flatten_text(raw_system)
269
+ else:
270
+ snap["system_prompt"] = str(raw_system)
271
+ if raw_system:
272
+ snap["system_blocks"] = [{"type": "text", "text": raw_system}]
273
+
274
+ # Messages — keep both normalized (for display) and raw (for fidelity)
275
+ snap["messages_raw"] = body.get("messages", [])
276
+ for msg in body.get("messages", []):
277
+ snap["messages"].append({
278
+ "role": msg.get("role", "user"),
279
+ "content": _flatten_text(msg.get("content", "")),
280
+ })
281
+
282
+ # Tools — rename input_schema → parameters
283
+ for tool in body.get("tools", []):
284
+ normalized: dict[str, Any] = {"name": tool.get("name", "")}
285
+ if "description" in tool:
286
+ normalized["description"] = tool["description"]
287
+ if "input_schema" in tool:
288
+ normalized["parameters"] = tool["input_schema"]
289
+ snap["tools"].append(normalized)
290
+
291
+ return _finalize(snap)
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # OpenAI (and Azure OpenAI)
296
+ # ---------------------------------------------------------------------------
297
+
298
+ def parse_openai(body: dict[str, Any]) -> dict[str, Any]:
299
+ """Parse an OpenAI ``/v1/chat/completions`` request body."""
300
+ snap = _base_snapshot()
301
+ snap["provider"] = "openai"
302
+ snap["model"] = body.get("model", "unknown")
303
+ snap["max_tokens"] = body.get("max_completion_tokens") or body.get("max_tokens")
304
+ snap["temperature"] = body.get("temperature")
305
+ snap["top_p"] = body.get("top_p")
306
+ snap["stop_sequences"] = body.get("stop") if isinstance(body.get("stop"), list) else ([body["stop"]] if body.get("stop") else [])
307
+ snap["stream"] = body.get("stream", False)
308
+ snap["tool_choice"] = body.get("tool_choice")
309
+ snap["api_metadata"] = {"user": body.get("user")} if body.get("user") else None
310
+
311
+ system_parts: list[str] = []
312
+ developer_parts: list[str] = []
313
+ messages: list[dict[str, str]] = []
314
+
315
+ snap["messages_raw"] = body.get("messages", [])
316
+ for msg in body.get("messages", []):
317
+ role = msg.get("role", "user")
318
+ content = _flatten_text(msg.get("content", ""))
319
+ if role == "system":
320
+ system_parts.append(content)
321
+ elif role == "developer":
322
+ developer_parts.append(content)
323
+ else:
324
+ messages.append({"role": role, "content": content})
325
+
326
+ snap["system_prompt"] = "\n".join(system_parts)
327
+ snap["injected_instructions"] = developer_parts
328
+ snap["messages"] = messages
329
+
330
+ # Tools — unwrap function wrapper
331
+ for tool in body.get("tools", []):
332
+ func = tool.get("function", tool)
333
+ normalized: dict[str, Any] = {"name": func.get("name", "")}
334
+ if "description" in func:
335
+ normalized["description"] = func["description"]
336
+ if "parameters" in func:
337
+ normalized["parameters"] = func["parameters"]
338
+ snap["tools"].append(normalized)
339
+
340
+ return _finalize(snap)
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Gemini
345
+ # ---------------------------------------------------------------------------
346
+
347
+ def parse_gemini(body: dict[str, Any]) -> dict[str, Any]:
348
+ """Parse a Gemini ``generateContent`` request body."""
349
+ snap = _base_snapshot()
350
+ snap["provider"] = "gemini"
351
+ snap["model"] = body.get("model", "unknown")
352
+
353
+ gen_config = body.get("generationConfig", {})
354
+ snap["max_tokens"] = gen_config.get("maxOutputTokens")
355
+ snap["temperature"] = gen_config.get("temperature")
356
+
357
+ # System instruction
358
+ sys_instr = body.get("systemInstruction", {})
359
+ parts = sys_instr.get("parts", [])
360
+ sys_texts = [p.get("text", "") for p in parts if isinstance(p, dict)]
361
+ snap["system_prompt"] = "\n".join(sys_texts)
362
+
363
+ # Contents
364
+ snap["messages_raw"] = body.get("contents", [])
365
+ for content in body.get("contents", []):
366
+ role = content.get("role", "user")
367
+ if role == "model":
368
+ role = "assistant"
369
+ text_parts = content.get("parts", [])
370
+ text = "\n".join(
371
+ p.get("text", "") for p in text_parts if isinstance(p, dict) and "text" in p
372
+ )
373
+ snap["messages"].append({"role": role, "content": text})
374
+
375
+ # Tools — functionDeclarations
376
+ for tool_group in body.get("tools", []):
377
+ for decl in tool_group.get("functionDeclarations", []):
378
+ normalized: dict[str, Any] = {"name": decl.get("name", "")}
379
+ if "description" in decl:
380
+ normalized["description"] = decl["description"]
381
+ if "parameters" in decl:
382
+ normalized["parameters"] = decl["parameters"]
383
+ snap["tools"].append(normalized)
384
+
385
+ return _finalize(snap)
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Bedrock
390
+ # ---------------------------------------------------------------------------
391
+
392
+ def parse_bedrock(body: dict[str, Any]) -> dict[str, Any]:
393
+ """Parse an AWS Bedrock ``/converse`` request body."""
394
+ snap = _base_snapshot()
395
+ snap["provider"] = "bedrock"
396
+ snap["model"] = body.get("modelId", "unknown")
397
+
398
+ inf_config = body.get("inferenceConfig", {})
399
+ snap["max_tokens"] = inf_config.get("maxTokens")
400
+ snap["temperature"] = inf_config.get("temperature")
401
+
402
+ # System — list of text blocks
403
+ sys_blocks = body.get("system", [])
404
+ sys_texts = [
405
+ b.get("text", "") for b in sys_blocks if isinstance(b, dict) and "text" in b
406
+ ]
407
+ snap["system_prompt"] = "\n".join(sys_texts)
408
+ snap["system_blocks"] = sys_blocks
409
+
410
+ # Messages
411
+ snap["messages_raw"] = body.get("messages", [])
412
+ for msg in body.get("messages", []):
413
+ role = msg.get("role", "user")
414
+ content_blocks = msg.get("content", [])
415
+ text = "\n".join(
416
+ b.get("text", "")
417
+ for b in content_blocks
418
+ if isinstance(b, dict) and "text" in b
419
+ )
420
+ snap["messages"].append({"role": role, "content": text})
421
+
422
+ # Tools — toolConfig.tools[].toolSpec
423
+ tool_config = body.get("toolConfig", {})
424
+ for tool in tool_config.get("tools", []):
425
+ spec = tool.get("toolSpec", {})
426
+ normalized: dict[str, Any] = {"name": spec.get("name", "")}
427
+ if "description" in spec:
428
+ normalized["description"] = spec["description"]
429
+ input_schema = spec.get("inputSchema", {})
430
+ if "json" in input_schema:
431
+ normalized["parameters"] = input_schema["json"]
432
+ snap["tools"].append(normalized)
433
+
434
+ return _finalize(snap)
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # GitHub Copilot FIM (Fill-in-the-Middle)
439
+ # ---------------------------------------------------------------------------
440
+
441
+ def parse_copilot_fim(body: dict[str, Any]) -> dict[str, Any]:
442
+ """Parse a Copilot FIM ``/v1/engines/.../completions`` request body.
443
+
444
+ FIM requests use ``prompt`` + ``suffix`` instead of ``messages``.
445
+ """
446
+ snap = _base_snapshot()
447
+ snap["provider"] = "github_copilot_fim"
448
+ snap["model"] = body.get("model", "unknown")
449
+ snap["max_tokens"] = body.get("max_tokens")
450
+ snap["temperature"] = body.get("temperature")
451
+ snap["top_p"] = body.get("top_p")
452
+ snap["stop_sequences"] = body.get("stop", [])
453
+ snap["stream"] = body.get("stream", False)
454
+
455
+ # Prompt is the code context before the cursor
456
+ prompt = body.get("prompt", "")
457
+ snap["system_prompt"] = prompt
458
+
459
+ # Suffix is the code after the cursor
460
+ suffix = body.get("suffix", "")
461
+ if suffix:
462
+ snap["messages"].append({"role": "context", "content": suffix})
463
+
464
+ # Raw body for hashing
465
+ snap["messages_raw"] = [{"prompt": prompt, "suffix": suffix}]
466
+
467
+ # Extra metadata (language, repo, token counts)
468
+ extra = body.get("extra", {})
469
+ metadata: dict[str, Any] = {}
470
+ if extra.get("language"):
471
+ metadata["language"] = extra["language"]
472
+ if body.get("nwo"):
473
+ metadata["repo"] = body["nwo"]
474
+ if extra.get("prompt_tokens"):
475
+ metadata["prompt_tokens"] = extra["prompt_tokens"]
476
+ if extra.get("suffix_tokens"):
477
+ metadata["suffix_tokens"] = extra["suffix_tokens"]
478
+ if body.get("n"):
479
+ metadata["n"] = body["n"]
480
+ if metadata:
481
+ snap["api_metadata"] = metadata
482
+
483
+ return _finalize(snap)