@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,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
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|