@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,206 @@
1
+ """Read active RuleMetric session info from $TMPDIR/rulemetric/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger("rulemetric.session_linker")
12
+
13
+ # UUID v4 pattern — used to validate FILE CONTENTS (a real RuleMetric DB
14
+ # session id) where strict UUID format is guaranteed.
15
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
16
+
17
+ # Loose pattern for harness-supplied EXTERNAL session ids (filenames). Some
18
+ # harnesses append a suffix to the UUID — VS Code's `vscode-sessionid` is
19
+ # `<uuid><epoch-ms>` (no separator) — so we accept anything that's a
20
+ # reasonable, filesystem-safe identifier without requiring strict UUID form.
21
+ # Bounded length to avoid pathologically long filenames.
22
+ _EXTERNAL_ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{8,128}$")
23
+
24
+ # Consider session files stale after 4 hours
25
+ MAX_SESSION_AGE_SECONDS = 4 * 60 * 60
26
+
27
+
28
+ def _get_session_dir() -> Path:
29
+ return Path(tempfile.gettempdir()) / "rulemetric"
30
+
31
+
32
+ # Map provider names to the tool types that generate that traffic.
33
+ # Used to prefer session files whose tool type matches the request provider.
34
+ _PROVIDER_TO_TOOLS: dict[str, list[str]] = {
35
+ "anthropic": ["claude_code"],
36
+ "openai": ["cursor", "vscode_copilot"],
37
+ "github_copilot_chat": ["vscode_copilot"],
38
+ "github_copilot_fim": ["vscode_copilot"],
39
+ "azure_openai": ["cursor", "vscode_copilot"],
40
+ }
41
+
42
+
43
+ def _parse_session_file(f: Path, now: float) -> dict[str, str] | None:
44
+ """Parse a single session file, returning its info or None if invalid/stale.
45
+
46
+ Returned dict has keys: session_id, rw_session_id, tool_type.
47
+ """
48
+ SKIP_SUFFIXES = (".log", ".seq", ".ctxhash", ".pid", ".lock")
49
+
50
+ if not f.is_file():
51
+ return None
52
+ if any(f.name.endswith(s) for s in SKIP_SUFFIXES):
53
+ return None
54
+
55
+ # Check file age — use the newer of session file or its .seq companion
56
+ try:
57
+ file_mtime = f.stat().st_mtime
58
+ seq_file = f.with_suffix(".seq")
59
+ if seq_file.is_file():
60
+ seq_mtime = seq_file.stat().st_mtime
61
+ file_mtime = max(file_mtime, seq_mtime)
62
+ file_age = now - file_mtime
63
+ except OSError:
64
+ return None
65
+ if file_age > MAX_SESSION_AGE_SECONDS:
66
+ logger.debug("Skipping stale session file %s (age: %.0fs)", f.name, file_age)
67
+ return None
68
+
69
+ try:
70
+ content = f.read_text().strip()
71
+ if not content:
72
+ logger.debug("Empty session file: %s", f.name)
73
+ return None
74
+
75
+ # File format: line 1 = RuleMetric session ID (UUID),
76
+ # line 2 = tool type (e.g. claude_code, cursor) — optional
77
+ lines = content.splitlines()
78
+ session_id = lines[0].strip()
79
+ tool_type = lines[1].strip() if len(lines) > 1 else ""
80
+
81
+ # Validate UUID format
82
+ if not _UUID_RE.match(session_id):
83
+ logger.warning("Invalid session ID format in %s: '%s' — skipping", f.name, session_id[:50])
84
+ return None
85
+
86
+ logger.debug(
87
+ "Found session: %s (tool=%s, file=%s, age=%.0fs)",
88
+ session_id[:8], tool_type or "unknown", f.name, file_age,
89
+ )
90
+ return {
91
+ "session_id": session_id,
92
+ "rw_session_id": "", # Not used in current format; session_id IS the RW ID
93
+ "tool_type": tool_type,
94
+ }
95
+ except PermissionError as exc:
96
+ logger.error("Permission denied reading session file %s: %s", f, exc)
97
+ return None
98
+ except OSError as exc:
99
+ logger.warning("Cannot read session file %s: %s", f, exc)
100
+ return None
101
+
102
+
103
+ def _effective_mtime(f: Path) -> float:
104
+ """Return the most recent mtime between a session file and its .seq companion.
105
+
106
+ The session file is written once at session-start and never touched again.
107
+ The .seq file is bumped per event, so it's the canonical liveness signal.
108
+ Sorting by this value (not the raw file mtime) ensures the actively-active
109
+ session is preferred over a more-recently-started but idle one — without
110
+ this, multiple concurrent claude_code sessions cause snapshots to pile onto
111
+ whichever session started last, regardless of which is currently producing
112
+ events.
113
+ """
114
+ try:
115
+ m = f.stat().st_mtime
116
+ except OSError:
117
+ return 0.0
118
+ seq = f.with_suffix(".seq")
119
+ if seq.is_file():
120
+ try:
121
+ m = max(m, seq.stat().st_mtime)
122
+ except OSError:
123
+ pass
124
+ return m
125
+
126
+
127
+ def find_session_by_external_id(external_session_id: str) -> dict[str, str] | None:
128
+ """Look up a single session file by its harness session id (= filename).
129
+
130
+ The harness (Claude Code, Cursor, etc.) writes one file per session at
131
+ `$TMPDIR/rulemetric/<harness-session-id>`, content = the RuleMetric DB
132
+ session id. When the wire request carries the harness session id (e.g.
133
+ Claude Code embeds it in Anthropic's `metadata.user_id`), this is the
134
+ canonical attribution path — race-free across concurrent same-tool
135
+ sessions because every request has its OWN session id.
136
+
137
+ Returns the same dict shape as ``find_active_session`` or None.
138
+ """
139
+ if not external_session_id or not _EXTERNAL_ID_RE.match(external_session_id):
140
+ return None
141
+ session_dir = _get_session_dir()
142
+ if not session_dir.is_dir():
143
+ return None
144
+ f = session_dir / external_session_id
145
+ if not f.is_file():
146
+ return None
147
+ return _parse_session_file(f, time.time())
148
+
149
+
150
+ def find_active_session(provider: str | None = None) -> dict[str, str] | None:
151
+ """Find the most recently active session file in $TMPDIR/rulemetric/.
152
+
153
+ When *provider* is given (e.g. ``"anthropic"``, ``"openai"``), prefer
154
+ sessions whose tool type matches the expected source of that traffic.
155
+ Falls back to the most recent session if no tool-type match is found.
156
+
157
+ Liveness is measured against the session's `.seq` companion (which is
158
+ bumped per event), not the session file itself (which is write-once).
159
+
160
+ Returns a dict with session_id, rw_session_id, and tool_type keys, or None.
161
+ """
162
+ session_dir = _get_session_dir()
163
+
164
+ if not session_dir.is_dir():
165
+ logger.warning("Session dir does not exist: %s — session tracking may not be installed", session_dir)
166
+ return None
167
+
168
+ try:
169
+ files = sorted(session_dir.iterdir(), key=_effective_mtime, reverse=True)
170
+ except PermissionError as exc:
171
+ logger.error("Permission denied reading session dir %s: %s", session_dir, exc)
172
+ return None
173
+ except OSError as exc:
174
+ logger.warning("Cannot read session dir %s: %s", session_dir, exc)
175
+ return None
176
+
177
+ now = time.time()
178
+ candidates: list[dict[str, str]] = []
179
+
180
+ for f in files:
181
+ info = _parse_session_file(f, now)
182
+ if info:
183
+ candidates.append(info)
184
+
185
+ if not candidates:
186
+ logger.warning("No valid session files found in %s (%d files checked)", session_dir, len(files))
187
+ return None
188
+
189
+ # If a provider is given, try to find a session whose tool type matches
190
+ if provider:
191
+ preferred_tools = _PROVIDER_TO_TOOLS.get(provider, [])
192
+ if preferred_tools:
193
+ for candidate in candidates:
194
+ if candidate.get("tool_type") in preferred_tools:
195
+ logger.debug(
196
+ "Matched session %s (tool=%s) for provider %s",
197
+ candidate["session_id"][:8], candidate["tool_type"], provider,
198
+ )
199
+ return candidate
200
+
201
+ # Fall back to most recent session (candidates are already sorted by mtime desc)
202
+ logger.debug(
203
+ "Using most recent session %s (tool=%s) — no provider-specific match",
204
+ candidates[0]["session_id"][:8], candidates[0].get("tool_type", "unknown"),
205
+ )
206
+ return candidates[0]
@@ -0,0 +1,364 @@
1
+ """Parse SSE (Server-Sent Events) streaming responses from LLM APIs.
2
+
3
+ Extracts usage stats, stop_reason, and model info from streaming responses
4
+ that would otherwise be lost because the response body isn't a single JSON object.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger("rulemetric.sse_parser")
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Generic SSE parser
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def parse_sse_events(text: str) -> list[dict[str, Any]]:
21
+ """Parse an SSE body into a list of event dicts.
22
+
23
+ Each event has:
24
+ - "event": the event type (str or None)
25
+ - "data": parsed JSON object, or raw string if JSON fails
26
+ """
27
+ events: list[dict[str, Any]] = []
28
+ # Normalize line endings: \r\n → \n, stray \r → \n
29
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
30
+ # SSE events are separated by double (or more) newlines
31
+ chunks = text.split("\n\n")
32
+
33
+ for chunk in chunks:
34
+ chunk = chunk.strip()
35
+ if not chunk:
36
+ continue
37
+
38
+ event_type: str | None = None
39
+ data_lines: list[str] = []
40
+
41
+ for line in chunk.split("\n"):
42
+ line = line.strip()
43
+ if not line:
44
+ continue
45
+ if line.startswith("event:"):
46
+ event_type = line[len("event:"):].strip()
47
+ elif line.startswith("data:"):
48
+ data_lines.append(line[len("data:"):].strip())
49
+ # Skip comments (lines starting with :) and other fields
50
+
51
+ if not data_lines:
52
+ continue
53
+
54
+ raw_data = "\n".join(data_lines)
55
+
56
+ # Skip [DONE] sentinel (OpenAI and most compatible providers)
57
+ if raw_data in ("[DONE]", ""):
58
+ continue
59
+
60
+ # Try to parse as JSON
61
+ try:
62
+ parsed = json.loads(raw_data)
63
+ except (json.JSONDecodeError, ValueError):
64
+ parsed = raw_data
65
+
66
+ events.append({"event": event_type, "data": parsed})
67
+
68
+ return events
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Anthropic streaming aggregator
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def aggregate_anthropic_sse(events: list[dict[str, Any]]) -> dict[str, Any]:
76
+ """Extract usage, stop_reason, model, and text content from Anthropic SSE events."""
77
+ result: dict[str, Any] = {"streaming": True}
78
+ usage: dict[str, Any] = {}
79
+ content_block_types: dict[str, int] = {}
80
+ text_parts: list[str] = []
81
+
82
+ for ev in events:
83
+ data = ev.get("data")
84
+ if not isinstance(data, dict):
85
+ continue
86
+
87
+ msg_type = data.get("type")
88
+
89
+ if msg_type == "message_start":
90
+ message = data.get("message", {})
91
+ # Input tokens + cache tokens from message_start
92
+ msg_usage = message.get("usage", {})
93
+ if msg_usage:
94
+ usage["input_tokens"] = msg_usage.get("input_tokens")
95
+ usage["cache_creation_input_tokens"] = msg_usage.get("cache_creation_input_tokens")
96
+ usage["cache_read_input_tokens"] = msg_usage.get("cache_read_input_tokens")
97
+ # Granular cache TTL breakdown (Anthropic ephemeral 5m vs 1h pricing tiers).
98
+ # Prefer the nested `cache_creation` object when present; fall back to
99
+ # treating the flat sum as 5m (older response shape).
100
+ cache_creation_obj = msg_usage.get("cache_creation")
101
+ if isinstance(cache_creation_obj, dict):
102
+ usage["cache_creation_5m"] = cache_creation_obj.get("ephemeral_5m_input_tokens", 0) or 0
103
+ usage["cache_creation_1h"] = cache_creation_obj.get("ephemeral_1h_input_tokens", 0) or 0
104
+ elif msg_usage.get("cache_creation_input_tokens") is not None:
105
+ usage["cache_creation_5m"] = msg_usage.get("cache_creation_input_tokens") or 0
106
+ usage["cache_creation_1h"] = 0
107
+ result["model_used"] = message.get("model")
108
+ result["request_id"] = message.get("id")
109
+
110
+ elif msg_type == "message_delta":
111
+ # Output tokens + stop_reason from message_delta
112
+ delta_usage = data.get("usage", {})
113
+ if delta_usage:
114
+ usage["output_tokens"] = delta_usage.get("output_tokens")
115
+ delta = data.get("delta", {})
116
+ result["stop_reason"] = delta.get("stop_reason")
117
+
118
+ elif msg_type == "content_block_start":
119
+ block = data.get("content_block", {})
120
+ block_type = block.get("type", "unknown")
121
+ content_block_types[block_type] = content_block_types.get(block_type, 0) + 1
122
+
123
+ elif msg_type == "content_block_delta":
124
+ delta = data.get("delta", {})
125
+ if delta.get("type") == "text_delta":
126
+ text_parts.append(delta.get("text", ""))
127
+
128
+ if usage:
129
+ result["usage"] = usage
130
+ if content_block_types:
131
+ result["content_block_types"] = content_block_types
132
+ if text_parts:
133
+ result["text_content"] = "".join(text_parts)
134
+
135
+ return result
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # OpenAI streaming aggregator
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def aggregate_openai_sse(events: list[dict[str, Any]]) -> dict[str, Any]:
143
+ """Extract usage, finish_reason, model from OpenAI-compatible SSE events.
144
+
145
+ Handles known provider variations:
146
+ - OpenAI: ``prompt_tokens`` / ``completion_tokens`` in usage
147
+ - Some providers: ``input_tokens`` / ``output_tokens`` (Anthropic naming)
148
+ - DeepSeek: ``reasoning_content`` in delta (chain-of-thought)
149
+ - Perplexity: ``citations`` array in final chunk
150
+ - Some providers: usage in a standalone final chunk with no ``choices``
151
+ - Some providers: ``x_groq`` or similar provider-specific metadata
152
+ """
153
+ result: dict[str, Any] = {"streaming": True}
154
+ usage: dict[str, Any] = {}
155
+ has_reasoning = False
156
+ text_parts: list[str] = []
157
+ reasoning_parts: list[str] = []
158
+
159
+ for ev in events:
160
+ data = ev.get("data")
161
+ if not isinstance(data, dict):
162
+ continue
163
+
164
+ # Model and ID from first chunk
165
+ if "model" in data and "model_used" not in result:
166
+ result["model_used"] = data["model"]
167
+ if "id" in data and "request_id" not in result:
168
+ result["request_id"] = data["id"]
169
+
170
+ # Finish reason + delta content from choices
171
+ choices = data.get("choices", [])
172
+ if choices:
173
+ finish_reason = choices[0].get("finish_reason")
174
+ if finish_reason:
175
+ result["stop_reason"] = finish_reason
176
+
177
+ # Accumulate streamed assistant text. OpenAI/Copilot/Azure stream
178
+ # one token (or fragment) per chunk in `choices[0].delta.content`;
179
+ # without joining them the assistant_response event ends up with
180
+ # empty content and the timeline shows nothing for the response.
181
+ delta = choices[0].get("delta", {})
182
+ content = delta.get("content")
183
+ if isinstance(content, str) and content:
184
+ text_parts.append(content)
185
+
186
+ # DeepSeek-style reasoning content (chain-of-thought).
187
+ reasoning = delta.get("reasoning_content")
188
+ if isinstance(reasoning, str) and reasoning:
189
+ reasoning_parts.append(reasoning)
190
+ has_reasoning = True
191
+ elif reasoning:
192
+ # Truthy non-string → flag presence even if we can't capture
193
+ has_reasoning = True
194
+
195
+ # Usage — may appear in final chunk (with or without choices).
196
+ # Normalize both OpenAI naming (prompt_tokens) and Anthropic naming (input_tokens).
197
+ chunk_usage = data.get("usage")
198
+ if chunk_usage and isinstance(chunk_usage, dict):
199
+ usage["input_tokens"] = (
200
+ chunk_usage.get("prompt_tokens")
201
+ or chunk_usage.get("input_tokens")
202
+ )
203
+ usage["output_tokens"] = (
204
+ chunk_usage.get("completion_tokens")
205
+ or chunk_usage.get("output_tokens")
206
+ )
207
+ usage["total_tokens"] = chunk_usage.get("total_tokens")
208
+
209
+ # Some providers include cache stats
210
+ if chunk_usage.get("prompt_tokens_details"):
211
+ details = chunk_usage["prompt_tokens_details"]
212
+ if details.get("cached_tokens"):
213
+ usage["cache_read_input_tokens"] = details["cached_tokens"]
214
+
215
+ # Perplexity citations
216
+ citations = data.get("citations")
217
+ if citations and isinstance(citations, list):
218
+ result["citations"] = citations
219
+
220
+ if usage:
221
+ result["usage"] = usage
222
+ if has_reasoning:
223
+ result["has_reasoning"] = True
224
+ if text_parts:
225
+ result["text_content"] = "".join(text_parts)
226
+ if reasoning_parts:
227
+ result["reasoning_text"] = "".join(reasoning_parts)
228
+
229
+ return result
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Gemini streaming aggregator
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def aggregate_gemini_sse(events: list[dict[str, Any]]) -> dict[str, Any]:
237
+ """Extract usage from Gemini streaming responses."""
238
+ result: dict[str, Any] = {"streaming": True}
239
+
240
+ for ev in events:
241
+ data = ev.get("data")
242
+ if not isinstance(data, dict):
243
+ continue
244
+
245
+ # Usage metadata
246
+ usage_meta = data.get("usageMetadata")
247
+ if usage_meta and isinstance(usage_meta, dict):
248
+ result["usage"] = {
249
+ "input_tokens": usage_meta.get("promptTokenCount"),
250
+ "output_tokens": usage_meta.get("candidatesTokenCount"),
251
+ "total_tokens": usage_meta.get("totalTokenCount"),
252
+ }
253
+
254
+ # Finish reason from candidates
255
+ candidates = data.get("candidates", [])
256
+ if candidates:
257
+ finish_reason = candidates[0].get("finishReason")
258
+ if finish_reason:
259
+ result["stop_reason"] = finish_reason
260
+
261
+ return result
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # GitHub Copilot FIM streaming aggregator
266
+ # ---------------------------------------------------------------------------
267
+
268
+ def aggregate_copilot_fim_sse(events: list[dict[str, Any]]) -> dict[str, Any]:
269
+ """Extract usage and finish_reason from Copilot FIM SSE events.
270
+
271
+ FIM responses use ``choices[].text`` instead of ``choices[].delta.content``.
272
+ Usage appears in the final event.
273
+ """
274
+ result: dict[str, Any] = {"streaming": True}
275
+ usage: dict[str, Any] = {}
276
+
277
+ for ev in events:
278
+ data = ev.get("data")
279
+ if not isinstance(data, dict):
280
+ continue
281
+
282
+ # Model and ID from first chunk
283
+ if "model" in data and "model_used" not in result:
284
+ result["model_used"] = data["model"]
285
+ if "id" in data and "request_id" not in result:
286
+ result["request_id"] = data["id"]
287
+
288
+ # Finish reason from choices (same structure as OpenAI but text instead of delta)
289
+ choices = data.get("choices", [])
290
+ if choices:
291
+ finish_reason = choices[0].get("finish_reason")
292
+ if finish_reason:
293
+ result["stop_reason"] = finish_reason
294
+
295
+ # Usage — appears in the final chunk
296
+ chunk_usage = data.get("usage")
297
+ if chunk_usage and isinstance(chunk_usage, dict):
298
+ usage["input_tokens"] = (
299
+ chunk_usage.get("prompt_tokens")
300
+ or chunk_usage.get("input_tokens")
301
+ )
302
+ usage["output_tokens"] = (
303
+ chunk_usage.get("completion_tokens")
304
+ or chunk_usage.get("output_tokens")
305
+ )
306
+ usage["total_tokens"] = chunk_usage.get("total_tokens")
307
+
308
+ if usage:
309
+ result["usage"] = usage
310
+
311
+ return result
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Dispatcher
316
+ # ---------------------------------------------------------------------------
317
+
318
+ # Non-OpenAI SSE formats. Any provider not listed here
319
+ # is assumed to use OpenAI-compatible SSE.
320
+ _NON_OPENAI_AGGREGATORS = {
321
+ "anthropic": aggregate_anthropic_sse,
322
+ "gemini": aggregate_gemini_sse,
323
+ "github_copilot_fim": aggregate_copilot_fim_sse,
324
+ }
325
+
326
+
327
+ def _get_aggregator(provider: str):
328
+ """Return the SSE aggregator for a provider, defaulting to OpenAI."""
329
+ return _NON_OPENAI_AGGREGATORS.get(provider, aggregate_openai_sse)
330
+
331
+
332
+ def parse_streaming_response(
333
+ body_text: str,
334
+ provider: str,
335
+ content_type: str | None = None,
336
+ ) -> dict[str, Any] | None:
337
+ """Parse a streaming SSE response body and extract usage/stop_reason.
338
+
339
+ Returns None if the body is not SSE (allows fallback to JSON parsing).
340
+ """
341
+ # Detect SSE
342
+ is_sse = False
343
+ if content_type and "text/event-stream" in content_type:
344
+ is_sse = True
345
+ elif body_text.lstrip().startswith(("event:", "data:")):
346
+ is_sse = True
347
+
348
+ if not is_sse:
349
+ return None
350
+
351
+ events = parse_sse_events(body_text)
352
+ if not events:
353
+ logger.debug("SSE body parsed but no events found")
354
+ return None
355
+
356
+ aggregator = _get_aggregator(provider)
357
+ result = aggregator(events)
358
+ logger.debug(
359
+ "SSE parsed: %d events, usage=%s, stop_reason=%s",
360
+ len(events),
361
+ result.get("usage"),
362
+ result.get("stop_reason"),
363
+ )
364
+ return result
@@ -0,0 +1,10 @@
1
+ export * from './types.js';
2
+ export { BaseAdapter } from './providers/base.js';
3
+ export { ProviderRegistry, detectProvider } from './providers/index.js';
4
+ export { AnthropicAdapter } from './providers/anthropic.js';
5
+ export { OpenAIAdapter } from './providers/openai.js';
6
+ export { GeminiAdapter } from './providers/gemini.js';
7
+ export { BedrockAdapter } from './providers/bedrock.js';
8
+ export { findActiveSession } from './session-linker.js';
9
+ export type { ActiveSession } from './session-linker.js';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * from './types.js';
2
+ export { BaseAdapter } from './providers/base.js';
3
+ export { ProviderRegistry, detectProvider } from './providers/index.js';
4
+ export { AnthropicAdapter } from './providers/anthropic.js';
5
+ export { OpenAIAdapter } from './providers/openai.js';
6
+ export { GeminiAdapter } from './providers/gemini.js';
7
+ export { BedrockAdapter } from './providers/bedrock.js';
8
+ export { findActiveSession } from './session-linker.js';
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,8 @@
1
+ import type { ContextSnapshot, Provider } from '../types.js';
2
+ import { BaseAdapter } from './base.js';
3
+ export declare class AnthropicAdapter extends BaseAdapter {
4
+ provider: Provider;
5
+ canHandle(host: string, path: string): boolean;
6
+ parse(body: string): ContextSnapshot | null;
7
+ }
8
+ //# sourceMappingURL=anthropic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anthropic.d.ts","sourceRoot":"","sources":["../../src/providers/anthropic.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAqC,QAAQ,EAAe,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAExC,qBAAa,gBAAiB,SAAQ,WAAW;IAC/C,QAAQ,EAAE,QAAQ,CAAe;IAEjC,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAI9C,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI;CAsI5C"}