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:
parent
2bbb9db5de
commit
afaf600f56
3 changed files with 238 additions and 0 deletions
|
|
@ -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 '')
|
||||
|
|
|
|||
180
src/antigravity_provider/router/model_entitlements.py
Normal file
180
src/antigravity_provider/router/model_entitlements.py
Normal 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
|
||||
|
|
@ -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>
|
||||
${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>
|
||||
${['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>`;
|
||||
}
|
||||
|
||||
// Каталог 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue