From afaf600f565792c03f5e344443c0af5f5e1c1a52 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 02:45:01 +0700 Subject: [PATCH] =?UTF-8?q?feat(nvidia):=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9,=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=D1=83=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каталог NVIDIA публичный: GET /v1/models отдаётся вообще без ключа и возвращает одни и те же 83 модели всем. Проверено запросом. Полей о доступности в нём нет — только id, object, created, owned_by. Значит узнать из каталога, чем может пользоваться аккаунт, невозможно. Единственный достоверный способ — спросить у самой модели. Недоступная отвечает «404 Function ... Not found for account » до генерации, то есть её проверка ничего не стоит; доступная расходует один токен при 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 --- .../router/action_handler.py | 44 +++++ .../router/model_entitlements.py | 180 ++++++++++++++++++ .../router/web/static/app.js | 14 ++ 3 files changed, 238 insertions(+) create mode 100644 src/antigravity_provider/router/model_entitlements.py diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 7198a51..1c1e794 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -707,6 +707,50 @@ class ActionExecutor: return {'ok': False, 'message': reason, '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': 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 '') diff --git a/src/antigravity_provider/router/model_entitlements.py b/src/antigravity_provider/router/model_entitlements.py new file mode 100644 index 0000000..bb33456 --- /dev/null +++ b/src/antigravity_provider/router/model_entitlements.py @@ -0,0 +1,180 @@ +"""Определение моделей, доступных конкретному аккаунту. + +Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и +возвращает один и тот же список всем. Полей о доступности в нём нет — только +`id`, `object`, `created`, `owned_by`. Проверено запросом. + +Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя. +Единственный достоверный способ — спросить у самой модели. Недоступная +отвечает до генерации: + + 404 Function ... Not found for account '' + +то есть проверка недоступной модели не стоит ничего, а доступной — один токен +при `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 diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 184baec..15ac56a 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -880,9 +880,23 @@ function renderAccountCheck(profile) {
Каталог моделей (${models.length})
${profile.provider === 'ollama' ? `

Выше — модели указанного сервера Ollama.

Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}

Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.

` : ''} + ${['nvidia', 'nvidia-nim', 'openrouter'].includes(String(profile.provider || '').toLowerCase()) + ? `` + : ''} `; } +// Каталог 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) { await executeAction('check_account', {profile_id: profileId}); await fetchSnapshot();