hermes-hub/src/antigravity_provider/router/model_discovery_service.py
Hermes Team 0df7dba2ce feat(grok): подписка вместо кредитов — квота и модели заработали
Владелец был прав: «апи не нужен». Подтверждено на его аккаунте GROK PRO —
api.x.ai/v1/chat/completions вернул 200 и ответ модели grok-4.3 БЕЗ
покупки кредитов. Прежний 402 был целиком из-за того, что подключён был
другой аккаунт, без подписки. Моё утверждение про «разные кошельки»
окончательно снято.

1. Квота показывала «Н/Д» при живой подписке. Читались только
   prepaidBalance и onDemandCap — у подписчика оба нулевые. А расход
   подписки лежит в creditUsagePercent, и разбивка по продуктам в
   productUsage. Теперь оттуда и берётся: на живом аккаунте выходит
   14% за неделю, GrokChat 13%, GrokBuild 1% — ровно то, что владелец
   видит на grok.com. Корзина кредитов создаётся только когда они реально
   заведены: нули у подписчика — норма, а не повод рисовать пустое.

2. Выбор модели отвергал настоящие имена: «кэш моделей для grok пуст, а
   модель grok-4.5 не найдена». Причина — в _probe_provider грока не было
   вовсе, обнаружение знало только antigravity, codex, opencode и local.
   При этом api.x.ai/v1/models принимает тот же OAuth-токен и отдаёт 12
   моделей. Зонд добавлен; grok-4.5 теперь принимается, выдуманная
   grok-99-turbo — отклоняется.

Тесты: 428 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:29:08 +07:00

370 lines
16 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
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._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) -> Dict[str, Any]:
"""Return cached models and freshness status without blocking."""
with self._cache_lock:
entry = self._cache.get(provider.lower())
if not entry or "models" not in entry:
return {
"provider": provider,
"models": None,
"discovered_at": None,
"is_stale": True,
"has_cache": False,
}
discovered_at = entry.get("discovered_at", 0)
is_stale = (time.time() - discovered_at) > self._ttl_seconds
return {
"provider": provider,
"models": list(entry["models"]),
"discovered_at": discovered_at,
"is_stale": is_stale,
"has_cache": True,
}
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 5 providers concurrently in background."""
def _worker():
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok", "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) -> Optional[List[str]]:
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
result_holder: List[Optional[List[str]]] = [None]
error_holder: List[Optional[Exception]] = [None]
def _do_probe():
try:
result_holder[0] = self._probe_provider(provider)
except Exception as exc:
error_holder[0] = 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: retain existing cache if any
with self._cache_lock:
entry = self._cache.get(provider.lower())
return list(entry["models"]) if entry and "models" in entry else None
if error_holder[0]:
logger.info("Model discovery probe for '%s' returned error: %s", provider, error_holder[0])
with self._cache_lock:
entry = self._cache.get(provider.lower())
return list(entry["models"]) if entry and "models" in entry else None
models = result_holder[0]
if models:
with self._cache_lock:
self._cache[provider.lower()] = {
"models": models,
"discovered_at": time.time(),
}
self._save_cache_to_disk()
logger.info("Discovered %d models for provider '%s': %s", len(models), provider, models)
return models
# If probe returned None or empty, retain existing cache if any
with self._cache_lock:
entry = self._cache.get(provider.lower())
return list(entry["models"]) if entry and "models" in entry else None
def _probe_provider(self, provider: str) -> Optional[List[str]]:
"""Perform provider-specific model discovery."""
prov = provider.lower()
from antigravity_provider.router.profile_manager import ProfileAuthManager
if prov == "antigravity":
from antigravity_provider.agy_subprocess import discover_models
main_p = ProfileAuthManager.get_main_profile("antigravity")
res = discover_models(profile_id=main_p)
if res:
return sorted(list(set(res.values())))
return None
elif prov in ("openai-codex", "codex"):
for pid in ["codex-orch", "codex-worker-1", "codex-worker-2"]:
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid)
if not auth:
continue
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
try:
req = urllib.request.Request(
"https://api.openai.com/v1/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"))
]
return sorted(chat_models or models)
except Exception as exc:
logger.debug("Codex model query failed on %s: %s", pid, exc)
return None
elif prov in ("opencode-go", "opencode"):
for pid in ["opengo-1", "opengo-2", "opengo-3"]:
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid)
if not auth:
continue
api_key = auth.get("api_key")
if not api_key:
continue
try:
req = urllib.request.Request(
"https://opencode.ai/zen/go/v1/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]
return sorted(models)
except Exception as exc:
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None
elif prov == "grok":
# Провайдера здесь не было вовсе, поэтому кэш моделей Grok всегда
# оставался пустым, и выбор модели отвергал даже настоящие имена:
# «модель grok-4.5 не найдена в списке известных». При этом
# api.x.ai/v1/models принимает тот же OAuth-токен, что и вызовы, и
# отдаёт полный список.
for pid in ("grok-orch", "grok-worker-1", "grok-worker-2"):
auth = ProfileAuthManager.load_profile_auth("grok", pid)
if not auth:
continue
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
try:
request = urllib.request.Request(
"https://api.x.ai/v1/models",
headers={"Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(request, 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))
except Exception as exc:
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
return None
elif prov in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
from antigravity_provider.router.router_config import load_router_config
cfg = load_router_config()
for pid in ["local-1", "local-2"]:
pcfg = cfg.get_profile(pid)
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"
)
if not base_url:
continue
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(models)
except Exception as exc:
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
return None
return None