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,21 +1,54 @@
1
- """gemini_surface.py — Gemini native and OpenAI-compat surfaces."""
1
+ """gemini_surface.py — Gemini native and OpenAI-compat surfaces.
2
+
3
+ BUG-FIX (v3.6.4): Both surfaces were pure pass-throughs with zero cache or
4
+ compress operations. Fixed:
5
+
6
+ - handle_gemini_native(): full cache check → compress → stream-and-cache
7
+ pipeline, matching the pattern in anthropic_surface.py. Streaming uses
8
+ _parse_gemini_sse_to_json / _gemini_sse_from_cached_json helpers that
9
+ understand Gemini's SSE format (no 'event:' prefix; final chunk carries
10
+ 'finishReason'). Non-streaming response is cached verbatim.
11
+
12
+ - handle_gemini_openai_compat(): cache check → compress → forward pipeline
13
+ for POST requests. The /v1beta/openai/chat/completions endpoint speaks
14
+ standard OpenAI JSON, so _parse_openai_sse_to_json and the OpenAI SSE
15
+ replay helper are reused after importing from openai_surface.
16
+
17
+ Gemini CLI and AGY/Antigravity (when using Google models) both hit the native
18
+ surface. Codex/Antigravity in OpenAI-compat mode hit the compat surface.
19
+
20
+ NOTE ON GOOGLE GENAI ENV VARS (v3.6.4):
21
+ Set GOOGLE_GENAI_BASE_URL=http://127.0.0.1:8765 for google-genai SDK >=0.6
22
+ Set GOOGLE_API_BASE=http://127.0.0.1:8765 for older google-generativeai SDK
23
+ Gemini CLI (≤2025-06-18): GEMINI_API_HOST=http://127.0.0.1:8765
24
+ AGY (Antigravity): GOOGLE_GENAI_BASE_URL=http://127.0.0.1:8765
25
+ """
2
26
 
3
27
  from __future__ import annotations
4
28
 
29
+ import json
5
30
  import logging
6
31
  import re
7
32
  import urllib.parse
8
33
 
9
34
  from fastapi.requests import Request
10
- from fastapi.responses import Response
35
+ from fastapi.responses import Response, StreamingResponse
11
36
 
12
37
  from superlocalmemory.optimize.proxy._helpers import (
13
38
  _GEMINI_NATIVE_FORWARD_HEADERS,
14
39
  _GEMINI_OPENAI_COMPAT_FORWARD_HEADERS,
40
+ _body_has_tools,
15
41
  _fail_open_forward,
16
42
  _filter_response_headers,
43
+ _redact_headers,
44
+ _safe_cache_check,
45
+ _safe_cache_hit_callbacks,
46
+ _safe_cache_store,
47
+ _safe_compress,
48
+ _stream_and_cache_forward,
17
49
  _stream_forward,
18
50
  )
51
+ from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
19
52
 
20
53
  logger = logging.getLogger("slm.optimize.proxy.gemini")
21
54
 
@@ -35,11 +68,149 @@ def _validate_gemini_path(model_and_method: str) -> bool:
35
68
  return bool(_GEMINI_PATH_RE.match(model_and_method))
36
69
 
37
70
 
71
+ # ---------------------------------------------------------------------------
72
+ # Gemini native SSE helpers
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def _parse_gemini_sse_to_json(sse_bytes: bytes) -> bytes | None:
76
+ """Assemble a Gemini SSE stream into a single generateContent JSON response.
77
+
78
+ Gemini streaming format (no 'event:' prefix — just 'data:' lines):
79
+ data: {"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}],"usageMetadata":{...}}
80
+ data: {"candidates":[{"content":{"parts":[{"text":" world"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{...}}
81
+
82
+ Completeness gate: returns None if no candidate ever emits a non-null
83
+ finishReason — ensures incomplete streams are not cached. This is the
84
+ Gemini equivalent of checking 'message_stop' (Anthropic) or '[DONE]' (OpenAI).
85
+ """
86
+ parts_by_idx: dict[int, list[str]] = {}
87
+ finish_reasons: dict[int, str] = {}
88
+ usage_metadata: dict = {}
89
+ model_version = ""
90
+
91
+ for raw_line in sse_bytes.decode("utf-8", errors="replace").split("\n"):
92
+ line = raw_line.rstrip("\r")
93
+ if not line.startswith("data: "):
94
+ continue
95
+ data_str = line[6:].strip()
96
+ if not data_str or data_str == "[DONE]":
97
+ continue
98
+ try:
99
+ chunk = json.loads(data_str)
100
+ except json.JSONDecodeError:
101
+ continue
102
+
103
+ if "usageMetadata" in chunk:
104
+ usage_metadata = chunk["usageMetadata"]
105
+ if "modelVersion" in chunk:
106
+ model_version = chunk["modelVersion"]
107
+
108
+ for candidate in chunk.get("candidates", []):
109
+ idx = candidate.get("index", 0)
110
+ for part in candidate.get("content", {}).get("parts", []):
111
+ text = part.get("text")
112
+ if text is not None:
113
+ parts_by_idx.setdefault(idx, []).append(text)
114
+ fr = candidate.get("finishReason")
115
+ # Gemini emits finishReason as a string on the final chunk.
116
+ # Intermediate chunks omit it or include None / "null".
117
+ if fr and fr not in (None, "null", "FINISH_REASON_UNSPECIFIED"):
118
+ finish_reasons[idx] = fr
119
+
120
+ # Completeness gate
121
+ if not finish_reasons:
122
+ return None
123
+
124
+ candidates = []
125
+ for idx in sorted(parts_by_idx.keys()):
126
+ candidates.append({
127
+ "content": {
128
+ "parts": [{"text": "".join(parts_by_idx.get(idx, []))}],
129
+ "role": "model",
130
+ },
131
+ "finishReason": finish_reasons.get(idx, "STOP"),
132
+ "index": idx,
133
+ })
134
+
135
+ if not candidates:
136
+ return None
137
+
138
+ result: dict = {"candidates": candidates, "usageMetadata": usage_metadata}
139
+ if model_version:
140
+ result["modelVersion"] = model_version
141
+
142
+ return json.dumps(result, separators=(",", ":")).encode("utf-8")
143
+
144
+
145
+ def _gemini_sse_from_cached_json(cached_bytes: bytes) -> "StreamingResponse | None":
146
+ """Replay a stored generateContent JSON as a Gemini SSE stream.
147
+
148
+ Emits the text in 100-char chunks followed by a final chunk containing
149
+ finishReason + usageMetadata, matching what the real API would send.
150
+ """
151
+ try:
152
+ resp = json.loads(cached_bytes)
153
+ except (json.JSONDecodeError, ValueError):
154
+ return None
155
+
156
+ if "candidates" not in resp:
157
+ return None
158
+
159
+ async def _generate():
160
+ for candidate in resp.get("candidates", []):
161
+ idx = candidate.get("index", 0)
162
+ text = "".join(
163
+ p.get("text", "")
164
+ for p in candidate.get("content", {}).get("parts", [])
165
+ )
166
+ finish_reason = candidate.get("finishReason", "STOP")
167
+
168
+ # Emit text in chunks
169
+ chunk_size = 100
170
+ for start in range(0, max(len(text), 1), chunk_size):
171
+ piece = text[start: start + chunk_size]
172
+ chunk = {
173
+ "candidates": [{
174
+ "content": {"parts": [{"text": piece}], "role": "model"},
175
+ "index": idx,
176
+ }]
177
+ }
178
+ yield f"data: {json.dumps(chunk)}\n\n".encode()
179
+
180
+ # Final chunk with finishReason and usageMetadata
181
+ final: dict = {
182
+ "candidates": [{
183
+ "content": {"parts": [{"text": ""}], "role": "model"},
184
+ "finishReason": finish_reason,
185
+ "index": idx,
186
+ }],
187
+ "usageMetadata": resp.get("usageMetadata", {}),
188
+ }
189
+ if "modelVersion" in resp:
190
+ final["modelVersion"] = resp["modelVersion"]
191
+ yield f"data: {json.dumps(final)}\n\n".encode()
192
+
193
+ return StreamingResponse(_generate(), media_type="text/event-stream")
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Route handlers
198
+ # ---------------------------------------------------------------------------
199
+
38
200
  async def handle_gemini_native(
39
201
  proxy: object,
40
202
  request: Request,
41
203
  model_and_method: str,
42
204
  ) -> Response:
205
+ """Handle /v1beta/models/{model}:{method} — native Gemini API.
206
+
207
+ BUG-FIX (v3.6.4): Was a pure pass-through. Now applies cache check,
208
+ optional compression, and post-stream cache store for streaming responses.
209
+ Non-streaming responses are cached verbatim.
210
+
211
+ Streaming: _parse_gemini_sse_to_json validates completeness via finishReason.
212
+ Non-streaming: response JSON stored directly.
213
+ """
43
214
  request_id = await proxy.next_request_id()
44
215
 
45
216
  if not _validate_gemini_path(model_and_method):
@@ -58,62 +229,250 @@ async def handle_gemini_native(
58
229
 
59
230
  try:
60
231
  body_bytes = await request.body()
232
+
233
+ try:
234
+ body = json.loads(body_bytes) if body_bytes else {}
235
+ except json.JSONDecodeError:
236
+ body = {}
237
+
238
+ stream = "streamGenerateContent" in model_and_method
239
+ has_tools = _body_has_tools(body)
240
+
241
+ ctx = ProxyRequest(
242
+ provider="gemini",
243
+ method="POST",
244
+ path=f"/v1beta/{model_and_method}",
245
+ headers=_redact_headers(dict(request.headers)),
246
+ body=body,
247
+ body_bytes=body_bytes,
248
+ request_id=request_id,
249
+ stream=stream,
250
+ has_tools=has_tools,
251
+ )
252
+
61
253
  fwd_headers = {
62
254
  k: v for k, v in request.headers.items()
63
255
  if k.lower() in _GEMINI_NATIVE_FORWARD_HEADERS
64
256
  }
65
- fwd_headers["content-length"] = str(len(body_bytes))
66
257
 
67
- stream = "streamGenerateContent" in model_and_method
68
258
  if stream:
259
+ # ── 1. Cache check ──────────────────────────────────────────────
260
+ if proxy.hooks.cache:
261
+ cache_result = await _safe_cache_check(proxy.hooks, ctx)
262
+ if cache_result and cache_result.hit and cache_result.data:
263
+ logger.debug(
264
+ "[%s] Gemini native streaming cache HIT key=%s",
265
+ request_id, cache_result.cache_key,
266
+ )
267
+ await _safe_cache_hit_callbacks(
268
+ proxy.hooks, ctx, cache_result.data, tokens_saved=0
269
+ )
270
+ sse_resp = _gemini_sse_from_cached_json(cache_result.data)
271
+ if sse_resp is not None:
272
+ return sse_resp
273
+
274
+ # ── 2. Compression ──────────────────────────────────────────────
275
+ outbound_bytes = body_bytes
276
+ if proxy.hooks.compress:
277
+ compress_result = await _safe_compress(proxy.hooks, ctx)
278
+ if compress_result.body_bytes != body_bytes:
279
+ outbound_bytes = compress_result.body_bytes
280
+
281
+ fwd_headers["content-length"] = str(len(outbound_bytes))
282
+
283
+ # ── 3. Build upstream streaming URL ─────────────────────────────
69
284
  allowed = {
70
285
  k: v for k, v in request.query_params.items()
71
286
  if k.lower() in _GEMINI_ALLOWED_QUERY_PARAMS
72
287
  }
73
288
  allowed["alt"] = "sse"
74
- upstream_url = f"{upstream_url}?{urllib.parse.urlencode(allowed)}"
75
- return await _stream_forward(
76
- proxy, request_id, fwd_headers, body_bytes, upstream_url
289
+ upstream_stream_url = f"{upstream_url}?{urllib.parse.urlencode(allowed)}"
290
+
291
+ # ── 4. Stream + accumulate → cache store ─────────────────────────
292
+ store_callback = None
293
+ if proxy.hooks.cache:
294
+ _hooks = proxy.hooks
295
+ _ctx = ctx
296
+
297
+ async def _store_from_gemini_sse(sse_bytes: bytes) -> None:
298
+ parsed = _parse_gemini_sse_to_json(sse_bytes)
299
+ if parsed is None:
300
+ return # incomplete stream — skip
301
+ prov = ProviderResponse(
302
+ modified=False, body={}, body_bytes=parsed,
303
+ tokens_before=0, tokens_after=0, strategy="none",
304
+ )
305
+ await _safe_cache_store(_hooks, _ctx, prov)
306
+
307
+ store_callback = _store_from_gemini_sse
308
+
309
+ return await _stream_and_cache_forward(
310
+ proxy, request_id, fwd_headers, outbound_bytes, upstream_stream_url,
311
+ on_complete=store_callback,
77
312
  )
78
313
 
314
+ # ── Non-streaming path ──────────────────────────────────────────────
315
+ cache_result = None
316
+ if proxy.hooks.cache:
317
+ cache_result = await _safe_cache_check(proxy.hooks, ctx)
318
+ if cache_result.hit and cache_result.data:
319
+ await _safe_cache_hit_callbacks(
320
+ proxy.hooks, ctx, cache_result.data, tokens_saved=0
321
+ )
322
+ return Response(
323
+ content=cache_result.data,
324
+ status_code=200,
325
+ media_type="application/json",
326
+ )
327
+
328
+ outbound_bytes = body_bytes
329
+ if proxy.hooks.compress:
330
+ compress_result = await _safe_compress(proxy.hooks, ctx)
331
+ if compress_result.body_bytes != body_bytes:
332
+ outbound_bytes = compress_result.body_bytes
333
+
334
+ fwd_headers["content-length"] = str(len(outbound_bytes))
335
+
79
336
  upstream_resp = await proxy.http_client.post(
80
- upstream_url, content=body_bytes, headers=fwd_headers,
337
+ upstream_url, content=outbound_bytes, headers=fwd_headers,
81
338
  )
339
+ resp_bytes = upstream_resp.content
340
+
341
+ if (
342
+ upstream_resp.status_code == 200
343
+ and proxy.hooks.cache
344
+ and cache_result is not None
345
+ and cache_result.cache_key
346
+ ):
347
+ prov_resp = ProviderResponse(
348
+ modified=False, body={}, body_bytes=resp_bytes,
349
+ tokens_before=0, tokens_after=0, strategy="none",
350
+ )
351
+ await _safe_cache_store(proxy.hooks, ctx, prov_resp)
352
+
82
353
  return Response(
83
- content=upstream_resp.content,
354
+ content=resp_bytes,
84
355
  status_code=upstream_resp.status_code,
85
356
  media_type="application/json",
86
357
  headers=_filter_response_headers(dict(upstream_resp.headers)),
87
358
  )
359
+
88
360
  except Exception as exc:
89
361
  logger.error("[%s] handle_gemini_native exc=%r — fail-open", request_id, exc)
90
362
  return await _fail_open_forward(proxy, request, upstream_url)
91
363
 
92
364
 
93
365
  async def handle_gemini_openai_compat(proxy: object, request: Request) -> Response:
366
+ """Handle /v1beta/openai/chat/completions and /v1beta/openai/models.
367
+
368
+ BUG-FIX (v3.6.4): POST requests (chat completions) now go through the
369
+ cache + compress pipeline. The /v1beta/openai surface accepts standard
370
+ OpenAI JSON, so we reuse OpenAI format parsing from openai_surface.py
371
+ for streaming SSE detection and replay.
372
+
373
+ GET requests (models list) are pass-through — no caching needed.
374
+ """
94
375
  local_path = request.url.path
95
376
  upstream_url = f"{_GEMINI_UPSTREAM_BASE}{local_path}"
96
377
  request_id = await proxy.next_request_id()
378
+
97
379
  try:
98
380
  body_bytes = await request.body()
381
+
382
+ # GET (models) — pass-through
383
+ if request.method == "GET" or not body_bytes:
384
+ fwd_headers = {
385
+ k: v for k, v in request.headers.items()
386
+ if k.lower() in _GEMINI_OPENAI_COMPAT_FORWARD_HEADERS
387
+ }
388
+ upstream_resp = await proxy.http_client.request(
389
+ method=request.method,
390
+ url=upstream_url,
391
+ content=body_bytes if body_bytes else None,
392
+ headers=fwd_headers,
393
+ )
394
+ return Response(
395
+ content=upstream_resp.content,
396
+ status_code=upstream_resp.status_code,
397
+ media_type="application/json",
398
+ headers=_filter_response_headers(dict(upstream_resp.headers)),
399
+ )
400
+
401
+ # POST (chat completions) — cache + compress
402
+ try:
403
+ body = json.loads(body_bytes)
404
+ except json.JSONDecodeError:
405
+ return await _fail_open_forward(proxy, request, upstream_url)
406
+
407
+ has_tools = _body_has_tools(body)
408
+ stream = bool(body.get("stream", False))
409
+
410
+ ctx = ProxyRequest(
411
+ provider="gemini-openai-compat",
412
+ method="POST",
413
+ path=local_path,
414
+ headers=_redact_headers(dict(request.headers)),
415
+ body=body,
416
+ body_bytes=body_bytes,
417
+ request_id=request_id,
418
+ stream=stream,
419
+ has_tools=has_tools,
420
+ )
421
+
422
+ # Cache check
423
+ cache_result = None
424
+ if proxy.hooks.cache:
425
+ cache_result = await _safe_cache_check(proxy.hooks, ctx)
426
+ if cache_result.hit and cache_result.data:
427
+ await _safe_cache_hit_callbacks(
428
+ proxy.hooks, ctx, cache_result.data, tokens_saved=0
429
+ )
430
+ return Response(
431
+ content=cache_result.data,
432
+ status_code=200,
433
+ media_type="application/json",
434
+ )
435
+
436
+ # Compress
437
+ outbound_bytes = body_bytes
438
+ if proxy.hooks.compress:
439
+ compress_result = await _safe_compress(proxy.hooks, ctx)
440
+ if compress_result.body_bytes != body_bytes:
441
+ outbound_bytes = compress_result.body_bytes
442
+
99
443
  fwd_headers = {
100
444
  k: v for k, v in request.headers.items()
101
445
  if k.lower() in _GEMINI_OPENAI_COMPAT_FORWARD_HEADERS
102
446
  }
103
- if body_bytes:
104
- fwd_headers["content-length"] = str(len(body_bytes))
447
+ fwd_headers["content-length"] = str(len(outbound_bytes))
448
+
105
449
  upstream_resp = await proxy.http_client.request(
106
- method=request.method,
450
+ method="POST",
107
451
  url=upstream_url,
108
- content=body_bytes if body_bytes else None,
452
+ content=outbound_bytes,
109
453
  headers=fwd_headers,
110
454
  )
455
+ resp_bytes = upstream_resp.content
456
+
457
+ if (
458
+ upstream_resp.status_code == 200
459
+ and proxy.hooks.cache
460
+ and cache_result is not None
461
+ and cache_result.cache_key
462
+ ):
463
+ prov_resp = ProviderResponse(
464
+ modified=False, body={}, body_bytes=resp_bytes,
465
+ tokens_before=0, tokens_after=0, strategy="none",
466
+ )
467
+ await _safe_cache_store(proxy.hooks, ctx, prov_resp)
468
+
111
469
  return Response(
112
- content=upstream_resp.content,
470
+ content=resp_bytes,
113
471
  status_code=upstream_resp.status_code,
114
472
  media_type="application/json",
115
473
  headers=_filter_response_headers(dict(upstream_resp.headers)),
116
474
  )
475
+
117
476
  except Exception as exc:
118
477
  logger.error(
119
478
  "[%s] handle_gemini_openai_compat exc=%r — fail-open", request_id, exc