superlocalmemory 3.6.2 → 3.6.3
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/CHANGELOG.md +159 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/optimize/adapters/wrap.py +12 -3
- package/src/superlocalmemory/optimize/cache/manager.py +63 -9
- package/src/superlocalmemory/optimize/compress/router.py +7 -9
- package/src/superlocalmemory/optimize/proxy/_helpers.py +236 -1
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +187 -5
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +373 -14
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +329 -7
- package/src/superlocalmemory/optimize/proxy/server.py +10 -3
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +1 -1
|
@@ -8,6 +8,8 @@ import logging
|
|
|
8
8
|
from fastapi.requests import Request
|
|
9
9
|
from fastapi.responses import Response
|
|
10
10
|
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
12
|
+
|
|
11
13
|
from superlocalmemory.optimize.proxy._helpers import (
|
|
12
14
|
_ANTHROPIC_FORWARD_HEADERS,
|
|
13
15
|
_MAX_REQUEST_BODY_BYTES,
|
|
@@ -15,10 +17,13 @@ from superlocalmemory.optimize.proxy._helpers import (
|
|
|
15
17
|
_build_forward_headers,
|
|
16
18
|
_fail_open_forward,
|
|
17
19
|
_filter_response_headers,
|
|
20
|
+
_parse_sse_to_json,
|
|
18
21
|
_redact_headers,
|
|
19
22
|
_safe_cache_check,
|
|
20
23
|
_safe_cache_hit_callbacks,
|
|
21
24
|
_safe_cache_store,
|
|
25
|
+
_safe_compress,
|
|
26
|
+
_stream_and_cache_forward,
|
|
22
27
|
_stream_forward,
|
|
23
28
|
)
|
|
24
29
|
from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
|
|
@@ -28,6 +33,130 @@ logger = logging.getLogger("slm.optimize.proxy.anthropic")
|
|
|
28
33
|
_UPSTREAM_BASE = "https://api.anthropic.com"
|
|
29
34
|
|
|
30
35
|
|
|
36
|
+
def _sse_from_cached_json(cached_bytes: bytes) -> "StreamingResponse | None":
|
|
37
|
+
"""Convert a cached non-streaming Anthropic response into an SSE StreamingResponse.
|
|
38
|
+
|
|
39
|
+
Used when a streaming request hits the cache — we replay the stored JSON as
|
|
40
|
+
the sequence of SSE events the Anthropic API would have emitted, preserving
|
|
41
|
+
the streaming contract with the client (e.g. Claude Code).
|
|
42
|
+
|
|
43
|
+
Returns None if the bytes are not a parseable Anthropic message object so the
|
|
44
|
+
caller can fall back to a live upstream request.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
resp = json.loads(cached_bytes)
|
|
48
|
+
except (json.JSONDecodeError, ValueError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
if resp.get("type") != "message":
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
async def _generate():
|
|
55
|
+
# 1. message_start
|
|
56
|
+
msg_start = {
|
|
57
|
+
"type": "message_start",
|
|
58
|
+
"message": {
|
|
59
|
+
"id": resp.get("id", ""),
|
|
60
|
+
"type": "message",
|
|
61
|
+
"role": resp.get("role", "assistant"),
|
|
62
|
+
"content": [],
|
|
63
|
+
"model": resp.get("model", ""),
|
|
64
|
+
"stop_reason": None,
|
|
65
|
+
"stop_sequence": None,
|
|
66
|
+
"usage": resp.get("usage", {}),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
yield f"event: message_start\ndata: {json.dumps(msg_start)}\n\n".encode()
|
|
70
|
+
yield b"data: {\"type\":\"ping\"}\n\n"
|
|
71
|
+
|
|
72
|
+
# 2. content blocks — supports both text and tool_use (BUG-FIX v3.6.4)
|
|
73
|
+
for i, block in enumerate(resp.get("content", [])):
|
|
74
|
+
block_type = block.get("type", "text")
|
|
75
|
+
|
|
76
|
+
if block_type == "tool_use":
|
|
77
|
+
# tool_use block: content_block_start carries id + name (not text)
|
|
78
|
+
block_start = {
|
|
79
|
+
"type": "content_block_start",
|
|
80
|
+
"index": i,
|
|
81
|
+
"content_block": {
|
|
82
|
+
"type": "tool_use",
|
|
83
|
+
"id": block.get("id", ""),
|
|
84
|
+
"name": block.get("name", ""),
|
|
85
|
+
"input": {},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
yield (
|
|
89
|
+
f"event: content_block_start\n"
|
|
90
|
+
f"data: {json.dumps(block_start)}\n\n"
|
|
91
|
+
).encode()
|
|
92
|
+
|
|
93
|
+
# Emit input JSON as input_json_delta chunks (50-char pieces)
|
|
94
|
+
input_str = json.dumps(block.get("input", {}), separators=(",", ":"))
|
|
95
|
+
chunk_size = 50
|
|
96
|
+
for start in range(0, max(len(input_str), 1), chunk_size):
|
|
97
|
+
piece = input_str[start: start + chunk_size]
|
|
98
|
+
delta = {
|
|
99
|
+
"type": "content_block_delta",
|
|
100
|
+
"index": i,
|
|
101
|
+
"delta": {"type": "input_json_delta", "partial_json": piece},
|
|
102
|
+
}
|
|
103
|
+
yield (
|
|
104
|
+
f"event: content_block_delta\n"
|
|
105
|
+
f"data: {json.dumps(delta)}\n\n"
|
|
106
|
+
).encode()
|
|
107
|
+
|
|
108
|
+
else:
|
|
109
|
+
# text block (default)
|
|
110
|
+
block_start = {
|
|
111
|
+
"type": "content_block_start",
|
|
112
|
+
"index": i,
|
|
113
|
+
"content_block": {"type": block_type, "text": ""},
|
|
114
|
+
}
|
|
115
|
+
yield (
|
|
116
|
+
f"event: content_block_start\n"
|
|
117
|
+
f"data: {json.dumps(block_start)}\n\n"
|
|
118
|
+
).encode()
|
|
119
|
+
|
|
120
|
+
if block_type == "text":
|
|
121
|
+
text = block.get("text", "")
|
|
122
|
+
# Emit text in chunks of 100 chars to preserve the streaming feel.
|
|
123
|
+
chunk_size = 100
|
|
124
|
+
for start in range(0, max(len(text), 1), chunk_size):
|
|
125
|
+
chunk = text[start: start + chunk_size]
|
|
126
|
+
delta = {
|
|
127
|
+
"type": "content_block_delta",
|
|
128
|
+
"index": i,
|
|
129
|
+
"delta": {"type": "text_delta", "text": chunk},
|
|
130
|
+
}
|
|
131
|
+
yield (
|
|
132
|
+
f"event: content_block_delta\n"
|
|
133
|
+
f"data: {json.dumps(delta)}\n\n"
|
|
134
|
+
).encode()
|
|
135
|
+
|
|
136
|
+
block_stop = {"type": "content_block_stop", "index": i}
|
|
137
|
+
yield (
|
|
138
|
+
f"event: content_block_stop\n"
|
|
139
|
+
f"data: {json.dumps(block_stop)}\n\n"
|
|
140
|
+
).encode()
|
|
141
|
+
|
|
142
|
+
# 3. message_delta
|
|
143
|
+
usage = resp.get("usage", {})
|
|
144
|
+
msg_delta = {
|
|
145
|
+
"type": "message_delta",
|
|
146
|
+
"delta": {
|
|
147
|
+
"stop_reason": resp.get("stop_reason", "end_turn"),
|
|
148
|
+
"stop_sequence": resp.get("stop_sequence", None),
|
|
149
|
+
},
|
|
150
|
+
"usage": {"output_tokens": usage.get("output_tokens", 0)},
|
|
151
|
+
}
|
|
152
|
+
yield f"event: message_delta\ndata: {json.dumps(msg_delta)}\n\n".encode()
|
|
153
|
+
|
|
154
|
+
# 4. message_stop
|
|
155
|
+
yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
|
156
|
+
|
|
157
|
+
return StreamingResponse(_generate(), media_type="text/event-stream")
|
|
158
|
+
|
|
159
|
+
|
|
31
160
|
async def handle_messages(proxy: object, request: Request) -> Response:
|
|
32
161
|
request_id = await proxy.next_request_id()
|
|
33
162
|
upstream_url = f"{_UPSTREAM_BASE}/v1/messages"
|
|
@@ -76,14 +205,68 @@ async def handle_messages(proxy: object, request: Request) -> Response:
|
|
|
76
205
|
)
|
|
77
206
|
|
|
78
207
|
if stream:
|
|
208
|
+
# BUG-FIX (v3.6.3): streaming path previously bypassed both the
|
|
209
|
+
# cache and compression. Claude Code exclusively uses streaming, so
|
|
210
|
+
# savings were permanently 0. Fixed below:
|
|
211
|
+
#
|
|
212
|
+
# 1. Cache check — if a prior streaming call's response was
|
|
213
|
+
# accumulated and stored, return it as a proper SSE stream rather
|
|
214
|
+
# than forwarding to Anthropic at all.
|
|
215
|
+
if proxy.hooks.cache:
|
|
216
|
+
cache_result = await _safe_cache_check(proxy.hooks, ctx)
|
|
217
|
+
if cache_result and cache_result.hit and cache_result.data:
|
|
218
|
+
logger.debug(
|
|
219
|
+
"[%s] streaming cache HIT key=%s",
|
|
220
|
+
request_id, cache_result.cache_key,
|
|
221
|
+
)
|
|
222
|
+
await _safe_cache_hit_callbacks(
|
|
223
|
+
proxy.hooks, ctx, cache_result.data, tokens_saved=0
|
|
224
|
+
)
|
|
225
|
+
sse_resp = _sse_from_cached_json(cache_result.data)
|
|
226
|
+
if sse_resp is not None:
|
|
227
|
+
return sse_resp
|
|
228
|
+
# Fallback: cached bytes unparseable as an Anthropic message —
|
|
229
|
+
# forward normally rather than returning garbage.
|
|
230
|
+
|
|
231
|
+
# 2. Compression — apply to the REQUEST body even for streaming.
|
|
232
|
+
# Tokens saved in the prompt are real savings regardless of whether
|
|
233
|
+
# the response is streamed.
|
|
234
|
+
outbound_bytes = body_bytes
|
|
235
|
+
if proxy.hooks.compress:
|
|
236
|
+
compress_result = await _safe_compress(proxy.hooks, ctx)
|
|
237
|
+
if compress_result.body_bytes != body_bytes:
|
|
238
|
+
outbound_bytes = compress_result.body_bytes
|
|
239
|
+
|
|
79
240
|
fwd_headers = _build_forward_headers(request, _ANTHROPIC_FORWARD_HEADERS)
|
|
80
|
-
fwd_headers["content-length"] = str(len(
|
|
81
|
-
|
|
82
|
-
|
|
241
|
+
fwd_headers["content-length"] = str(len(outbound_bytes))
|
|
242
|
+
|
|
243
|
+
# 3. Cache store callback — accumulate SSE bytes and store after the
|
|
244
|
+
# stream completes so future identical requests are served from cache.
|
|
245
|
+
# BUG-FIX (v3.6.3): previously there was no accumulation at all —
|
|
246
|
+
# the cache was never populated from streaming calls, which is the
|
|
247
|
+
# ONLY call type Claude Code makes.
|
|
248
|
+
store_callback = None
|
|
249
|
+
if proxy.hooks.cache:
|
|
250
|
+
_hooks = proxy.hooks
|
|
251
|
+
_ctx = ctx
|
|
252
|
+
async def _store_from_sse(sse_bytes: bytes) -> None:
|
|
253
|
+
parsed = _parse_sse_to_json(sse_bytes)
|
|
254
|
+
if parsed is None:
|
|
255
|
+
return
|
|
256
|
+
prov = ProviderResponse(
|
|
257
|
+
modified=False, body={}, body_bytes=parsed,
|
|
258
|
+
tokens_before=0, tokens_after=0, strategy="none",
|
|
259
|
+
)
|
|
260
|
+
await _safe_cache_store(_hooks, _ctx, prov)
|
|
261
|
+
store_callback = _store_from_sse
|
|
262
|
+
|
|
263
|
+
return await _stream_and_cache_forward(
|
|
264
|
+
proxy, request_id, fwd_headers, outbound_bytes, upstream_url,
|
|
265
|
+
on_complete=store_callback,
|
|
83
266
|
)
|
|
84
267
|
|
|
85
268
|
cache_result = None
|
|
86
|
-
if
|
|
269
|
+
if proxy.hooks.cache:
|
|
87
270
|
cache_result = await _safe_cache_check(proxy.hooks, ctx)
|
|
88
271
|
if cache_result.hit and cache_result.data:
|
|
89
272
|
logger.debug("[%s] cache HIT key=%s", request_id, cache_result.cache_key)
|
|
@@ -112,7 +295,6 @@ async def handle_messages(proxy: object, request: Request) -> Response:
|
|
|
112
295
|
|
|
113
296
|
if (
|
|
114
297
|
upstream_resp.status_code == 200
|
|
115
|
-
and not has_tools
|
|
116
298
|
and proxy.hooks.cache
|
|
117
299
|
and cache_result is not None
|
|
118
300
|
and cache_result.cache_key
|