OpenCode identifies clients by request headers, the same way OpenRouter does. Our opencode-zen and opencode-go profiles never set any, so every request went out with the OpenAI SDK default "OpenAI/Python x.y.z" User-Agent and OpenCode had no way to tell the traffic was Hermes Agent. Two changes: - Add HTTP-Referer, X-Title, and a HermesAgent User-Agent to both OpenCode profiles through profile.default_headers, the same path Fireworks uses. This covers chat_completions, codex_responses, auxiliary clients, model switches, and the models catalog fetch. - Merge the same headers in build_anthropic_client for opencode.ai base URLs. The Anthropic Messages route (Claude on Zen, MiniMax and Qwen on Go) builds its client there and never sees profile headers. Verified against the live Go relay with a real key. Both wire formats return HTTP 200 and the requests now carry X-Title "Hermes Agent", HTTP-Referer, and User-Agent HermesAgent/0.20.0.
162 lines
6.4 KiB
Python
162 lines
6.4 KiB
Python
"""OpenCode provider profiles (Zen + Go).
|
|
|
|
Both use per-model api_mode routing:
|
|
- OpenCode Zen: Claude → anthropic_messages, GPT-5/Codex → codex_responses,
|
|
everything else → chat_completions (this profile)
|
|
- OpenCode Go: GPT → codex_responses, MiniMax/Qwen → anthropic_messages,
|
|
GLM/Kimi/DeepSeek/MiMo → chat_completions (this profile)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from hermes_cli import __version__ as _HERMES_VERSION
|
|
from providers import register_provider
|
|
from providers.base import ProviderProfile
|
|
|
|
# Attribution headers sent on every OpenCode request. Same values we send
|
|
# to OpenRouter, Vercel AI Gateway, and Fireworks. Going through
|
|
# profile.default_headers means they survive model switches and credential
|
|
# rotation. Without them OpenCode only sees the OpenAI SDK's generic
|
|
# "OpenAI/Python x.y.z" User-Agent and can't tell the traffic is Hermes Agent.
|
|
_ATTRIBUTION_HEADERS = {
|
|
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
|
"X-Title": "Hermes Agent",
|
|
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
|
|
}
|
|
|
|
|
|
def _flat_model_name(model: str | None) -> str:
|
|
"""Return the bare OpenCode model ID, tolerating aggregator prefixes."""
|
|
return (model or "").strip().rsplit("/", 1)[-1].lower()
|
|
|
|
|
|
def _is_kimi_k2_model(model: str | None) -> bool:
|
|
return _flat_model_name(model).startswith("kimi-k2")
|
|
|
|
|
|
def _is_deepseek_thinking_model(model: str | None) -> bool:
|
|
m = _flat_model_name(model)
|
|
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
|
|
return True
|
|
return m == "deepseek-reasoner"
|
|
|
|
|
|
def _is_glm_5_2_model(model: str | None) -> bool:
|
|
"""Detect GLM-5.2 across alias spellings (glm-5.2 / glm-5-2 / glm-5p2)."""
|
|
m = _flat_model_name(model)
|
|
return any(token in m for token in ("glm-5.2", "glm-5-2", "glm-5p2"))
|
|
|
|
|
|
class OpenCodeGoProfile(ProviderProfile):
|
|
"""OpenCode Go - model-specific reasoning controls."""
|
|
|
|
# Per-model completion-token cap. The opencode-go relay's default is
|
|
# too large for mimo-v2.5-pro — it sends max_tokens=262144 but Xiaomi
|
|
# only supports 131072 completion tokens and 400s the request.
|
|
# Setting an explicit cap here prevents the relay default from being
|
|
# applied. Keys are normalized via _flat_model_name().
|
|
_MODEL_MAX_TOKENS: dict[str, int] = {
|
|
"mimo-v2.5-pro": 131072,
|
|
}
|
|
|
|
def get_max_tokens(self, model: str | None) -> int | None:
|
|
cap = self._MODEL_MAX_TOKENS.get(_flat_model_name(model))
|
|
if cap is not None:
|
|
return cap
|
|
return self.default_max_tokens
|
|
|
|
def build_api_kwargs_extras(
|
|
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
extra_body: dict[str, Any] = {}
|
|
top_level: dict[str, Any] = {}
|
|
|
|
if _is_glm_5_2_model(model):
|
|
# GLM-5.2 on OpenCode Go uses its native OpenAI-compatible
|
|
# reasoning_effort knob, which has exactly two enabled levels:
|
|
# high and max. Map Hermes' richer scale onto those; leave the
|
|
# server default alone when reasoning is disabled or unset.
|
|
if not isinstance(reasoning_config, dict):
|
|
return extra_body, top_level
|
|
if reasoning_config.get("enabled") is False:
|
|
return extra_body, top_level
|
|
effort = (reasoning_config.get("effort") or "").strip().lower()
|
|
if not effort or effort == "none":
|
|
return extra_body, top_level
|
|
top_level["reasoning_effort"] = "max" if effort in {"xhigh", "max", "ultra"} else "high"
|
|
return extra_body, top_level
|
|
|
|
if _is_kimi_k2_model(model):
|
|
# Kimi K2 on OpenCode Go uses Moonshot's native wire shape:
|
|
# extra_body.thinking (binary toggle) + top-level reasoning_effort
|
|
# (low|medium|high). Mirrors the KimiProfile (api.moonshot.ai/v1).
|
|
if not isinstance(reasoning_config, dict):
|
|
# No config → leave server defaults alone.
|
|
return extra_body, top_level
|
|
|
|
enabled = reasoning_config.get("enabled") is not False
|
|
if not enabled:
|
|
extra_body["thinking"] = {"type": "disabled"}
|
|
return extra_body, top_level
|
|
|
|
effort = (reasoning_config.get("effort") or "").strip().lower()
|
|
if effort in {"xhigh", "max", "ultra"}:
|
|
top_level["reasoning_effort"] = "high"
|
|
elif effort in {"low", "medium", "high"}:
|
|
top_level["reasoning_effort"] = effort
|
|
|
|
# Avoid "cannot specify both 'thinking' and 'reasoning_effort'" HTTP 400:
|
|
# only send extra_body["thinking"] when no reasoning_effort is set.
|
|
if "reasoning_effort" not in top_level:
|
|
extra_body["thinking"] = {"type": "enabled"}
|
|
return extra_body, top_level
|
|
|
|
if not _is_deepseek_thinking_model(model):
|
|
return extra_body, top_level
|
|
|
|
enabled = True
|
|
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
|
|
enabled = False
|
|
|
|
if not enabled:
|
|
extra_body["thinking"] = {"type": "disabled"}
|
|
return extra_body, top_level
|
|
|
|
if isinstance(reasoning_config, dict):
|
|
effort = (reasoning_config.get("effort") or "").strip().lower()
|
|
if effort in {"xhigh", "max", "ultra"}:
|
|
top_level["reasoning_effort"] = "max"
|
|
elif effort in {"low", "medium", "high"}:
|
|
top_level["reasoning_effort"] = effort
|
|
|
|
# Avoid "cannot specify both 'thinking' and 'reasoning_effort'" HTTP 400:
|
|
# only send extra_body["thinking"] when no reasoning_effort is set.
|
|
if "reasoning_effort" not in top_level:
|
|
extra_body["thinking"] = {"type": "enabled"}
|
|
|
|
return extra_body, top_level
|
|
|
|
|
|
opencode_zen = ProviderProfile(
|
|
name="opencode-zen",
|
|
aliases=("opencode", "opencode_zen", "zen"),
|
|
env_vars=("OPENCODE_ZEN_API_KEY",),
|
|
base_url="https://opencode.ai/zen/v1",
|
|
default_headers=dict(_ATTRIBUTION_HEADERS),
|
|
default_aux_model="gemini-3-flash",
|
|
)
|
|
|
|
opencode_go = OpenCodeGoProfile(
|
|
name="opencode-go",
|
|
aliases=("opencode_go", "go", "opencode-go-sub"),
|
|
env_vars=("OPENCODE_GO_API_KEY",),
|
|
base_url="https://opencode.ai/zen/go/v1",
|
|
default_headers=dict(_ATTRIBUTION_HEADERS),
|
|
default_aux_model="glm-5",
|
|
)
|
|
|
|
register_provider(opencode_zen)
|
|
register_provider(opencode_go)
|