llmstack-cli 0.1.0__py3-none-any.whl → 0.2.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.1.0"
3
+ __version__ = "0.2.0"
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/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
- console.print(f"\n[bold green]Generated API key:[/] {key}\n")
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="")
@@ -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
- container = self.client.containers.run(
57
- detach=True,
58
- name=name,
59
- network=self.network_name,
60
- labels=labels,
61
- **spec,
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:
@@ -8,7 +8,9 @@ RUN pip install --no-cache-dir \
8
8
  httpx>=0.27 \
9
9
  starlette>=0.40
10
10
 
11
- COPY . /app/llmstack/gateway/
11
+ COPY src/llmstack/__init__.py /app/llmstack/__init__.py
12
+ COPY src/llmstack/gateway/ /app/llmstack/gateway/
13
+
12
14
  ENV PYTHONPATH=/app
13
15
 
14
16
  EXPOSE 8000
@@ -2,11 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from pathlib import Path
5
6
  from typing import Any
6
7
 
7
8
  from llmstack.config.schema import GatewayConfig
8
9
  from llmstack.services.base import ServiceBase
9
10
 
11
+ IMAGE_TAG = "llmstack-gateway:local"
12
+
10
13
 
11
14
  class GatewayService(ServiceBase):
12
15
  name = "gateway"
@@ -26,9 +29,21 @@ class GatewayService(ServiceBase):
26
29
  self.qdrant_url = qdrant_url
27
30
  self.redis_url = redis_url
28
31
 
32
+ def build_info(self) -> dict[str, str] | None:
33
+ """Return build context for the gateway Docker image."""
34
+ import llmstack
35
+ pkg_dir = Path(llmstack.__file__).resolve().parent
36
+ project_root = pkg_dir.parent.parent # src/llmstack -> src -> project root
37
+ dockerfile = str(pkg_dir / "gateway" / "Dockerfile")
38
+ return {
39
+ "path": str(project_root),
40
+ "dockerfile": dockerfile,
41
+ "tag": IMAGE_TAG,
42
+ }
43
+
29
44
  def container_spec(self) -> dict[str, Any]:
30
45
  return {
31
- "image": "ghcr.io/mara-werils/llmstack-gateway:latest",
46
+ "image": IMAGE_TAG,
32
47
  "name": "llmstack-gateway",
33
48
  "ports": {"8000/tcp": self.config.port},
34
49
  "environment": {
@@ -3,31 +3,38 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
7
+ from pathlib import Path
6
8
  from typing import Any
7
9
 
10
+ import yaml
11
+
8
12
  from llmstack.config.schema import ObserveConfig
9
13
  from llmstack.services.base import ServiceBase
10
14
 
15
+ CONFIG_DIR = Path.home() / ".llmstack" / "config"
11
16
 
12
17
  # Prometheus config that scrapes the gateway /metrics endpoint
13
- PROMETHEUS_CONFIG = """
14
- global:
15
- scrape_interval: 15s
16
- evaluation_interval: 15s
17
-
18
- scrape_configs:
19
- - job_name: 'llmstack-gateway'
20
- metrics_path: '/metrics'
21
- static_configs:
22
- - targets: ['llmstack-gateway:8000']
23
- scrape_interval: 5s
24
-
25
- - job_name: 'qdrant'
26
- metrics_path: '/metrics'
27
- static_configs:
28
- - targets: ['llmstack-qdrant:6333']
29
- scrape_interval: 15s
30
- """
18
+ PROMETHEUS_CONFIG = {
19
+ "global": {
20
+ "scrape_interval": "15s",
21
+ "evaluation_interval": "15s",
22
+ },
23
+ "scrape_configs": [
24
+ {
25
+ "job_name": "llmstack-gateway",
26
+ "metrics_path": "/metrics",
27
+ "static_configs": [{"targets": ["llmstack-gateway:8000"]}],
28
+ "scrape_interval": "5s",
29
+ },
30
+ {
31
+ "job_name": "qdrant",
32
+ "metrics_path": "/metrics",
33
+ "static_configs": [{"targets": ["llmstack-qdrant:6333"]}],
34
+ "scrape_interval": "15s",
35
+ },
36
+ ],
37
+ }
31
38
 
32
39
  # Grafana datasource provisioning
33
40
  GRAFANA_DATASOURCE = {
@@ -47,7 +54,7 @@ GRAFANA_DASHBOARD_PROVIDER = {
47
54
  "providers": [{
48
55
  "name": "LLMStack",
49
56
  "type": "file",
50
- "options": {"path": "/var/lib/grafana/dashboards"},
57
+ "options": {"path": "/opt/grafana/dashboards"},
51
58
  }],
52
59
  }
53
60
 
@@ -97,6 +104,12 @@ GRAFANA_DASHBOARD = {
97
104
  }
98
105
 
99
106
 
107
+ def _write_file(path: Path, content: str) -> None:
108
+ """Write content to file, creating parent dirs as needed."""
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ path.write_text(content)
111
+
112
+
100
113
  class PrometheusService(ServiceBase):
101
114
  name = "prometheus"
102
115
  category = "observe"
@@ -105,7 +118,14 @@ class PrometheusService(ServiceBase):
105
118
  self.config = config
106
119
  self.host_port = 9090
107
120
 
121
+ def _prepare_config(self) -> str:
122
+ """Write prometheus.yml to ~/.llmstack/config/prometheus/ and return the dir path."""
123
+ config_dir = CONFIG_DIR / "prometheus"
124
+ _write_file(config_dir / "prometheus.yml", yaml.dump(PROMETHEUS_CONFIG, default_flow_style=False))
125
+ return str(config_dir)
126
+
108
127
  def container_spec(self) -> dict[str, Any]:
128
+ config_dir = self._prepare_config()
109
129
  return {
110
130
  "image": "prom/prometheus:latest",
111
131
  "name": "llmstack-prometheus",
@@ -116,7 +136,7 @@ class PrometheusService(ServiceBase):
116
136
  "--web.enable-lifecycle",
117
137
  ],
118
138
  "volumes": {
119
- "llmstack_prometheus_config": {"bind": "/etc/prometheus", "mode": "rw"},
139
+ config_dir: {"bind": "/etc/prometheus", "mode": "ro"},
120
140
  "llmstack_prometheus_data": {"bind": "/prometheus", "mode": "rw"},
121
141
  },
122
142
  "environment": {},
@@ -127,7 +147,7 @@ class PrometheusService(ServiceBase):
127
147
 
128
148
  def get_config_yaml(self) -> str:
129
149
  """Return the prometheus.yml content."""
130
- return PROMETHEUS_CONFIG
150
+ return yaml.dump(PROMETHEUS_CONFIG, default_flow_style=False)
131
151
 
132
152
 
133
153
  class GrafanaService(ServiceBase):
@@ -138,7 +158,32 @@ class GrafanaService(ServiceBase):
138
158
  self.config = config
139
159
  self.host_port = config.dashboard_port
140
160
 
161
+ def _prepare_provisioning(self) -> str:
162
+ """Write Grafana provisioning files to ~/.llmstack/config/grafana/ and return the dir."""
163
+ base = CONFIG_DIR / "grafana"
164
+
165
+ # Datasource provisioning
166
+ _write_file(
167
+ base / "provisioning" / "datasources" / "datasource.yml",
168
+ yaml.dump(GRAFANA_DATASOURCE, default_flow_style=False),
169
+ )
170
+
171
+ # Dashboard provider provisioning
172
+ _write_file(
173
+ base / "provisioning" / "dashboards" / "provider.yml",
174
+ yaml.dump(GRAFANA_DASHBOARD_PROVIDER, default_flow_style=False),
175
+ )
176
+
177
+ # Dashboard JSON
178
+ _write_file(
179
+ base / "dashboards" / "llmstack.json",
180
+ json.dumps(GRAFANA_DASHBOARD, indent=2),
181
+ )
182
+
183
+ return str(base)
184
+
141
185
  def container_spec(self) -> dict[str, Any]:
186
+ base = self._prepare_provisioning()
142
187
  return {
143
188
  "image": "grafana/grafana:latest",
144
189
  "name": "llmstack-grafana",
@@ -148,10 +193,18 @@ class GrafanaService(ServiceBase):
148
193
  "GF_SECURITY_ADMIN_PASSWORD": "llmstack",
149
194
  "GF_AUTH_ANONYMOUS_ENABLED": "true",
150
195
  "GF_AUTH_ANONYMOUS_ORG_ROLE": "Viewer",
151
- "GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH": "/var/lib/grafana/dashboards/llmstack.json",
196
+ "GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH": "/opt/grafana/dashboards/llmstack.json",
152
197
  },
153
198
  "volumes": {
154
- "llmstack_grafana_data": {"bind": "/var/lib/grafana", "mode": "rw"},
199
+ os.path.join(base, "provisioning", "datasources"): {
200
+ "bind": "/etc/grafana/provisioning/datasources", "mode": "ro",
201
+ },
202
+ os.path.join(base, "provisioning", "dashboards"): {
203
+ "bind": "/etc/grafana/provisioning/dashboards", "mode": "ro",
204
+ },
205
+ os.path.join(base, "dashboards"): {
206
+ "bind": "/opt/grafana/dashboards", "mode": "ro",
207
+ },
155
208
  },
156
209
  }
157
210
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llmstack-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: One command. Full LLM stack. Zero config.
5
5
  Author: mara-werils
6
6
  License-Expression: Apache-2.0
@@ -44,21 +44,43 @@ Description-Content-Type: text/markdown
44
44
  <a href="https://github.com/mara-werils/llmstack/actions/workflows/ci.yml"><img src="https://github.com/mara-werils/llmstack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
45
45
  <a href="https://github.com/mara-werils/llmstack/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License"></a>
46
46
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.11+-blue" alt="Python"></a>
47
+ <a href="https://github.com/mara-werils/llmstack/stargazers"><img src="https://img.shields.io/github/stars/mara-werils/llmstack?style=social" alt="Stars"></a>
47
48
  </p>
48
49
 
49
50
  ---
50
51
 
51
- **llmstack** spins up a production-grade LLM stack locally with a single command. It auto-detects your hardware, picks the optimal inference backend, and wires everything together.
52
+ <p align="center">
53
+ <img src="demo.gif" alt="llmstack demo" width="800">
54
+ </p>
55
+
56
+ ## Quick Start
52
57
 
53
58
  ```bash
54
59
  pip install llmstack-cli
55
- llmstack init
60
+ llmstack init --preset rag
56
61
  llmstack up
57
62
  ```
58
63
 
59
- That's it. You now have a full LLM API running locally.
64
+ That's it. You now have **7 services** running: inference, embeddings, vector DB, cache, API gateway, Prometheus, and Grafana.
65
+
66
+ ```bash
67
+ # Test it immediately
68
+ curl http://localhost:8000/v1/chat/completions \
69
+ -H "Authorization: Bearer YOUR_KEY" \
70
+ -H "Content-Type: application/json" \
71
+ -d '{"model":"llama3.2","messages":[{"role":"user","content":"Hello!"}]}'
72
+ ```
73
+
74
+ Works with **any OpenAI-compatible client**: LangChain, LlamaIndex, Vercel AI SDK, openai-python.
75
+
76
+ ## Who is this for?
60
77
 
61
- ## Architecture
78
+ - **AI app developers** who want local inference without Docker boilerplate
79
+ - **Teams** who need an OpenAI-compatible API backed by local models
80
+ - **Hobbyists** running LLMs locally who want vector search, caching, and monitoring out of the box
81
+ - **Anyone** tired of writing 200+ lines of docker-compose.yml every time
82
+
83
+ ## What you get
62
84
 
63
85
  ```
64
86
  llmstack up
@@ -84,8 +106,6 @@ That's it. You now have a full LLM API running locally.
84
106
  :8080
85
107
  ```
86
108
 
87
- ## What you get
88
-
89
109
  | Layer | Service | Default | Port |
90
110
  |-------|---------|---------|------|
91
111
  | Inference | Ollama / vLLM (auto) | llama3.2 | 11434 |
@@ -97,7 +117,7 @@ That's it. You now have a full LLM API running locally.
97
117
 
98
118
  ## How it works
99
119
 
100
- ```
120
+ ```bash
101
121
  llmstack init # Detects hardware, generates llmstack.yaml
102
122
  # Picks optimal backend: vLLM for NVIDIA 16GB+, Ollama otherwise
103
123
 
@@ -109,27 +129,6 @@ llmstack logs ollama # Stream inference logs
109
129
  llmstack down # Stops everything
110
130
  ```
111
131
 
112
- ### Use the API
113
-
114
- ```bash
115
- curl http://localhost:8000/v1/chat/completions \
116
- -H "Authorization: Bearer YOUR_KEY" \
117
- -H "Content-Type: application/json" \
118
- -d '{"model":"llama3.2","messages":[{"role":"user","content":"Hello!"}]}'
119
- ```
120
-
121
- Works with **any OpenAI-compatible client**: LangChain, LlamaIndex, Vercel AI SDK, openai-python.
122
-
123
- ```python
124
- from openai import OpenAI
125
-
126
- client = OpenAI(base_url="http://localhost:8000/v1", api_key="YOUR_KEY")
127
- response = client.chat.completions.create(
128
- model="llama3.2",
129
- messages=[{"role": "user", "content": "Explain quantum computing"}]
130
- )
131
- ```
132
-
133
132
  ## Auto hardware detection
134
133
 
135
134
  | Your hardware | Backend | Why |
@@ -181,6 +180,50 @@ observe:
181
180
  dashboard_port: 8080
182
181
  ```
183
182
 
183
+ ## Interactive Chat
184
+
185
+ ```bash
186
+ llmstack chat
187
+ ```
188
+
189
+ ```
190
+ LLMStack Chat — model: llama3.2
191
+ Type 'exit' or Ctrl+C to quit. '/clear' to reset conversation.
192
+
193
+ You: What is quantum computing?
194
+ Assistant: Quantum computing uses quantum mechanical phenomena like
195
+ superposition and entanglement to process information...
196
+
197
+ You: /clear
198
+ Conversation cleared.
199
+ ```
200
+
201
+ Streaming responses, conversation history, works with any model in your stack.
202
+
203
+ ## Export to Docker Compose
204
+
205
+ Don't want to install llmstack? Generate a standalone `docker-compose.yml`:
206
+
207
+ ```bash
208
+ llmstack export
209
+ # Exported 7 services to docker-compose.yml
210
+ # Run with: docker compose up -d
211
+ ```
212
+
213
+ Share the generated file with your team — no llmstack dependency required.
214
+
215
+ ## Use the API
216
+
217
+ ```python
218
+ from openai import OpenAI
219
+
220
+ client = OpenAI(base_url="http://localhost:8000/v1", api_key="YOUR_KEY")
221
+ response = client.chat.completions.create(
222
+ model="llama3.2",
223
+ messages=[{"role": "user", "content": "Explain quantum computing"}]
224
+ )
225
+ ```
226
+
184
227
  ## CLI
185
228
 
186
229
  | Command | Description |
@@ -189,6 +232,8 @@ observe:
189
232
  | `llmstack up [--attach]` | Start all services |
190
233
  | `llmstack down [--volumes]` | Stop and clean up |
191
234
  | `llmstack status` | Health check all services |
235
+ | `llmstack chat [--model]` | Interactive terminal chat |
236
+ | `llmstack export [--output]` | Generate docker-compose.yml |
192
237
  | `llmstack logs <service>` | Stream service logs |
193
238
  | `llmstack doctor` | Diagnose system issues |
194
239
 
@@ -204,6 +249,34 @@ When `observe.metrics: true`, llmstack boots Prometheus + Grafana with a pre-bui
204
249
 
205
250
  Access at `http://localhost:8080` (login: admin / llmstack)
206
251
 
252
+ ## Why not just Docker Compose?
253
+
254
+ Here's what llmstack replaces:
255
+
256
+ ```yaml
257
+ # Without llmstack: ~200 lines of docker-compose.yml
258
+ # You have to configure each service, write health checks,
259
+ # set up networking, manage GPU passthrough, create Prometheus
260
+ # scrape configs, provision Grafana dashboards...
261
+
262
+ # With llmstack:
263
+ llmstack init && llmstack up
264
+ ```
265
+
266
+ ## Comparison
267
+
268
+ | | llmstack | Ollama | LocalAI | AnythingLLM | LiteLLM |
269
+ |---|---|---|---|---|---|
270
+ | One-command full stack | **Yes** | No (inference only) | No | Partial | No (proxy only) |
271
+ | Auto hardware detection | **Yes** | No | No | No | No |
272
+ | OpenAI-compatible API | **Yes** | Yes | Yes | No | Yes |
273
+ | Built-in vector DB | **Yes** | No | No | Bundled | No |
274
+ | Built-in embeddings | **Yes** | No | No | Bundled | No |
275
+ | Caching (Redis) | **Yes** | No | No | No | No |
276
+ | Auth + rate limiting | **Yes** | No | No | Yes | Yes |
277
+ | Observability dashboard | **Yes** | No | Partial | No | Partial |
278
+ | Plugin ecosystem | **Yes** | No | No | No | No |
279
+
207
280
  ## Plugins
208
281
 
209
282
  Extend llmstack with new backends via pip:
@@ -215,21 +288,6 @@ pip install llmstack-cli-plugin-chromadb
215
288
 
216
289
  Create your own: implement `ServiceBase`, register via entry_points. See [CONTRIBUTING.md](CONTRIBUTING.md).
217
290
 
218
- ## Why llmstack?
219
-
220
- | | llmstack | Ollama | Harbor | AnythingLLM | LiteLLM |
221
- |---|---|---|---|---|---|
222
- | One-command full stack | Yes | No (inference only) | Partial | Partial | No (proxy only) |
223
- | Auto hardware detection | Yes | No | No | No | No |
224
- | OpenAI-compatible API | Yes | Yes | Varies | No | Yes |
225
- | Built-in vector DB | Yes | No | Config needed | Bundled | No |
226
- | Built-in embeddings | Yes | No | No | Bundled | No |
227
- | Caching (Redis) | Yes | No | No | No | No |
228
- | Auth + rate limiting | Yes | No | No | Yes | Yes |
229
- | Observability dashboard | Yes | No | Partial | No | Partial |
230
- | Plugin ecosystem | Yes | No | No | No | No |
231
- | SSE streaming | Yes | Yes | Yes | Yes | Yes |
232
-
233
291
  ## Tech stack
234
292
 
235
293
  - **CLI**: [Typer](https://typer.tiangolo.com/) + [Rich](https://rich.readthedocs.io/)
@@ -1,11 +1,13 @@
1
- llmstack/__init__.py,sha256=eeHMsyABNyNRnVgYQqyU_R4RZpbJ7Kz3HzPWsisW5zo,84
1
+ llmstack/__init__.py,sha256=ixXALf2pEpG4MqBgKPZQGn27zrgKhzcpe22AoV7XXdY,84
2
2
  llmstack/__main__.py,sha256=XhknKG6tlsgyslTC57MpF3IGUu_cIo6G5rSIz4kxuyc,86
3
3
  llmstack/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- llmstack/cli/app.py,sha256=qcrUcYkJApyX2JYVCFsu2wXZx81b0ma5aBXRNVNukSw,2378
4
+ llmstack/cli/app.py,sha256=eYm9U7BNTFsFNYjfE3COHBw7FX0znRNtce4A0Lbb4ic,2924
5
5
  llmstack/cli/console.py,sha256=qnwJ2g39BwXhA_WhGtJW5E1ZdbTau4npG9j9mBo2zX0,259
6
6
  llmstack/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ llmstack/cli/commands/chat.py,sha256=pdhVHqHj2hXMcxhcywYUgAkFLHN98x8jQyCu4I-zgYU,3586
7
8
  llmstack/cli/commands/doctor.py,sha256=71PM5uG-a8A4nNf4ywMXmVIL8w6W5MwLgLco9DxbnG8,2440
8
9
  llmstack/cli/commands/down.py,sha256=51XiP8n_Nq3oX4gbeLd_vCvXB1OP7dZEi3392eYgSJM,752
10
+ llmstack/cli/commands/export.py,sha256=Xv6r-htPfHCyxqOFQlC0jlRNAD7nP0ocM3NU_aXscWU,7425
9
11
  llmstack/cli/commands/init.py,sha256=1olPvaPLapdSkuSBVIum4kzXh2BMtkRw-HNZKs1slKc,2311
10
12
  llmstack/cli/commands/logs.py,sha256=CGZO9kFiqc224LtpYH22uf0k5E26sCmAucrozjtb7BQ,840
11
13
  llmstack/cli/commands/status.py,sha256=ahnhKsw0C6-x2umOVUNsCnTU5XQS-nGIjKOFxEZVjdA,1382
@@ -21,10 +23,9 @@ llmstack/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
23
  llmstack/core/hardware.py,sha256=dO2VRD2OZ4BnDETkHBRw1YeTERz6XhFByQpy8z7-jWc,3844
22
24
  llmstack/core/health.py,sha256=BxH-Y1DsoDFc0zNxYVTdYEmKTC1dIWiBxhCLDLbUyr0,706
23
25
  llmstack/core/resolver.py,sha256=MPUNLAoe0U_htLtMy789RbBir6xljWnw9p0AGOHYrAc,1633
24
- llmstack/core/stack.py,sha256=v9kPijvUm5IF-B608b0fUw71wDv14vg0K1ovffSuHf4,8310
26
+ llmstack/core/stack.py,sha256=p0nPreFr8B7g1kKDJ1gONxxrjt9uLV488qW5NXb2tpk,8812
25
27
  llmstack/docker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
- llmstack/docker/manager.py,sha256=OzfY8H46dEw7acSe8sUglayo4hgxO0tj3SxEC47Xmmg,4728
27
- llmstack/gateway/Dockerfile,sha256=Mtiy9fcfK1DikZ6v-I4zBI_Op1pABVm1nYZ-LT8J9fg,308
28
+ llmstack/docker/manager.py,sha256=dpKFNgkpKYOvrIPHeMGHwmVGrHLXu9rIArlquZToQqI,5528
28
29
  llmstack/gateway/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
30
  llmstack/gateway/main.py,sha256=PYT48w4MuBNCqJ3YTq27OF_yC0dRRpnC61bpos1aveY,1483
30
31
  llmstack/gateway/proxy.py,sha256=WLErhSUICVK1XWu6Qt9v5gBWYtwalP3LKJE_D5Dj6C4,2077
@@ -47,16 +48,17 @@ llmstack/services/cache/redis.py,sha256=_6_JlFxFEvVutkmFwo-qzola6heODG_fxrfGVt0x
47
48
  llmstack/services/embeddings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
49
  llmstack/services/embeddings/tei.py,sha256=GNJL6PxQVUu7FQEzR0jj1N2jDCkhZoO1ezKLcEdo5EI,1502
49
50
  llmstack/services/gateway/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
- llmstack/services/gateway/service.py,sha256=4PSED82DHUlkqP4FxL-lNxY5kO1E-ODTN-x6msEY3HE,1580
51
+ llmstack/services/gateway/service.py,sha256=lDJ1f7JMkSUMsnfUH-PjvZQLG37vs-zEQZajpV48d0E,2086
51
52
  llmstack/services/inference/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
53
  llmstack/services/inference/ollama.py,sha256=0A-4V3Ks3fiyQwnJkBhLtj9oIvKAYPK-QgsbcAsuqFI,1814
53
54
  llmstack/services/inference/vllm.py,sha256=fhi7xa2RitNG0FNnhypUYfjEemvylnnxOIksRfBzJcE,1607
54
55
  llmstack/services/observe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
- llmstack/services/observe/prometheus.py,sha256=6NG8yDY2JQqYCWtSldb6LEKbOqLdHiPB9u9G0c2dJpw,5343
56
+ llmstack/services/observe/prometheus.py,sha256=9gyoU7uhrNpQCvnmIKcr5xLpIx8e9z05LB31ri2UryI,7418
56
57
  llmstack/services/vectordb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
58
  llmstack/services/vectordb/qdrant.py,sha256=zIOqf5km-4pItXOW8Y-qdxHLiU9lFfhDz33nBrTgPhM,900
58
- llmstack_cli-0.1.0.dist-info/METADATA,sha256=aWalxTiNMqv0lz5GjICnaOhTC1QgYXRR8Tq7Jyk4LQE,8128
59
- llmstack_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
60
- llmstack_cli-0.1.0.dist-info/entry_points.txt,sha256=i2BIacwqAqUaN1yAe-MaJZ22unHqAAUkTopk9M_iZPo,50
61
- llmstack_cli-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
62
- llmstack_cli-0.1.0.dist-info/RECORD,,
59
+ llmstack/gateway/Dockerfile,sha256=ZoId9IGitvOViaF4m6cXo-GGVwPYxPWZZHKu89Tnjak,385
60
+ llmstack_cli-0.2.0.dist-info/METADATA,sha256=nfDKQ6CRTHENvR7ohwk9FgSZxAnWd1rrCgimY6S2KKs,9791
61
+ llmstack_cli-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
62
+ llmstack_cli-0.2.0.dist-info/entry_points.txt,sha256=i2BIacwqAqUaN1yAe-MaJZ22unHqAAUkTopk9M_iZPo,50
63
+ llmstack_cli-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
64
+ llmstack_cli-0.2.0.dist-info/RECORD,,