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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """LLMStack — One command. Full LLM stack. Zero config."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
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 (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout):
19
+ except httpx.HTTPError:
20
20
  pass
21
21
  await asyncio.sleep(interval)
22
22
  elapsed += interval
@@ -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 for LLMStack",
22
- version="0.1.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
- # Auth
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
- # Metrics
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