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.
@@ -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(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