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.
- llamawatch/__init__.py +2 -0
- llamawatch/__main__.py +73 -0
- llamawatch/adapters/__init__.py +108 -0
- llamawatch/adapters/base.py +43 -0
- llamawatch/adapters/llamacpp.py +148 -0
- llamawatch/adapters/ollama.py +43 -0
- llamawatch/adapters/openai_compat.py +39 -0
- llamawatch/agents.py +451 -0
- llamawatch/approvals.py +449 -0
- llamawatch/audit.py +34 -0
- llamawatch/auth.py +109 -0
- llamawatch/auto_detect.py +278 -0
- llamawatch/collectors/__init__.py +199 -0
- llamawatch/collectors/anomaly.py +104 -0
- llamawatch/collectors/claude_code.py +173 -0
- llamawatch/collectors/docker_collector.py +293 -0
- llamawatch/collectors/email_collector.py +224 -0
- llamawatch/collectors/fleet.py +284 -0
- llamawatch/collectors/gpu.py +226 -0
- llamawatch/collectors/inference_speed.py +363 -0
- llamawatch/collectors/library_collector.py +151 -0
- llamawatch/collectors/logs_collector.py +182 -0
- llamawatch/collectors/model_status.py +345 -0
- llamawatch/collectors/network.py +105 -0
- llamawatch/collectors/power.py +170 -0
- llamawatch/collectors/press_room.py +104 -0
- llamawatch/collectors/request_history.py +32 -0
- llamawatch/collectors/resource_hogs.py +337 -0
- llamawatch/collectors/services.py +110 -0
- llamawatch/collectors/slots.py +250 -0
- llamawatch/collectors/system.py +187 -0
- llamawatch/collectors/timers.py +113 -0
- llamawatch/collectors/token_usage.py +414 -0
- llamawatch/collectors/topology.py +80 -0
- llamawatch/collectors/weather.py +178 -0
- llamawatch/config.json +28 -0
- llamawatch/config.py +259 -0
- llamawatch/connections.py +62 -0
- llamawatch/jobs.py +715 -0
- llamawatch/layout.py +13 -0
- llamawatch/request_log.py +68 -0
- llamawatch/research/__init__.py +5 -0
- llamawatch/research/evaluate.py +154 -0
- llamawatch/research/llm.py +235 -0
- llamawatch/research/pipeline.py +1400 -0
- llamawatch/research/prompts.py +416 -0
- llamawatch/research/quotes.py +79 -0
- llamawatch/research/replay.py +196 -0
- llamawatch/research/rerank.py +80 -0
- llamawatch/research/safety.py +46 -0
- llamawatch/research/store.py +185 -0
- llamawatch/research/style.py +88 -0
- llamawatch/research/trust.py +93 -0
- llamawatch/research/web.py +657 -0
- llamawatch/routes/__init__.py +30 -0
- llamawatch/routes/actions.py +365 -0
- llamawatch/routes/agents.py +48 -0
- llamawatch/routes/approvals.py +290 -0
- llamawatch/routes/auth.py +85 -0
- llamawatch/routes/chat.py +451 -0
- llamawatch/routes/dashboard.py +83 -0
- llamawatch/routes/history.py +105 -0
- llamawatch/routes/jobs.py +179 -0
- llamawatch/routes/knowledge.py +478 -0
- llamawatch/routes/research.py +399 -0
- llamawatch/routes/settings.py +165 -0
- llamawatch/routes/spend.py +118 -0
- llamawatch/routes_framework.py +332 -0
- llamawatch/sample.py +99 -0
- llamawatch/secrets_vault.py +63 -0
- llamawatch/security.py +160 -0
- llamawatch/server.py +285 -0
- llamawatch/spend.py +512 -0
- llamawatch/spend_providers.py +486 -0
- llamawatch/sse.py +54 -0
- llamawatch/static/agents.css +117 -0
- llamawatch/static/agents.html +91 -0
- llamawatch/static/agents.js +177 -0
- llamawatch/static/approvals.css +198 -0
- llamawatch/static/approvals.html +142 -0
- llamawatch/static/approvals.js +312 -0
- llamawatch/static/gauge.js +213 -0
- llamawatch/static/icons/icon-192.png +0 -0
- llamawatch/static/icons/icon-192.svg +10 -0
- llamawatch/static/icons/icon-512.png +0 -0
- llamawatch/static/icons/icon-512.svg +10 -0
- llamawatch/static/jobs.css +170 -0
- llamawatch/static/jobs.html +111 -0
- llamawatch/static/jobs.js +347 -0
- llamawatch/static/layout.js +191 -0
- llamawatch/static/manifest.json +18 -0
- llamawatch/static/rail.css +54 -0
- llamawatch/static/research-report.html +1026 -0
- llamawatch/static/research.css +133 -0
- llamawatch/static/research.html +132 -0
- llamawatch/static/research.js +327 -0
- llamawatch/static/settings.js +1630 -0
- llamawatch/static/spend.css +159 -0
- llamawatch/static/spend.html +137 -0
- llamawatch/static/spend.js +290 -0
- llamawatch/static/studio.css +2977 -0
- llamawatch/static/studio.html +608 -0
- llamawatch/static/studio.js +3909 -0
- llamawatch/static/style.css +4547 -0
- llamawatch/static/sw.js +60 -0
- llamawatch/static/topojson-client.min.js +2 -0
- llamawatch/static/vendor/addon-fit.min.js +8 -0
- llamawatch/static/vendor/addon-web-links.min.js +8 -0
- llamawatch/static/vendor/gridstack-extra.min.css +1 -0
- llamawatch/static/vendor/gridstack.min.css +1 -0
- llamawatch/static/vendor/gridstack.min.js +9 -0
- llamawatch/static/vendor/uPlot.iife.min.js +2 -0
- llamawatch/static/vendor/uPlot.min.css +1 -0
- llamawatch/static/vendor/xterm.min.css +8 -0
- llamawatch/static/vendor/xterm.min.js +8 -0
- llamawatch/static/wide.js +114 -0
- llamawatch/ws_hub.py +146 -0
- llamawatch-0.6.0.dist-info/METADATA +404 -0
- llamawatch-0.6.0.dist-info/RECORD +123 -0
- llamawatch-0.6.0.dist-info/WHEEL +5 -0
- llamawatch-0.6.0.dist-info/entry_points.txt +2 -0
- llamawatch-0.6.0.dist-info/licenses/LICENSE +661 -0
- llamawatch-0.6.0.dist-info/top_level.txt +1 -0
llamawatch/__init__.py
ADDED
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"
|