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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,178 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.3] - 2026-06-08 — Cache + compression now work for Claude Code, Claude Desktop, Codex CLI
9
+
10
+ ### Fixed
11
+
12
+ - **CRITICAL — Cache permanently bypassed for ALL tool-bearing clients (Claude Code, Claude Desktop, Codex CLI):**
13
+ `anthropic_surface.py` and `openai_surface.py` both wrapped every cache operation with
14
+ `if not has_tools and proxy.hooks.cache:`. Because Claude Code ALWAYS sends a `tools` array,
15
+ caching was structurally impossible — zero savings regardless of how many identical prompts
16
+ were sent. Fix: removed all four `has_tools` guards across both surfaces. Cache now fires
17
+ unconditionally for every request on both streaming and non-streaming paths.
18
+
19
+ - **CRITICAL — Anthropic streaming SSE parser returned `None` for tool_use responses:**
20
+ `_parse_sse_to_json()` only accumulated `text_delta` events. Any response containing a
21
+ `tool_use` content block (every Claude Code response) caused the parser to emit an empty
22
+ content array and return `None`, meaning the completed SSE stream was never stored even if the
23
+ `has_tools` guard had been removed. Full rewrite: the parser now tracks content blocks by
24
+ index, handles both `text_delta` and `input_json_delta` events, and assembles complete
25
+ `tool_use` entries (with `id`, `name`, `input` JSON object) in the stored JSON. Returns
26
+ `None` only on genuinely incomplete streams (missing `message_start` or `message_stop`).
27
+
28
+ - **CRITICAL — Anthropic SSE cache replay did not emit tool_use blocks:**
29
+ `_sse_from_cached_json()` only replayed `text` content blocks. Tool-use blocks were silently
30
+ dropped, producing a truncated response on cache hits. Fixed: the replay function now handles
31
+ `tool_use` blocks — emits `content_block_start` with `{type:"tool_use", id, name, input:{}}`,
32
+ then `input_json_delta` chunks (50-char pieces), then `content_block_stop`. Clients receive
33
+ a byte-for-byte equivalent of the original SSE stream.
34
+
35
+ - **CRITICAL — OpenAI SSE parser returned `None` for tool_calls responses:**
36
+ `_parse_openai_sse_to_json()` detected `tool_calls` in the delta and returned `None`
37
+ immediately. OpenAI-compatible clients (Codex CLI, Antigravity) were therefore never cached.
38
+ Fixed: parser now accumulates `tool_calls` by `choice_index → tc_index → {id, type, function}`
39
+ and stores them in the assembled `chat.completion` JSON. Tool call arguments are joined from
40
+ streaming `arguments` deltas.
41
+
42
+ - **CRITICAL — OpenAI SSE cache replay dropped tool_calls entirely:**
43
+ `_openai_sse_from_cached_json()` only replayed text content. Tool calls were silently dropped.
44
+ Fixed: replays `tool_calls` as proper OpenAI SSE delta events — first chunk with role +
45
+ tool_call headers (id, type, name, empty arguments), then argument chunks (50-char pieces),
46
+ then finish chunk. Preserves the streaming contract with clients.
47
+
48
+ - **CompressRouter never instantiated — `compress_hook=None` always:**
49
+ `_load_hooks()` in `server.py` had dead code: `if config.compress_enabled: pass`. The
50
+ `CompressRouter` singleton was never created, so compression was silently a no-op for every
51
+ session since v3.6.0. Fixed: `_load_hooks()` now calls `CompressRouter.get_instance()` and
52
+ wires it into the `HookChain`. Daemon log now correctly reports `compress_hook=CompressRouter`.
53
+
54
+ - **MetricsCollector never wired to CompressRouter — `compress_runs=0` always:**
55
+ `CompressRouter.set_metrics()` was never called during proxy startup, so
56
+ `_metrics_counters=None` permanently. Result: `compress_runs` counter was always 0 in the
57
+ dashboard even when compression was running. Fixed: `_load_hooks()` calls
58
+ `compress_hook.set_metrics(MetricsCollector.get_instance())` immediately after instantiation.
59
+
60
+ - **`on_compress` signature mismatch — metrics counter never incremented:**
61
+ `CompressRouter._compress_messages()` called `self._metrics_counters.on_compress(saved, lossy)`
62
+ where `saved` was bytes-saved and `lossy` was a bool. `MetricsCollector.on_compress()` expects
63
+ `(bytes_original, bytes_after)`. The mismatch meant `compress_runs` and `bytes_saved` were
64
+ always wrong even after wiring. Fixed: caller now passes `(before_tokens, after_tokens)`.
65
+
66
+ - **`is_tool_msg` in `CompressRouter` skipped ALL user messages from compression:**
67
+ The original guard was `is_tool_msg = (role == "tool" or role == "user")`. This silently
68
+ skipped every `user` turn, including long tool-result messages (the main source of savings
69
+ in Claude Code sessions). Fixed: only OpenAI `role=="tool"` messages are skipped. Anthropic
70
+ `tool_result` blocks (embedded in user message content arrays) are now compressed by
71
+ `_compress_content_block()`, which handles the nested structure correctly.
72
+
73
+ ### Tests
74
+
75
+ - `tests/optimize/proxy/test_openai_surface.py`: updated `test_parse_openai_sse_to_json_tool_calls_returns_none`
76
+ → renamed to `test_parse_openai_sse_to_json_tool_calls_cached`, asserts valid JSON returned
77
+ with `tool_calls` array preserved instead of `None`.
78
+ - `tests/optimize/proxy/test_server.py`: updated `test_load_hooks_compress_enabled_placeholder`
79
+ → renamed to `test_load_hooks_compress_enabled_loads_router`, asserts `hooks.compress is not None`
80
+ and `isinstance(hooks.compress, CompressRouter)`.
81
+ - All 83 proxy tests pass (0 failures).
82
+
83
+ ### Documentation
84
+
85
+ - `docs/proxy-setup.md`: removed "Cache fires only for requests WITHOUT tools" caveat. Updated
86
+ "What Gets Cached" table — Claude Code, Claude Desktop, Codex CLI now show `✓ Yes`. Added
87
+ explanation of how tool-use caching works (SSE accumulate → parse → store → replay as SSE).
88
+ Updated troubleshooting section — removed stale "tool-bearing requests are bypassed" note,
89
+ added actionable checklist for diagnosing zero-savings scenarios.
90
+
91
+ ---
92
+
93
+ ## [3.6.3] - 2026-06-08 — Proxy streaming cache fix for Anthropic, OpenAI surfaces
94
+
95
+ ### Fixed
96
+
97
+ - **CRITICAL — Anthropic streaming cache never populated (miss permanently 0 savings):**
98
+ `anthropic_surface.py`'s streaming path called `_stream_forward()` (no-op passthrough)
99
+ instead of the new `_stream_and_cache_forward()`. Claude Code, AGY, and every other
100
+ streaming Anthropic client could never populate the cache because the response body was
101
+ never accumulated. Cache was always empty, `tokens_saved` was always 0, regardless of how
102
+ many identical prompts were sent. Fix: streaming path now checks cache on the way in
103
+ (`_safe_cache_check`), runs compression on the request body, and passes a `store_callback`
104
+ to `_stream_and_cache_forward()` that accumulates the SSE stream, parses it via
105
+ `_parse_sse_to_json`, and stores the assembled JSON message after `message_stop` is seen.
106
+ Second identical streaming call returns a properly re-emitted SSE stream from cache
107
+ (verified: `msg_id` identical, no upstream call on hit).
108
+
109
+ - **CRITICAL — OpenAI streaming surface: cache bypassed + `_safe_compress` NameError:**
110
+ `openai_surface.py` had the same streaming bypass bug AND a missing import — `_safe_compress`
111
+ was called on the non-streaming compression path but never imported, causing a silent
112
+ `NameError` on any non-streaming request with compression enabled. Both issues fixed:
113
+ (1) streaming path now wires `_stream_and_cache_forward` with `_parse_openai_sse_to_json`
114
+ and `_openai_sse_from_cached_json` helpers (OpenAI SSE format differs from Anthropic's —
115
+ uses `[DONE]` sentinel and `chat.completion.chunk` objects). (2) `_safe_compress` added
116
+ to imports. Cache hit on OpenAI streaming calls now returns a re-emitted SSE stream with
117
+ proper `chat.completion.chunk` events and `[DONE]` terminator.
118
+
119
+ - **`_stream_and_cache_forward` completion marker was Anthropic-only:**
120
+ The `finally` block checked for `b"message_stop"` to detect a complete stream before
121
+ calling `on_complete`. OpenAI SSE streams end with `data: [DONE]\n\n` — not `message_stop`.
122
+ Result: OpenAI streaming responses were never stored in cache even after the fix above
123
+ because the `on_complete` callback was never fired. Fixed by checking both markers:
124
+ `b"message_stop"` (Anthropic) OR `b"[DONE]"` (OpenAI / any compatible provider).
125
+
126
+ - **`_stream_and_cache_forward` redundant join:** `full = b"".join(acc)` recomputed the
127
+ join that `_joined` had already computed. Fixed to reuse `_joined` directly.
128
+
129
+ - **`server.py` version string stuck at `"3.6.0"`:** `_PROXY_VERSION` was not updated
130
+ during the 3.6.1 and 3.6.2 releases. Fixed to `"3.6.3"`. The `/health` endpoint now
131
+ correctly reports `"version":"3.6.3"`.
132
+
133
+ - **`CacheManager.get()` returned `None` on miss, discarding the cache key:** Store
134
+ condition `cache_result.cache_key` was always falsy on miss because `get()` returned
135
+ `None` (no `CachedResponse` object). Non-streaming responses after a miss were never
136
+ stored. Fixed: `get()` now returns `CachedResponse(hit=False, data=None, cache_key=key)`
137
+ so the key propagates to the store condition.
138
+
139
+ - **`CacheManager.check()` never called `MetricsCollector.on_miss()`:** Miss events were
140
+ not counted, so `hits/(hits+misses)` was always 0 in the dashboard. Fixed: `check()`
141
+ calls `MetricsCollector.get_instance().on_miss()` when `result.hit is False`.
142
+
143
+ ### Added
144
+
145
+ - `_parse_openai_sse_to_json(sse_bytes)`: assembles OpenAI streaming chunks into a
146
+ single `chat.completion` JSON for cache storage. Handles multi-index choices, usage
147
+ capture, `tool_calls` detection (never caches tool responses), and `[DONE]` sentinel.
148
+ - `_openai_sse_from_cached_json(cached_bytes)`: replays a stored `chat.completion` as
149
+ a proper OpenAI SSE stream for cache-hit responses. Emits role chunk, content chunks
150
+ (100-char batches), finish chunk, and `[DONE]`. Preserves the streaming contract with
151
+ clients (Codex CLI, Antigravity, openai-python).
152
+ - `docs/proxy-setup.md`: comprehensive per-CLI proxy activation guide covering Claude Code
153
+ CLI, Claude Desktop, Cursor, Windsurf, AGY/Antigravity, Gemini CLI, Codex CLI, Python
154
+ anthropic/openai SDK, Node.js SDK, LangChain, LlamaIndex, SDK adapter, and raw curl.
155
+ Includes an honest "What Gets Cached" table showing which clients benefit from caching.
156
+
157
+ ### Tests
158
+
159
+ - `tests/optimize/proxy/test_openai_surface.py`: 9 new tests covering
160
+ `_parse_openai_sse_to_json` (complete stream, missing `[DONE]`, tool calls, empty bytes,
161
+ usage capture) and `_openai_sse_from_cached_json` (roundtrip, bad JSON, wrong object
162
+ type) plus an end-to-end streaming cache miss→store→hit cycle.
163
+ - All 583 optimize tests pass (4 skipped — platform-specific).
164
+
165
+ ---
166
+
167
+ ## [3.6.2] - 2026-06-08 — wrap dry_run fix for config-file mechanism (macOS CI)
168
+
169
+ ### Fixed
170
+ - **`slm wrap` `config-file` dry_run fails on machines without VS Code installed:** `wrap_agent()`
171
+ with `mechanism="config-file"` called `_vscode_user_dir()` before the `if dry_run: return 0`
172
+ guard. On CI runners (and any machine without VS Code), `_vscode_user_dir()` returns `None`,
173
+ causing the function to return 1 with "[slm wrap] VS Code user dir not found" even for
174
+ `dry_run=True` calls that never need the path to exist. Fix: moved `if dry_run: return 0`
175
+ to before the `_vscode_user_dir()` lookup — consistent with the same pattern already applied
176
+ to the `env` mechanism in v3.6.1.
177
+
178
+ ---
179
+
8
180
  ## [3.6.1] - 2026-06-07 — Optimize module fixes: proxy liveness probe, UI tab init, PyPI CI unblock
9
181
 
10
182
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.6.1",
3
+ "version": "3.6.3",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.6.1"
3
+ version = "3.6.3"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -28,7 +28,7 @@ if "OMP_NUM_THREADS" not in os.environ:
28
28
  os.environ["OMP_NUM_THREADS"] = "2"
29
29
  # ---------------------------------------------------------------------------
30
30
 
31
- __version__ = "3.6.1"
31
+ __version__ = "3.6.3"
32
32
 
33
33
  _REQUIRED_VERSIONS = {
34
34
  "sentence_transformers": "5.3.0",
@@ -21,10 +21,19 @@ _STATIC_MECHANISMS = {"settings-file", "config-file", "print-only"}
21
21
 
22
22
 
23
23
  def _proxy_configured() -> bool:
24
- """Return True if proxy_enabled=True in optimize.json (no liveness check)."""
24
+ """Return True if proxy_enabled=True in optimize.json (no liveness check).
25
+
26
+ Reads from the module-level _store if it has been set (daemon process or
27
+ tests that call _set_config_store). Falls back to a fresh ConfigStore read
28
+ from disk when _store is None — which is always the case in CLI subprocess
29
+ context because _set_config_store() is only called by the daemon on startup.
30
+ """
25
31
  try:
26
- from superlocalmemory.optimize.config import get_optimize_config
27
- return get_optimize_config().proxy_enabled
32
+ from superlocalmemory.optimize.config import _store
33
+ if _store is not None:
34
+ return _store.get().proxy_enabled
35
+ from superlocalmemory.optimize.config.store import ConfigStore
36
+ return ConfigStore().get().proxy_enabled
28
37
  except Exception:
29
38
  return False
30
39
 
@@ -130,6 +139,10 @@ def wrap_agent(
130
139
  if not config_path_str:
131
140
  print("[slm wrap] no config_path specified", file=sys.stderr)
132
141
  return 1
142
+ # dry_run: show intent without requiring VS Code to be installed.
143
+ if dry_run:
144
+ print(f"[slm wrap] would write {config_path_str} key={config_key} value={config_value}")
145
+ return 0
133
146
  if "{vscode_user_dir}" in config_path_str:
134
147
  vscode_dir = _vscode_user_dir()
135
148
  if vscode_dir is None:
@@ -138,9 +151,6 @@ def wrap_agent(
138
151
  path = Path(config_path_str.replace("{vscode_user_dir}", str(vscode_dir)))
139
152
  else:
140
153
  path = Path(config_path_str).expanduser()
141
- if dry_run:
142
- print(f"[slm wrap] would write {path} key={config_key} value={config_value}")
143
- return 0
144
154
  existing = {}
145
155
  if path.exists():
146
156
  try:
@@ -153,9 +153,42 @@ class CacheManager:
153
153
  # ---- INTERFACE-CONTRACT §4 public methods ----
154
154
 
155
155
  def build_key(self, req: Any, tenant_id: str) -> str | None:
156
- """Build a deterministic cache key for req + tenant_id."""
157
- if isinstance(req, dict):
158
- model_id = req.get("model", "")
156
+ """Build a deterministic cache key for req + tenant_id.
157
+
158
+ BUG-FIX (v3.6.3): Two bugs repaired here:
159
+ 1. tenant_id="default" failed KeyBuilder's 64-char hex SHA-256 validation,
160
+ silently raising ValueError caught by fail-open wrappers → cache never
161
+ stored or retrieved anything via the proxy path. Fix: normalize any
162
+ non-hex tenant_id to its SHA-256 digest before passing to KeyBuilder.
163
+ 2. ProxyRequest objects have a `body` dict, not model_id/messages/system
164
+ attributes. The old `getattr(req, "model_id", "")` path silently
165
+ returned empty strings → all proxy requests got the same (invalid) key.
166
+ Fix: detect ProxyRequest and extract fields from .body.
167
+ """
168
+ import hashlib as _hashlib
169
+ import json as _json
170
+ import re as _re
171
+ _HEX64 = _re.compile(r"[0-9a-f]{64}")
172
+ # Normalize tenant_id: KeyBuilder requires a 64-char lowercase hex SHA-256.
173
+ if not _HEX64.fullmatch(tenant_id or ""):
174
+ tenant_id = _hashlib.sha256(tenant_id.encode()).hexdigest()
175
+
176
+ if isinstance(req, ProxyRequest):
177
+ # Extract semantic fields from the parsed JSON body.
178
+ body = req.body or {}
179
+ model_id = body.get("model", "") or ""
180
+ messages = body.get("messages", []) or []
181
+ system_raw = body.get("system", "") or ""
182
+ # Anthropic allows system as a list of content blocks — normalise to str.
183
+ if isinstance(system_raw, list):
184
+ system = _json.dumps(system_raw, sort_keys=True, separators=(",", ":"))
185
+ else:
186
+ system = str(system_raw)
187
+ # params: everything except fields extracted above and stream flag.
188
+ _SKIP = frozenset({"model", "messages", "system", "stream"})
189
+ params = {k: v for k, v in body.items() if k not in _SKIP}
190
+ elif isinstance(req, dict):
191
+ model_id = req.get("model", "") or ""
159
192
  messages = req.get("messages", []) or []
160
193
  params = req.get("params", {}) or {}
161
194
  system = req.get("system", "") or ""
@@ -174,9 +207,18 @@ class CacheManager:
174
207
  )
175
208
 
176
209
  def get(self, req: Any, tenant_id: str) -> "CachedResponse | None":
177
- """CacheHook.check() entry point."""
210
+ """CacheHook.check() entry point.
211
+
212
+ BUG-FIX (v3.6.3): Previously returned None on cache miss, which caused
213
+ _safe_cache_check to return CachedResponse(cache_key=""). The empty
214
+ cache_key is falsy, so the store condition in handle_messages
215
+ (``cache_result.cache_key``) was always False → cache was NEVER
216
+ populated. Fix: return a miss CachedResponse that carries the computed
217
+ key so the store path can proceed.
218
+ """
178
219
  key = self.build_key(req, tenant_id)
179
220
  if key is None:
221
+ # Uncacheable (non-zero temperature, etc.) — signal with None.
180
222
  return None
181
223
  row = self._exact.get(key, tenant_id)
182
224
  if row is not None:
@@ -188,7 +230,8 @@ class CacheManager:
188
230
  ttl_seconds=0,
189
231
  )
190
232
  self._metrics.exact_misses += 1
191
- return None
233
+ # Return miss WITH the key so callers can use it for cache storage.
234
+ return CachedResponse(hit=False, data=None, cache_key=key, ttl_seconds=0)
192
235
 
193
236
  def set(self, req: Any, resp: Any, tenant_id: str) -> None:
194
237
  """CacheHook.store() entry point."""
@@ -196,8 +239,10 @@ class CacheManager:
196
239
  key = self.build_key(req, tenant_id)
197
240
  if key is None:
198
241
  return
199
- if isinstance(req, dict):
200
- model_id = req.get("model", "")
242
+ if isinstance(req, ProxyRequest):
243
+ model_id = (req.body or {}).get("model", "") or ""
244
+ elif isinstance(req, dict):
245
+ model_id = req.get("model", "") or ""
201
246
  else:
202
247
  model_id = getattr(req, "model_id", "") or ""
203
248
  tags = [
@@ -216,9 +261,18 @@ class CacheManager:
216
261
  # ---- CacheHook protocol implementation (INTERFACE-CONTRACT §3) ----
217
262
 
218
263
  def check(self, req: ProxyRequest) -> "CachedResponse | None":
219
- """CacheHook.check() — look up by ProxyRequest; fail-open on error."""
264
+ """CacheHook.check() — look up by ProxyRequest; fail-open on error.
265
+
266
+ BUG-FIX (v3.6.3): on_miss() was never called from the proxy path,
267
+ so MetricsCollector.misses stayed at 0 and the dashboard always showed
268
+ 0 misses. Fixed by calling on_miss() here whenever get() returns a
269
+ cache-miss result.
270
+ """
220
271
  try:
221
- return self.get(req, tenant_id="default")
272
+ result = self.get(req, tenant_id="default")
273
+ if result is not None and not result.hit:
274
+ MetricsCollector.get_instance().on_miss()
275
+ return result
222
276
  except Exception as exc:
223
277
  logger.warning("CacheManager.check raised (fail-open): %s", exc)
224
278
  return None
@@ -71,10 +71,6 @@ class CompressRouter:
71
71
 
72
72
  if not cfg.compress_enabled:
73
73
  return req
74
- if req.stream:
75
- return req
76
- if req.has_tools:
77
- return req # §6.5 safety rule
78
74
 
79
75
  body = dict(req.body)
80
76
  messages = body.get("messages", [])
@@ -139,8 +135,8 @@ class CompressRouter:
139
135
  try:
140
136
  saved = max(0, before_tokens - after_tokens)
141
137
  if self._metrics_counters is not None:
142
- # M-02: Call proper method instead of accessing private attribute
143
- self._metrics_counters.on_compress(saved, lossy)
138
+ # M-02: pass before/after directly (bytes_original, bytes_after contract)
139
+ self._metrics_counters.on_compress(before_tokens, after_tokens)
144
140
  logger.debug("on_compress: saved=%d tokens lossy=%s", saved, lossy)
145
141
  except Exception as exc:
146
142
  logger.debug("on_compress metrics update failed (non-fatal): %s", exc)
@@ -169,9 +165,11 @@ class CompressRouter:
169
165
  for idx, msg in enumerate(messages):
170
166
  role = msg.get("role", "")
171
167
  is_tool_msg = (
172
- role == "tool"
173
- or role == "user"
174
- or _msg_has_tool_result(msg)
168
+ role == "tool" # OpenAI tool result messages (plain text, skip entirely)
169
+ # Anthropic tool_result blocks are NOT skipped — _compress_content_block
170
+ # handles type=="tool_result" blocks by compressing their text content
171
+ # while preserving the block structure. This is where the bulk of
172
+ # Claude Code output lives (bash results, file contents, JSON data).
175
173
  )
176
174
  if idx in protect_indices or is_tool_msg:
177
175
  new_messages.append(msg)
@@ -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)