feat(nvidia): определение моделей, доступных конкретному аккаунту

Каталог 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>
This commit is contained in:
Hermes Team 2026-09-01 02:45:01 +07:00
parent 2bbb9db5de
commit afaf600f56
3 changed files with 238 additions and 0 deletions

View file

@ -707,6 +707,50 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}} return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}} return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
if action == 'probe_account_models':
# Определение доступных аккаунту моделей. Запускается только по
# явному действию владельца: каталог NVIDIA публичный и о правах
# аккаунта ничего не сообщает, поэтому доступность выясняется
# опросом, а он тратит вызовы и упирается в ограничения частоты.
from .model_entitlements import probe_account_models, load_entitlements
from .model_discovery_service import ModelDiscoveryService
pid = data.get('profile_id') or ''
prov_norm = (prov or data.get('provider') or '').strip().lower()
if not pid or not prov_norm:
return {'ok': False, 'message': 'Не указан профиль или провайдер'}
if data.get('cached_only'):
cached = load_entitlements(prov_norm, pid)
if not cached:
return {'ok': True, 'message': 'Н/Д: доступность моделей ещё не определялась', 'data': {}}
return {'ok': True, 'message': 'Сохранённый результат', 'data': cached}
auth = ProfileAuthManager.load_profile_auth(prov_norm, pid) or {}
token = (auth.get('api_key') or auth.get('token') or '').strip()
if not token:
return {'ok': False, 'message': f'У профиля {pid} нет сохранённого API-ключа'}
pcfg = load_router_config().get_profile(pid)
base_url = (getattr(pcfg, 'custom_base_url', None) or auth.get('base_url') or '').strip()
if not base_url:
base_url = 'https://integrate.api.nvidia.com/v1' if prov_norm.startswith('nvidia') else ''
if not base_url:
return {'ok': False, 'message': f'Не известен адрес провайдера для {pid}'}
models = ModelDiscoveryService.get().get_models(prov_norm) or []
if not models:
return {'ok': False, 'message': 'Каталог моделей ещё не получен — сначала запросите список'}
res = probe_account_models(prov_norm, pid, token, base_url, models)
d = res.to_dict()
msg = (
f"Доступно {len(res.available)} из {d['total']}; "
f"не выдано {len(res.unavailable)}; "
f"не определено {len(res.undetermined)}"
)
return {'ok': True, 'message': msg, 'data': d}
if action == 'validate_connection': if action == 'validate_connection':
from .connection_preflight import validate_connection from .connection_preflight import validate_connection
return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '') return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '')

View file

@ -0,0 +1,180 @@
"""Определение моделей, доступных конкретному аккаунту.
Каталог 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

View file

@ -880,9 +880,23 @@ function renderAccountCheck(profile) {
<details ${models.length <= 16 ? 'open' : ''}><summary>Каталог моделей (${models.length})</summary><div class="account-models">${models.map(modelBrandLabel).join('')}</div></details> <details ${models.length <= 16 ? 'open' : ''}><summary>Каталог моделей (${models.length})</summary><div class="account-models">${models.map(modelBrandLabel).join('')}</div></details>
${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''} ${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение'}</button> <button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение'}</button>
${['nvidia', 'nvidia-nim', 'openrouter'].includes(String(profile.provider || '').toLowerCase())
? `<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); handleProbeAccountModels('${escapeHtml(profile.profile_id)}', '${escapeHtml(profile.provider)}')" title="Каталог провайдера общий для всех и о правах аккаунта не сообщает. Доступность выясняется опросом моделей и расходует вызовы.">Определить доступные модели</button>`
: ''}
</div>`; </div>`;
} }
// Каталог NVIDIA и OpenRouter публичный: он одинаков у всех и о правах
// аккаунта ничего не говорит. Доступность выясняется опросом моделей, а он
// тратит вызовы — поэтому только по явному нажатию, и результат сохраняется.
async function handleProbeAccountModels(profileId, provider) {
if (!confirm('Хаб опросит каталог провайдера, чтобы выяснить, какие модели доступны этому аккаунту. Каталог общий для всех и о правах не сообщает, поэтому каждая модель проверяется отдельным запросом. Недоступная отвечает отказом и ничего не стоит, доступная расходует один токен. Продолжить?')) return;
showToast('Опрос моделей начат. Это может занять около минуты.', 'info');
const res = await executeAction('probe_account_models', { profile_id: profileId, provider: provider });
showToast(res?.message || 'Нет ответа от сервера', res?.ok ? 'success' : 'error');
await fetchSnapshot();
}
async function handleAccountProbe(profileId) { async function handleAccountProbe(profileId) {
await executeAction('check_account', {profile_id: profileId}); await executeAction('check_account', {profile_id: profileId});
await fetchSnapshot(); await fetchSnapshot();