superlocalmemory 3.6.2 → 3.6.4
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 +180 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/consolidation_engine.py +8 -0
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/fact_consolidator.py +54 -13
- package/src/superlocalmemory/core/graph_pruner.py +55 -19
- package/src/superlocalmemory/core/maintenance.py +9 -0
- package/src/superlocalmemory/core/store_pipeline.py +120 -51
- package/src/superlocalmemory/mcp/_stdin_guard.py +60 -0
- package/src/superlocalmemory/mcp/server.py +34 -5
- 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/retrieval/engine.py +17 -6
- package/src/superlocalmemory/retrieval/entity_channel.py +10 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +3 -1
- package/src/superlocalmemory/storage/database.py +113 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +3 -1
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
|
-
from typing import Any, AsyncIterator
|
|
8
|
+
from typing import Any, AsyncIterator, Callable
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
10
11
|
from fastapi.requests import Request
|
|
@@ -121,6 +122,143 @@ def _filter_response_headers(headers) -> dict:
|
|
|
121
122
|
return {k: v for k, v in items if k.lower() not in _HOP_BY_HOP}
|
|
122
123
|
|
|
123
124
|
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# SSE → JSON converter (for streaming cache hits and post-stream storage)
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _parse_sse_to_json(sse_bytes: bytes) -> bytes | None:
|
|
130
|
+
"""Parse an accumulated Anthropic SSE stream into a single JSON message.
|
|
131
|
+
|
|
132
|
+
Used for two purposes:
|
|
133
|
+
1. After a streaming response completes — store the assembled JSON in the
|
|
134
|
+
cache so the next identical streaming request is served from cache.
|
|
135
|
+
2. Cache hit path — convert stored JSON back to SSE via _sse_from_cached_json
|
|
136
|
+
in anthropic_surface.py.
|
|
137
|
+
|
|
138
|
+
Returns None if the bytes are not a valid/complete Anthropic SSE stream
|
|
139
|
+
(e.g. error response, client-disconnected partial, or empty). The caller
|
|
140
|
+
MUST treat None as "do not cache".
|
|
141
|
+
|
|
142
|
+
BUG-FIX (v3.6.4): Previously returned None for any response containing
|
|
143
|
+
tool_use blocks, which meant Claude Code responses were NEVER cached (Claude
|
|
144
|
+
Code always uses tools). Now handles both text AND tool_use content blocks:
|
|
145
|
+
- text blocks: accumulated via text_delta events
|
|
146
|
+
- tool_use blocks: accumulated via input_json_delta events, stored with
|
|
147
|
+
full {id, name, input} so _sse_from_cached_json can replay them correctly.
|
|
148
|
+
"""
|
|
149
|
+
# content_blocks[index] = {"type": "text", "text_parts": [...]} OR
|
|
150
|
+
# {"type": "tool_use", "id": "...", "name": "...", "input_parts": [...]}
|
|
151
|
+
content_blocks: dict[int, dict] = {}
|
|
152
|
+
message_id = ""
|
|
153
|
+
model = ""
|
|
154
|
+
role = "assistant"
|
|
155
|
+
input_tokens = 0
|
|
156
|
+
output_tokens = 0
|
|
157
|
+
stop_reason = "end_turn"
|
|
158
|
+
message_start_seen = False
|
|
159
|
+
message_stop_seen = False
|
|
160
|
+
|
|
161
|
+
current_event = ""
|
|
162
|
+
for raw_line in sse_bytes.decode("utf-8", errors="replace").split("\n"):
|
|
163
|
+
line = raw_line.rstrip("\r")
|
|
164
|
+
if line.startswith("event: "):
|
|
165
|
+
current_event = line[7:].strip()
|
|
166
|
+
elif line.startswith("data: "):
|
|
167
|
+
data_str = line[6:].strip()
|
|
168
|
+
if not data_str or data_str == "[DONE]":
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
data = json.loads(data_str)
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
if current_event == "message_start":
|
|
176
|
+
message_start_seen = True
|
|
177
|
+
msg = data.get("message", {})
|
|
178
|
+
message_id = msg.get("id", "")
|
|
179
|
+
model = msg.get("model", "")
|
|
180
|
+
role = msg.get("role", "assistant")
|
|
181
|
+
usage = msg.get("usage", {})
|
|
182
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
183
|
+
|
|
184
|
+
elif current_event == "content_block_start":
|
|
185
|
+
idx = data.get("index", 0)
|
|
186
|
+
cb = data.get("content_block", {})
|
|
187
|
+
block_type = cb.get("type", "text")
|
|
188
|
+
if block_type == "tool_use":
|
|
189
|
+
content_blocks[idx] = {
|
|
190
|
+
"type": "tool_use",
|
|
191
|
+
"id": cb.get("id", ""),
|
|
192
|
+
"name": cb.get("name", ""),
|
|
193
|
+
"input_parts": [],
|
|
194
|
+
}
|
|
195
|
+
else:
|
|
196
|
+
content_blocks[idx] = {"type": "text", "text_parts": []}
|
|
197
|
+
|
|
198
|
+
elif current_event == "content_block_delta":
|
|
199
|
+
idx = data.get("index", 0)
|
|
200
|
+
delta = data.get("delta", {})
|
|
201
|
+
block = content_blocks.get(idx)
|
|
202
|
+
if block is None:
|
|
203
|
+
continue
|
|
204
|
+
delta_type = delta.get("type", "")
|
|
205
|
+
if delta_type == "text_delta":
|
|
206
|
+
block.setdefault("text_parts", []).append(delta.get("text", ""))
|
|
207
|
+
elif delta_type == "input_json_delta":
|
|
208
|
+
block.setdefault("input_parts", []).append(delta.get("partial_json", ""))
|
|
209
|
+
|
|
210
|
+
elif current_event == "message_delta":
|
|
211
|
+
usage2 = data.get("usage", {})
|
|
212
|
+
output_tokens = usage2.get("output_tokens", 0)
|
|
213
|
+
stop = data.get("delta", {})
|
|
214
|
+
stop_reason = stop.get("stop_reason", stop_reason) or stop_reason
|
|
215
|
+
|
|
216
|
+
elif current_event == "message_stop":
|
|
217
|
+
message_stop_seen = True
|
|
218
|
+
|
|
219
|
+
if not message_start_seen or not message_stop_seen:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
# Assemble content array — preserve block ordering by index
|
|
223
|
+
content: list[dict] = []
|
|
224
|
+
for idx in sorted(content_blocks.keys()):
|
|
225
|
+
block = content_blocks[idx]
|
|
226
|
+
if block["type"] == "tool_use":
|
|
227
|
+
input_json_str = "".join(block.get("input_parts", []))
|
|
228
|
+
try:
|
|
229
|
+
input_obj = json.loads(input_json_str) if input_json_str else {}
|
|
230
|
+
except json.JSONDecodeError:
|
|
231
|
+
input_obj = {"_raw": input_json_str}
|
|
232
|
+
content.append({
|
|
233
|
+
"type": "tool_use",
|
|
234
|
+
"id": block["id"],
|
|
235
|
+
"name": block["name"],
|
|
236
|
+
"input": input_obj,
|
|
237
|
+
})
|
|
238
|
+
else:
|
|
239
|
+
text = "".join(block.get("text_parts", []))
|
|
240
|
+
content.append({"type": "text", "text": text})
|
|
241
|
+
|
|
242
|
+
result = {
|
|
243
|
+
"id": message_id,
|
|
244
|
+
"type": "message",
|
|
245
|
+
"role": role,
|
|
246
|
+
"content": content,
|
|
247
|
+
"model": model,
|
|
248
|
+
"stop_reason": stop_reason,
|
|
249
|
+
"stop_sequence": None,
|
|
250
|
+
"usage": {
|
|
251
|
+
"input_tokens": input_tokens,
|
|
252
|
+
"output_tokens": output_tokens,
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
return json.dumps(result, separators=(",", ":")).encode("utf-8")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# HTTP helpers
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
124
262
|
async def _fail_open_forward(proxy: Any, request: Request, upstream_url: str) -> Response:
|
|
125
263
|
if proxy.http_client is None:
|
|
126
264
|
logger.error(
|
|
@@ -167,6 +305,7 @@ async def _stream_forward(
|
|
|
167
305
|
body_bytes: bytes,
|
|
168
306
|
upstream_url: str,
|
|
169
307
|
) -> Response | StreamingResponse:
|
|
308
|
+
"""Simple passthrough streaming — no cache accumulation."""
|
|
170
309
|
if proxy.http_client is None:
|
|
171
310
|
logger.error(
|
|
172
311
|
"[%s] _stream_forward: http_client is None - startup() was not called. "
|
|
@@ -212,6 +351,102 @@ async def _stream_forward(
|
|
|
212
351
|
)
|
|
213
352
|
|
|
214
353
|
|
|
354
|
+
async def _stream_and_cache_forward(
|
|
355
|
+
proxy: Any,
|
|
356
|
+
request_id: str,
|
|
357
|
+
fwd_headers: dict,
|
|
358
|
+
body_bytes: bytes,
|
|
359
|
+
upstream_url: str,
|
|
360
|
+
on_complete: "Callable[[bytes], Any] | None" = None,
|
|
361
|
+
) -> Response | StreamingResponse:
|
|
362
|
+
"""Stream-forward with optional post-stream cache-store callback.
|
|
363
|
+
|
|
364
|
+
BUG-FIX (v3.6.3): Claude Code exclusively uses streaming. The old
|
|
365
|
+
_stream_forward never accumulated the response body, so the cache was
|
|
366
|
+
NEVER populated — savings were permanently 0.
|
|
367
|
+
|
|
368
|
+
This helper tees the stream: each chunk is yielded to the client AND
|
|
369
|
+
appended to an in-memory accumulator. After the LAST chunk (detected by
|
|
370
|
+
``message_stop`` in the SSE bytes), ``on_complete`` is awaited with the
|
|
371
|
+
full accumulated bytes. The caller converts those bytes to a JSON message
|
|
372
|
+
via ``_parse_sse_to_json`` and stores them in the cache.
|
|
373
|
+
|
|
374
|
+
on_complete is only called when:
|
|
375
|
+
- the accumulated bytes contain ``b"message_stop"`` (complete response),
|
|
376
|
+
- the response did NOT error mid-stream.
|
|
377
|
+
"""
|
|
378
|
+
if proxy.http_client is None:
|
|
379
|
+
logger.error(
|
|
380
|
+
"[%s] _stream_and_cache_forward: http_client is None.",
|
|
381
|
+
request_id,
|
|
382
|
+
)
|
|
383
|
+
return Response(
|
|
384
|
+
content=b'{"type":"error","error":{"type":"api_error",'
|
|
385
|
+
b'"message":"SLM proxy not started - lifespan wiring error"}}',
|
|
386
|
+
status_code=502,
|
|
387
|
+
media_type="application/json",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
acc: list[bytes] = []
|
|
391
|
+
complete_called = False
|
|
392
|
+
|
|
393
|
+
async def _generate() -> AsyncIterator[bytes]:
|
|
394
|
+
nonlocal complete_called
|
|
395
|
+
stream_error = False
|
|
396
|
+
try:
|
|
397
|
+
async with proxy.http_client.stream(
|
|
398
|
+
"POST", upstream_url, content=body_bytes, headers=fwd_headers,
|
|
399
|
+
) as upstream_resp:
|
|
400
|
+
async for chunk in upstream_resp.aiter_bytes():
|
|
401
|
+
if chunk:
|
|
402
|
+
acc.append(chunk)
|
|
403
|
+
yield chunk
|
|
404
|
+
except httpx.RemoteProtocolError as exc:
|
|
405
|
+
stream_error = True
|
|
406
|
+
logger.warning("[%s] upstream stream closed early: %r", request_id, exc)
|
|
407
|
+
yield (
|
|
408
|
+
b'event: error\ndata: {"type":"error","error":{'
|
|
409
|
+
b'"type":"api_error","message":"upstream stream closed"}}\n\n'
|
|
410
|
+
)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
stream_error = True
|
|
413
|
+
logger.error("[%s] stream forward error: %r", request_id, exc)
|
|
414
|
+
yield (
|
|
415
|
+
b'event: error\ndata: {"type":"error","error":{'
|
|
416
|
+
b'"type":"api_error","message":"SLM proxy stream error"}}\n\n'
|
|
417
|
+
)
|
|
418
|
+
finally:
|
|
419
|
+
# BUG-FIX (v3.6.4): Removed surface-specific sentinel check
|
|
420
|
+
# ("message_stop" / "[DONE]"). Completeness is now validated
|
|
421
|
+
# inside each parser (_parse_sse_to_json, _parse_openai_sse_to_json,
|
|
422
|
+
# _parse_gemini_sse_to_json) which return None for incomplete
|
|
423
|
+
# streams. This makes _stream_and_cache_forward universal: it
|
|
424
|
+
# calls on_complete whenever the stream ends without error and
|
|
425
|
+
# lets the parser decide whether to store. Gemini SSE has neither
|
|
426
|
+
# sentinel — streams end by connection close after the final chunk
|
|
427
|
+
# containing "finishReason".
|
|
428
|
+
_joined = b"".join(acc)
|
|
429
|
+
if on_complete and acc and not complete_called and not stream_error:
|
|
430
|
+
complete_called = True
|
|
431
|
+
try:
|
|
432
|
+
await on_complete(_joined)
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
logger.warning(
|
|
435
|
+
"[%s] cache store callback raised (fail-open): %r",
|
|
436
|
+
request_id, exc,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return StreamingResponse(
|
|
440
|
+
_generate(),
|
|
441
|
+
media_type="text/event-stream",
|
|
442
|
+
headers={
|
|
443
|
+
"Cache-Control": "no-cache",
|
|
444
|
+
"Connection": "keep-alive",
|
|
445
|
+
"X-Accel-Buffering": "no",
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
215
450
|
async def _safe_cache_check(hooks: HookChain, ctx: ProxyRequest) -> CachedResponse:
|
|
216
451
|
try:
|
|
217
452
|
result = hooks.cache.check(ctx)
|
|
@@ -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
|