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 +172 -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 +16 -6
- 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
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.
|
|
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
|
@@ -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.
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
143
|
-
self._metrics_counters.on_compress(
|
|
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
|
-
|
|
174
|
-
|
|
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)
|