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.
- package/CHANGELOG.md +159 -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 +12 -3
- 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
|
@@ -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(
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
149
|
-
|
|
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)
|
|
@@ -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/
|
|
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
|