llmstack-cli 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- llmstack/__init__.py +1 -1
- llmstack/core/health.py +1 -1
- llmstack/gateway/Dockerfile +3 -1
- llmstack/gateway/cache.py +187 -0
- llmstack/gateway/circuit_breaker.py +177 -0
- llmstack/gateway/main.py +28 -5
- llmstack/gateway/middleware/logging.py +114 -0
- llmstack/gateway/middleware/rate_limit.py +204 -0
- llmstack/gateway/proxy.py +62 -10
- llmstack/gateway/rag/__init__.py +0 -0
- llmstack/gateway/rag/pipeline.py +188 -0
- llmstack/gateway/rag/store.py +242 -0
- llmstack/gateway/routes/chat.py +33 -9
- llmstack/gateway/routes/health.py +31 -5
- llmstack/gateway/routes/rag.py +137 -0
- {llmstack_cli-0.2.0.dist-info → llmstack_cli-0.3.0.dist-info}/METADATA +162 -48
- {llmstack_cli-0.2.0.dist-info → llmstack_cli-0.3.0.dist-info}/RECORD +20 -12
- {llmstack_cli-0.2.0.dist-info → llmstack_cli-0.3.0.dist-info}/WHEEL +0 -0
- {llmstack_cli-0.2.0.dist-info → llmstack_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {llmstack_cli-0.2.0.dist-info → llmstack_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
llmstack/__init__.py
CHANGED
llmstack/core/health.py
CHANGED
|
@@ -16,7 +16,7 @@ async def wait_healthy(url: str, timeout: int = 120, interval: float = 2.0) -> b
|
|
|
16
16
|
resp = await client.get(url)
|
|
17
17
|
if resp.status_code == 200:
|
|
18
18
|
return True
|
|
19
|
-
except
|
|
19
|
+
except httpx.HTTPError:
|
|
20
20
|
pass
|
|
21
21
|
await asyncio.sleep(interval)
|
|
22
22
|
elapsed += interval
|
llmstack/gateway/Dockerfile
CHANGED
|
@@ -6,7 +6,9 @@ RUN pip install --no-cache-dir \
|
|
|
6
6
|
fastapi>=0.115 \
|
|
7
7
|
uvicorn[standard]>=0.30 \
|
|
8
8
|
httpx>=0.27 \
|
|
9
|
-
starlette>=0.40
|
|
9
|
+
starlette>=0.40 \
|
|
10
|
+
redis>=5.0 \
|
|
11
|
+
pydantic>=2.0
|
|
10
12
|
|
|
11
13
|
COPY src/llmstack/__init__.py /app/llmstack/__init__.py
|
|
12
14
|
COPY src/llmstack/gateway/ /app/llmstack/gateway/
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Semantic response cache backed by Redis.
|
|
2
|
+
|
|
3
|
+
Caches LLM completions keyed by a hash of (model + messages).
|
|
4
|
+
Supports TTL-based expiration and cache hit/miss metrics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import redis.asyncio as aioredis
|
|
16
|
+
|
|
17
|
+
REDIS_URL = os.getenv("LLMSTACK_REDIS_URL", "")
|
|
18
|
+
CACHE_TTL = int(os.getenv("LLMSTACK_CACHE_TTL", "3600")) # 1 hour default
|
|
19
|
+
CACHE_ENABLED = os.getenv("LLMSTACK_CACHE_ENABLED", "true").lower() == "true"
|
|
20
|
+
|
|
21
|
+
# Prefix to avoid key collisions
|
|
22
|
+
_KEY_PREFIX = "llmstack:cache:"
|
|
23
|
+
_STATS_KEY = "llmstack:cache:stats"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CacheStats:
|
|
28
|
+
hits: int = 0
|
|
29
|
+
misses: int = 0
|
|
30
|
+
evictions: int = 0
|
|
31
|
+
avg_hit_latency_ms: float = 0.0
|
|
32
|
+
_hit_latencies: list[float] = field(default_factory=list, repr=False)
|
|
33
|
+
|
|
34
|
+
def record_hit(self, latency_ms: float) -> None:
|
|
35
|
+
self.hits += 1
|
|
36
|
+
self._hit_latencies.append(latency_ms)
|
|
37
|
+
self.avg_hit_latency_ms = sum(self._hit_latencies) / len(self._hit_latencies)
|
|
38
|
+
|
|
39
|
+
def record_miss(self) -> None:
|
|
40
|
+
self.misses += 1
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def hit_rate(self) -> float:
|
|
44
|
+
total = self.hits + self.misses
|
|
45
|
+
return self.hits / total if total > 0 else 0.0
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"hits": self.hits,
|
|
50
|
+
"misses": self.misses,
|
|
51
|
+
"hit_rate": round(self.hit_rate, 4),
|
|
52
|
+
"avg_hit_latency_ms": round(self.avg_hit_latency_ms, 2),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResponseCache:
|
|
57
|
+
"""Redis-backed LLM response cache with semantic key hashing."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, redis_url: str = "", ttl: int = CACHE_TTL):
|
|
60
|
+
self._url = redis_url or REDIS_URL
|
|
61
|
+
self._ttl = ttl
|
|
62
|
+
self._redis: aioredis.Redis | None = None
|
|
63
|
+
self._stats = CacheStats()
|
|
64
|
+
self._connected = False
|
|
65
|
+
|
|
66
|
+
async def connect(self) -> None:
|
|
67
|
+
"""Lazily connect to Redis."""
|
|
68
|
+
if self._connected or not self._url:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
self._redis = aioredis.from_url(
|
|
72
|
+
self._url,
|
|
73
|
+
decode_responses=True,
|
|
74
|
+
socket_connect_timeout=3,
|
|
75
|
+
socket_timeout=5,
|
|
76
|
+
retry_on_timeout=True,
|
|
77
|
+
)
|
|
78
|
+
await self._redis.ping()
|
|
79
|
+
self._connected = True
|
|
80
|
+
except Exception:
|
|
81
|
+
self._redis = None
|
|
82
|
+
self._connected = False
|
|
83
|
+
|
|
84
|
+
async def close(self) -> None:
|
|
85
|
+
if self._redis:
|
|
86
|
+
await self._redis.aclose()
|
|
87
|
+
self._connected = False
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _build_cache_key(model: str, messages: list[dict], temperature: float = 1.0) -> str:
|
|
91
|
+
"""Deterministic hash of the request for cache lookup.
|
|
92
|
+
|
|
93
|
+
Only caches when temperature <= 0.1 (near-deterministic outputs).
|
|
94
|
+
For higher temperatures, returns empty string (skip cache).
|
|
95
|
+
"""
|
|
96
|
+
if temperature > 0.1:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
# Normalize messages to a stable representation
|
|
100
|
+
normalized = []
|
|
101
|
+
for msg in messages:
|
|
102
|
+
normalized.append({
|
|
103
|
+
"role": msg.get("role", ""),
|
|
104
|
+
"content": msg.get("content", ""),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
payload = json.dumps({"model": model, "messages": normalized}, sort_keys=True)
|
|
108
|
+
digest = hashlib.sha256(payload.encode()).hexdigest()
|
|
109
|
+
return f"{_KEY_PREFIX}{digest}"
|
|
110
|
+
|
|
111
|
+
async def get(self, model: str, messages: list[dict], temperature: float = 1.0) -> dict | None:
|
|
112
|
+
"""Look up a cached response. Returns None on miss."""
|
|
113
|
+
if not self._connected or not CACHE_ENABLED:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
key = self._build_cache_key(model, messages, temperature)
|
|
117
|
+
if not key:
|
|
118
|
+
self._stats.record_miss()
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
start = time.monotonic()
|
|
122
|
+
try:
|
|
123
|
+
raw = await self._redis.get(key) # type: ignore[union-attr]
|
|
124
|
+
if raw is None:
|
|
125
|
+
self._stats.record_miss()
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
129
|
+
self._stats.record_hit(latency_ms)
|
|
130
|
+
|
|
131
|
+
cached = json.loads(raw)
|
|
132
|
+
# Mark response as cached
|
|
133
|
+
cached["_cached"] = True
|
|
134
|
+
cached["_cache_age_s"] = int(time.time() - cached.get("_cached_at", 0))
|
|
135
|
+
return cached
|
|
136
|
+
except Exception:
|
|
137
|
+
self._stats.record_miss()
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
async def put(self, model: str, messages: list[dict], response: dict,
|
|
141
|
+
temperature: float = 1.0) -> None:
|
|
142
|
+
"""Store a response in the cache."""
|
|
143
|
+
if not self._connected or not CACHE_ENABLED:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
key = self._build_cache_key(model, messages, temperature)
|
|
147
|
+
if not key:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
response["_cached_at"] = int(time.time())
|
|
152
|
+
raw = json.dumps(response)
|
|
153
|
+
await self._redis.set(key, raw, ex=self._ttl) # type: ignore[union-attr]
|
|
154
|
+
except Exception:
|
|
155
|
+
pass # Cache write failure is non-fatal
|
|
156
|
+
|
|
157
|
+
async def invalidate(self, pattern: str = "*") -> int:
|
|
158
|
+
"""Delete cache entries matching a pattern. Returns count deleted."""
|
|
159
|
+
if not self._connected:
|
|
160
|
+
return 0
|
|
161
|
+
try:
|
|
162
|
+
keys = []
|
|
163
|
+
async for key in self._redis.scan_iter(f"{_KEY_PREFIX}{pattern}"): # type: ignore[union-attr]
|
|
164
|
+
keys.append(key)
|
|
165
|
+
if keys:
|
|
166
|
+
return await self._redis.delete(*keys) # type: ignore[union-attr]
|
|
167
|
+
return 0
|
|
168
|
+
except Exception:
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def stats(self) -> CacheStats:
|
|
173
|
+
return self._stats
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Module-level singleton
|
|
177
|
+
_cache: ResponseCache | None = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def get_cache() -> ResponseCache:
|
|
181
|
+
"""Get or create the global cache instance."""
|
|
182
|
+
global _cache
|
|
183
|
+
if _cache is None:
|
|
184
|
+
_cache = ResponseCache()
|
|
185
|
+
if not _cache._connected:
|
|
186
|
+
await _cache.connect()
|
|
187
|
+
return _cache
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Circuit breaker for inference backend resilience.
|
|
2
|
+
|
|
3
|
+
Implements the three-state circuit breaker pattern:
|
|
4
|
+
CLOSED → OPEN → HALF_OPEN → CLOSED
|
|
5
|
+
|
|
6
|
+
Prevents cascading failures when the inference backend is down
|
|
7
|
+
by failing fast instead of timing out on every request.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CircuitState(str, Enum):
|
|
18
|
+
CLOSED = "closed" # Normal operation — requests flow through
|
|
19
|
+
OPEN = "open" # Backend is down — fail fast
|
|
20
|
+
HALF_OPEN = "half_open" # Probing — allow one request to test recovery
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CircuitBreakerError(Exception):
|
|
24
|
+
"""Raised when the circuit is open and requests are rejected."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, retry_after: float):
|
|
27
|
+
self.retry_after = retry_after
|
|
28
|
+
super().__init__(f"Circuit breaker is open. Retry after {retry_after:.0f}s")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CircuitBreaker:
|
|
32
|
+
"""Circuit breaker with exponential backoff and half-open probing.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
breaker = CircuitBreaker()
|
|
36
|
+
|
|
37
|
+
async def call_inference(payload):
|
|
38
|
+
breaker.check() # raises CircuitBreakerError if open
|
|
39
|
+
try:
|
|
40
|
+
result = await make_request(payload)
|
|
41
|
+
breaker.record_success()
|
|
42
|
+
return result
|
|
43
|
+
except Exception as e:
|
|
44
|
+
breaker.record_failure()
|
|
45
|
+
raise
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
failure_threshold: int = 5,
|
|
51
|
+
recovery_timeout: float = 30.0,
|
|
52
|
+
half_open_max_calls: int = 1,
|
|
53
|
+
max_recovery_timeout: float = 300.0,
|
|
54
|
+
backoff_multiplier: float = 2.0,
|
|
55
|
+
):
|
|
56
|
+
self.failure_threshold = failure_threshold
|
|
57
|
+
self.base_recovery_timeout = recovery_timeout
|
|
58
|
+
self.half_open_max_calls = half_open_max_calls
|
|
59
|
+
self.max_recovery_timeout = max_recovery_timeout
|
|
60
|
+
self.backoff_multiplier = backoff_multiplier
|
|
61
|
+
|
|
62
|
+
self._state = CircuitState.CLOSED
|
|
63
|
+
self._failure_count = 0
|
|
64
|
+
self._success_count = 0
|
|
65
|
+
self._last_failure_time = 0.0
|
|
66
|
+
self._half_open_calls = 0
|
|
67
|
+
self._consecutive_opens = 0
|
|
68
|
+
self._lock = asyncio.Lock()
|
|
69
|
+
|
|
70
|
+
# Metrics
|
|
71
|
+
self._total_failures = 0
|
|
72
|
+
self._total_successes = 0
|
|
73
|
+
self._total_rejections = 0
|
|
74
|
+
self._last_state_change = time.monotonic()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def state(self) -> CircuitState:
|
|
78
|
+
return self._state
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def current_recovery_timeout(self) -> float:
|
|
82
|
+
"""Exponential backoff on recovery timeout."""
|
|
83
|
+
timeout = self.base_recovery_timeout * (
|
|
84
|
+
self.backoff_multiplier ** self._consecutive_opens
|
|
85
|
+
)
|
|
86
|
+
return min(timeout, self.max_recovery_timeout)
|
|
87
|
+
|
|
88
|
+
def check(self) -> None:
|
|
89
|
+
"""Check if a request is allowed. Raises CircuitBreakerError if not."""
|
|
90
|
+
if self._state == CircuitState.CLOSED:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if self._state == CircuitState.OPEN:
|
|
94
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
95
|
+
if elapsed >= self.current_recovery_timeout:
|
|
96
|
+
# Transition to half-open
|
|
97
|
+
self._state = CircuitState.HALF_OPEN
|
|
98
|
+
self._half_open_calls = 0
|
|
99
|
+
self._last_state_change = time.monotonic()
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
retry_after = self.current_recovery_timeout - elapsed
|
|
103
|
+
self._total_rejections += 1
|
|
104
|
+
raise CircuitBreakerError(retry_after=retry_after)
|
|
105
|
+
|
|
106
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
107
|
+
if self._half_open_calls >= self.half_open_max_calls:
|
|
108
|
+
# Too many half-open calls pending, reject
|
|
109
|
+
self._total_rejections += 1
|
|
110
|
+
raise CircuitBreakerError(retry_after=5.0)
|
|
111
|
+
self._half_open_calls += 1
|
|
112
|
+
|
|
113
|
+
def record_success(self) -> None:
|
|
114
|
+
"""Record a successful request."""
|
|
115
|
+
self._total_successes += 1
|
|
116
|
+
|
|
117
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
118
|
+
self._success_count += 1
|
|
119
|
+
# Recovered — close the circuit
|
|
120
|
+
self._state = CircuitState.CLOSED
|
|
121
|
+
self._failure_count = 0
|
|
122
|
+
self._success_count = 0
|
|
123
|
+
self._consecutive_opens = 0
|
|
124
|
+
self._last_state_change = time.monotonic()
|
|
125
|
+
elif self._state == CircuitState.CLOSED:
|
|
126
|
+
# Reset failure count on success
|
|
127
|
+
self._failure_count = 0
|
|
128
|
+
|
|
129
|
+
def record_failure(self) -> None:
|
|
130
|
+
"""Record a failed request."""
|
|
131
|
+
self._total_failures += 1
|
|
132
|
+
self._failure_count += 1
|
|
133
|
+
self._last_failure_time = time.monotonic()
|
|
134
|
+
|
|
135
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
136
|
+
# Probe failed — back to open with longer timeout
|
|
137
|
+
self._state = CircuitState.OPEN
|
|
138
|
+
self._consecutive_opens += 1
|
|
139
|
+
self._last_state_change = time.monotonic()
|
|
140
|
+
elif self._state == CircuitState.CLOSED:
|
|
141
|
+
if self._failure_count >= self.failure_threshold:
|
|
142
|
+
self._state = CircuitState.OPEN
|
|
143
|
+
self._consecutive_opens += 1
|
|
144
|
+
self._last_state_change = time.monotonic()
|
|
145
|
+
|
|
146
|
+
def reset(self) -> None:
|
|
147
|
+
"""Manually reset the circuit breaker to closed state."""
|
|
148
|
+
self._state = CircuitState.CLOSED
|
|
149
|
+
self._failure_count = 0
|
|
150
|
+
self._success_count = 0
|
|
151
|
+
self._half_open_calls = 0
|
|
152
|
+
self._consecutive_opens = 0
|
|
153
|
+
self._last_state_change = time.monotonic()
|
|
154
|
+
|
|
155
|
+
def metrics(self) -> dict:
|
|
156
|
+
"""Return circuit breaker metrics."""
|
|
157
|
+
return {
|
|
158
|
+
"state": self._state.value,
|
|
159
|
+
"failure_count": self._failure_count,
|
|
160
|
+
"consecutive_opens": self._consecutive_opens,
|
|
161
|
+
"current_recovery_timeout_s": round(self.current_recovery_timeout, 1),
|
|
162
|
+
"total_successes": self._total_successes,
|
|
163
|
+
"total_failures": self._total_failures,
|
|
164
|
+
"total_rejections": self._total_rejections,
|
|
165
|
+
"time_in_state_s": round(time.monotonic() - self._last_state_change, 1),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Module-level singleton for the inference backend
|
|
170
|
+
_inference_breaker: CircuitBreaker | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_inference_breaker() -> CircuitBreaker:
|
|
174
|
+
global _inference_breaker
|
|
175
|
+
if _inference_breaker is None:
|
|
176
|
+
_inference_breaker = CircuitBreaker()
|
|
177
|
+
return _inference_breaker
|
llmstack/gateway/main.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""LLMStack Gateway — OpenAI-compatible API gateway."""
|
|
1
|
+
"""LLMStack Gateway — OpenAI-compatible API gateway with caching, RAG, and resilience."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
6
7
|
|
|
7
8
|
from fastapi import FastAPI
|
|
8
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -11,15 +12,28 @@ from llmstack.gateway.routes.chat import router as chat_router
|
|
|
11
12
|
from llmstack.gateway.routes.embeddings import router as embeddings_router
|
|
12
13
|
from llmstack.gateway.routes.models import router as models_router
|
|
13
14
|
from llmstack.gateway.routes.health import router as health_router
|
|
15
|
+
from llmstack.gateway.routes.rag import router as rag_router
|
|
14
16
|
from llmstack.gateway.middleware.auth import AuthMiddleware
|
|
15
17
|
from llmstack.gateway.middleware.metrics import MetricsMiddleware
|
|
18
|
+
from llmstack.gateway.middleware.rate_limit import RateLimitMiddleware
|
|
19
|
+
from llmstack.gateway.middleware.logging import LoggingMiddleware
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def lifespan(app: FastAPI):
|
|
24
|
+
"""Startup: connect cache. Shutdown: close connections."""
|
|
25
|
+
from llmstack.gateway.cache import get_cache
|
|
26
|
+
cache = await get_cache()
|
|
27
|
+
yield
|
|
28
|
+
await cache.close()
|
|
16
29
|
|
|
17
30
|
|
|
18
31
|
def create_app() -> FastAPI:
|
|
19
32
|
app = FastAPI(
|
|
20
33
|
title="LLMStack Gateway",
|
|
21
|
-
description="OpenAI-compatible API gateway
|
|
22
|
-
version="0.
|
|
34
|
+
description="OpenAI-compatible API gateway with caching, RAG, and resilience",
|
|
35
|
+
version="0.3.0",
|
|
36
|
+
lifespan=lifespan,
|
|
23
37
|
)
|
|
24
38
|
|
|
25
39
|
# CORS
|
|
@@ -32,18 +46,27 @@ def create_app() -> FastAPI:
|
|
|
32
46
|
allow_headers=["*"],
|
|
33
47
|
)
|
|
34
48
|
|
|
35
|
-
#
|
|
49
|
+
# Middleware stack (order matters: outermost first)
|
|
50
|
+
# 1. Logging (outermost — captures everything)
|
|
51
|
+
app.add_middleware(LoggingMiddleware)
|
|
52
|
+
|
|
53
|
+
# 2. Auth
|
|
36
54
|
api_keys = os.getenv("LLMSTACK_API_KEYS", "")
|
|
37
55
|
if api_keys:
|
|
38
56
|
app.add_middleware(AuthMiddleware, api_keys=api_keys.split(","))
|
|
39
57
|
|
|
40
|
-
#
|
|
58
|
+
# 3. Rate limiting
|
|
59
|
+
rate_limit = os.getenv("LLMSTACK_RATE_LIMIT", "100/min")
|
|
60
|
+
app.add_middleware(RateLimitMiddleware, rate_limit=rate_limit)
|
|
61
|
+
|
|
62
|
+
# 4. Metrics (innermost — measures actual handler time)
|
|
41
63
|
app.add_middleware(MetricsMiddleware)
|
|
42
64
|
|
|
43
65
|
# Routes
|
|
44
66
|
app.include_router(chat_router, prefix="/v1")
|
|
45
67
|
app.include_router(embeddings_router, prefix="/v1")
|
|
46
68
|
app.include_router(models_router, prefix="/v1")
|
|
69
|
+
app.include_router(rag_router, prefix="/v1")
|
|
47
70
|
app.include_router(health_router)
|
|
48
71
|
|
|
49
72
|
return app
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Structured request logging middleware with correlation IDs.
|
|
2
|
+
|
|
3
|
+
Adds X-Request-ID to every request/response and logs structured JSON
|
|
4
|
+
for each request including method, path, status, duration, and tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
LOG_LEVEL = os.getenv("LLMSTACK_LOG_LEVEL", "INFO").upper()
|
|
21
|
+
LOG_FORMAT = os.getenv("LLMSTACK_LOG_FORMAT", "json") # json | text
|
|
22
|
+
|
|
23
|
+
# Configure structured logger
|
|
24
|
+
logger = logging.getLogger("llmstack.gateway")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _setup_logger() -> None:
|
|
28
|
+
"""Configure the gateway logger with structured JSON output."""
|
|
29
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
30
|
+
handler.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
|
31
|
+
|
|
32
|
+
if LOG_FORMAT == "json":
|
|
33
|
+
handler.setFormatter(_JsonFormatter())
|
|
34
|
+
else:
|
|
35
|
+
handler.setFormatter(logging.Formatter(
|
|
36
|
+
"%(asctime)s %(levelname)s [%(request_id)s] %(message)s",
|
|
37
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
38
|
+
))
|
|
39
|
+
|
|
40
|
+
logger.addHandler(handler)
|
|
41
|
+
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
|
42
|
+
logger.propagate = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _JsonFormatter(logging.Formatter):
|
|
46
|
+
"""Emit log records as single-line JSON."""
|
|
47
|
+
|
|
48
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
49
|
+
log_dict = {
|
|
50
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
|
51
|
+
"level": record.levelname,
|
|
52
|
+
"msg": record.getMessage(),
|
|
53
|
+
}
|
|
54
|
+
# Merge extra fields
|
|
55
|
+
for key in ("request_id", "method", "path", "status", "duration_ms",
|
|
56
|
+
"client_ip", "tokens_in", "tokens_out", "cache_hit", "model"):
|
|
57
|
+
val = getattr(record, key, None)
|
|
58
|
+
if val is not None:
|
|
59
|
+
log_dict[key] = val
|
|
60
|
+
|
|
61
|
+
return json.dumps(log_dict, default=str)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
65
|
+
"""Adds correlation ID and structured logging to every request."""
|
|
66
|
+
|
|
67
|
+
SKIP_PATHS = {"/metrics", "/healthz"}
|
|
68
|
+
|
|
69
|
+
def __init__(self, app):
|
|
70
|
+
super().__init__(app)
|
|
71
|
+
_setup_logger()
|
|
72
|
+
|
|
73
|
+
async def dispatch(self, request: Request, call_next):
|
|
74
|
+
if request.url.path in self.SKIP_PATHS:
|
|
75
|
+
return await call_next(request)
|
|
76
|
+
|
|
77
|
+
# Generate or reuse correlation ID
|
|
78
|
+
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
|
|
79
|
+
|
|
80
|
+
# Store in request state for downstream use
|
|
81
|
+
request.state.request_id = request_id
|
|
82
|
+
|
|
83
|
+
start = time.monotonic()
|
|
84
|
+
response = await call_next(request)
|
|
85
|
+
duration_ms = round((time.monotonic() - start) * 1000, 1)
|
|
86
|
+
|
|
87
|
+
# Add correlation headers
|
|
88
|
+
response.headers["X-Request-ID"] = request_id
|
|
89
|
+
|
|
90
|
+
# Extract client IP
|
|
91
|
+
client = request.client
|
|
92
|
+
client_ip = client.host if client else "unknown"
|
|
93
|
+
forwarded = request.headers.get("X-Forwarded-For", "")
|
|
94
|
+
if forwarded:
|
|
95
|
+
client_ip = forwarded.split(",")[0].strip()
|
|
96
|
+
|
|
97
|
+
# Log the request
|
|
98
|
+
logger.info(
|
|
99
|
+
"%s %s %d %.1fms",
|
|
100
|
+
request.method,
|
|
101
|
+
request.url.path,
|
|
102
|
+
response.status_code,
|
|
103
|
+
duration_ms,
|
|
104
|
+
extra={
|
|
105
|
+
"request_id": request_id,
|
|
106
|
+
"method": request.method,
|
|
107
|
+
"path": request.url.path,
|
|
108
|
+
"status": response.status_code,
|
|
109
|
+
"duration_ms": duration_ms,
|
|
110
|
+
"client_ip": client_ip,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return response
|