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': 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 '')
|
||||||
|
|
|
||||||
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>
|
<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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue