hermes-hub/src/antigravity_provider/router/adapters/nvidia_adapter.py
ochenstarik-ui ad07425c06 feat(providers): A32 интеграция Ollama с API, Claude probe, OpenRouter headers/metadata, NVIDIA Retry-After и экспорт лимитов
1. OllamaAdapter: поддержка локального инстанса по умолчанию и удаленного Ollama API
   с кастомным base_url и опциональным Bearer токеном, discovery по /v1/models и /api/tags,
   статус квоты «Без ограничений».
2. ClaudeAdapter: реальный health_check API probe и динамический discover_models.
3. OpenRouterAdapter: обязательные заголовки HTTP-Referer и X-OpenRouter-Title,
   сбор метаданных моделей (context_length, display_name).
4. NvidiaAdapter: парсинг заголовка Retry-After и динамическая задержка при 429.
5. Экспорт лимитов: эндпоинт GET /api/quotas/export (JSON / CSV), экшен export_quotas
   и кнопка выгрузки в веб-интерфейсе с маскированием секретов.
6. tests/test_api_providers_a32.py: 15 тестов, 434 passed, ruff чисто.
2026-08-31 01:15:10 +07:00

233 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""NVIDIA NIM (integrate.api.nvidia.com) OpenAI-compatible provider adapter."""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message
logger = logging.getLogger("hermes.router.adapter.nvidia")
DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
class NvidiaAdapter(BaseProviderAdapter):
"""Adapter for NVIDIA NIM's OpenAI-compatible chat completions API."""
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or default."""
url = (
profile.custom_base_url
or profile.auth_config.get("base_url")
or os.environ.get("NVIDIA_BASE_URL")
or DEFAULT_NVIDIA_BASE_URL
)
url_str = str(url).strip().rstrip("/")
if not url_str.startswith(("http://", "https://")):
url_str = f"https://{url_str}"
return url_str
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
"""Resolve API key from profile auth_config or environment."""
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
if key:
return str(key).strip()
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
val = os.environ.get(candidate, "").strip()
if val:
return val
return None
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
model = request.get("model", "")
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "default"
payload: Dict[str, Any] = {
"model": model,
"messages": list(request.get("messages", [])),
"temperature": request.get("temperature", 0.7),
}
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
if "tool_choice" in request:
payload["tool_choice"] = request["tool_choice"]
if "response_format" in request:
payload["response_format"] = request["response_format"]
if "max_tokens" in request:
payload["max_tokens"] = request["max_tokens"]
if "stream" in request:
payload["stream"] = request["stream"]
if "stop" in request:
payload["stop"] = request["stop"]
headers: Dict[str, str] = {
"Content-Type": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url}/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace")
try:
err_msg = extract_api_error_message(raw_err)
except Exception:
err_msg = raw_err
retry_after = http_err.headers.get("Retry-After")
if retry_after:
err_msg = f"{err_msg} (Retry-After: {retry_after})"
raise RuntimeError(f"NVIDIA API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"NVIDIA Transport Error: {exc}") from exc
self._reject_empty_answer(data)
return data
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Пустой ответ — это отказ, а не успех."""
choices = data.get("choices") or []
if not choices:
raise RuntimeError("NVIDIA вернул ответ без choices")
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if content:
return
finish = choices[0].get("finish_reason")
raise RuntimeError(f"NVIDIA вернул пустой ответ (finish_reason={finish})")
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
"""Request GET {base_url}/models and return the server's model list.
No invented/hardcoded model list: on error, fall back to
profile.preferred_models only.
"""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(models)
except Exception as exc:
logger.debug("Failed to discover models for nvidia profile %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or [])
def health_check(self, profile: RouterProfileConfig) -> bool:
"""Fast GET {base_url}/models probe. Returns True on success, False on error."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status in (200, 204)
except Exception:
return False
def classify_error(
self,
exc: Exception,
response_data: Optional[Dict[str, Any]] = None,
) -> ErrorClassification:
"""Classify execution failure into structured error category."""
err_msg = str(exc)
err_lower = err_msg.lower()
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
delay = 30
import re
m = re.search(r"retry-after:\s*(\d+)", err_lower)
if m:
delay = int(m.group(1))
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=delay,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=1800,
)
if any(k in err_lower for k in (
"connection refused", "connection error", "connect", "refused",
"timeout", "timed out", "502", "503", "504", "gateway",
"econnrefused", "econnreset", "transport error", "urlerror",
"winerror 10061", "nodename nor servname provided",
)):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=2,
)
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)