forked from odysseus-dev/odysseus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodel_discovery.py
More file actions
292 lines (255 loc) · 10.7 KB
/
Copy pathmodel_discovery.py
File metadata and controls
292 lines (255 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import subprocess
import json
import time
import httpx
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Any, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# Cache for discovered hosts
_hosts_cache: List[str] = []
_hosts_cache_time: float = 0
_HOSTS_CACHE_TTL = 60 # seconds
def _parse_tailscale_status(raw: str) -> Dict[str, Any]:
try:
data = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _first_tailscale_ipv4(value: Any) -> Optional[str]:
if not isinstance(value, list):
return None
for ip in value:
if isinstance(ip, str) and "." in ip:
return ip
return None
def discover_tailscale_hosts() -> List[str]:
"""Discover online Tailscale peers, returning their IPv4 addresses."""
global _hosts_cache, _hosts_cache_time
now = time.time()
if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
return list(_hosts_cache)
hosts = []
try:
result = subprocess.run(
["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return hosts
data = _parse_tailscale_status(result.stdout)
if not data:
return hosts
# Add self
self_data = data.get("Self") if isinstance(data.get("Self"), dict) else {}
self_ip = _first_tailscale_ipv4(self_data.get("TailscaleIPs"))
if self_ip:
hosts.append(self_ip)
# Add online peers (skip funnel-ingress-nodes and android devices)
peers = data.get("Peer") if isinstance(data.get("Peer"), dict) else {}
for peer in peers.values():
if not isinstance(peer, dict):
continue
if not peer.get("Online"):
continue
hostname = peer.get("HostName", "")
if hostname == "funnel-ingress-node":
continue
os_name = peer.get("OS", "")
if os_name == "android":
continue
peer_ip = _first_tailscale_ipv4(peer.get("TailscaleIPs"))
if peer_ip:
hosts.append(peer_ip)
_hosts_cache = hosts
_hosts_cache_time = now
logger.info(f"Tailscale discovery found {len(hosts)} hosts: {hosts}")
except FileNotFoundError:
logger.debug("tailscale command not found")
except Exception as e:
logger.warning(f"Tailscale discovery failed: {e}")
return hosts
class ModelDiscovery:
def __init__(self, default_host: str, openai_api_key: Optional[str] = None):
self.default_host = default_host
self.openai_api_key = openai_api_key
self.openai_compat_path = "/v1/chat/completions"
# Custom ports from env vars, merged into the scan list by discover_models.
self._extra_ports: set = set()
def _get_hosts(self) -> List[str]:
"""Get all hosts to scan, using env override, Tailscale, or default."""
self._extra_ports = set()
def _append_host(out: List[str], host: str) -> None:
host = (host or "").strip()
if not host or host in out:
return
out.append(host)
def _append_env_hosts(out: List[str]) -> None:
"""Add hosts (and any custom ports) from provider-specific env vars."""
for env_name in ("OLLAMA_BASE_URL", "OLLAMA_URL", "LM_STUDIO_URL"):
raw = os.getenv(env_name, "").strip()
if not raw:
continue
try:
parsed = urlparse(raw if "://" in raw else "http://" + raw)
_append_host(out, parsed.hostname or "")
if parsed.port:
self._extra_ports.add(parsed.port)
except Exception:
pass
# Manual override takes priority
extra = os.getenv("LLM_HOSTS", "").strip()
if extra:
hosts = [h.strip() for h in extra.split(",") if h.strip()]
# Always include the default host too
if self.default_host not in hosts:
hosts.insert(0, self.default_host)
_append_host(hosts, "host.docker.internal")
_append_env_hosts(hosts)
return hosts
# Try Tailscale discovery
ts_hosts = discover_tailscale_hosts()
if ts_hosts:
# Ensure default_host is included
if self.default_host not in ts_hosts:
ts_hosts.insert(0, self.default_host)
_append_host(ts_hosts, "host.docker.internal")
_append_env_hosts(ts_hosts)
return ts_hosts
hosts = [self.default_host]
# Docker desktop/Linux compose maps this to the host machine. That is
# the common "I started Ollama normally on this computer" case.
_append_host(hosts, "host.docker.internal")
_append_env_hosts(hosts)
return hosts
def _fingerprint_provider(self, host: str, port: int) -> Optional[str]:
"""Identify the server software via its native API, independent of port."""
try:
r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5)
if r.is_success:
models = (r.json() or {}).get("models")
if (
isinstance(models, list)
and models
and isinstance(models[0], dict)
and "key" in models[0]
and "architecture" in models[0]
):
return "lmstudio"
except Exception:
pass
# llama.cpp's llama-server exposes a native /props endpoint (no /v1 prefix)
# describing the loaded model, slots, and chat template — distinct from
# LM Studio (/api/v1/models) and vLLM (/version, /metrics).
try:
r = httpx.get(f"http://{host}:{port}/props", timeout=1.5)
if r.is_success:
props = r.json() or {}
if isinstance(props, dict) and (
"default_generation_settings" in props
or "total_slots" in props
or "chat_template" in props
):
return "llamacpp"
except Exception:
pass
return None
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
"""Check a single host:port for models."""
base = f"http://{host}:{port}/v1"
try:
r = httpx.get(f"{base}/models", timeout=3)
if not r.is_success:
return None
data = r.json()
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
if ids:
return {
"host": host,
"port": port,
"url": f"http://{host}:{port}{self.openai_compat_path}",
"models": ids,
"models_display": [i.lstrip("/") for i in ids],
"provider": self._fingerprint_provider(host, port),
}
except Exception:
pass
return None
def discover_models(self) -> Dict[str, List[Dict[str, Any]]]:
"""Discover available models from all reachable hosts."""
hosts = self._get_hosts()
items = []
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
# Well-known ports: 8000-8020 (vLLM, SGLang, Cookbook), 8080 (llama.cpp /
# llama-server default), 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL
# as its default port is occupied by Ollama. The env vars can add more
# ports which will be merged in.
ports = list(range(8000, 8021)) + [8080, 1234, 11434, 11435]
ports += [p for p in sorted(self._extra_ports) if p not in ports]
targets = [(h, p) for h in hosts for p in ports]
seen_models = (
set()
) # dedupe by (port, model_ids) to avoid same machine via different IPs
with ThreadPoolExecutor(max_workers=50) as pool:
futures = {pool.submit(self._check_port, h, p): (h, p) for h, p in targets}
for future in as_completed(futures):
result = future.result()
if result:
key = (result["port"], tuple(sorted(result["models"])))
if key not in seen_models:
seen_models.add(key)
items.append(result)
# Sort by host then port for consistent ordering
items.sort(key=lambda x: (x["host"], x["port"]))
logger.info(
f"Discovered {len(items)} model endpoints across {len(hosts)} hosts"
)
return {"hosts": hosts, "items": items}
def warmup_ping_urls(self, limit: int = 5) -> List[str]:
"""The ``/models`` URLs of up to ``limit`` discovered endpoints.
Used by the startup warmup / keepalive loop to prime connections. Each
discovered item already carries a ``/v1/chat/completions`` url; swap the
suffix for the cheap ``/models`` probe. Failures degrade to an empty list
so warmup never crashes the caller.
"""
try:
items = (self.discover_models() or {}).get("items", [])
except Exception:
return []
urls: List[str] = []
for ep in items[:limit]:
url = (ep.get("url") or "").replace("/chat/completions", "/models")
if url:
urls.append(url)
return urls
def get_providers(self) -> Dict[str, Any]:
"""Get all available providers"""
discovery = self.discover_models()
items = discovery["items"]
providers = [{"provider": "vllm", "hosts": discovery["hosts"], "items": items}]
if self.openai_api_key:
openai_models = [
"gpt-5.6-sol",
"gpt-5.6-luna",
"gpt-5.2",
"gpt-5.2-pro",
"gpt-5.2-codex",
"gpt-4o",
"gpt-4o-mini",
"gpt-image-1.5",
]
providers.append(
{
"provider": "openai",
"items": [
{
"url": "https://api.openai.com/v1/chat/completions",
"models": openai_models,
}
],
}
)
return {"providers": providers}