superlocalmemory 3.6.1 → 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.
@@ -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(body_bytes))
81
- return await _stream_forward(
82
- proxy, request_id, fwd_headers, body_bytes, upstream_url
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 not has_tools and proxy.hooks.cache:
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