hermes-hub/src/antigravity_provider/router/adapters/openrouter_adapter.py
Hermes Team 266fc51adf fix(providers): ключ не доходил до адаптера; счётчик аккаунтов расходился
Ключ 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>
2026-09-01 01:46:46 +07:00

282 lines
12 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.

"""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)