hermes-hub/src/antigravity_provider/router/model_discovery_service.py

721 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Hermes Hub — Model Discovery Service.
Provides background discovery of available models across all providers with:
- Persistent disk caching (models_cache.json in HERMES_HOME)
- Strict non-blocking read access for UI and routers
- Strict timeout enforcement for background network / subprocess probes
- Honest empty/None returns when models have not been discovered yet (zero invented lists)
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger("hermes.router.model_discovery")
class ModelDiscoveryService:
"""Thread-safe singleton service for discovering and caching provider models."""
_instance: Optional["ModelDiscoveryService"] = None
_lock = threading.Lock()
def __init__(self, cache_path: Optional[Path] = None) -> None:
if cache_path is None:
from antigravity_provider.paths import get_hermes_home
cache_path = get_hermes_home() / "models_cache.json"
self._cache_path = cache_path
self._cache_lock = threading.Lock()
self._cache: Dict[str, Dict[str, Any]] = {}
self._probe_context = threading.local()
self._ttl_seconds: int = 3600 # 1 hour
self._load_cache_from_disk()
@classmethod
def get(cls) -> "ModelDiscoveryService":
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
# ─────────────────────────────────────────────────────────────
# DISK PERSISTENCE
# ─────────────────────────────────────────────────────────────
def _load_cache_from_disk(self) -> None:
with self._cache_lock:
if not self._cache_path.is_file():
self._cache = {}
return
try:
data = json.loads(self._cache_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._cache = data
except Exception as exc:
logger.warning("Could not read models cache from %s: %s", self._cache_path, exc)
self._cache = {}
def _save_cache_to_disk(self) -> None:
try:
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
temp_file = self._cache_path.with_name(f"{self._cache_path.name}.tmp")
temp_file.write_text(json.dumps(self._cache, indent=2, ensure_ascii=False), encoding="utf-8")
temp_file.replace(self._cache_path)
except Exception as exc:
logger.warning("Could not persist models cache to %s: %s", self._cache_path, exc)
# ─────────────────────────────────────────────────────────────
# NON-BLOCKING READ API
# ─────────────────────────────────────────────────────────────
def get_models(self, provider: str) -> Optional[List[str]]:
"""Return cached models for provider immediately, or None if undiscovered."""
meta = self.get_models_with_metadata(provider)
if not meta or not meta.get("models"):
return None
return list(meta["models"])
def get_models_with_metadata(self, provider: str, profile_id: Optional[str] = None) -> Dict[str, Any]:
"""Return cached models and freshness status without blocking."""
with self._cache_lock:
key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower()
entry = self._cache.get(key)
if not entry or "models" not in entry:
return {
"provider": provider,
"models": None,
"discovered_at": None,
"is_stale": True,
"has_cache": False,
"error": entry.get("error") if entry else None,
}
discovered_at = entry.get("discovered_at")
is_stale = (time.time() - float(discovered_at)) > self._ttl_seconds if discovered_at else True
models = entry.get("models")
return {
"provider": provider,
"models": list(models) if models is not None else None,
"discovered_at": discovered_at,
"is_stale": is_stale,
"has_cache": models is not None,
"error": entry.get("error"),
}
def get_error(self, provider: str) -> Optional[str]:
"""Return last discovery error message for provider if any."""
with self._cache_lock:
entry = self._cache.get(provider.lower())
return entry.get("error") if entry else None
def get_cached(self, provider: str) -> Dict[str, Any]:
"""Convenience alias for get_models_with_metadata."""
return self.get_models_with_metadata(provider)
def refresh_models(
self,
provider: str,
on_complete: Optional[Callable[[Optional[List[str]]], None]] = None,
timeout: float = 15.0,
) -> None:
"""Trigger background model discovery with strict timeout (alias)."""
return self.refresh_models_async(provider, on_complete=on_complete, timeout=timeout)
# ─────────────────────────────────────────────────────────────
# DISCOVERY PROBES WITH TIMEOUT
# ─────────────────────────────────────────────────────────────
def refresh_models_async(
self,
provider: str,
on_complete: Optional[Callable[[Optional[List[str]]], None]] = None,
timeout: float = 15.0,
) -> None:
"""Trigger background model discovery with strict timeout."""
def _worker():
res = self.discover_models_sync(provider, timeout=timeout)
if on_complete:
try:
on_complete(res)
except Exception:
pass
threading.Thread(target=_worker, daemon=True).start()
def refresh_all_async(
self,
on_complete: Optional[Callable[[Dict[str, Optional[List[str]]]], None]] = None,
timeout: float = 15.0,
) -> None:
"""Discover models for all configured providers concurrently in background."""
def _worker():
providers = [
"antigravity",
"openai-codex",
"opencode-go",
"claude",
"grok",
"openrouter",
"nvidia",
"ollama",
"local",
]
results: Dict[str, Optional[List[str]]] = {}
threads = []
def _probe(p):
results[p] = self.discover_models_sync(p, timeout=timeout)
for prov in providers:
t = threading.Thread(target=_probe, args=(prov,), daemon=True)
threads.append(t)
t.start()
for t in threads:
t.join(timeout=timeout + 2.0)
if on_complete:
try:
on_complete(results)
except Exception:
pass
threading.Thread(target=_worker, daemon=True).start()
def discover_models_sync(self, provider: str, timeout: float = 15.0, profile_id: Optional[str] = None) -> Optional[List[str]]:
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
cache_key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower()
result_holder: List[Optional[List[str]]] = [None]
error_holder: List[Optional[str]] = [None]
def _do_probe():
try:
self._probe_context.profile_id = profile_id
models, err_msg = self._probe_provider(provider)
result_holder[0] = models
error_holder[0] = err_msg
except Exception as exc:
error_holder[0] = str(exc)
worker = threading.Thread(target=_do_probe, daemon=True)
worker.start()
worker.join(timeout=timeout)
if worker.is_alive():
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
with self._cache_lock:
entry = self._cache.get(cache_key, {})
existing_models = entry.get("models")
self._cache[cache_key] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": timeout_msg,
}
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
models = result_holder[0]
err_text = error_holder[0]
if models is not None and not err_text:
with self._cache_lock:
self._cache[cache_key] = {
"models": models,
"discovered_at": time.time(),
"error": None,
}
self._save_cache_to_disk()
logger.info("Discovered %d models for provider '%s': %s", len(models), provider, models)
return models
if err_text:
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
with self._cache_lock:
entry = self._cache.get(cache_key, {})
existing_models = entry.get("models")
self._cache[cache_key] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": err_text,
}
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
with self._cache_lock:
entry = self._cache.get(cache_key, {})
existing_models = entry.get("models")
self._cache[cache_key] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": entry.get("error") or "Модели не найдены",
}
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
def discover_ollama_cloud(self) -> Dict[str, Any]:
"""Public catalog documented at https://docs.ollama.com/cloud#listing-models.
Catalog presence is not proof of an account's inference entitlement.
"""
key = "ollama-cloud-catalog"
error = None
models = None
try:
req = urllib.request.Request("https://ollama.com/api/tags", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
models = sorted({str(m.get("name") or m.get("model")) for m in data.get("models", []) if isinstance(m, dict) and (m.get("name") or m.get("model"))})
except urllib.error.HTTPError as exc:
error = self._extract_http_error(exc)
except Exception as exc:
error = str(exc)
with self._cache_lock:
previous = self._cache.get(key, {})
self._cache[key] = {
"models": models if models is not None else previous.get("models"),
"discovered_at": time.time() if models is not None else previous.get("discovered_at"),
"error": error,
}
self._save_cache_to_disk()
return self.get_models_with_metadata(key)
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
raw_err = ""
try:
raw_err = http_err.read().decode("utf-8", errors="replace")[:2000]
err_json = json.loads(raw_err)
if isinstance(err_json, dict):
if "error" in err_json:
err_obj = err_json["error"]
if isinstance(err_obj, dict):
msg = err_obj.get("message") or str(err_obj)
else:
msg = str(err_obj)
elif "message" in err_json:
msg = str(err_json["message"])
elif "detail" in err_json:
msg = str(err_json["detail"])
else:
msg = raw_err
else:
msg = raw_err
return f"HTTP {http_err.code}: {msg}"
except Exception:
return f"HTTP {http_err.code}: {raw_err or http_err.reason}"
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
from antigravity_provider.router.router_config import load_router_config
cfg = load_router_config()
p_lower = prov.lower()
requested = getattr(self._probe_context, "profile_id", None)
if requested:
pcfg = cfg.get_profile(requested)
return [(requested, pcfg)] if pcfg else []
matched = [
(pid, pcfg)
for pid, pcfg in cfg.profiles.items()
if pcfg.provider.lower() == p_lower
or (p_lower in ("nvidia", "nvidia-nim") and pcfg.provider.lower() in ("nvidia", "nvidia-nim"))
or (p_lower in ("openai-codex", "codex") and pcfg.provider.lower() in ("openai-codex", "codex"))
or (p_lower in ("opencode-go", "opencode") and pcfg.provider.lower() in ("opencode-go", "opencode"))
or (p_lower in ("claude", "anthropic") and pcfg.provider.lower() in ("claude", "anthropic"))
or (p_lower in ("grok", "xai") and pcfg.provider.lower() in ("grok", "xai"))
or (p_lower in ("local", "local-llm", "llama.cpp", "vllm") and pcfg.provider.lower() in ("local", "local-llm", "llama.cpp", "vllm"))
]
if matched:
return matched
default_slots = {
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
"codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
"opencode": ["opengo-1", "opengo-2", "opengo-3"],
"grok": ["grok-orch", "grok-worker-1", "grok-worker-2"],
"xai": ["grok-orch", "grok-worker-1", "grok-worker-2"],
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
"anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"],
"openrouter": ["openrouter-1", "openrouter-2"],
"nvidia": ["nvidia-1", "nvidia-2"],
"nvidia-nim": ["nvidia-nim-1", "nvidia-nim-2"],
"ollama": ["ollama-1", "ollama-2"],
"local": ["local-1", "local-2"],
"local-llm": ["local-1", "local-2"],
"llama.cpp": ["local-1", "local-2"],
"vllm": ["local-1", "local-2"],
}
candidates = default_slots.get(p_lower, [f"{p_lower}-1", f"{p_lower}-2"])
return [(pid, cfg.get_profile(pid)) for pid in candidates]
def _probe_provider(self, provider: str) -> Tuple[Optional[List[str]], Optional[str]]:
"""Perform provider-specific model discovery returning (models_list, error_msg)."""
prov = provider.lower()
from antigravity_provider.router.profile_manager import ProfileAuthManager
if prov in ("antigravity", "google-antigravity"):
from antigravity_provider.agy_subprocess import discover_models
main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
try:
res = discover_models(profile_id=main_p)
if res:
return sorted(list(set(res.values()))), None
return None, "Модели Google Antigravity не обнаружены"
except Exception as exc:
return None, str(exc)
elif prov in ("openai-codex", "codex"):
profiles = self._get_provider_candidate_profiles("openai-codex")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or auth
access_token = (
tokens.get("access_token")
if isinstance(tokens, dict)
else auth.get("api_key") or auth.get("access_token")
)
if not access_token:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.openai.com/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
try:
req = urllib.request.Request(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8") or "{}")
items = data.get("data", [])
if isinstance(items, list):
models = [str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")]
chat_models = [
m for m in models
if any(x in m for x in ("gpt-4", "gpt-3.5", "o1", "o3", "codex", "chatgpt"))
]
if chat_models or models:
return sorted(chat_models or models), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Codex model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
elif prov in ("opencode-go", "opencode"):
profiles = self._get_provider_candidate_profiles("opencode-go")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid) or {}
api_key = auth.get("api_key")
if not api_key:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://opencode.ai/zen/go/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
try:
req = urllib.request.Request(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8") or "{}")
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
models = [str(m.get("id") or m) for m in items if m]
if models:
return sorted(models), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
elif prov in ("grok", "xai"):
profiles = self._get_provider_candidate_profiles("grok")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("grok", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or {}
token = tokens.get("access_token") if isinstance(tokens, dict) else None
token = token or auth.get("access_token") or auth.get("api_key")
if not token:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.x.ai/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
try:
req = urllib.request.Request(
f"{base_url}/models",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "hermes-hub/1.0"},
)
with urllib.request.urlopen(req, timeout=15) as response:
payload = json.loads(response.read().decode("utf-8") or "{}")
models = [
str(item.get("id"))
for item in (payload.get("data") or [])
if isinstance(item, dict) and item.get("id")
]
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для Grok"
elif prov in ("claude", "anthropic"):
profiles = self._get_provider_candidate_profiles("claude")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("claude", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or {}
token = tokens.get("access_token") if isinstance(tokens, dict) else None
token = token or auth.get("access_token") or auth.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
if not token:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.anthropic.com/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
headers = {
"Accept": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-hub/1.0",
}
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode("utf-8") or "{}")
items = payload.get("data") or payload.get("models") or []
models = [str(item.get("id") or item) for item in items if item]
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
except Exception as exc:
last_err = str(exc)
return None, last_err or "Отсутствуют учетные данные для Claude"
elif prov in ("openrouter",):
profiles = self._get_provider_candidate_profiles("openrouter")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("openrouter", pid) or {}
api_key = auth.get("api_key") or auth.get("token") or os.environ.get("OPENROUTER_API_KEY")
if not api_key:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("OPENROUTER_BASE_URL") or "https://openrouter.ai/api/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
referer = (
os.environ.get("OPENROUTER_HTTP_REFERER")
or os.environ.get("HERMES_REFERER")
or "https://github.com/ochenstarik-ui/hermes-hub"
)
title = (
os.environ.get("OPENROUTER_APP_TITLE")
or os.environ.get("OPENROUTER_TITLE")
or "Hermes Hub"
)
headers = {
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": referer,
"X-OpenRouter-Title": title,
"X-Title": title,
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
payload = json.loads(resp.read().decode("utf-8") or "{}")
items = payload.get("data") or payload.get("models") or []
models = []
if isinstance(items, list):
for item in items:
mid = item.get("id") if isinstance(item, dict) else str(item)
if mid:
models.append(str(mid))
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
elif prov in ("nvidia", "nvidia-nim"):
profiles = self._get_provider_candidate_profiles("nvidia")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("nvidia", pid) or ProfileAuthManager.load_profile_auth("nvidia-nim", pid) or {}
api_key = auth.get("api_key") or auth.get("token") or os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY")
if not api_key:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("NVIDIA_BASE_URL") or "https://integrate.api.nvidia.com/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
payload = json.loads(resp.read().decode("utf-8") or "{}")
items = payload.get("data") or payload.get("models") or []
models = []
if isinstance(items, list):
for item in items:
mid = item.get("id") if isinstance(item, dict) else str(item)
if mid:
models.append(str(mid))
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
elif prov == "ollama":
profiles = self._get_provider_candidate_profiles("ollama")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("ollama", pid) or {}
raw_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("OLLAMA_BASE_URL") or os.environ.get("OLLAMA_HOST") or "http://127.0.0.1:11434"
raw_url = str(raw_url).strip().rstrip("/")
if not raw_url.startswith(("http://", "https://")):
raw_url = f"http://{raw_url}"
native_host = raw_url[:-3] if raw_url.endswith("/v1") else raw_url
v1_url = raw_url if raw_url.endswith("/v1") else f"{raw_url}/v1"
token = auth.get("api_key") or auth.get("token") or os.environ.get("OLLAMA_API_KEY")
headers = {
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
# 1. Try native Ollama endpoint /api/tags
try:
req = urllib.request.Request(f"{native_host}/api/tags", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("models") or []
models = []
if isinstance(items, list):
for m in items:
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
if name:
models.append(str(name))
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
# 2. Try OpenAI-compatible endpoint /v1/models
try:
req = urllib.request.Request(f"{v1_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("data") or data.get("models") or []
models = []
if isinstance(items, list):
for m in items:
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
if mid:
models.append(str(mid))
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
return None, last_err or "Не удалось подключиться к серверу Ollama"
elif prov in ("local", "local-llm", "llama.cpp", "vllm"):
profiles = self._get_provider_candidate_profiles("local")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("local", pid) or {}
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("LOCAL_LLM_BASE_URL") or "http://127.0.0.1:8081/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"http://{base_url}"
api_key = auth.get("api_key") or os.environ.get("LOCAL_LLM_API_KEY")
headers = {
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
return None, last_err or "Не удалось подключиться к локальному серверу LLM"
return None, f"Неизвестный провайдер: {provider}"