Каталог NVIDIA публичный: GET /v1/models отдаётся вообще без ключа и возвращает одни и те же 83 модели всем. Проверено запросом. Полей о доступности в нём нет — только id, object, created, owned_by. Значит узнать из каталога, чем может пользоваться аккаунт, невозможно. Единственный достоверный способ — спросить у самой модели. Недоступная отвечает «404 Function ... Not found for account <id>» до генерации, то есть её проверка ничего не стоит; доступная расходует один токен при max_tokens=1. Добавлены модуль model_entitlements, действие probe_account_models и кнопка «Определить доступные модели» в карточке аккаунта для NVIDIA и OpenRouter. Запуск только по явному нажатию с подтверждением: опрос тратит вызовы и упирается в ограничения частоты. Результат сохраняется. Модели раскладываются на три группы, а не на две: доступные, не выданные аккаунту и НЕ ОПРЕДЕЛЁННЫЕ с причиной. Отвергнутый ключ (401/403), превышение частоты (429) и сетевой сбой попадают в третью группу — выдавать их за «недоступно» нельзя. Проверено: без ключа все модели получают «ключ не задан», с неверным ключом — «ключ отвергнут (HTTP 403)». 610 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""Определение моделей, доступных конкретному аккаунту.
|
||
|
||
Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и
|
||
возвращает один и тот же список всем. Полей о доступности в нём нет — только
|
||
`id`, `object`, `created`, `owned_by`. Проверено запросом.
|
||
|
||
Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя.
|
||
Единственный достоверный способ — спросить у самой модели. Недоступная
|
||
отвечает до генерации:
|
||
|
||
404 Function ... Not found for account '<id>'
|
||
|
||
то есть проверка недоступной модели не стоит ничего, а доступной — один токен
|
||
при `max_tokens=1`.
|
||
|
||
Опрос запускается ТОЛЬКО по явному действию владельца и результат сохраняется:
|
||
83 запроса подряд упираются в ограничения провайдера, и делать это молча при
|
||
каждом подключении неправильно.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Callable, Dict, List, Optional
|
||
|
||
from antigravity_provider import paths
|
||
|
||
logger = logging.getLogger("hermes.router.entitlements")
|
||
|
||
# Опрашиваем небольшими пачками: провайдеры ограничивают частоту, а выигрыш от
|
||
# большего числа потоков всё равно съедается их лимитом.
|
||
DEFAULT_WORKERS = 8
|
||
DEFAULT_TIMEOUT_SEC = 20.0
|
||
|
||
|
||
@dataclass
|
||
class EntitlementResult:
|
||
"""Итог опроса одного аккаунта."""
|
||
|
||
provider: str
|
||
profile_id: str
|
||
checked_at: float
|
||
available: List[str] = field(default_factory=list)
|
||
unavailable: List[str] = field(default_factory=list)
|
||
undetermined: Dict[str, str] = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"provider": self.provider,
|
||
"profile_id": self.profile_id,
|
||
"checked_at": self.checked_at,
|
||
"available": list(self.available),
|
||
"unavailable": list(self.unavailable),
|
||
"undetermined": dict(self.undetermined),
|
||
"total": len(self.available) + len(self.unavailable) + len(self.undetermined),
|
||
}
|
||
|
||
|
||
def _store_path() -> Any:
|
||
return paths.get_hermes_home() / "model_entitlements.json"
|
||
|
||
|
||
def load_entitlements(provider: str, profile_id: str) -> Optional[Dict[str, Any]]:
|
||
"""Ранее определённая доступность или None, если опрос не проводился."""
|
||
try:
|
||
path = _store_path()
|
||
if not path.is_file():
|
||
return None
|
||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||
return data.get(f"{provider}:{profile_id}")
|
||
except Exception as exc:
|
||
logger.debug("Не удалось прочитать сохранённую доступность: %s", exc)
|
||
return None
|
||
|
||
|
||
def save_entitlements(result: EntitlementResult) -> None:
|
||
path = _store_path()
|
||
try:
|
||
data: Dict[str, Any] = {}
|
||
if path.is_file():
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||
except Exception:
|
||
data = {}
|
||
data[f"{result.provider}:{result.profile_id}"] = result.to_dict()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception as exc:
|
||
logger.warning("Не удалось сохранить доступность моделей: %s", exc)
|
||
|
||
|
||
def _probe_one(base_url: str, token: str, model: str, timeout: float) -> tuple:
|
||
"""Вернуть (model, state, note): state — available | unavailable | undetermined."""
|
||
body = json.dumps(
|
||
{"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
|
||
).encode()
|
||
req = urllib.request.Request(
|
||
base_url.rstrip("/") + "/chat/completions",
|
||
data=body,
|
||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||
payload = json.load(response)
|
||
if isinstance(payload.get("choices"), list):
|
||
return (model, "available", "")
|
||
return (model, "undetermined", "провайдер вернул ответ без choices")
|
||
except urllib.error.HTTPError as exc:
|
||
# 404 — модель аккаунту не выдана. Это ответ провайдера, а не сбой.
|
||
if exc.code == 404:
|
||
return (model, "unavailable", "не выдана аккаунту")
|
||
# 401/403 — отвергнут сам ключ: дальше опрашивать бессмысленно.
|
||
if exc.code in (401, 403):
|
||
return (model, "undetermined", f"ключ отвергнут (HTTP {exc.code})")
|
||
if exc.code == 429:
|
||
return (model, "undetermined", "превышена частота запросов")
|
||
return (model, "undetermined", f"HTTP {exc.code}")
|
||
except Exception as exc:
|
||
return (model, "undetermined", str(exc)[:80])
|
||
|
||
|
||
def probe_account_models(
|
||
provider: str,
|
||
profile_id: str,
|
||
token: str,
|
||
base_url: str,
|
||
models: List[str],
|
||
workers: int = DEFAULT_WORKERS,
|
||
timeout: float = DEFAULT_TIMEOUT_SEC,
|
||
progress: Optional[Callable[[int, int], None]] = None,
|
||
) -> EntitlementResult:
|
||
"""Опросить каталог и разложить модели по доступности.
|
||
|
||
Модель, про которую ответ неоднозначен, попадает в `undetermined` с
|
||
причиной — выдавать её за доступную или недоступную нельзя.
|
||
"""
|
||
result = EntitlementResult(
|
||
provider=provider, profile_id=profile_id, checked_at=time.time()
|
||
)
|
||
if not models:
|
||
return result
|
||
if not token:
|
||
result.undetermined = {m: "ключ не задан" for m in models}
|
||
return result
|
||
|
||
done = 0
|
||
lock = threading.Lock()
|
||
|
||
def _run(model: str):
|
||
nonlocal done
|
||
outcome = _probe_one(base_url, token, model, timeout)
|
||
with lock:
|
||
done += 1
|
||
if progress:
|
||
try:
|
||
progress(done, len(models))
|
||
except Exception:
|
||
pass
|
||
return outcome
|
||
|
||
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
||
for model, state, note in pool.map(_run, models):
|
||
if state == "available":
|
||
result.available.append(model)
|
||
elif state == "unavailable":
|
||
result.unavailable.append(model)
|
||
else:
|
||
result.undetermined[model] = note
|
||
|
||
result.available.sort()
|
||
result.unavailable.sort()
|
||
save_entitlements(result)
|
||
return result
|