hermes-hub/src/antigravity_provider/router/adapters/claude_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

197 lines
7.9 KiB
Python

"""Anthropic Claude provider adapter for multi-provider router."""
from __future__ import annotations
import json
import logging
import os
import re
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(__name__)
DEFAULT_CLAUDE_MODELS = [
"claude-3-7-sonnet",
"claude-3-5-sonnet",
"claude-3-5-haiku",
"claude-3-opus",
]
class ClaudeAdapter(BaseProviderAdapter):
"""Adapter for Anthropic Claude Messages API with multi-account isolation."""
def __init__(self) -> None:
self._auth_tokens: dict[str, str] = {}
def _resolve_token(self, profile: RouterProfileConfig) -> Optional[str]:
# 1. Profile auth_config token
if "access_token" in profile.auth_config and profile.auth_config["access_token"]:
return profile.auth_config["access_token"]
if "api_key" in profile.auth_config and profile.auth_config["api_key"]:
return profile.auth_config["api_key"]
# 2. Check profile-specific storage (Multi-account isolation)
try:
from ..profile_manager import ProfileAuthManager
creds = ProfileAuthManager.load_profile_auth("claude", profile.profile_id)
if creds:
if isinstance(creds.get("token"), dict) and creds["token"].get("access_token"):
return creds["token"]["access_token"]
if isinstance(creds.get("tokens"), dict) and creds["tokens"].get("access_token"):
return creds["tokens"]["access_token"]
if creds.get("access_token"):
return creds["access_token"]
if creds.get("api_key"):
return creds["api_key"]
except Exception:
pass
# 3. Environment variables
env_var_name = f"CLAUDE_TOKEN_{profile.profile_id.upper().replace('-', '_')}"
if env_var_name in os.environ and os.environ[env_var_name].strip():
return os.environ[env_var_name].strip()
for fallback_env in ("ANTHROPIC_API_KEY", "CLAUDE_API_KEY"):
if fallback_env in os.environ and os.environ[fallback_env].strip():
return os.environ[fallback_env].strip()
return None
def _build_headers(self, token: str) -> Dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-router/1.0",
}
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
return headers
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
token = self._resolve_token(profile)
if not token:
raise RuntimeError(f"No authentication token found for Claude profile '{profile.profile_id}'")
base_url = profile.custom_base_url or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1").rstrip("/")
url = f"{base_url}/messages"
model = request.get("model", "")
if not model or model == "default" or "antigravity" in model:
model = profile.preferred_models[0] if profile.preferred_models else "claude-3-7-sonnet"
payload = {
"model": model,
"messages": request.get("messages", []),
"max_tokens": request.get("max_tokens", 4096),
"temperature": request.get("temperature", 0.7),
}
if "system" in request and request["system"]:
payload["system"] = request["system"]
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
headers = self._build_headers(token)
body_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body_bytes, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=120) as resp:
resp_bytes = resp.read()
return json.loads(resp_bytes.decode("utf-8", errors="replace"))
except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace")
try:
err_json = json.loads(raw_err)
except Exception:
err_json = {"error": {"message": raw_err}}
err_msg = extract_api_error_message(raw_err)
raise RuntimeError(f"Claude API Error ({http_err.code}): {err_msg}") from http_err
except Exception as e:
raise RuntimeError(f"Claude Transport Error: {e}") from e
def health_check(self, profile: RouterProfileConfig) -> bool:
token = self._resolve_token(profile)
if not token:
return False
base_url = (
profile.custom_base_url
or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1")
).rstrip("/")
url = f"{base_url}/models"
headers = self._build_headers(token)
req = urllib.request.Request(url, 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 discover_models(self, profile: RouterProfileConfig) -> List[str]:
token = self._resolve_token(profile)
if not token:
return list(profile.preferred_models or DEFAULT_CLAUDE_MODELS)
base_url = (
profile.custom_base_url
or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1")
).rstrip("/")
url = f"{base_url}/models"
headers = self._build_headers(token)
req = urllib.request.Request(url, 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) and items:
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(set(models))
except Exception as exc:
logger.debug("Claude /models discovery failed for %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or DEFAULT_CLAUDE_MODELS)
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:
err_msg = str(exc)
err_lower = err_msg.lower()
if any(k in err_lower for k in ("quota", "overloaded", "usage_limit", "credit", "rate_limit")):
reset_sec = 1800
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
if m_sec:
reset_sec = int(m_sec.group(1))
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=reset_sec,
model_family="claude",
)
if "401" in err_lower or "403" in err_lower or "authentication" in err_lower or "invalid_api_key" in err_lower:
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
model_family="claude",
)
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
model_family="claude",
)