llmstack-cli 0.1.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/cli/app.py +18 -0
- llmstack/cli/commands/chat.py +104 -0
- llmstack/cli/commands/export.py +190 -0
- llmstack/core/health.py +1 -1
- llmstack/core/stack.py +12 -1
- llmstack/docker/manager.py +23 -7
- llmstack/gateway/Dockerfile +6 -2
- 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/services/gateway/service.py +16 -1
- llmstack/services/observe/prometheus.py +76 -23
- llmstack_cli-0.3.0.dist-info/METADATA +424 -0
- {llmstack_cli-0.1.0.dist-info → llmstack_cli-0.3.0.dist-info}/RECORD +27 -17
- llmstack_cli-0.1.0.dist-info/METADATA +0 -252
- {llmstack_cli-0.1.0.dist-info → llmstack_cli-0.3.0.dist-info}/WHEEL +0 -0
- {llmstack_cli-0.1.0.dist-info → llmstack_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {llmstack_cli-0.1.0.dist-info → llmstack_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
llmstack/__init__.py
CHANGED
llmstack/cli/app.py
CHANGED
|
@@ -80,6 +80,24 @@ def logs(
|
|
|
80
80
|
_logs(service=service, follow=follow, tail=tail)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
@app.command()
|
|
84
|
+
def chat(
|
|
85
|
+
model: str = typer.Option(None, "--model", "-m", help="Model name to chat with"),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Interactive chat with your local LLM."""
|
|
88
|
+
from llmstack.cli.commands.chat import chat as _chat
|
|
89
|
+
_chat(model=model)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def export(
|
|
94
|
+
output: str = typer.Option("docker-compose.yml", "--output", "-o", help="Output file path"),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Export llmstack.yaml as a standalone docker-compose.yml."""
|
|
97
|
+
from llmstack.cli.commands.export import export as _export
|
|
98
|
+
_export(output=output)
|
|
99
|
+
|
|
100
|
+
|
|
83
101
|
@app.command()
|
|
84
102
|
def doctor() -> None:
|
|
85
103
|
"""Check system requirements and diagnose issues."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""llmstack chat — interactive terminal chat with your local LLM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from llmstack.cli.console import console
|
|
11
|
+
from llmstack.config.loader import load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def chat(model: str | None = None) -> None:
|
|
15
|
+
"""Start an interactive chat session with the running LLM."""
|
|
16
|
+
config = load_config()
|
|
17
|
+
base_url = f"http://localhost:{config.gateway.port}"
|
|
18
|
+
model_name = model or config.models.chat.name
|
|
19
|
+
|
|
20
|
+
# Get API key
|
|
21
|
+
api_key = config.gateway.api_keys[0] if config.gateway.api_keys else ""
|
|
22
|
+
headers = {"Content-Type": "application/json"}
|
|
23
|
+
if api_key:
|
|
24
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
25
|
+
|
|
26
|
+
# Verify gateway is reachable
|
|
27
|
+
try:
|
|
28
|
+
resp = httpx.get(f"{base_url}/healthz", headers=headers, timeout=5)
|
|
29
|
+
if resp.status_code != 200:
|
|
30
|
+
console.print("[error]Gateway is not healthy. Run 'llmstack up' first.[/]")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
except httpx.ConnectError:
|
|
33
|
+
console.print("[error]Cannot connect to gateway. Run 'llmstack up' first.[/]")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
console.print(f"\n[bold]LLMStack Chat[/] — model: [cyan]{model_name}[/]")
|
|
37
|
+
console.print("[dim]Type 'exit' or Ctrl+C to quit. '/clear' to reset conversation.[/]\n")
|
|
38
|
+
|
|
39
|
+
messages: list[dict[str, str]] = []
|
|
40
|
+
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
user_input = console.input("[bold green]You:[/] ")
|
|
44
|
+
except (KeyboardInterrupt, EOFError):
|
|
45
|
+
console.print("\n[dim]Goodbye![/]")
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
text = user_input.strip()
|
|
49
|
+
if not text:
|
|
50
|
+
continue
|
|
51
|
+
if text.lower() in ("exit", "quit"):
|
|
52
|
+
console.print("[dim]Goodbye![/]")
|
|
53
|
+
break
|
|
54
|
+
if text == "/clear":
|
|
55
|
+
messages.clear()
|
|
56
|
+
console.print("[dim]Conversation cleared.[/]\n")
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
messages.append({"role": "user", "content": text})
|
|
60
|
+
|
|
61
|
+
# Stream response
|
|
62
|
+
console.print("[bold cyan]Assistant:[/] ", end="")
|
|
63
|
+
assistant_text = ""
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with httpx.stream(
|
|
67
|
+
"POST",
|
|
68
|
+
f"{base_url}/v1/chat/completions",
|
|
69
|
+
headers=headers,
|
|
70
|
+
json={"model": model_name, "messages": messages, "stream": True},
|
|
71
|
+
timeout=httpx.Timeout(300, connect=10),
|
|
72
|
+
) as resp:
|
|
73
|
+
if resp.status_code != 200:
|
|
74
|
+
console.print(f"[error]Error {resp.status_code}[/]")
|
|
75
|
+
messages.pop()
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
for line in resp.iter_lines():
|
|
79
|
+
if not line.startswith("data: "):
|
|
80
|
+
continue
|
|
81
|
+
data = line[6:]
|
|
82
|
+
if data.strip() == "[DONE]":
|
|
83
|
+
break
|
|
84
|
+
try:
|
|
85
|
+
chunk = json.loads(data)
|
|
86
|
+
delta = chunk["choices"][0].get("delta", {})
|
|
87
|
+
token = delta.get("content", "")
|
|
88
|
+
if token:
|
|
89
|
+
print(token, end="", flush=True)
|
|
90
|
+
assistant_text += token
|
|
91
|
+
except (json.JSONDecodeError, KeyError, IndexError):
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
except httpx.ConnectError:
|
|
95
|
+
console.print("[error]Connection lost.[/]")
|
|
96
|
+
messages.pop()
|
|
97
|
+
continue
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
print() # newline after streamed response
|
|
102
|
+
if assistant_text:
|
|
103
|
+
messages.append({"role": "assistant", "content": assistant_text})
|
|
104
|
+
print()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""llmstack export — generate a standalone docker-compose.yml from llmstack.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from llmstack.cli.console import console
|
|
8
|
+
from llmstack.config.loader import load_config
|
|
9
|
+
from llmstack.config.schema import StackConfig
|
|
10
|
+
from llmstack.core.hardware import detect_hardware
|
|
11
|
+
from llmstack.core.resolver import resolve_inference_backend, resolve_embedding_backend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_compose(config: StackConfig) -> dict:
|
|
15
|
+
"""Convert StackConfig into a docker-compose.yml dict."""
|
|
16
|
+
hw = detect_hardware()
|
|
17
|
+
inference_backend = resolve_inference_backend(config.models.chat, hw)
|
|
18
|
+
embed_backend = resolve_embedding_backend(config.models.embeddings, hw)
|
|
19
|
+
|
|
20
|
+
services: dict = {}
|
|
21
|
+
volumes: dict = {}
|
|
22
|
+
network_name = config.docker.network
|
|
23
|
+
|
|
24
|
+
# 1. Qdrant
|
|
25
|
+
services["qdrant"] = {
|
|
26
|
+
"image": "qdrant/qdrant:latest",
|
|
27
|
+
"container_name": "llmstack-qdrant",
|
|
28
|
+
"ports": [f"{config.services.vectors.port}:6333", f"{config.services.vectors.port + 1}:6334"],
|
|
29
|
+
"volumes": ["qdrant_data:/qdrant/storage"],
|
|
30
|
+
"restart": "unless-stopped",
|
|
31
|
+
"networks": [network_name],
|
|
32
|
+
}
|
|
33
|
+
volumes["qdrant_data"] = None
|
|
34
|
+
|
|
35
|
+
# 2. Redis
|
|
36
|
+
services["redis"] = {
|
|
37
|
+
"image": "redis:7-alpine",
|
|
38
|
+
"container_name": "llmstack-redis",
|
|
39
|
+
"ports": [f"{config.services.cache.port}:6379"],
|
|
40
|
+
"command": f"redis-server --maxmemory {config.services.cache.max_memory} --maxmemory-policy allkeys-lru",
|
|
41
|
+
"restart": "unless-stopped",
|
|
42
|
+
"networks": [network_name],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# 3. Inference
|
|
46
|
+
if inference_backend == "vllm":
|
|
47
|
+
model = config.models.chat
|
|
48
|
+
cmd = f"--model {model.name} --host 0.0.0.0 --port 8000 --max-model-len {model.context_length}"
|
|
49
|
+
if model.quantization:
|
|
50
|
+
cmd += f" --quantization {model.quantization}"
|
|
51
|
+
services["vllm"] = {
|
|
52
|
+
"image": "vllm/vllm-openai:latest",
|
|
53
|
+
"container_name": "llmstack-vllm",
|
|
54
|
+
"ports": ["8001:8000"],
|
|
55
|
+
"command": cmd,
|
|
56
|
+
"volumes": ["vllm_cache:/root/.cache/huggingface"],
|
|
57
|
+
"shm_size": "4g",
|
|
58
|
+
"deploy": {"resources": {"reservations": {"devices": [{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}]}}},
|
|
59
|
+
"restart": "unless-stopped",
|
|
60
|
+
"networks": [network_name],
|
|
61
|
+
}
|
|
62
|
+
volumes["vllm_cache"] = None
|
|
63
|
+
inference_url = "http://vllm:8000/v1"
|
|
64
|
+
else:
|
|
65
|
+
svc: dict = {
|
|
66
|
+
"image": "ollama/ollama:latest",
|
|
67
|
+
"container_name": "llmstack-ollama",
|
|
68
|
+
"ports": ["11434:11434"],
|
|
69
|
+
"volumes": ["ollama_data:/root/.ollama"],
|
|
70
|
+
"restart": "unless-stopped",
|
|
71
|
+
"networks": [network_name],
|
|
72
|
+
}
|
|
73
|
+
if hw.gpu_vendor == "nvidia":
|
|
74
|
+
svc["deploy"] = {"resources": {"reservations": {"devices": [{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}]}}}
|
|
75
|
+
services["ollama"] = svc
|
|
76
|
+
volumes["ollama_data"] = None
|
|
77
|
+
inference_url = "http://ollama:11434/v1"
|
|
78
|
+
|
|
79
|
+
# 4. Embeddings
|
|
80
|
+
embeddings_url = inference_url # fallback: use Ollama for embeddings
|
|
81
|
+
if embed_backend == "tei":
|
|
82
|
+
tei_image = "ghcr.io/huggingface/text-embeddings-inference:cpu-latest"
|
|
83
|
+
tei_svc: dict = {
|
|
84
|
+
"image": tei_image,
|
|
85
|
+
"container_name": "llmstack-tei",
|
|
86
|
+
"ports": ["8002:80"],
|
|
87
|
+
"command": f"--model-id {config.models.embeddings.name} --port 80",
|
|
88
|
+
"volumes": ["tei_cache:/data"],
|
|
89
|
+
"restart": "unless-stopped",
|
|
90
|
+
"networks": [network_name],
|
|
91
|
+
}
|
|
92
|
+
if hw.gpu_vendor == "nvidia":
|
|
93
|
+
tei_svc["image"] = "ghcr.io/huggingface/text-embeddings-inference:latest"
|
|
94
|
+
tei_svc["deploy"] = {"resources": {"reservations": {"devices": [{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}]}}}
|
|
95
|
+
services["tei"] = tei_svc
|
|
96
|
+
volumes["tei_cache"] = None
|
|
97
|
+
embeddings_url = "http://tei:80/v1"
|
|
98
|
+
|
|
99
|
+
# 5. Gateway
|
|
100
|
+
qdrant_url = f"http://qdrant:{config.services.vectors.port}"
|
|
101
|
+
redis_url = f"redis://redis:{config.services.cache.port}"
|
|
102
|
+
api_keys = ",".join(config.gateway.api_keys) if config.gateway.api_keys else ""
|
|
103
|
+
|
|
104
|
+
services["gateway"] = {
|
|
105
|
+
"build": {
|
|
106
|
+
"context": ".",
|
|
107
|
+
"dockerfile": "src/llmstack/gateway/Dockerfile",
|
|
108
|
+
},
|
|
109
|
+
"container_name": "llmstack-gateway",
|
|
110
|
+
"ports": [f"{config.gateway.port}:8000"],
|
|
111
|
+
"environment": {
|
|
112
|
+
"LLMSTACK_INFERENCE_URL": inference_url,
|
|
113
|
+
"LLMSTACK_EMBEDDINGS_URL": embeddings_url,
|
|
114
|
+
"LLMSTACK_QDRANT_URL": qdrant_url,
|
|
115
|
+
"LLMSTACK_REDIS_URL": redis_url,
|
|
116
|
+
"LLMSTACK_API_KEYS": api_keys,
|
|
117
|
+
"LLMSTACK_CORS_ORIGINS": ",".join(config.gateway.cors),
|
|
118
|
+
"LLMSTACK_REQUEST_TIMEOUT": str(config.gateway.request_timeout),
|
|
119
|
+
"LLMSTACK_RATE_LIMIT": config.gateway.rate_limit,
|
|
120
|
+
},
|
|
121
|
+
"depends_on": ["qdrant", "redis"],
|
|
122
|
+
"restart": "unless-stopped",
|
|
123
|
+
"networks": [network_name],
|
|
124
|
+
}
|
|
125
|
+
# Add inference dependency
|
|
126
|
+
if inference_backend == "vllm":
|
|
127
|
+
services["gateway"]["depends_on"].append("vllm")
|
|
128
|
+
else:
|
|
129
|
+
services["gateway"]["depends_on"].append("ollama")
|
|
130
|
+
if embed_backend == "tei":
|
|
131
|
+
services["gateway"]["depends_on"].append("tei")
|
|
132
|
+
|
|
133
|
+
# 6. Observability
|
|
134
|
+
if config.observe.metrics:
|
|
135
|
+
services["prometheus"] = {
|
|
136
|
+
"image": "prom/prometheus:latest",
|
|
137
|
+
"container_name": "llmstack-prometheus",
|
|
138
|
+
"ports": ["9090:9090"],
|
|
139
|
+
"command": [
|
|
140
|
+
"--config.file=/etc/prometheus/prometheus.yml",
|
|
141
|
+
f"--storage.tsdb.retention.time={config.observe.retention}",
|
|
142
|
+
"--web.enable-lifecycle",
|
|
143
|
+
],
|
|
144
|
+
"volumes": [
|
|
145
|
+
"./prometheus.yml:/etc/prometheus/prometheus.yml:ro",
|
|
146
|
+
"prometheus_data:/prometheus",
|
|
147
|
+
],
|
|
148
|
+
"restart": "unless-stopped",
|
|
149
|
+
"networks": [network_name],
|
|
150
|
+
}
|
|
151
|
+
volumes["prometheus_data"] = None
|
|
152
|
+
|
|
153
|
+
services["grafana"] = {
|
|
154
|
+
"image": "grafana/grafana:latest",
|
|
155
|
+
"container_name": "llmstack-grafana",
|
|
156
|
+
"ports": [f"{config.observe.dashboard_port}:3000"],
|
|
157
|
+
"environment": {
|
|
158
|
+
"GF_SECURITY_ADMIN_USER": "admin",
|
|
159
|
+
"GF_SECURITY_ADMIN_PASSWORD": "llmstack",
|
|
160
|
+
"GF_AUTH_ANONYMOUS_ENABLED": "true",
|
|
161
|
+
"GF_AUTH_ANONYMOUS_ORG_ROLE": "Viewer",
|
|
162
|
+
},
|
|
163
|
+
"restart": "unless-stopped",
|
|
164
|
+
"networks": [network_name],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
compose = {
|
|
168
|
+
"version": "3.8",
|
|
169
|
+
"services": services,
|
|
170
|
+
"volumes": volumes,
|
|
171
|
+
"networks": {network_name: {"driver": "bridge"}},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return compose
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def export(output: str = "docker-compose.yml") -> None:
|
|
178
|
+
"""Generate a docker-compose.yml from the current llmstack.yaml."""
|
|
179
|
+
config = load_config()
|
|
180
|
+
compose = _build_compose(config)
|
|
181
|
+
|
|
182
|
+
# Clean up None volume values for yaml output
|
|
183
|
+
compose["volumes"] = {k: {} for k in compose["volumes"]}
|
|
184
|
+
|
|
185
|
+
with open(output, "w") as f:
|
|
186
|
+
yaml.dump(compose, f, default_flow_style=False, sort_keys=False)
|
|
187
|
+
|
|
188
|
+
service_count = len(compose["services"])
|
|
189
|
+
console.print(f"\n[success]Exported {service_count} services to {output}[/]")
|
|
190
|
+
console.print(f"Run with: [bold]docker compose -f {output} up -d[/]\n")
|
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/core/stack.py
CHANGED
|
@@ -7,6 +7,7 @@ import secrets
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from rich.table import Table
|
|
9
9
|
|
|
10
|
+
from llmstack.config.loader import save_config
|
|
10
11
|
from llmstack.config.schema import StackConfig
|
|
11
12
|
from llmstack.core.hardware import detect_hardware
|
|
12
13
|
from llmstack.core.health import wait_healthy
|
|
@@ -100,11 +101,21 @@ class Stack:
|
|
|
100
101
|
self._services = self._build_services()
|
|
101
102
|
self.docker.ensure_network()
|
|
102
103
|
|
|
104
|
+
# Build local images if needed
|
|
105
|
+
for svc in self._services:
|
|
106
|
+
if hasattr(svc, "build_info") and svc.build_info():
|
|
107
|
+
info = svc.build_info()
|
|
108
|
+
console.print(f" [cyan]Building {svc.name} image...[/]", end="")
|
|
109
|
+
self.docker.build_image(**info)
|
|
110
|
+
console.print(" [green]done[/]")
|
|
111
|
+
|
|
103
112
|
# Generate API key if needed
|
|
104
113
|
if self.config.gateway.auth == "api_key" and not self.config.gateway.api_keys:
|
|
105
114
|
key = f"sk-llmstack-{secrets.token_urlsafe(24)}"
|
|
106
115
|
self.config.gateway.api_keys = [key]
|
|
107
|
-
|
|
116
|
+
save_config(self.config)
|
|
117
|
+
console.print(f"\n[bold green]Generated API key:[/] {key}")
|
|
118
|
+
console.print("[dim]Saved to llmstack.yaml[/]\n")
|
|
108
119
|
|
|
109
120
|
for svc in self._services:
|
|
110
121
|
console.print(f" [cyan]Starting {svc.name}...[/]", end="")
|
llmstack/docker/manager.py
CHANGED
|
@@ -35,6 +35,11 @@ class DockerManager:
|
|
|
35
35
|
except NotFound:
|
|
36
36
|
self.client.networks.create(self.network_name, driver="bridge")
|
|
37
37
|
|
|
38
|
+
def build_image(self, path: str, dockerfile: str, tag: str) -> str:
|
|
39
|
+
"""Build a Docker image from a local Dockerfile. Returns the image tag."""
|
|
40
|
+
self.client.images.build(path=path, dockerfile=dockerfile, tag=tag, rm=True)
|
|
41
|
+
return tag
|
|
42
|
+
|
|
38
43
|
def run_service(self, service: ServiceBase) -> Container:
|
|
39
44
|
"""Start a container for a service. Removes any existing container with the same name."""
|
|
40
45
|
spec = service.container_spec()
|
|
@@ -53,13 +58,24 @@ class DockerManager:
|
|
|
53
58
|
self.LABEL_SERVICE: service.name,
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
try:
|
|
62
|
+
container = self.client.containers.run(
|
|
63
|
+
detach=True,
|
|
64
|
+
name=name,
|
|
65
|
+
network=self.network_name,
|
|
66
|
+
labels=labels,
|
|
67
|
+
**spec,
|
|
68
|
+
)
|
|
69
|
+
except APIError as exc:
|
|
70
|
+
msg = str(exc)
|
|
71
|
+
if "port is already allocated" in msg or "address already in use" in msg:
|
|
72
|
+
ports = spec.get("ports", {})
|
|
73
|
+
port = next(iter(ports.values()), "unknown") if ports else "unknown"
|
|
74
|
+
raise SystemExit(
|
|
75
|
+
f"Port {port} is already in use. "
|
|
76
|
+
f"Stop the conflicting service or change the port in llmstack.yaml."
|
|
77
|
+
) from exc
|
|
78
|
+
raise
|
|
63
79
|
return container
|
|
64
80
|
|
|
65
81
|
def stop_service(self, service_name: str) -> None:
|
llmstack/gateway/Dockerfile
CHANGED
|
@@ -6,9 +6,13 @@ 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
|
|
12
|
+
|
|
13
|
+
COPY src/llmstack/__init__.py /app/llmstack/__init__.py
|
|
14
|
+
COPY src/llmstack/gateway/ /app/llmstack/gateway/
|
|
10
15
|
|
|
11
|
-
COPY . /app/llmstack/gateway/
|
|
12
16
|
ENV PYTHONPATH=/app
|
|
13
17
|
|
|
14
18
|
EXPOSE 8000
|
|
@@ -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
|