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 чисто.
233 lines
8.7 KiB
Python
233 lines
8.7 KiB
Python
"""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)
|