721 lines
36 KiB
Python
721 lines
36 KiB
Python
"""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}"
|