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 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.3.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/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
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:
@@ -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