@rulemetric/proxy 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +28 -0
- package/addon/context_classifier.py +375 -0
- package/addon/providers.py +483 -0
- package/addon/reporter.py +778 -0
- package/addon/requirements.txt +1 -0
- package/addon/rulemetric_addon.py +1288 -0
- package/addon/secret_redactor.py +130 -0
- package/addon/security_scanner.py +828 -0
- package/addon/session_linker.py +206 -0
- package/addon/sse_parser.py +364 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/anthropic.d.ts +8 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +135 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +43 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +125 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/gemini.d.ts +9 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +140 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +116 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +129 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/session-linker.d.ts +6 -0
- package/dist/session-linker.d.ts.map +1 -0
- package/dist/session-linker.js +68 -0
- package/dist/session-linker.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,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)
|