Ключ API. add_account сохраняет его через ProfileAuthManager, а адаптеры NVIDIA и OpenRouter читали только profile.auth_config из router_profiles.yaml — туда ключ не попадает. Запрос уходил без заголовка Authorization, и провайдер отвечал «401: Header of type authorization was missing», хотя список моделей тем же ключом получался: обнаружение читает ключ из хранилища, а адаптер читал из конфигурации. Проверено: auth_config в yaml пуст, адаптер теперь находит ключ в хранилище учётных данных. Счётчик аккаунтов. Значок в меню брал readiness.accounts_connected_count (строго AUTHENTICATED), а карточка на странице считала профили правилом «не NOT_CONFIGURED». Владелец видел 9 в меню и 3 на странице. Приведено к одному определению — тому же, что у страницы. 609 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
12 KiB
Python
282 lines
12 KiB
Python
"""OpenRouter 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.openrouter")
|
||
|
||
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||
|
||
|
||
class OpenRouterAdapter(BaseProviderAdapter):
|
||
"""Adapter for OpenRouter's OpenAI-compatible chat completions API."""
|
||
|
||
_models_metadata: Dict[str, Dict[str, Any]] = {}
|
||
_context_window_cache: Dict[str, int] = {}
|
||
|
||
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("OPENROUTER_BASE_URL")
|
||
or DEFAULT_OPENROUTER_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()
|
||
|
||
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
|
||
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
|
||
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
|
||
# authorization was missing», хотя список моделей тем же ключом получался.
|
||
try:
|
||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||
|
||
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||
key = stored.get("api_key") or stored.get("token")
|
||
if key:
|
||
return str(key).strip()
|
||
except Exception:
|
||
pass
|
||
|
||
suffix = profile.profile_id.upper().replace("-", "_")
|
||
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
|
||
val = os.environ.get(candidate, "").strip()
|
||
if val:
|
||
return val
|
||
return None
|
||
|
||
def _build_headers(self, api_key: Optional[str] = None) -> Dict[str, str]:
|
||
"""Build standard headers with OpenRouter attribution headers."""
|
||
referer = (
|
||
os.environ.get("OPENROUTER_HTTP_REFERER")
|
||
or os.environ.get("HERMES_REFERER")
|
||
or "https://github.com/ochenstarik-ui/hermes-hub"
|
||
)
|
||
title = (
|
||
os.environ.get("OPENROUTER_APP_TITLE")
|
||
or os.environ.get("OPENROUTER_TITLE")
|
||
or "Hermes Hub"
|
||
)
|
||
headers: Dict[str, str] = {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
"User-Agent": "hermes-router/1.0",
|
||
"HTTP-Referer": referer,
|
||
"X-OpenRouter-Title": title,
|
||
}
|
||
if api_key:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
return headers
|
||
|
||
def get_context_window(
|
||
self,
|
||
profile: RouterProfileConfig,
|
||
model: Optional[str] = None,
|
||
) -> Optional[int]:
|
||
"""Fetch actual context_window / max_context_length from profile or discovery cache."""
|
||
cache_key = f"{profile.profile_id}:{model or 'default'}"
|
||
if cache_key in self._context_window_cache:
|
||
return self._context_window_cache[cache_key]
|
||
if model and model in self._context_window_cache:
|
||
return self._context_window_cache[model]
|
||
return None
|
||
|
||
def get_model_metadata(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||
"""Return cached metadata for model ID if available."""
|
||
return self._models_metadata.get(model_id)
|
||
|
||
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 = self._build_headers(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
|
||
raise RuntimeError(f"OpenRouter API Error ({http_err.code}): {err_msg}") from http_err
|
||
except Exception as exc:
|
||
raise RuntimeError(f"OpenRouter 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("OpenRouter вернул ответ без 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"OpenRouter вернул пустой ответ (finish_reason={finish})")
|
||
|
||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||
"""Request GET {base_url}/models and return the server's model list.
|
||
|
||
Extracts context_length and display_name metadata when provided by the API.
|
||
No invented/hardcoded model list: on error, fall back to profile.preferred_models.
|
||
"""
|
||
base_url = self._resolve_base_url(profile)
|
||
api_key = self._resolve_api_key(profile)
|
||
headers = self._build_headers(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) and items:
|
||
models = []
|
||
for m in items:
|
||
if isinstance(m, dict):
|
||
m_id = str(m.get("id") or m.get("name") or "")
|
||
if not m_id:
|
||
continue
|
||
display_name = str(m.get("name") or m.get("display_name") or m_id)
|
||
ctx_len = m.get("context_length") or m.get("context_window") or m.get("max_context_length")
|
||
meta: Dict[str, Any] = {
|
||
"id": m_id,
|
||
"display_name": display_name,
|
||
}
|
||
if ctx_len is not None:
|
||
try:
|
||
val = int(ctx_len)
|
||
meta["context_length"] = val
|
||
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = val
|
||
self._context_window_cache[m_id] = val
|
||
except (ValueError, TypeError):
|
||
pass
|
||
self._models_metadata[m_id] = meta
|
||
models.append(m_id)
|
||
elif isinstance(m, str) and m:
|
||
models.append(m)
|
||
if models:
|
||
return sorted(set(models))
|
||
except Exception as exc:
|
||
logger.debug("Failed to discover models for openrouter 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 = self._build_headers(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:
|
||
return ErrorClassification(
|
||
category=ErrorCategory.RATE_LIMITED,
|
||
message=err_msg,
|
||
retry_delay_seconds=30,
|
||
)
|
||
|
||
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)
|