hermes-hub/src/antigravity_provider/router/adapters/opencode_adapter.py
Hermes Team fb23bff0b0 fix(opencode): адаптер не читал сохранённый ключ — аккаунт не работал никогда
Владелец: «стоит опенкод аккаунт, который не подключен, у него кончились
лимиты и аккаунт не работает». Лимиты ни при чём.

Мастер подключения сохраняет ключ через ProfileAuthManager, а
_resolve_api_key смотрел только в auth_config из YAML и в переменные
окружения. Хранилище профилей он не читал вовсе — в отличие от grok,
claude и codex, где такая проверка есть.

Следствие: любой аккаунт OpenCode Go, подключённый через интерфейс, был
нерабочим. Маршрутизация падала с «No API key found for OpenCode Go
profile», и эта строка уже попадалась в следе отказов оркестратора.

Проверено на живом профиле владельца: до правки health_check=False и
тест профиля возвращал «локальный runtime недоступен», хотя api_key
лежал в auth.json. После — health_check=True, тест проходит.

Закрыто тестами, включая проверку, что пустое хранилище по-прежнему
даёт отказ, а не ложноположительный результат.

Тесты: 331 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:45:32 +07:00

213 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.

"""OpenCode Go provider adapter for multi-provider router."""
from __future__ import annotations
import json
import logging
import os
import re
import urllib.request
import urllib.error
from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification
logger = logging.getLogger(__name__)
DEFAULT_OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"
DEFAULT_OPENCODE_MODELS = [
"kimi-k2.7-code",
"deepseek-v4-pro",
"deepseek-v4-flash",
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"grok-4.5",
"glm-5.3",
"mimo-v2.5-pro",
"minimax-m3",
]
class OpenCodeGoAdapter(BaseProviderAdapter):
"""Adapter for OpenCode Go with support for multi-account key pools and reasoning knobs."""
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
# 1. Profile explicit auth_config
if "api_key" in profile.auth_config and profile.auth_config["api_key"]:
return profile.auth_config["api_key"]
# 2. Профиль в хранилище учётных данных.
# Мастер подключения сохраняет ключ именно сюда, а адаптер его не читал:
# смотрел только YAML-конфиг и переменные окружения. Из-за этого любой
# аккаунт OpenCode, подключённый через интерфейс, не работал никогда —
# маршрутизация падала с «No API key found», хотя ключ лежал на диске.
# Остальные адаптеры (grok, claude, codex) читают профиль; этот — нет.
try:
from ..profile_manager import ProfileAuthManager
creds = ProfileAuthManager.load_profile_auth("opencode-go", profile.profile_id)
if creds:
for key in ("api_key", "access_token", "token"):
value = creds.get(key)
if isinstance(value, dict):
value = value.get("api_key") or value.get("access_token")
if isinstance(value, str) and value.strip():
return value.strip()
except Exception:
pass
# 3. Account-specific environment variable (e.g. OPENCODE_GO_KEY_OPENGO_1, OPENCODE_GO_KEY_1)
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (
f"OPENCODE_GO_KEY_{suffix}",
f"OPENCODE_KEY_{suffix}",
f"OPENCODE_GO_API_KEY_{suffix}",
):
if candidate in os.environ and os.environ[candidate].strip():
return os.environ[candidate].strip()
# Account ID mapped env
acc_suffix = profile.account_id.upper().replace("-", "_")
for candidate in (
f"OPENCODE_GO_KEY_{acc_suffix}",
f"OPENCODE_KEY_{acc_suffix}",
):
if candidate in os.environ and os.environ[candidate].strip():
return os.environ[candidate].strip()
# 4. Global OpenCode Go keys
for global_env in ("OPENCODE_GO_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"):
if global_env in os.environ and os.environ[global_env].strip():
return os.environ[global_env].strip()
return None
def _build_model_kwargs(self, model: str, request: Dict[str, Any]) -> tuple[dict, dict]:
"""Format reasoning_effort and extra_body thinking based on OpenCode model policies."""
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
effort = request.get("reasoning_effort")
if "kimi" in model.lower():
if effort in ("low", "medium", "high"):
top_level["reasoning_effort"] = effort
elif effort in ("xhigh", "max", "ultra"):
top_level["reasoning_effort"] = "high"
else:
extra_body["thinking"] = {"type": "enabled"}
elif "deepseek" in model.lower():
if effort in ("xhigh", "max", "ultra"):
top_level["reasoning_effort"] = "max"
elif effort in ("low", "medium", "high"):
top_level["reasoning_effort"] = effort
else:
extra_body["thinking"] = {"type": "enabled"}
elif "glm" in model.lower():
if effort:
top_level["reasoning_effort"] = "max" if effort in ("xhigh", "max") else "high"
return extra_body, top_level
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
api_key = self._resolve_api_key(profile)
if not api_key:
raise RuntimeError(f"No API key found for OpenCode Go profile '{profile.profile_id}'")
base_url = (profile.custom_base_url or os.environ.get("OPENCODE_GO_BASE_URL", DEFAULT_OPENCODE_GO_BASE_URL)).rstrip("/")
url = f"{base_url}/chat/completions"
# Model selection
model = request.get("model", "")
if not model or model == "default" or "antigravity" in model:
model = profile.preferred_models[0] if profile.preferred_models else "kimi-k2.7-code"
extra_body, top_level = self._build_model_kwargs(model, request)
payload: dict[str, Any] = {
"model": model,
"messages": request.get("messages", []),
"temperature": request.get("temperature", 0.6),
}
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
if "tool_choice" in request:
payload["tool_choice"] = request["tool_choice"]
if extra_body:
payload["extra_body"] = extra_body
payload.update(top_level)
body_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=body_bytes,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "hermes-router/1.0",
"X-OpenCode-Source": "hermes-agent",
},
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 = err_json.get("error", {}).get("message", raw_err)
raise RuntimeError(f"OpenCode Go Error ({http_err.code}): {err_msg}") from http_err
except Exception as e:
raise RuntimeError(f"OpenCode Go Transport Error: {e}") from e
def health_check(self, profile: RouterProfileConfig) -> bool:
return self._resolve_api_key(profile) is not None
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
return list(profile.preferred_models or DEFAULT_OPENCODE_MODELS)
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:
err_msg = str(exc)
err_lower = err_msg.lower()
# Quota / balance
if any(k in err_lower for k in ("quota", "insufficient_quota", "insufficient balance", "balance", "arrears")):
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=1800,
)
# Rate limited (429)
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=60,
)
# Auth errors (401, 403)
if any(k in err_lower for k in ("401", "403", "unauthorized", "invalid api key", "invalid_token")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
# Transient
if any(k in err_lower for k in ("timeout", "502", "503", "504", "gateway", "econnreset")):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=5,
)
return ErrorClassification(
category=ErrorCategory.FATAL,
message=err_msg,
)