superlocalmemory 3.5.8 → 3.6.0
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/ATTRIBUTION.md +24 -0
- package/CHANGELOG.md +35 -0
- package/README.md +142 -35
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/cache_cmd.py +198 -0
- package/src/superlocalmemory/cli/commands.py +80 -2
- package/src/superlocalmemory/cli/compress_cmd.py +179 -0
- package/src/superlocalmemory/cli/help_cmd.py +197 -0
- package/src/superlocalmemory/cli/main.py +122 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +175 -0
- package/src/superlocalmemory/cli/optimize_constants.py +31 -0
- package/src/superlocalmemory/cli/proxy_cmd.py +95 -0
- package/src/superlocalmemory/core/config.py +5 -0
- package/src/superlocalmemory/core/engine.py +23 -0
- package/src/superlocalmemory/core/mcp_embedder_proxy.py +89 -0
- package/src/superlocalmemory/llm/backbone.py +10 -4
- package/src/superlocalmemory/mcp/server.py +34 -0
- package/src/superlocalmemory/mcp/tools_v3.py +6 -2
- package/src/superlocalmemory/optimize/NOTICE +11 -0
- package/src/superlocalmemory/optimize/__init__.py +0 -0
- package/src/superlocalmemory/optimize/adapters/__init__.py +68 -0
- package/src/superlocalmemory/optimize/adapters/_agent_registry.py +120 -0
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +115 -0
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +125 -0
- package/src/superlocalmemory/optimize/adapters/wrap.py +188 -0
- package/src/superlocalmemory/optimize/cache/__init__.py +31 -0
- package/src/superlocalmemory/optimize/cache/boundary_store.py +455 -0
- package/src/superlocalmemory/optimize/cache/centroid_store.py +158 -0
- package/src/superlocalmemory/optimize/cache/context_key.py +67 -0
- package/src/superlocalmemory/optimize/cache/exact.py +85 -0
- package/src/superlocalmemory/optimize/cache/invalidation.py +36 -0
- package/src/superlocalmemory/optimize/cache/key_builder.py +98 -0
- package/src/superlocalmemory/optimize/cache/manager.py +452 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +568 -0
- package/src/superlocalmemory/optimize/cache/stampede.py +50 -0
- package/src/superlocalmemory/optimize/compress/__init__.py +17 -0
- package/src/superlocalmemory/optimize/compress/align.py +153 -0
- package/src/superlocalmemory/optimize/compress/ccr.py +157 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +311 -0
- package/src/superlocalmemory/optimize/compress/extractive_json.py +72 -0
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +77 -0
- package/src/superlocalmemory/optimize/compress/router.py +548 -0
- package/src/superlocalmemory/optimize/config/__init__.py +35 -0
- package/src/superlocalmemory/optimize/config/defaults.py +48 -0
- package/src/superlocalmemory/optimize/config/schema.py +255 -0
- package/src/superlocalmemory/optimize/config/store.py +209 -0
- package/src/superlocalmemory/optimize/metrics/__init__.py +8 -0
- package/src/superlocalmemory/optimize/metrics/counters.py +138 -0
- package/src/superlocalmemory/optimize/metrics/estimator.py +90 -0
- package/src/superlocalmemory/optimize/metrics/exporters.py +77 -0
- package/src/superlocalmemory/optimize/metrics/persistence.py +115 -0
- package/src/superlocalmemory/optimize/proxy/__init__.py +28 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +257 -0
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +171 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +121 -0
- package/src/superlocalmemory/optimize/proxy/lifecycle.py +126 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +125 -0
- package/src/superlocalmemory/optimize/proxy/server.py +151 -0
- package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
- package/src/superlocalmemory/optimize/storage/db.py +1016 -0
- package/src/superlocalmemory/optimize/storage/schema.py +184 -0
- package/src/superlocalmemory/server/routes/optimize.py +166 -0
- package/src/superlocalmemory/server/routes/v3_api.py +63 -1
- package/src/superlocalmemory/server/unified_daemon.py +105 -0
- package/src/superlocalmemory/ui/index.html +98 -0
- package/src/superlocalmemory/ui/js/ng-shell.js +2 -1
- package/src/superlocalmemory/ui/js/optimize.js +173 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +2 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +51 -0
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
#
|
|
5
|
+
# ATTRIBUTION: PricingRegistry + stale-detection pattern adapted from:
|
|
6
|
+
# headroom/pricing/registry.py (Apache-2.0)
|
|
7
|
+
|
|
8
|
+
"""Savings estimator — converts MetricsSnapshot to dollar/rupee savings."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import date, timedelta
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from superlocalmemory.optimize.storage.db import MetricsSnapshot
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("superlocalmemory.optimize.metrics.estimator")
|
|
19
|
+
|
|
20
|
+
# DATED: 2026-06-07 — prices verified from official pages on this date.
|
|
21
|
+
BUILTIN_PRICING_TABLE: dict[str, dict[str, float]] = {
|
|
22
|
+
"anthropic": {"input_per_1m_usd": 3.00, "output_per_1m_usd": 15.00},
|
|
23
|
+
"openai": {"input_per_1m_usd": 2.50, "output_per_1m_usd": 10.00},
|
|
24
|
+
"gemini": {"input_per_1m_usd": 1.25, "output_per_1m_usd": 10.00},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_PRICING_STALE_DAYS: int = 90
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SavingsEstimator:
|
|
31
|
+
"""Converts MetricsSnapshot to dollar/rupee savings. Daemon-scoped singleton.
|
|
32
|
+
|
|
33
|
+
Pricing (June 2026 — dated defaults, user-configurable):
|
|
34
|
+
anthropic: $3.00/M input tokens
|
|
35
|
+
openai: $2.50/M input tokens
|
|
36
|
+
gemini: $1.25/M input tokens
|
|
37
|
+
|
|
38
|
+
CRITICAL pricing rule (INTERFACE-CONTRACT §7):
|
|
39
|
+
Cache SKIP saves BOTH input+output tokens (whole call avoided).
|
|
40
|
+
Compression saves INPUT tokens ONLY (output tokens not compressed).
|
|
41
|
+
NEVER apply output-token pricing to compression savings.
|
|
42
|
+
|
|
43
|
+
INR conversion: 83.5 (hardcoded, configurable).
|
|
44
|
+
Stale detection: if pricing data is > 90 days old → include is_stale=True in output.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
INR_RATE: float = 83.5
|
|
48
|
+
_PRICING_DATE: str = "2026-06-07"
|
|
49
|
+
|
|
50
|
+
def estimate(self, snap: MetricsSnapshot, provider: str = "anthropic") -> dict[str, Any]:
|
|
51
|
+
"""Compute savings from a MetricsSnapshot.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
dict with keys: usd, inr, tokens_saved_total, cache_tokens,
|
|
55
|
+
compress_tokens, is_stale, pricing_date
|
|
56
|
+
"""
|
|
57
|
+
provider_key = provider if provider in BUILTIN_PRICING_TABLE else "anthropic"
|
|
58
|
+
rates = BUILTIN_PRICING_TABLE[provider_key]
|
|
59
|
+
input_rate = rates["input_per_1m_usd"]
|
|
60
|
+
output_rate = rates["output_per_1m_usd"]
|
|
61
|
+
|
|
62
|
+
cache_tokens = snap.tokens_saved_input + snap.tokens_saved_output
|
|
63
|
+
compress_tokens = snap.tokens_saved_compress
|
|
64
|
+
tokens_saved_total = cache_tokens + compress_tokens
|
|
65
|
+
|
|
66
|
+
# Cache skip: input + output tokens saved (whole call avoided)
|
|
67
|
+
savings_cache_usd = (snap.tokens_saved_input / 1_000_000) * input_rate + (snap.tokens_saved_output / 1_000_000) * output_rate
|
|
68
|
+
# Compression: input tokens only
|
|
69
|
+
savings_compress_usd = (compress_tokens / 1_000_000) * input_rate
|
|
70
|
+
total_usd = savings_cache_usd + savings_compress_usd
|
|
71
|
+
|
|
72
|
+
is_stale = self._is_stale()
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"usd": round(total_usd, 6),
|
|
76
|
+
"inr": round(total_usd * self.INR_RATE, 4),
|
|
77
|
+
"tokens_saved_total": tokens_saved_total,
|
|
78
|
+
"cache_tokens": cache_tokens,
|
|
79
|
+
"compress_tokens": compress_tokens,
|
|
80
|
+
"is_stale": is_stale,
|
|
81
|
+
"pricing_date": self._PRICING_DATE,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def _is_stale(self) -> bool:
|
|
85
|
+
"""Return True if pricing data is older than _PRICING_STALE_DAYS."""
|
|
86
|
+
try:
|
|
87
|
+
table_date = date.fromisoformat(self._PRICING_DATE)
|
|
88
|
+
return (date.today() - table_date) > timedelta(days=_PRICING_STALE_DAYS)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return True
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Optional Prometheus/OpenTelemetry export. Guarded by try/except — no hard dependency."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("superlocalmemory.optimize.metrics.exporters")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_prometheus_exporter() -> Any | None:
|
|
16
|
+
"""Return a Prometheus exporter if prometheus_client is installed, else None.
|
|
17
|
+
|
|
18
|
+
Prometheus port: 9091 (NOT 8765, NOT 8766).
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
from prometheus_client import Counter, Gauge, start_http_server # noqa: F401
|
|
22
|
+
return _PrometheusExporter()
|
|
23
|
+
except ImportError:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _PrometheusExporter:
|
|
28
|
+
"""Thin wrapper around prometheus_client for SLM Optimize metrics."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
from prometheus_client import Counter, Gauge, start_http_server
|
|
32
|
+
|
|
33
|
+
self._cache_hits = Counter(
|
|
34
|
+
"slm_cache_hits_total", "Total cache hits"
|
|
35
|
+
)
|
|
36
|
+
self._cache_misses = Counter(
|
|
37
|
+
"slm_cache_misses_total", "Total cache misses"
|
|
38
|
+
)
|
|
39
|
+
self._tokens_saved = Counter(
|
|
40
|
+
"slm_tokens_saved_total", "Total tokens saved (cache + compress)"
|
|
41
|
+
)
|
|
42
|
+
self._compress_runs = Counter(
|
|
43
|
+
"slm_compress_runs_total", "Total compression runs"
|
|
44
|
+
)
|
|
45
|
+
self._cache_size = Gauge(
|
|
46
|
+
"slm_cache_size_bytes", "Cache size in bytes"
|
|
47
|
+
)
|
|
48
|
+
self._started = False
|
|
49
|
+
|
|
50
|
+
def start(self, port: int = 9091) -> None:
|
|
51
|
+
"""Start Prometheus HTTP server on the given port."""
|
|
52
|
+
if self._started:
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
from prometheus_client import start_http_server
|
|
56
|
+
start_http_server(port)
|
|
57
|
+
self._started = True
|
|
58
|
+
logger.info("Prometheus exporter started on port %d", port)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.warning("Failed to start Prometheus exporter: %s", exc)
|
|
61
|
+
|
|
62
|
+
def update(self, snapshot: dict[str, Any]) -> None:
|
|
63
|
+
"""Update Prometheus gauges/counters from a metrics snapshot dict."""
|
|
64
|
+
if not self._started:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
self._cache_hits._value.set(snapshot.get("hits", 0))
|
|
68
|
+
self._cache_misses._value.set(snapshot.get("misses", 0))
|
|
69
|
+
self._tokens_saved._value.set(
|
|
70
|
+
snapshot.get("tokens_saved_input", 0)
|
|
71
|
+
+ snapshot.get("tokens_saved_output", 0)
|
|
72
|
+
+ snapshot.get("tokens_saved_compress", 0)
|
|
73
|
+
)
|
|
74
|
+
self._compress_runs._value.set(snapshot.get("compress_runs", 0))
|
|
75
|
+
self._cache_size.set(snapshot.get("cache_size_bytes", 0))
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.debug("Prometheus update failed: %s", exc)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Flush in-memory counters to llmcache.db. Load on startup to restore counters."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from superlocalmemory.optimize.metrics.counters import MetricsCollector
|
|
15
|
+
from superlocalmemory.optimize.storage.db import CacheDB, MetricsSnapshot
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("superlocalmemory.optimize.metrics.persistence")
|
|
21
|
+
|
|
22
|
+
_FLUSH_INTERVAL_SECONDS: float = 60.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MetricsPersistence:
|
|
26
|
+
"""Flush in-memory counters -> llmcache.db. Load on startup -> restore counters.
|
|
27
|
+
|
|
28
|
+
Table: llmcache_metrics (16 columns — INTERFACE-CONTRACT §6).
|
|
29
|
+
Single aggregate row (id=1). INSERT OR REPLACE pattern.
|
|
30
|
+
NEVER reads or writes memory.db.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._thread: threading.Thread | None = None
|
|
35
|
+
self._stop_event = threading.Event()
|
|
36
|
+
|
|
37
|
+
def flush(self, collector: MetricsCollector, db: CacheDB) -> None:
|
|
38
|
+
"""Flush current collector state to llmcache_metrics via CacheDB."""
|
|
39
|
+
try:
|
|
40
|
+
try:
|
|
41
|
+
cache_size_bytes = db.db_size_bytes()
|
|
42
|
+
except Exception:
|
|
43
|
+
cache_size_bytes = 0
|
|
44
|
+
try:
|
|
45
|
+
cache_entry_count = db.entry_count()
|
|
46
|
+
except Exception:
|
|
47
|
+
cache_entry_count = 0
|
|
48
|
+
snap = collector.snapshot(
|
|
49
|
+
cache_size_bytes=cache_size_bytes,
|
|
50
|
+
cache_entry_count=cache_entry_count,
|
|
51
|
+
)
|
|
52
|
+
db.metrics_flush(snap)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning("MetricsPersistence.flush failed: %s", exc)
|
|
55
|
+
|
|
56
|
+
def load(self, collector: MetricsCollector, db: CacheDB) -> None:
|
|
57
|
+
"""Load persisted counters from llmcache_metrics into the live collector.
|
|
58
|
+
|
|
59
|
+
Runs at daemon startup to restore counters after restart.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
snap = db.metrics_load()
|
|
63
|
+
if snap.hits > 0 or snap.misses > 0:
|
|
64
|
+
# Restore counters from persisted snapshot
|
|
65
|
+
with collector._data_lock:
|
|
66
|
+
collector._hits = snap.hits
|
|
67
|
+
collector._misses = snap.misses
|
|
68
|
+
collector._calls_skipped = snap.calls_skipped
|
|
69
|
+
collector._tokens_saved_input = snap.tokens_saved_input
|
|
70
|
+
collector._tokens_saved_output = snap.tokens_saved_output
|
|
71
|
+
collector._tokens_saved_compress = snap.tokens_saved_compress
|
|
72
|
+
collector._evictions = snap.evictions
|
|
73
|
+
collector._latency_overhead_ms_sum = snap.latency_overhead_ms_sum
|
|
74
|
+
collector._latency_samples = snap.latency_samples
|
|
75
|
+
collector._compress_runs = snap.compress_runs
|
|
76
|
+
collector._compress_bytes_original = snap.compress_bytes_original
|
|
77
|
+
collector._compress_bytes_after = snap.compress_bytes_after
|
|
78
|
+
collector._cache_size_bytes = snap.cache_size_bytes
|
|
79
|
+
collector._cache_entry_count = snap.cache_entry_count
|
|
80
|
+
logger.info(
|
|
81
|
+
"MetricsPersistence: restored %d hits, %d misses from llmcache.db",
|
|
82
|
+
snap.hits, snap.misses,
|
|
83
|
+
)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.warning("MetricsPersistence.load failed: %s", exc)
|
|
86
|
+
|
|
87
|
+
def start_background_flush(
|
|
88
|
+
self, collector: MetricsCollector, db: CacheDB
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Start a background thread that flushes every 60s."""
|
|
91
|
+
if self._thread is not None and self._thread.is_alive():
|
|
92
|
+
return
|
|
93
|
+
self._stop_event.clear()
|
|
94
|
+
|
|
95
|
+
def _loop() -> None:
|
|
96
|
+
while not self._stop_event.is_set():
|
|
97
|
+
if self._stop_event.wait(timeout=_FLUSH_INTERVAL_SECONDS):
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
self.flush(collector, db)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
logger.warning("MetricsPersistence background flush error: %s", exc)
|
|
103
|
+
|
|
104
|
+
self._thread = threading.Thread(
|
|
105
|
+
target=_loop, name="slm-metrics-flush", daemon=True,
|
|
106
|
+
)
|
|
107
|
+
self._thread.start()
|
|
108
|
+
|
|
109
|
+
def stop_background_flush(self) -> None:
|
|
110
|
+
"""Signal the background flush thread to stop."""
|
|
111
|
+
self._stop_event.set()
|
|
112
|
+
t = self._thread
|
|
113
|
+
if t is not None:
|
|
114
|
+
t.join(timeout=5.0)
|
|
115
|
+
self._thread = None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Public exports for the Optimize proxy package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from superlocalmemory.optimize.proxy.lifecycle import (
|
|
6
|
+
CachedResponse,
|
|
7
|
+
CompressHook,
|
|
8
|
+
CacheHook,
|
|
9
|
+
HookChain,
|
|
10
|
+
ProviderResponse,
|
|
11
|
+
ProxyRequest,
|
|
12
|
+
ensure_proxy_running,
|
|
13
|
+
proxy_port,
|
|
14
|
+
)
|
|
15
|
+
from superlocalmemory.optimize.proxy.server import ProxyApp, build_proxy_router
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ProxyApp",
|
|
19
|
+
"build_proxy_router",
|
|
20
|
+
"ProxyRequest",
|
|
21
|
+
"CachedResponse",
|
|
22
|
+
"ProviderResponse",
|
|
23
|
+
"CacheHook",
|
|
24
|
+
"CompressHook",
|
|
25
|
+
"HookChain",
|
|
26
|
+
"ensure_proxy_running",
|
|
27
|
+
"proxy_port",
|
|
28
|
+
]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""_helpers.py — Shared HTTP utilities used by all surface modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, AsyncIterator
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from fastapi.requests import Request
|
|
11
|
+
from fastapi.responses import Response, StreamingResponse
|
|
12
|
+
|
|
13
|
+
from superlocalmemory.optimize.proxy.lifecycle import (
|
|
14
|
+
CachedResponse,
|
|
15
|
+
HookChain,
|
|
16
|
+
ProviderResponse,
|
|
17
|
+
ProxyRequest,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_get_running_loop = asyncio.get_running_loop
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("slm.optimize.proxy.helpers")
|
|
23
|
+
|
|
24
|
+
# SEC-M-02 (CWE-400): reject oversized bodies to prevent compression-bomb DoS.
|
|
25
|
+
_MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Test helper: in-test httpx mock transport. NOT used in production code.
|
|
29
|
+
from typing import Callable as _Callable
|
|
30
|
+
import httpx as _httpx
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _MockTransport(_httpx.AsyncBaseTransport):
|
|
34
|
+
"""Minimal in-test mock for httpx that records requests and returns canned responses.
|
|
35
|
+
|
|
36
|
+
Handler may be sync (returns Response) or async (returns coroutine).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, handler: _Callable) -> None:
|
|
40
|
+
self._handler = handler
|
|
41
|
+
self.requests: list = []
|
|
42
|
+
|
|
43
|
+
async def handle_async_request(self, request: _httpx.Request) -> _httpx.Response:
|
|
44
|
+
self.requests.append(request)
|
|
45
|
+
result = self._handler(request)
|
|
46
|
+
if hasattr(result, "__await__"):
|
|
47
|
+
return await result
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
_HOP_BY_HOP = frozenset([
|
|
51
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
52
|
+
"te", "trailer", "transfer-encoding", "upgrade", "host",
|
|
53
|
+
"x-forwarded-for", "x-forwarded-host", "x-forwarded-proto",
|
|
54
|
+
"x-real-ip", "x-original-forwarded-for",
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
_ANTHROPIC_FORWARD_HEADERS = frozenset([
|
|
58
|
+
"x-api-key",
|
|
59
|
+
"anthropic-version",
|
|
60
|
+
"anthropic-beta",
|
|
61
|
+
"x-claude-code-session-id",
|
|
62
|
+
"x-claude-code-agent-id",
|
|
63
|
+
"x-claude-code-parent-agent-id",
|
|
64
|
+
"authorization",
|
|
65
|
+
"content-type",
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
_OPENAI_FORWARD_HEADERS = frozenset([
|
|
69
|
+
"authorization",
|
|
70
|
+
"content-type",
|
|
71
|
+
"openai-beta",
|
|
72
|
+
"openai-organization",
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
_GEMINI_NATIVE_FORWARD_HEADERS = frozenset([
|
|
76
|
+
"x-goog-api-key",
|
|
77
|
+
"content-type",
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
_GEMINI_OPENAI_COMPAT_FORWARD_HEADERS = frozenset([
|
|
81
|
+
"authorization",
|
|
82
|
+
"content-type",
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
_SENSITIVE_HEADER_KEYS = frozenset([
|
|
86
|
+
"authorization",
|
|
87
|
+
"x-api-key",
|
|
88
|
+
"x-goog-api-key",
|
|
89
|
+
])
|
|
90
|
+
_REDACTED = "[REDACTED]"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _redact_headers(headers: dict) -> dict:
|
|
94
|
+
return {
|
|
95
|
+
k: (_REDACTED if k.lower() in _SENSITIVE_HEADER_KEYS else v)
|
|
96
|
+
for k, v in headers.items()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _body_has_tools(body: dict) -> bool:
|
|
101
|
+
tools = body.get("tools")
|
|
102
|
+
return isinstance(tools, list) and len(tools) > 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_forward_headers(request: Request, allowed: frozenset) -> dict:
|
|
106
|
+
result: dict = {}
|
|
107
|
+
for k, v in request.headers.items():
|
|
108
|
+
kl = k.lower()
|
|
109
|
+
if kl in _HOP_BY_HOP:
|
|
110
|
+
continue
|
|
111
|
+
if kl in allowed:
|
|
112
|
+
result[kl] = v
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _filter_response_headers(headers) -> dict:
|
|
117
|
+
if hasattr(headers, "items"):
|
|
118
|
+
items = headers.items()
|
|
119
|
+
else:
|
|
120
|
+
items = headers
|
|
121
|
+
return {k: v for k, v in items if k.lower() not in _HOP_BY_HOP}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _fail_open_forward(proxy: Any, request: Request, upstream_url: str) -> Response:
|
|
125
|
+
if proxy.http_client is None:
|
|
126
|
+
logger.error(
|
|
127
|
+
"fail_open_forward: http_client is None — startup() was not called. "
|
|
128
|
+
"Check daemon lifespan wiring."
|
|
129
|
+
)
|
|
130
|
+
return Response(
|
|
131
|
+
content=b'{"type":"error","error":{"type":"api_error",'
|
|
132
|
+
b'"message":"SLM proxy not started - lifespan wiring error"}}',
|
|
133
|
+
status_code=502,
|
|
134
|
+
media_type="application/json",
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
body_bytes = await request.body()
|
|
138
|
+
fwd_headers = {
|
|
139
|
+
k: v for k, v in request.headers.items()
|
|
140
|
+
if k.lower() not in _HOP_BY_HOP
|
|
141
|
+
}
|
|
142
|
+
upstream_resp = await proxy.http_client.request(
|
|
143
|
+
method=request.method,
|
|
144
|
+
url=upstream_url,
|
|
145
|
+
headers=fwd_headers,
|
|
146
|
+
content=body_bytes,
|
|
147
|
+
)
|
|
148
|
+
return Response(
|
|
149
|
+
content=upstream_resp.content,
|
|
150
|
+
status_code=upstream_resp.status_code,
|
|
151
|
+
headers=_filter_response_headers(dict(upstream_resp.headers)),
|
|
152
|
+
)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.error("fail_open_forward failed upstream=%s exc=%r", upstream_url, exc)
|
|
155
|
+
return Response(
|
|
156
|
+
content=b'{"type":"error","error":{"type":"api_error",'
|
|
157
|
+
b'"message":"SLM proxy unreachable - check upstream"}}',
|
|
158
|
+
status_code=502,
|
|
159
|
+
media_type="application/json",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _stream_forward(
|
|
164
|
+
proxy: Any,
|
|
165
|
+
request_id: str,
|
|
166
|
+
fwd_headers: dict,
|
|
167
|
+
body_bytes: bytes,
|
|
168
|
+
upstream_url: str,
|
|
169
|
+
) -> Response | StreamingResponse:
|
|
170
|
+
if proxy.http_client is None:
|
|
171
|
+
logger.error(
|
|
172
|
+
"[%s] _stream_forward: http_client is None - startup() was not called. "
|
|
173
|
+
"Check daemon lifespan wiring.",
|
|
174
|
+
request_id,
|
|
175
|
+
)
|
|
176
|
+
return Response(
|
|
177
|
+
content=b'{"type":"error","error":{"type":"api_error",'
|
|
178
|
+
b'"message":"SLM proxy not started - lifespan wiring error"}}',
|
|
179
|
+
status_code=502,
|
|
180
|
+
media_type="application/json",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def _generate() -> AsyncIterator[bytes]:
|
|
184
|
+
try:
|
|
185
|
+
async with proxy.http_client.stream(
|
|
186
|
+
"POST", upstream_url, content=body_bytes, headers=fwd_headers,
|
|
187
|
+
) as upstream_resp:
|
|
188
|
+
async for chunk in upstream_resp.aiter_bytes():
|
|
189
|
+
if chunk:
|
|
190
|
+
yield chunk
|
|
191
|
+
except httpx.RemoteProtocolError as exc:
|
|
192
|
+
logger.warning("[%s] upstream stream closed early: %r", request_id, exc)
|
|
193
|
+
yield (
|
|
194
|
+
b'event: error\ndata: {"type":"error","error":{'
|
|
195
|
+
b'"type":"api_error","message":"upstream stream closed"}}\n\n'
|
|
196
|
+
)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
logger.error("[%s] stream forward error: %r", request_id, exc)
|
|
199
|
+
yield (
|
|
200
|
+
b'event: error\ndata: {"type":"error","error":{'
|
|
201
|
+
b'"type":"api_error","message":"SLM proxy stream error"}}\n\n'
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return StreamingResponse(
|
|
205
|
+
_generate(),
|
|
206
|
+
media_type="text/event-stream",
|
|
207
|
+
headers={
|
|
208
|
+
"Cache-Control": "no-cache",
|
|
209
|
+
"Connection": "keep-alive",
|
|
210
|
+
"X-Accel-Buffering": "no",
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _safe_cache_check(hooks: HookChain, ctx: ProxyRequest) -> CachedResponse:
|
|
216
|
+
try:
|
|
217
|
+
result = hooks.cache.check(ctx)
|
|
218
|
+
return result if result is not None else CachedResponse(
|
|
219
|
+
hit=False, data=None, cache_key="", ttl_seconds=0
|
|
220
|
+
)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logger.warning("cache.check failed (fail-open): %s", exc)
|
|
223
|
+
return CachedResponse(hit=False, data=None, cache_key="", ttl_seconds=0)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _safe_cache_store(
|
|
227
|
+
hooks: HookChain,
|
|
228
|
+
ctx: ProxyRequest,
|
|
229
|
+
resp: ProviderResponse,
|
|
230
|
+
) -> None:
|
|
231
|
+
try:
|
|
232
|
+
hooks.cache.store(ctx, resp)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
logger.warning("cache.store failed (fail-open): %s", exc)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _safe_cache_hit_callbacks(
|
|
238
|
+
hooks: HookChain,
|
|
239
|
+
ctx: ProxyRequest,
|
|
240
|
+
response_bytes: bytes,
|
|
241
|
+
tokens_saved: int,
|
|
242
|
+
) -> None:
|
|
243
|
+
try:
|
|
244
|
+
hooks.cache.on_hit(ctx, response_bytes, tokens_saved)
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
logger.warning("cache.on_hit failed (fail-open): %s", exc)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _safe_compress(hooks: HookChain, ctx: ProxyRequest) -> ProxyRequest:
|
|
250
|
+
try:
|
|
251
|
+
result: ProxyRequest = hooks.compress.compress(ctx)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.warning("compress.compress failed (fail-open): %s", exc)
|
|
254
|
+
return ctx
|
|
255
|
+
# on_compress is fired internally by CompressRouter.compress() with
|
|
256
|
+
# actual token counts — no duplicate fire here.
|
|
257
|
+
return result
|