llamawatch 0.6.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.
Files changed (123) hide show
  1. llamawatch/__init__.py +2 -0
  2. llamawatch/__main__.py +73 -0
  3. llamawatch/adapters/__init__.py +108 -0
  4. llamawatch/adapters/base.py +43 -0
  5. llamawatch/adapters/llamacpp.py +148 -0
  6. llamawatch/adapters/ollama.py +43 -0
  7. llamawatch/adapters/openai_compat.py +39 -0
  8. llamawatch/agents.py +451 -0
  9. llamawatch/approvals.py +449 -0
  10. llamawatch/audit.py +34 -0
  11. llamawatch/auth.py +109 -0
  12. llamawatch/auto_detect.py +278 -0
  13. llamawatch/collectors/__init__.py +199 -0
  14. llamawatch/collectors/anomaly.py +104 -0
  15. llamawatch/collectors/claude_code.py +173 -0
  16. llamawatch/collectors/docker_collector.py +293 -0
  17. llamawatch/collectors/email_collector.py +224 -0
  18. llamawatch/collectors/fleet.py +284 -0
  19. llamawatch/collectors/gpu.py +226 -0
  20. llamawatch/collectors/inference_speed.py +363 -0
  21. llamawatch/collectors/library_collector.py +151 -0
  22. llamawatch/collectors/logs_collector.py +182 -0
  23. llamawatch/collectors/model_status.py +345 -0
  24. llamawatch/collectors/network.py +105 -0
  25. llamawatch/collectors/power.py +170 -0
  26. llamawatch/collectors/press_room.py +104 -0
  27. llamawatch/collectors/request_history.py +32 -0
  28. llamawatch/collectors/resource_hogs.py +337 -0
  29. llamawatch/collectors/services.py +110 -0
  30. llamawatch/collectors/slots.py +250 -0
  31. llamawatch/collectors/system.py +187 -0
  32. llamawatch/collectors/timers.py +113 -0
  33. llamawatch/collectors/token_usage.py +414 -0
  34. llamawatch/collectors/topology.py +80 -0
  35. llamawatch/collectors/weather.py +178 -0
  36. llamawatch/config.json +28 -0
  37. llamawatch/config.py +259 -0
  38. llamawatch/connections.py +62 -0
  39. llamawatch/jobs.py +715 -0
  40. llamawatch/layout.py +13 -0
  41. llamawatch/request_log.py +68 -0
  42. llamawatch/research/__init__.py +5 -0
  43. llamawatch/research/evaluate.py +154 -0
  44. llamawatch/research/llm.py +235 -0
  45. llamawatch/research/pipeline.py +1400 -0
  46. llamawatch/research/prompts.py +416 -0
  47. llamawatch/research/quotes.py +79 -0
  48. llamawatch/research/replay.py +196 -0
  49. llamawatch/research/rerank.py +80 -0
  50. llamawatch/research/safety.py +46 -0
  51. llamawatch/research/store.py +185 -0
  52. llamawatch/research/style.py +88 -0
  53. llamawatch/research/trust.py +93 -0
  54. llamawatch/research/web.py +657 -0
  55. llamawatch/routes/__init__.py +30 -0
  56. llamawatch/routes/actions.py +365 -0
  57. llamawatch/routes/agents.py +48 -0
  58. llamawatch/routes/approvals.py +290 -0
  59. llamawatch/routes/auth.py +85 -0
  60. llamawatch/routes/chat.py +451 -0
  61. llamawatch/routes/dashboard.py +83 -0
  62. llamawatch/routes/history.py +105 -0
  63. llamawatch/routes/jobs.py +179 -0
  64. llamawatch/routes/knowledge.py +478 -0
  65. llamawatch/routes/research.py +399 -0
  66. llamawatch/routes/settings.py +165 -0
  67. llamawatch/routes/spend.py +118 -0
  68. llamawatch/routes_framework.py +332 -0
  69. llamawatch/sample.py +99 -0
  70. llamawatch/secrets_vault.py +63 -0
  71. llamawatch/security.py +160 -0
  72. llamawatch/server.py +285 -0
  73. llamawatch/spend.py +512 -0
  74. llamawatch/spend_providers.py +486 -0
  75. llamawatch/sse.py +54 -0
  76. llamawatch/static/agents.css +117 -0
  77. llamawatch/static/agents.html +91 -0
  78. llamawatch/static/agents.js +177 -0
  79. llamawatch/static/approvals.css +198 -0
  80. llamawatch/static/approvals.html +142 -0
  81. llamawatch/static/approvals.js +312 -0
  82. llamawatch/static/gauge.js +213 -0
  83. llamawatch/static/icons/icon-192.png +0 -0
  84. llamawatch/static/icons/icon-192.svg +10 -0
  85. llamawatch/static/icons/icon-512.png +0 -0
  86. llamawatch/static/icons/icon-512.svg +10 -0
  87. llamawatch/static/jobs.css +170 -0
  88. llamawatch/static/jobs.html +111 -0
  89. llamawatch/static/jobs.js +347 -0
  90. llamawatch/static/layout.js +191 -0
  91. llamawatch/static/manifest.json +18 -0
  92. llamawatch/static/rail.css +54 -0
  93. llamawatch/static/research-report.html +1026 -0
  94. llamawatch/static/research.css +133 -0
  95. llamawatch/static/research.html +132 -0
  96. llamawatch/static/research.js +327 -0
  97. llamawatch/static/settings.js +1630 -0
  98. llamawatch/static/spend.css +159 -0
  99. llamawatch/static/spend.html +137 -0
  100. llamawatch/static/spend.js +290 -0
  101. llamawatch/static/studio.css +2977 -0
  102. llamawatch/static/studio.html +608 -0
  103. llamawatch/static/studio.js +3909 -0
  104. llamawatch/static/style.css +4547 -0
  105. llamawatch/static/sw.js +60 -0
  106. llamawatch/static/topojson-client.min.js +2 -0
  107. llamawatch/static/vendor/addon-fit.min.js +8 -0
  108. llamawatch/static/vendor/addon-web-links.min.js +8 -0
  109. llamawatch/static/vendor/gridstack-extra.min.css +1 -0
  110. llamawatch/static/vendor/gridstack.min.css +1 -0
  111. llamawatch/static/vendor/gridstack.min.js +9 -0
  112. llamawatch/static/vendor/uPlot.iife.min.js +2 -0
  113. llamawatch/static/vendor/uPlot.min.css +1 -0
  114. llamawatch/static/vendor/xterm.min.css +8 -0
  115. llamawatch/static/vendor/xterm.min.js +8 -0
  116. llamawatch/static/wide.js +114 -0
  117. llamawatch/ws_hub.py +146 -0
  118. llamawatch-0.6.0.dist-info/METADATA +404 -0
  119. llamawatch-0.6.0.dist-info/RECORD +123 -0
  120. llamawatch-0.6.0.dist-info/WHEEL +5 -0
  121. llamawatch-0.6.0.dist-info/entry_points.txt +2 -0
  122. llamawatch-0.6.0.dist-info/licenses/LICENSE +661 -0
  123. llamawatch-0.6.0.dist-info/top_level.txt +1 -0
llamawatch/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """llamawatch — self-hosted ops dashboard for local LLMs."""
2
+ __version__ = "0.6.0"
llamawatch/__main__.py ADDED
@@ -0,0 +1,73 @@
1
+ """llamawatch CLI entry point."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(prog="llamawatch", description="Self-hosted ops dashboard for local LLMs")
10
+ subparsers = parser.add_subparsers(dest="command")
11
+
12
+ init_parser = subparsers.add_parser("init", help="Auto-detect backends and write config")
13
+ init_parser.add_argument("--guided", action="store_true", help="Interactive setup wizard")
14
+
15
+ parser.add_argument("--port", type=int, help="Override port")
16
+ parser.add_argument("--host", type=str, help="Override host")
17
+ parser.add_argument("--no-auth", action="store_true", help="Disable auth for this session")
18
+ parser.add_argument("--sample", type=str, metavar="FILE",
19
+ help="Serve a fixed JSON snapshot (demo/tests); config is read from the file's folder")
20
+
21
+ args = parser.parse_args()
22
+
23
+ if args.command == "init":
24
+ from .auto_detect import run_init
25
+ run_init(guided=args.guided)
26
+ return
27
+
28
+ cli_overrides = {}
29
+ if args.port:
30
+ cli_overrides["port"] = args.port
31
+ if args.host:
32
+ cli_overrides["host"] = args.host
33
+ if args.no_auth:
34
+ cli_overrides["auth_enabled"] = False
35
+
36
+ from .config import load_config
37
+ config_dir = None
38
+ if args.sample:
39
+ import os
40
+ from pathlib import Path
41
+ from .sample import ENV
42
+ sample_path = Path(args.sample).resolve()
43
+ os.environ[ENV] = str(sample_path)
44
+ config_dir = sample_path.parent
45
+ config = load_config(cli_overrides, config_dir=config_dir)
46
+
47
+ host = config.get("host", "127.0.0.1")
48
+ port = config.get("port", 8400)
49
+
50
+ # Secure-by-default warning: exposing to the network with no password means
51
+ # only the localhost-gated dangerous actions are protected; reads are open.
52
+ loopback = host in ("127.0.0.1", "::1", "localhost")
53
+ if not loopback and not config.get("auth_enabled"):
54
+ print(
55
+ "\n ⚠ llamawatch is bound to a NETWORK address (" + str(host) + ") with NO password.\n"
56
+ " Anyone on your network can view the dashboard. Shell/Docker actions stay\n"
57
+ " localhost-only, but you should set a password: Settings → General → Authentication.\n",
58
+ file=sys.stderr,
59
+ )
60
+
61
+ import uvicorn
62
+ # Give open streams (SSE, websockets, terminals) five seconds to finish,
63
+ # then leave hard: a leftover worker thread must not hang the stop until
64
+ # systemd aborts the process and writes a core dump.
65
+ uvicorn.run("llamawatch.server:app", host=host, port=port, log_level="info",
66
+ timeout_graceful_shutdown=5)
67
+ sys.stdout.flush()
68
+ sys.stderr.flush()
69
+ os._exit(0)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,108 @@
1
+ """Backend adapter registry for LlamaWatch.
2
+
3
+ Provides AdapterRegistry factory that creates the right adapter
4
+ based on backend type configuration.
5
+ """
6
+
7
+ from .base import BackendAdapter
8
+ from .llamacpp import LlamaCppAdapter
9
+ from .ollama import OllamaAdapter
10
+ from .openai_compat import OpenAICompatAdapter
11
+
12
+ _ADAPTER_TYPES: dict[str, type[BackendAdapter]] = {
13
+ "llamacpp": LlamaCppAdapter,
14
+ "ollama": OllamaAdapter,
15
+ "openai": OpenAICompatAdapter,
16
+ }
17
+
18
+
19
+ def create_adapter(backend_config: dict, top_config: dict | None = None) -> BackendAdapter:
20
+ """Module-level factory: create a single adapter from a backend config dict.
21
+
22
+ Parameters
23
+ ----------
24
+ backend_config:
25
+ A single backend entry (type, url, name, …).
26
+ top_config:
27
+ Optional top-level config used to inherit ``model_names``.
28
+
29
+ Returns
30
+ -------
31
+ BackendAdapter
32
+ Concrete adapter instance (LlamaCppAdapter, OllamaAdapter, …).
33
+ """
34
+ top_config = top_config or {}
35
+ backend_type = backend_config.get("type", "openai")
36
+ url = backend_config.get("url", "")
37
+
38
+ adapter_cls = _ADAPTER_TYPES.get(backend_type, OpenAICompatAdapter)
39
+
40
+ merged_config = {**backend_config}
41
+ if "model_names" not in merged_config and "model_names" in top_config:
42
+ merged_config["model_names"] = top_config["model_names"]
43
+
44
+ return adapter_cls(url=url, config=merged_config)
45
+
46
+
47
+ class AdapterRegistry:
48
+ """Factory that creates and manages backend adapters from config."""
49
+
50
+ def __init__(self, config: dict) -> None:
51
+ self._adapters: list[BackendAdapter] = []
52
+ self._by_name: dict[str, BackendAdapter] = {}
53
+ self._top_config = config
54
+ self._load(config)
55
+
56
+ # ------------------------------------------------------------------
57
+ # Public dict-style view (name → adapter) for hot-reload consumers
58
+ # ------------------------------------------------------------------
59
+
60
+ @property
61
+ def adapters(self) -> dict[str, BackendAdapter]:
62
+ """Dict mapping backend name → adapter (live view, not a copy)."""
63
+ return self._by_name
64
+
65
+ # ------------------------------------------------------------------
66
+ # Internal helpers
67
+ # ------------------------------------------------------------------
68
+
69
+ def _load(self, config: dict) -> None:
70
+ """Populate internal lists from *config* without clearing first."""
71
+ backends = config.get("backends", [])
72
+ for backend_cfg in backends:
73
+ name = backend_cfg.get("name", backend_cfg.get("url", ""))
74
+ adapter = create_adapter(backend_cfg, top_config=config)
75
+ self._adapters.append(adapter)
76
+ self._by_name[name] = adapter
77
+
78
+ # ------------------------------------------------------------------
79
+ # Hot-reload
80
+ # ------------------------------------------------------------------
81
+
82
+ def rebuild(self, config: dict) -> None:
83
+ """Replace all adapters in-place from a new config dict.
84
+
85
+ Existing adapter objects are discarded and recreated from *config*.
86
+ All references to ``registry.adapters`` (the dict) will see the
87
+ updated entries because we mutate the same dict object.
88
+ """
89
+ self._adapters.clear()
90
+ self._by_name.clear()
91
+ self._top_config = config
92
+ self._load(config)
93
+
94
+ # ------------------------------------------------------------------
95
+ # Public accessors (unchanged API)
96
+ # ------------------------------------------------------------------
97
+
98
+ def get_primary(self) -> BackendAdapter | None:
99
+ """Return the first adapter, or None if no backends configured."""
100
+ return self._adapters[0] if self._adapters else None
101
+
102
+ def get_all(self) -> list[BackendAdapter]:
103
+ """Return all adapters."""
104
+ return list(self._adapters)
105
+
106
+ def get_by_name(self, name: str) -> BackendAdapter | None:
107
+ """Return adapter matching the given name, or None."""
108
+ return self._by_name.get(name)
@@ -0,0 +1,43 @@
1
+ """Backend adapter base class for LlamaWatch."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import AsyncGenerator
5
+
6
+
7
+ class BackendAdapter(ABC):
8
+ """Abstract base for all LLM backend adapters."""
9
+
10
+ def __init__(self, url: str, config: dict) -> None:
11
+ self.url: str = url.rstrip("/")
12
+ self.config: dict = config
13
+ self._model_names: dict[str, str] = config.get("model_names", {})
14
+ self.timeout: float = config.get("timeout", 3.0)
15
+
16
+ @abstractmethod
17
+ def health(self) -> str:
18
+ """Return 'healthy', 'unreachable', or 'error'."""
19
+
20
+ @abstractmethod
21
+ def model_name(self) -> str:
22
+ """Return the currently loaded model ID."""
23
+
24
+ @abstractmethod
25
+ def is_generating(self) -> bool:
26
+ """Return True if active inference is happening."""
27
+
28
+ @abstractmethod
29
+ def models_available(self) -> list[dict]:
30
+ """Return list of available models."""
31
+
32
+ @abstractmethod
33
+ def chat_completions_url(self) -> str:
34
+ """Return the chat completions endpoint URL."""
35
+
36
+ def model_friendly_name(self) -> str:
37
+ """Map model ID to user-configured display name."""
38
+ name = self.model_name()
39
+ return self._model_names.get(name, name)
40
+
41
+ def swap_status(self) -> dict | None:
42
+ """Return swap status info, or None if not swapping. Override in subclasses."""
43
+ return None
@@ -0,0 +1,148 @@
1
+ """llama.cpp / swap-proxy backend adapter for LlamaWatch."""
2
+
3
+ import json
4
+ import time
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+ from .base import BackendAdapter
9
+
10
+ SWAP_LOCK = Path("/tmp/model_swap_in_progress")
11
+
12
+ # Swap step timing thresholds (seconds elapsed -> step number + description)
13
+ # Based on observed model-swap timings
14
+ _SWAP_STEPS = [
15
+ (5, 1, "Unloading old model"),
16
+ (15, 2, "Waiting for unload"),
17
+ (25, 3, "GTT memory release (10s)"),
18
+ (35, 4, "Flushing RAM cache"),
19
+ (55, 5, "Warming up subsystems"),
20
+ (999, 6, "Loading new model"),
21
+ ]
22
+
23
+
24
+ def _http_get(url: str, timeout: float = 3.0) -> str | None:
25
+ """GET a URL and return the response body, or None on failure."""
26
+ try:
27
+ req = urllib.request.Request(url)
28
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
29
+ return resp.read().decode("utf-8")
30
+ except Exception:
31
+ return None
32
+
33
+
34
+ def _http_get_json(url: str, timeout: float = 3.0) -> dict | list | None:
35
+ """GET a URL and parse as JSON, or None on failure."""
36
+ body = _http_get(url, timeout)
37
+ if body is None:
38
+ return None
39
+ try:
40
+ return json.loads(body)
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ class LlamaCppAdapter(BackendAdapter):
46
+ """Adapter for llama.cpp servers, with optional swap-proxy support."""
47
+
48
+ def __init__(self, url: str, config: dict) -> None:
49
+ super().__init__(url, config)
50
+ self.is_swap_proxy: bool = config.get("swap_proxy", False)
51
+ self.direct_port: int = config.get("direct_port", 8080)
52
+
53
+ def health(self) -> str:
54
+ """Check /health endpoint."""
55
+ body = _http_get(f"{self.url}/health", timeout=3.0)
56
+ if body is None:
57
+ return "unreachable"
58
+ # llama.cpp /health returns JSON with "status" field
59
+ try:
60
+ data = json.loads(body)
61
+ status = data.get("status", "")
62
+ if status == "ok":
63
+ return "healthy"
64
+ if status in ("no slot available", "loading model"):
65
+ return "healthy"
66
+ return "error"
67
+ except (json.JSONDecodeError, AttributeError):
68
+ # Non-JSON 200 response — treat as healthy
69
+ return "healthy"
70
+
71
+ def model_name(self) -> str:
72
+ """Get model ID from /v1/models endpoint."""
73
+ data = _http_get_json(f"{self.url}/v1/models", timeout=3.0)
74
+ if data and "data" in data and len(data["data"]) > 0:
75
+ return data["data"][0].get("id", "unknown")
76
+ return "unknown"
77
+
78
+ def is_generating(self) -> bool:
79
+ """Check if any llama-server slot is actively processing.
80
+
81
+ NOTE: Queries localhost:{direct_port}/slots directly. For remote
82
+ proxy setups where llama-server is not on localhost, returns False.
83
+ """
84
+ model_id = self.model_name()
85
+ if model_id != "unknown":
86
+ url = f"http://localhost:{self.direct_port}/slots?model={model_id}"
87
+ else:
88
+ url = f"http://localhost:{self.direct_port}/slots"
89
+ data = _http_get_json(url, timeout=2.0)
90
+ if data and isinstance(data, list):
91
+ for slot in data:
92
+ if slot.get("is_processing", False):
93
+ return True
94
+ return False
95
+
96
+ def models_available(self) -> list[dict]:
97
+ """Get available models from /v1/models."""
98
+ data = _http_get_json(f"{self.url}/v1/models", timeout=3.0)
99
+ if data and "data" in data:
100
+ return data["data"]
101
+ return []
102
+
103
+ def chat_completions_url(self) -> str:
104
+ """Return the chat completions endpoint URL."""
105
+ return f"{self.url}/v1/chat/completions"
106
+
107
+ def swap_status(self) -> dict | None:
108
+ """Check if a model swap is in progress by reading the lock file."""
109
+ if not self.is_swap_proxy:
110
+ return None
111
+ try:
112
+ if not SWAP_LOCK.exists():
113
+ return None
114
+
115
+ content = SWAP_LOCK.read_text().strip()
116
+ lines = content.split("\n")
117
+ if not lines:
118
+ return None
119
+
120
+ timestamp = float(lines[0].strip())
121
+ elapsed = time.time() - timestamp
122
+
123
+ swap_from: str | None = None
124
+ swap_to: str | None = None
125
+ if len(lines) > 1 and "->" in lines[1]:
126
+ parts = lines[1].split("->", 1)
127
+ if len(parts) == 2:
128
+ swap_from = parts[0].strip()
129
+ swap_to = parts[1].strip()
130
+
131
+ # Determine step from elapsed time
132
+ step = 6
133
+ step_desc = "Loading new model"
134
+ for threshold, step_num, desc in _SWAP_STEPS:
135
+ if elapsed < threshold:
136
+ step = step_num
137
+ step_desc = desc
138
+ break
139
+
140
+ return {
141
+ "swap_from": swap_from,
142
+ "swap_to": swap_to,
143
+ "swap_step": step,
144
+ "swap_step_desc": step_desc,
145
+ "swap_elapsed": round(elapsed),
146
+ }
147
+ except Exception:
148
+ return None
@@ -0,0 +1,43 @@
1
+ """Ollama backend adapter for LlamaWatch."""
2
+
3
+ from .base import BackendAdapter
4
+
5
+ # Reuse HTTP helpers from llamacpp module (stdlib only, no extra deps)
6
+ from .llamacpp import _http_get, _http_get_json
7
+
8
+
9
+ class OllamaAdapter(BackendAdapter):
10
+ """Adapter for Ollama API servers."""
11
+
12
+ def health(self) -> str:
13
+ """Check Ollama health via root endpoint."""
14
+ body = _http_get(f"{self.url}/", timeout=3.0)
15
+ if body is None:
16
+ return "unreachable"
17
+ return "healthy"
18
+
19
+ def model_name(self) -> str:
20
+ """Get the currently running model from /api/ps."""
21
+ data = _http_get_json(f"{self.url}/api/ps", timeout=3.0)
22
+ if data and "models" in data and len(data["models"]) > 0:
23
+ return data["models"][0].get("name", "unknown")
24
+ return "unknown"
25
+
26
+ def is_generating(self) -> bool:
27
+ """Ollama does not expose per-request generation status.
28
+
29
+ Always returns False. The generating indicator will not work for
30
+ Ollama backends — this is a known limitation of the Ollama API.
31
+ """
32
+ return False
33
+
34
+ def models_available(self) -> list[dict]:
35
+ """Get available models from /api/tags."""
36
+ data = _http_get_json(f"{self.url}/api/tags", timeout=3.0)
37
+ if data and "models" in data:
38
+ return data["models"]
39
+ return []
40
+
41
+ def chat_completions_url(self) -> str:
42
+ """Return the Ollama chat completions endpoint (OpenAI-compatible)."""
43
+ return f"{self.url}/v1/chat/completions"
@@ -0,0 +1,39 @@
1
+ """Generic OpenAI-compatible backend adapter for LlamaWatch."""
2
+
3
+ from .base import BackendAdapter
4
+ from .llamacpp import _http_get, _http_get_json
5
+
6
+
7
+ class OpenAICompatAdapter(BackendAdapter):
8
+ """Adapter for any OpenAI-compatible API server."""
9
+
10
+ def health(self) -> str:
11
+ """Check health by querying /v1/models."""
12
+ data = _http_get_json(f"{self.url}/v1/models", timeout=3.0)
13
+ if data is None:
14
+ return "unreachable"
15
+ if "data" in data:
16
+ return "healthy"
17
+ return "error"
18
+
19
+ def model_name(self) -> str:
20
+ """Get the first model from /v1/models."""
21
+ data = _http_get_json(f"{self.url}/v1/models", timeout=3.0)
22
+ if data and "data" in data and len(data["data"]) > 0:
23
+ return data["data"][0].get("id", "unknown")
24
+ return "unknown"
25
+
26
+ def is_generating(self) -> bool:
27
+ """Generic OpenAI servers don't expose generation status."""
28
+ return False
29
+
30
+ def models_available(self) -> list[dict]:
31
+ """Get available models from /v1/models."""
32
+ data = _http_get_json(f"{self.url}/v1/models", timeout=3.0)
33
+ if data and "data" in data:
34
+ return data["data"]
35
+ return []
36
+
37
+ def chat_completions_url(self) -> str:
38
+ """Return the chat completions endpoint URL."""
39
+ return f"{self.url}/v1/chat/completions"