superlocalmemory 3.6.2 → 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.
@@ -1,4 +1,16 @@
1
- """openai_surface.py — OpenAI /v1/chat/completions and /v1/embeddings."""
1
+ """openai_surface.py — OpenAI /v1/chat/completions and /v1/embeddings.
2
+
3
+ BUG-FIXES (v3.6.3):
4
+ - Streaming path previously bypassed cache AND had _safe_compress not imported
5
+ (NameError on any non-streaming compressed call). Same set of bugs as
6
+ anthropic_surface.py pre-v3.6.3. Fixed with the same pattern:
7
+ cache check → _stream_and_cache_forward → post-stream store.
8
+ - _safe_compress added to imports (was missing; NameError on compress path).
9
+ - Streaming cache: accumulate SSE, parse to JSON, store so future identical
10
+ calls are served from cache. OpenAI SSE format differs from Anthropic's
11
+ so a dedicated _parse_openai_sse_to_json / _openai_sse_from_cached helper
12
+ is used.
13
+ """
2
14
 
3
15
  from __future__ import annotations
4
16
 
@@ -6,7 +18,7 @@ import json
6
18
  import logging
7
19
 
8
20
  from fastapi.requests import Request
9
- from fastapi.responses import Response
21
+ from fastapi.responses import Response, StreamingResponse
10
22
 
11
23
  from superlocalmemory.optimize.proxy._helpers import (
12
24
  _OPENAI_FORWARD_HEADERS,
@@ -18,6 +30,8 @@ from superlocalmemory.optimize.proxy._helpers import (
18
30
  _safe_cache_check,
19
31
  _safe_cache_hit_callbacks,
20
32
  _safe_cache_store,
33
+ _safe_compress,
34
+ _stream_and_cache_forward,
21
35
  _stream_forward,
22
36
  )
23
37
  from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
@@ -27,6 +41,269 @@ logger = logging.getLogger("slm.optimize.proxy.openai")
27
41
  _UPSTREAM_BASE = "https://api.openai.com"
28
42
 
29
43
 
44
+ # ---------------------------------------------------------------------------
45
+ # OpenAI SSE helpers (streaming cache hit/store)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _parse_openai_sse_to_json(sse_bytes: bytes) -> bytes | None:
49
+ """Parse an accumulated OpenAI SSE stream into a single chat.completion JSON.
50
+
51
+ OpenAI streaming format:
52
+ data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[
53
+ {"index":0,"delta":{"role":"assistant","content":"Hello"},...}]}
54
+ ...
55
+ data: [DONE]
56
+
57
+ Assembles into the non-streaming chat.completion format so it can be stored
58
+ in the cache and replayed as SSE on the next identical call.
59
+
60
+ BUG-FIX (v3.6.4): Previously returned None for responses containing
61
+ tool_calls, meaning OpenAI-compatible tool-bearing clients (Codex CLI,
62
+ Antigravity) were NEVER cached. Now accumulates tool_calls by index and
63
+ includes them in the stored JSON so _openai_sse_from_cached_json can
64
+ replay them correctly.
65
+
66
+ Returns None only for incomplete streams (no [DONE], missing id).
67
+ """
68
+ completion_id = ""
69
+ model = ""
70
+ created = 0
71
+ text_acc: dict[int, str] = {} # choice_index → accumulated content
72
+ finish_reasons: dict[int, str] = {}
73
+ # tool_calls_acc[choice_idx][tc_idx] = {id, type, function: {name, args_parts}}
74
+ tool_calls_acc: dict[int, dict[int, dict]] = {}
75
+ prompt_tokens = 0
76
+ completion_tokens = 0
77
+ done_seen = False
78
+
79
+ for raw_line in sse_bytes.decode("utf-8", errors="replace").split("\n"):
80
+ line = raw_line.rstrip("\r")
81
+ if not line.startswith("data: "):
82
+ continue
83
+ data_str = line[6:].strip()
84
+ if data_str == "[DONE]":
85
+ done_seen = True
86
+ continue
87
+ try:
88
+ chunk = json.loads(data_str)
89
+ except json.JSONDecodeError:
90
+ continue
91
+
92
+ if not completion_id:
93
+ completion_id = chunk.get("id", "")
94
+ if not model:
95
+ model = chunk.get("model", "")
96
+ if not created:
97
+ created = chunk.get("created", 0)
98
+
99
+ for choice in chunk.get("choices", []):
100
+ idx = choice.get("index", 0)
101
+ delta = choice.get("delta", {})
102
+
103
+ content = delta.get("content")
104
+ if content:
105
+ text_acc[idx] = text_acc.get(idx, "") + content
106
+
107
+ for tc in delta.get("tool_calls", []):
108
+ tc_idx = tc.get("index", 0)
109
+ if idx not in tool_calls_acc:
110
+ tool_calls_acc[idx] = {}
111
+ if tc_idx not in tool_calls_acc[idx]:
112
+ tool_calls_acc[idx][tc_idx] = {
113
+ "id": "",
114
+ "type": "function",
115
+ "function": {"name": "", "arguments_parts": []},
116
+ }
117
+ entry = tool_calls_acc[idx][tc_idx]
118
+ if tc.get("id"):
119
+ entry["id"] = tc["id"]
120
+ if tc.get("type"):
121
+ entry["type"] = tc["type"]
122
+ fn = tc.get("function", {})
123
+ if fn.get("name"):
124
+ entry["function"]["name"] = fn["name"]
125
+ if fn.get("arguments") is not None:
126
+ entry["function"]["arguments_parts"].append(fn["arguments"])
127
+
128
+ fr = choice.get("finish_reason")
129
+ if fr:
130
+ finish_reasons[idx] = fr
131
+
132
+ usage = chunk.get("usage") or {}
133
+ if usage:
134
+ prompt_tokens = usage.get("prompt_tokens", prompt_tokens)
135
+ completion_tokens = usage.get("completion_tokens", completion_tokens)
136
+
137
+ if not done_seen or not completion_id:
138
+ return None
139
+
140
+ all_choice_indices = sorted(set(list(text_acc.keys()) + list(tool_calls_acc.keys())))
141
+ if not all_choice_indices:
142
+ return None
143
+
144
+ choices = []
145
+ for i in all_choice_indices:
146
+ finish_reason = finish_reasons.get(i, "stop")
147
+ message: dict = {"role": "assistant"}
148
+
149
+ if i in tool_calls_acc:
150
+ tool_calls = [
151
+ {
152
+ "id": tool_calls_acc[i][ti]["id"],
153
+ "type": tool_calls_acc[i][ti].get("type", "function"),
154
+ "function": {
155
+ "name": tool_calls_acc[i][ti]["function"]["name"],
156
+ "arguments": "".join(
157
+ tool_calls_acc[i][ti]["function"]["arguments_parts"]
158
+ ),
159
+ },
160
+ }
161
+ for ti in sorted(tool_calls_acc[i].keys())
162
+ ]
163
+ message["content"] = None
164
+ message["tool_calls"] = tool_calls
165
+ else:
166
+ message["content"] = text_acc.get(i, "")
167
+
168
+ choices.append({
169
+ "index": i,
170
+ "message": message,
171
+ "logprobs": None,
172
+ "finish_reason": finish_reason,
173
+ })
174
+
175
+ result = {
176
+ "id": completion_id,
177
+ "object": "chat.completion",
178
+ "created": created,
179
+ "model": model,
180
+ "choices": choices,
181
+ "usage": {
182
+ "prompt_tokens": prompt_tokens,
183
+ "completion_tokens": completion_tokens,
184
+ "total_tokens": prompt_tokens + completion_tokens,
185
+ },
186
+ }
187
+ return json.dumps(result, separators=(",", ":")).encode("utf-8")
188
+
189
+
190
+ def _openai_sse_from_cached_json(cached_bytes: bytes) -> "StreamingResponse | None":
191
+ """Convert a stored chat.completion JSON back to an OpenAI SSE stream.
192
+
193
+ Used when an identical streaming request hits the cache — we replay the
194
+ stored JSON as the SSE events the OpenAI API would have emitted, preserving
195
+ the streaming contract with the client (e.g. Codex CLI, Antigravity).
196
+
197
+ BUG-FIX (v3.6.4): Handles tool_calls in the cached message, replaying
198
+ them as proper OpenAI SSE delta events with index/id/name/arguments chunks.
199
+
200
+ Returns None if the bytes are not a parseable chat.completion object.
201
+ """
202
+ try:
203
+ resp = json.loads(cached_bytes)
204
+ except (json.JSONDecodeError, ValueError):
205
+ return None
206
+
207
+ if resp.get("object") != "chat.completion":
208
+ return None
209
+
210
+ async def _generate():
211
+ completion_id = resp.get("id", "")
212
+ model = resp.get("model", "")
213
+ created = resp.get("created", 0)
214
+
215
+ for choice in resp.get("choices", []):
216
+ idx = choice.get("index", 0)
217
+ message = choice.get("message", {})
218
+ tool_calls = message.get("tool_calls")
219
+ content = message.get("content") or ""
220
+ finish_reason = choice.get("finish_reason", "stop")
221
+
222
+ if tool_calls:
223
+ # First chunk: role + tool_call headers (id, type, name, empty args)
224
+ first_delta = {
225
+ "role": "assistant",
226
+ "content": None,
227
+ "tool_calls": [
228
+ {
229
+ "index": ti,
230
+ "id": tc["id"],
231
+ "type": tc.get("type", "function"),
232
+ "function": {
233
+ "name": tc["function"]["name"],
234
+ "arguments": "",
235
+ },
236
+ }
237
+ for ti, tc in enumerate(tool_calls)
238
+ ],
239
+ }
240
+ first_chunk = {
241
+ "id": completion_id, "object": "chat.completion.chunk",
242
+ "created": created, "model": model,
243
+ "choices": [{"index": idx, "delta": first_delta, "finish_reason": None}],
244
+ }
245
+ yield f"data: {json.dumps(first_chunk)}\n\n".encode()
246
+
247
+ # Argument chunks per tool call (50-char pieces)
248
+ chunk_size = 50
249
+ for ti, tc in enumerate(tool_calls):
250
+ args = tc.get("function", {}).get("arguments", "")
251
+ for start in range(0, max(len(args), 1), chunk_size):
252
+ piece = args[start: start + chunk_size]
253
+ arg_chunk = {
254
+ "id": completion_id, "object": "chat.completion.chunk",
255
+ "created": created, "model": model,
256
+ "choices": [{
257
+ "index": idx,
258
+ "delta": {"tool_calls": [{"index": ti, "function": {"arguments": piece}}]},
259
+ "finish_reason": None,
260
+ }],
261
+ }
262
+ yield f"data: {json.dumps(arg_chunk)}\n\n".encode()
263
+
264
+ # Finish chunk
265
+ finish_chunk = {
266
+ "id": completion_id, "object": "chat.completion.chunk",
267
+ "created": created, "model": model,
268
+ "choices": [{"index": idx, "delta": {}, "finish_reason": finish_reason}],
269
+ }
270
+ yield f"data: {json.dumps(finish_chunk)}\n\n".encode()
271
+
272
+ else:
273
+ # Text response replay
274
+ role_chunk = {
275
+ "id": completion_id, "object": "chat.completion.chunk",
276
+ "created": created, "model": model,
277
+ "choices": [{"index": idx, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
278
+ }
279
+ yield f"data: {json.dumps(role_chunk)}\n\n".encode()
280
+
281
+ chunk_size = 100
282
+ for start in range(0, max(len(content), 1), chunk_size):
283
+ piece = content[start: start + chunk_size]
284
+ content_chunk = {
285
+ "id": completion_id, "object": "chat.completion.chunk",
286
+ "created": created, "model": model,
287
+ "choices": [{"index": idx, "delta": {"content": piece}, "finish_reason": None}],
288
+ }
289
+ yield f"data: {json.dumps(content_chunk)}\n\n".encode()
290
+
291
+ finish_chunk = {
292
+ "id": completion_id, "object": "chat.completion.chunk",
293
+ "created": created, "model": model,
294
+ "choices": [{"index": idx, "delta": {}, "finish_reason": finish_reason}],
295
+ }
296
+ yield f"data: {json.dumps(finish_chunk)}\n\n".encode()
297
+
298
+ yield b"data: [DONE]\n\n"
299
+
300
+ return StreamingResponse(_generate(), media_type="text/event-stream")
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Route handlers
305
+ # ---------------------------------------------------------------------------
306
+
30
307
  async def handle_chat_completions(proxy: object, request: Request) -> Response:
31
308
  request_id = await proxy.next_request_id()
32
309
  upstream_url = f"{_UPSTREAM_BASE}/v1/chat/completions"
@@ -47,14 +324,60 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
47
324
  )
48
325
 
49
326
  if stream:
327
+ # BUG-FIX (v3.6.3): streaming path previously bypassed cache
328
+ # entirely — same bug as anthropic_surface.py pre-fix. OpenAI
329
+ # clients (Codex CLI, Antigravity, openai-python) use stream=True
330
+ # by default, so savings were permanently 0.
331
+
332
+ # 1. Cache check
333
+ if proxy.hooks.cache:
334
+ cache_result = await _safe_cache_check(proxy.hooks, ctx)
335
+ if cache_result and cache_result.hit and cache_result.data:
336
+ logger.debug(
337
+ "[%s] OpenAI streaming cache HIT key=%s",
338
+ request_id, cache_result.cache_key,
339
+ )
340
+ await _safe_cache_hit_callbacks(
341
+ proxy.hooks, ctx, cache_result.data, tokens_saved=0
342
+ )
343
+ sse_resp = _openai_sse_from_cached_json(cache_result.data)
344
+ if sse_resp is not None:
345
+ return sse_resp
346
+
347
+ # 2. Compression on request body
348
+ outbound_bytes = body_bytes
349
+ if proxy.hooks.compress:
350
+ compress_result = await _safe_compress(proxy.hooks, ctx)
351
+ if compress_result.body_bytes != body_bytes:
352
+ outbound_bytes = compress_result.body_bytes
353
+
50
354
  fwd_headers = _build_forward_headers(request, _OPENAI_FORWARD_HEADERS)
51
- fwd_headers["content-length"] = str(len(body_bytes))
52
- return await _stream_forward(
53
- proxy, request_id, fwd_headers, body_bytes, upstream_url
355
+ fwd_headers["content-length"] = str(len(outbound_bytes))
356
+
357
+ # 3. Stream + accumulate for cache store
358
+ store_callback = None
359
+ if proxy.hooks.cache:
360
+ _hooks = proxy.hooks
361
+ _ctx = ctx
362
+ async def _store_from_openai_sse(sse_bytes: bytes) -> None:
363
+ parsed = _parse_openai_sse_to_json(sse_bytes)
364
+ if parsed is None:
365
+ return
366
+ prov = ProviderResponse(
367
+ modified=False, body={}, body_bytes=parsed,
368
+ tokens_before=0, tokens_after=0, strategy="none",
369
+ )
370
+ await _safe_cache_store(_hooks, _ctx, prov)
371
+ store_callback = _store_from_openai_sse
372
+
373
+ return await _stream_and_cache_forward(
374
+ proxy, request_id, fwd_headers, outbound_bytes, upstream_url,
375
+ on_complete=store_callback,
54
376
  )
55
377
 
378
+ # --- Non-streaming path ---
56
379
  cache_result = None
57
- if not has_tools and proxy.hooks.cache:
380
+ if proxy.hooks.cache:
58
381
  cache_result = await _safe_cache_check(proxy.hooks, ctx)
59
382
  if cache_result.hit and cache_result.data:
60
383
  await _safe_cache_hit_callbacks(
@@ -82,7 +405,6 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
82
405
 
83
406
  if (
84
407
  upstream_resp.status_code == 200
85
- and not has_tools
86
408
  and proxy.hooks.cache
87
409
  and cache_result is not None
88
410
  and cache_result.cache_key
@@ -17,7 +17,7 @@ from superlocalmemory.optimize.proxy.lifecycle import HookChain
17
17
 
18
18
  logger = logging.getLogger("slm.optimize.proxy")
19
19
 
20
- _PROXY_VERSION = "3.6.0"
20
+ _PROXY_VERSION = "3.6.3"
21
21
  _REQUEST_TIMEOUT_S = 300.0
22
22
  _CONNECT_TIMEOUT_S = 10.0
23
23
  _MAX_CONNECTIONS = 100
@@ -145,7 +145,14 @@ def _load_hooks(config: OptimizeConfig) -> HookChain:
145
145
  )
146
146
 
147
147
  if config.compress_enabled:
148
- # Compress is Phase 2; not wired in P1. P1 keeps the slot for the seam.
149
- pass
148
+ try:
149
+ from superlocalmemory.optimize.compress.router import CompressRouter
150
+ from superlocalmemory.optimize.metrics.counters import MetricsCollector
151
+ compress_hook = CompressRouter.get_instance()
152
+ compress_hook.set_metrics(MetricsCollector.get_instance())
153
+ except Exception as exc:
154
+ logger.warning(
155
+ "compress hook load failed (proxy continues without compress): %s", exc
156
+ )
150
157
 
151
158
  return HookChain(cache=cache_hook, compress=compress_hook)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.6.0
3
+ Version: 3.6.3
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: AGPL-3.0-or-later
@@ -494,7 +494,7 @@ tests/test_auto_hooks.py
494
494
  tests/test_before_web_hook.py
495
495
  tests/test_behavioral_full.py
496
496
  tests/test_claude_hooks.py
497
- tests/test_cli.py
497
+ tests/test_cli_core.py
498
498
  tests/test_cli_json.py
499
499
  tests/test_cli_v33.py
500
500
  tests/test_compliance_full.py