feat(providers): add tencent-tokenhub provider support

Registers tencent-tokenhub (https://tokenhub.tencentmaas.com/v1) as a
new API-key provider with model tencent/hy3-preview (256K context).

- PROVIDER_REGISTRY entry + TOKENHUB_API_KEY / TOKENHUB_BASE_URL env vars
- Aliases: tencent, tokenhub, tencent-cloud, tencentmaas
- openai_chat transport with is_tokenhub branch for top-level
  reasoning_effort (Hy3 is a reasoning model)
- tencent/hy3-preview:free added to OpenRouter curated list
- 60+ tests (provider registry, aliases, runtime resolution,
  credentials, model catalog, URL mapping, context length)
- Docs: integrations/providers.md, environment-variables.md,
  model-catalog.json

Author: simonweng <simonweng@tencent.com>
Salvaged from PR #16860 onto current main (resolved conflicts with
#16935 Azure Anthropic env-var hint tests and the --provider choices=
list removal in chat_parser).
This commit is contained in:
simonweng 2026-04-28 03:40:45 -07:00 committed by Teknium
parent bd10acd747
commit a6a6cf047d
16 changed files with 654 additions and 6 deletions

View File

@ -94,6 +94,10 @@ _PROVIDER_ALIASES = {
"github-models": "copilot",
"github-copilot-acp": "copilot-acp",
"copilot-acp-agent": "copilot-acp",
"tencent": "tencent-tokenhub",
"tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
}
@ -166,6 +170,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"opencode-go": "glm-5",
"kilocode": "google/gemini-3-flash-preview",
"ollama-cloud": "nemotron-3-nano:30b",
"tencent-tokenhub": "hy3-preview",
}
# Vision-specific model overrides for direct providers.

View File

@ -52,6 +52,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
"xiaomi",
"arcee",
"gmi",
"tencent-tokenhub",
"custom", "local",
# Common aliases
"google", "google-gemini", "google-ai-studio",
@ -60,6 +61,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
"ollama",
"stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
"tencent", "tokenhub", "tencent-cloud", "tencentmaas",
"arcee-ai", "arceeai",
"gmi-cloud", "gmicloud",
"xai", "x-ai", "x.ai", "grok",
@ -208,6 +210,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
"kimi": 262144,
# Tencent — Hy3 Preview (Hunyuan) with 256K context window
"hy3-preview": 256000,
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
"nemotron": 131072,
# Arcee
@ -310,6 +314,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"api.xiaomimimo.com": "xiaomi",
"xiaomimimo.com": "xiaomi",
"api.gmi-serving.com": "gmi",
"tokenhub.tencentmaas.com": "tencent-tokenhub",
"ollama.com": "ollama-cloud",
}

View File

@ -188,6 +188,7 @@ class ChatCompletionsTransport(ProviderTransport):
anthropic_max_out = params.get("anthropic_max_output")
is_nvidia_nim = params.get("is_nvidia_nim", False)
is_kimi = params.get("is_kimi", False)
is_tokenhub = params.get("is_tokenhub", False)
reasoning_config = params.get("reasoning_config")
if ephemeral is not None and max_tokens_fn:
@ -219,6 +220,21 @@ class ChatCompletionsTransport(ProviderTransport):
_kimi_effort = _e
api_kwargs["reasoning_effort"] = _kimi_effort
# Tencent TokenHub: top-level reasoning_effort (unless thinking disabled)
if is_tokenhub:
_tokenhub_thinking_off = bool(
reasoning_config
and isinstance(reasoning_config, dict)
and reasoning_config.get("enabled") is False
)
if not _tokenhub_thinking_off:
_tokenhub_effort = "high"
if reasoning_config and isinstance(reasoning_config, dict):
_e = (reasoning_config.get("effort") or "").strip().lower()
if _e in ("low", "medium", "high"):
_tokenhub_effort = _e
api_kwargs["reasoning_effort"] = _tokenhub_effort
# extra_body assembly
extra_body: Dict[str, Any] = {}

View File

@ -348,6 +348,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("XIAOMI_API_KEY",),
base_url_env_var="XIAOMI_BASE_URL",
),
"tencent-tokenhub": ProviderConfig(
id="tencent-tokenhub",
name="Tencent TokenHub",
auth_type="api_key",
inference_base_url="https://tokenhub.tencentmaas.com/v1",
api_key_env_vars=("TOKENHUB_API_KEY",),
base_url_env_var="TOKENHUB_BASE_URL",
),
"ollama-cloud": ProviderConfig(
id="ollama-cloud",
name="Ollama Cloud",
@ -1141,6 +1149,8 @@ def resolve_provider(
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
"go": "opencode-go", "opencode-go-sub": "opencode-go",
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",

View File

@ -57,6 +57,7 @@ _PROVIDER_ENV_HINTS = (
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
"XIAOMI_API_KEY",
"TOKENHUB_API_KEY",
)

View File

@ -1820,6 +1820,7 @@ def select_provider_and_model(args=None):
"gmi",
"nvidia",
"ollama-cloud",
"tencent-tokenhub",
):
_model_flow_api_key_provider(config, selected_provider, current_model)

View File

@ -44,6 +44,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("openai/gpt-5.4-mini", ""),
("xiaomi/mimo-v2.5-pro", ""),
("xiaomi/mimo-v2.5", ""),
("tencent/hy3-preview:free", "free"),
("openai/gpt-5.3-codex", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""),
@ -156,6 +157,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"moonshotai/kimi-k2.6",
"xiaomi/mimo-v2.5-pro",
"xiaomi/mimo-v2.5",
"tencent/hy3-preview",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
@ -315,6 +317,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"mimo-v2-omni",
"mimo-v2-flash",
],
"tencent-tokenhub": [
"hy3-preview",
],
"arcee": [
"trinity-large-thinking",
"trinity-large-preview",
@ -767,6 +772,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"),
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
@ -849,6 +855,10 @@ _PROVIDER_ALIASES = {
"huggingface-hub": "huggingface",
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub",
"tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
"aws": "bedrock",
"aws-bedrock": "bedrock",
"amazon-bedrock": "bedrock",

View File

@ -158,6 +158,10 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
base_url_env_var="XIAOMI_BASE_URL",
),
"tencent-tokenhub": HermesOverlay(
transport="openai_chat",
base_url_env_var="TOKENHUB_BASE_URL",
),
"arcee": HermesOverlay(
transport="openai_chat",
base_url_override="https://api.arcee.ai/api/v1",
@ -293,6 +297,12 @@ ALIASES: Dict[str, str] = {
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
# tencent
"tencent": "tencent-tokenhub",
"tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
# bedrock
"aws": "bedrock",
"aws-bedrock": "bedrock",
@ -330,6 +340,7 @@ _LABEL_OVERRIDES: Dict[str, str] = {
"stepfun": "StepFun Step Plan",
"xiaomi": "Xiaomi MiMo",
"gmi": "GMI Cloud",
"tencent-tokenhub": "Tencent TokenHub",
"local": "Local endpoint",
"bedrock": "AWS Bedrock",
"ollama-cloud": "Ollama Cloud",

View File

@ -7871,6 +7871,7 @@ class AIAgent:
or base_url_host_matches(self.base_url, "moonshot.ai")
or base_url_host_matches(self.base_url, "moonshot.cn")
)
_is_tokenhub = base_url_host_matches(self._base_url_lower, "tokenhub.tencentmaas.com")
# Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE
# sentinel (temperature omitted entirely), a numeric override, or None.
@ -7942,6 +7943,7 @@ class AIAgent:
is_github_models=_is_gh,
is_nvidia_nim=_is_nvidia,
is_kimi=_is_kimi,
is_tokenhub=_is_tokenhub,
is_custom_provider=self.provider == "custom",
ollama_num_ctx=self._ollama_num_ctx,
provider_preferences=_prefs or None,
@ -7989,6 +7991,7 @@ class AIAgent:
"x-ai/",
"google/gemini-2",
"qwen/qwen3",
"tencent/hy3-preview",
)
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)

View File

@ -18,7 +18,7 @@ _OTHER_PROVIDER_KEYS = (
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY",
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
"XIAOMI_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
"XIAOMI_API_KEY", "TOKENHUB_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
)

View File

@ -1763,7 +1763,6 @@ class TestAzureFoundryResolution:
assert resolved["api_mode"] == "codex_responses"
# ──────────────────────────────────────────────────────────────────────────
# Azure Anthropic — honor user-specified env var hints (key_env / api_key_env)
#
@ -1962,3 +1961,84 @@ class TestProviderEntryApiKeyEnvAlias:
key_env so the set stays in sync with what the runtime actually reads."""
from hermes_cli.config import _VALID_CUSTOM_PROVIDER_FIELDS
assert "key_env" in _VALID_CUSTOM_PROVIDER_FIELDS
# =============================================================================
# Tencent TokenHub — API-key provider runtime resolution
# =============================================================================
class TestTencentTokenhubRuntimeResolution:
"""Verify Tencent TokenHub resolves correctly through the generic
API-key provider path in resolve_runtime_provider."""
def test_resolves_with_env_key(self, monkeypatch):
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
assert resolved["provider"] == "tencent-tokenhub"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "https://tokenhub.tencentmaas.com/v1"
assert resolved["api_key"] == "test-tokenhub-key"
assert resolved["requested_provider"] == "tencent-tokenhub"
def test_custom_base_url_from_env(self, monkeypatch):
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
monkeypatch.setenv("TOKENHUB_BASE_URL", "https://custom-proxy.example.com/v1")
resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
assert resolved["provider"] == "tencent-tokenhub"
assert resolved["base_url"] == "https://custom-proxy.example.com/v1"
assert resolved["api_key"] == "test-tokenhub-key"
def test_config_base_url_honoured_when_provider_matches(self, monkeypatch):
"""model.base_url in config.yaml should override the hardcoded default
when model.provider == tencent-tokenhub."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
monkeypatch.setattr(rp, "_get_model_config", lambda: {
"provider": "tencent-tokenhub",
"base_url": "https://proxy.internal.com/v1",
})
monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
assert resolved["base_url"] == "https://proxy.internal.com/v1"
def test_config_base_url_ignored_for_different_provider(self, monkeypatch):
"""model.base_url should NOT be used when model.provider doesn't match."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
monkeypatch.setattr(rp, "_get_model_config", lambda: {
"provider": "openrouter",
"base_url": "https://some-other-endpoint.com/v1",
})
monkeypatch.setenv("TOKENHUB_API_KEY", "test-tokenhub-key")
monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
resolved = rp.resolve_runtime_provider(requested="tencent-tokenhub")
# Should use the default, NOT the config base_url from a different provider
assert resolved["base_url"] == "https://tokenhub.tencentmaas.com/v1"
def test_explicit_override_skips_env(self, monkeypatch):
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "tencent-tokenhub")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("TOKENHUB_API_KEY", "env-key-should-lose")
monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
resolved = rp.resolve_runtime_provider(
requested="tencent-tokenhub",
explicit_api_key="explicit-tokenhub-key",
explicit_base_url="https://explicit-proxy.example.com/v1/",
)
assert resolved["provider"] == "tencent-tokenhub"
assert resolved["api_key"] == "explicit-tokenhub-key"
assert resolved["base_url"] == "https://explicit-proxy.example.com/v1"
assert resolved["source"] == "explicit"

View File

@ -0,0 +1,494 @@
"""Tests for Tencent TokenHub provider support (Hy3 Preview)."""
import json
import os
import pytest
from hermes_cli.auth import (
PROVIDER_REGISTRY,
resolve_provider,
get_api_key_provider_status,
resolve_api_key_provider_credentials,
AuthError,
)
# Other provider env vars to clear during auto-detection tests
_OTHER_PROVIDER_KEYS = (
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY",
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
"XIAOMI_API_KEY", "OPENROUTER_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GITHUB_TOKEN", "ARCEEAI_API_KEY",
)
# =============================================================================
# Provider Registry
# =============================================================================
class TestTencentTokenhubProviderRegistry:
"""Verify tencent-tokenhub is registered correctly in the PROVIDER_REGISTRY."""
def test_registered(self):
assert "tencent-tokenhub" in PROVIDER_REGISTRY
def test_name(self):
assert PROVIDER_REGISTRY["tencent-tokenhub"].name == "Tencent TokenHub"
def test_auth_type(self):
assert PROVIDER_REGISTRY["tencent-tokenhub"].auth_type == "api_key"
def test_inference_base_url(self):
assert PROVIDER_REGISTRY["tencent-tokenhub"].inference_base_url == "https://tokenhub.tencentmaas.com/v1"
def test_api_key_env_vars(self):
assert PROVIDER_REGISTRY["tencent-tokenhub"].api_key_env_vars == ("TOKENHUB_API_KEY",)
def test_base_url_env_var(self):
assert PROVIDER_REGISTRY["tencent-tokenhub"].base_url_env_var == "TOKENHUB_BASE_URL"
# =============================================================================
# Aliases
# =============================================================================
class TestTencentTokenhubAliases:
"""All aliases should resolve to 'tencent-tokenhub'."""
@pytest.mark.parametrize("alias", [
"tencent-tokenhub", "tencent", "tokenhub", "tencent-cloud", "tencentmaas",
])
def test_alias_resolves(self, alias, monkeypatch):
for key in _OTHER_PROVIDER_KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TOKENHUB_API_KEY", "sk-test-key-12345678")
assert resolve_provider(alias) == "tencent-tokenhub"
def test_normalize_provider_models_py(self):
from hermes_cli.models import normalize_provider
assert normalize_provider("tencent") == "tencent-tokenhub"
assert normalize_provider("tokenhub") == "tencent-tokenhub"
assert normalize_provider("tencent-cloud") == "tencent-tokenhub"
assert normalize_provider("tencentmaas") == "tencent-tokenhub"
def test_normalize_provider_providers_py(self):
from hermes_cli.providers import normalize_provider
assert normalize_provider("tencent") == "tencent-tokenhub"
assert normalize_provider("tokenhub") == "tencent-tokenhub"
assert normalize_provider("tencent-cloud") == "tencent-tokenhub"
assert normalize_provider("tencentmaas") == "tencent-tokenhub"
# =============================================================================
# Auto-detection
# =============================================================================
class TestTencentTokenhubAutoDetection:
"""Setting TOKENHUB_API_KEY should auto-detect the provider."""
def test_auto_detect(self, monkeypatch):
for var in _OTHER_PROVIDER_KEYS:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("TOKENHUB_API_KEY", "sk-tokenhub-test-12345678")
provider = resolve_provider("auto")
assert provider == "tencent-tokenhub"
# =============================================================================
# Credentials
# =============================================================================
class TestTencentTokenhubCredentials:
"""Test credential resolution for the tencent-tokenhub provider."""
def test_status_configured(self, monkeypatch):
monkeypatch.setenv("TOKENHUB_API_KEY", "sk-test-12345678")
status = get_api_key_provider_status("tencent-tokenhub")
assert status["configured"]
def test_status_not_configured(self, monkeypatch):
monkeypatch.delenv("TOKENHUB_API_KEY", raising=False)
status = get_api_key_provider_status("tencent-tokenhub")
assert not status["configured"]
def test_resolve_credentials(self, monkeypatch):
monkeypatch.setenv("TOKENHUB_API_KEY", "sk-test-12345678")
monkeypatch.delenv("TOKENHUB_BASE_URL", raising=False)
creds = resolve_api_key_provider_credentials("tencent-tokenhub")
assert creds["api_key"] == "sk-test-12345678"
assert creds["base_url"] == "https://tokenhub.tencentmaas.com/v1"
def test_openrouter_key_does_not_make_tokenhub_configured(self, monkeypatch):
"""OpenRouter users should NOT see tencent-tokenhub as configured."""
monkeypatch.delenv("TOKENHUB_API_KEY", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
status = get_api_key_provider_status("tencent-tokenhub")
assert not status["configured"]
def test_custom_base_url_override(self, monkeypatch):
monkeypatch.setenv("TOKENHUB_API_KEY", "sk-test-12345678")
monkeypatch.setenv("TOKENHUB_BASE_URL", "https://custom.tokenhub.example/v1")
creds = resolve_api_key_provider_credentials("tencent-tokenhub")
assert creds["base_url"] == "https://custom.tokenhub.example/v1"
# =============================================================================
# Model catalog
# =============================================================================
class TestTencentTokenhubModelCatalog:
"""Tencent TokenHub static model list."""
def test_static_model_list_exists(self):
from hermes_cli.models import _PROVIDER_MODELS
assert "tencent-tokenhub" in _PROVIDER_MODELS
assert len(_PROVIDER_MODELS["tencent-tokenhub"]) >= 1
def test_hy3_preview_in_model_list(self):
from hermes_cli.models import _PROVIDER_MODELS
assert "hy3-preview" in _PROVIDER_MODELS["tencent-tokenhub"]
def test_default_model(self):
from hermes_cli.models import get_default_model_for_provider
assert get_default_model_for_provider("tencent-tokenhub") == "hy3-preview"
# =============================================================================
# CANONICAL_PROVIDERS (hermes model picker)
# =============================================================================
class TestTencentTokenhubCanonicalProvider:
"""Tencent TokenHub appears in the interactive model picker."""
def test_in_canonical_providers(self):
from hermes_cli.models import CANONICAL_PROVIDERS
slugs = [p.slug for p in CANONICAL_PROVIDERS]
assert "tencent-tokenhub" in slugs
def test_label(self):
from hermes_cli.models import CANONICAL_PROVIDERS
entry = next(p for p in CANONICAL_PROVIDERS if p.slug == "tencent-tokenhub")
assert entry.label == "Tencent TokenHub"
def test_description_contains_hy3(self):
from hermes_cli.models import CANONICAL_PROVIDERS
entry = next(p for p in CANONICAL_PROVIDERS if p.slug == "tencent-tokenhub")
assert "Hy3 Preview" in entry.tui_desc
# =============================================================================
# OpenRouter / Nous Portal curated lists
# =============================================================================
class TestTencentInOpenRouterAndNous:
"""tencent/hy3-preview:free should appear in OpenRouter and Nous curated lists."""
def test_in_openrouter_fallback(self):
from hermes_cli.models import OPENROUTER_MODELS
ids = [mid for mid, _ in OPENROUTER_MODELS]
assert "tencent/hy3-preview:free" in ids
def test_in_nous_provider_models(self):
from hermes_cli.models import _PROVIDER_MODELS
assert "tencent/hy3-preview" in _PROVIDER_MODELS["nous"]
# =============================================================================
# Model normalization
# =============================================================================
class TestTencentTokenhubNormalization:
"""Model name normalization — Tencent TokenHub is a direct provider
not in _MATCHING_PREFIX_STRIP_PROVIDERS, so names pass through as-is.
"""
def test_bare_name_passthrough(self):
"""hy3-preview should remain unchanged when targeting tencent-tokenhub."""
from hermes_cli.model_normalize import normalize_model_for_provider
result = normalize_model_for_provider("hy3-preview", "tencent-tokenhub")
assert result == "hy3-preview"
def test_vendor_prefixed_passthrough(self):
"""tencent/hy3-preview is not stripped since tencent-tokenhub is not in
_MATCHING_PREFIX_STRIP_PROVIDERS the slash survives."""
from hermes_cli.model_normalize import normalize_model_for_provider
result = normalize_model_for_provider("tencent/hy3-preview", "tencent-tokenhub")
# Direct providers not in any special set → passthrough
assert result == "tencent/hy3-preview"
def test_not_in_matching_prefix_strip_set(self):
"""tencent-tokenhub does NOT need prefix stripping — it only has
one model (hy3-preview) and users won't copy vendor/ form."""
from hermes_cli.model_normalize import _MATCHING_PREFIX_STRIP_PROVIDERS
assert "tencent-tokenhub" not in _MATCHING_PREFIX_STRIP_PROVIDERS
def test_not_in_lowercase_providers(self):
"""tencent-tokenhub does not require lowercase normalization."""
from hermes_cli.model_normalize import _LOWERCASE_MODEL_PROVIDERS
assert "tencent-tokenhub" not in _LOWERCASE_MODEL_PROVIDERS
@pytest.mark.parametrize("empty_input", ["", None, " "])
def test_normalize_empty_and_none(self, empty_input):
"""None, empty, and whitespace-only inputs return empty string."""
from hermes_cli.model_normalize import normalize_model_for_provider
result = normalize_model_for_provider(empty_input, "tencent-tokenhub")
assert result == "" or result.strip() == ""
# =============================================================================
# Provider label
# =============================================================================
class TestTencentTokenhubProviderLabel:
"""Test provider_label() from models.py for tencent-tokenhub."""
def test_label_from_provider_labels_dict(self):
from hermes_cli.models import _PROVIDER_LABELS
assert _PROVIDER_LABELS["tencent-tokenhub"] == "Tencent TokenHub"
def test_provider_label_function(self):
from hermes_cli.models import provider_label
assert provider_label("tencent-tokenhub") == "Tencent TokenHub"
def test_provider_label_via_alias(self):
from hermes_cli.models import provider_label
assert provider_label("tencent") == "Tencent TokenHub"
assert provider_label("tokenhub") == "Tencent TokenHub"
# =============================================================================
# URL mapping
# =============================================================================
class TestTencentTokenhubURLMapping:
"""Test URL → provider inference for Tencent TokenHub endpoints."""
def test_url_to_provider(self):
from agent.model_metadata import _URL_TO_PROVIDER
assert _URL_TO_PROVIDER.get("tokenhub.tencentmaas.com") == "tencent-tokenhub"
def test_provider_prefixes(self):
from agent.model_metadata import _PROVIDER_PREFIXES
assert "tencent-tokenhub" in _PROVIDER_PREFIXES
assert "tencent" in _PROVIDER_PREFIXES
assert "tokenhub" in _PROVIDER_PREFIXES
def test_infer_from_url(self):
from agent.model_metadata import _infer_provider_from_url
assert _infer_provider_from_url("https://tokenhub.tencentmaas.com/v1") == "tencent-tokenhub"
# =============================================================================
# Context length
# =============================================================================
class TestTencentTokenhubContextLength:
"""hy3-preview context length is registered."""
def test_hy3_preview_context_length(self):
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length("hy3-preview")
assert ctx == 256000
# =============================================================================
# providers.py (unified provider module)
# =============================================================================
class TestTencentTokenhubProvidersModule:
"""Test Tencent TokenHub in the unified providers module."""
def test_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "tencent-tokenhub" in HERMES_OVERLAYS
overlay = HERMES_OVERLAYS["tencent-tokenhub"]
assert overlay.transport == "openai_chat"
assert overlay.base_url_env_var == "TOKENHUB_BASE_URL"
assert not overlay.is_aggregator
def test_alias_resolves(self):
from hermes_cli.providers import normalize_provider
assert normalize_provider("tencent") == "tencent-tokenhub"
assert normalize_provider("tokenhub") == "tencent-tokenhub"
def test_label(self):
from hermes_cli.providers import get_label
assert get_label("tencent-tokenhub") == "Tencent TokenHub"
def test_get_provider(self):
pdef = None
try:
from hermes_cli.providers import get_provider
pdef = get_provider("tencent-tokenhub")
except Exception:
pass
if pdef is not None:
assert pdef.id == "tencent-tokenhub"
assert pdef.transport == "openai_chat"
# =============================================================================
# Auxiliary client
# =============================================================================
class TestTencentTokenhubAuxiliary:
"""Tencent TokenHub auxiliary model routing."""
def test_aux_model_registered(self):
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
assert "tencent-tokenhub" in _API_KEY_PROVIDER_AUX_MODELS
assert _API_KEY_PROVIDER_AUX_MODELS["tencent-tokenhub"] == "hy3-preview"
def test_aux_aliases(self):
from agent.auxiliary_client import _PROVIDER_ALIASES
assert _PROVIDER_ALIASES.get("tencent") == "tencent-tokenhub"
assert _PROVIDER_ALIASES.get("tokenhub") == "tencent-tokenhub"
# =============================================================================
# Doctor
# =============================================================================
class TestTencentTokenhubDoctor:
"""Verify hermes doctor recognizes Tencent TokenHub env vars."""
def test_provider_env_hints(self):
from hermes_cli.doctor import _PROVIDER_ENV_HINTS
assert "TOKENHUB_API_KEY" in _PROVIDER_ENV_HINTS
# =============================================================================
# Agent init (no SyntaxError, correct api_mode)
# =============================================================================
class TestTencentTokenhubAgentInit:
"""Verify the agent can be constructed with tencent-tokenhub provider without errors."""
def test_no_syntax_errors(self):
"""Importing run_agent with tencent-tokenhub should not raise."""
import importlib
importlib.import_module("run_agent")
def test_api_mode_is_chat_completions(self):
from hermes_cli.providers import HERMES_OVERLAYS, TRANSPORT_TO_API_MODE
overlay = HERMES_OVERLAYS["tencent-tokenhub"]
api_mode = TRANSPORT_TO_API_MODE[overlay.transport]
assert api_mode == "chat_completions"
# =============================================================================
# CLI model flow dispatch (main.py)
# =============================================================================
class TestTencentTokenhubCLIDispatch:
"""Verify tencent-tokenhub is routed through _model_flow_api_key_provider."""
def test_in_api_key_provider_tuple(self):
"""tencent-tokenhub must appear in the elif tuple in _model_flow dispatch
so ``hermes model`` routes it through the generic api_key_provider flow.
"""
import inspect
from hermes_cli import main as main_mod
source = inspect.getsource(main_mod)
# The source should contain tencent-tokenhub in the dispatch block
assert '"tencent-tokenhub"' in source or "'tencent-tokenhub'" in source
# =============================================================================
# Remote model catalog (model-catalog.json)
# =============================================================================
class TestTencentTokenhubModelCatalogJSON:
"""Verify tencent/hy3-preview:free is present in the website model-catalog.json."""
def test_in_model_catalog_json(self):
catalog_path = os.path.join(
os.path.dirname(__file__),
"..", "..",
"website", "static", "api", "model-catalog.json",
)
if not os.path.isfile(catalog_path):
pytest.skip("model-catalog.json not found in workspace")
with open(catalog_path) as f:
data = json.load(f)
# Collect all model IDs across all provider lists.
# providers is a dict keyed by provider name, each value has a "models" list.
all_ids = set()
providers = data.get("providers", {})
if isinstance(providers, dict):
for provider_entry in providers.values():
for model in provider_entry.get("models", []):
all_ids.add(model.get("id", ""))
else:
for provider_entry in providers:
for model in provider_entry.get("models", []):
all_ids.add(model.get("id", ""))
assert "tencent/hy3-preview:free" in all_ids
# =============================================================================
# determine_api_mode (providers.py)
# =============================================================================
class TestTencentTokenhubApiMode:
"""Verify determine_api_mode routes tencent-tokenhub correctly."""
def test_determine_api_mode_direct(self):
from hermes_cli.providers import determine_api_mode
mode = determine_api_mode("tencent-tokenhub")
assert mode == "chat_completions"
def test_determine_api_mode_with_base_url(self):
from hermes_cli.providers import determine_api_mode
mode = determine_api_mode("tencent-tokenhub", "https://tokenhub.tencentmaas.com/v1")
assert mode == "chat_completions"
def test_determine_api_mode_via_alias(self):
from hermes_cli.providers import determine_api_mode
mode = determine_api_mode("tencent")
assert mode == "chat_completions"
# =============================================================================
# _KNOWN_PROVIDER_NAMES (models.py)
# =============================================================================
class TestTencentTokenhubKnownProviderNames:
"""Verify tencent-tokenhub and its aliases are recognized as valid
provider names for the ``provider:model`` syntax.
"""
def test_canonical_id_known(self):
from hermes_cli.models import _KNOWN_PROVIDER_NAMES
assert "tencent-tokenhub" in _KNOWN_PROVIDER_NAMES
@pytest.mark.parametrize("alias", [
"tencent", "tokenhub", "tencent-cloud", "tencentmaas",
])
def test_alias_known(self, alias):
from hermes_cli.models import _KNOWN_PROVIDER_NAMES
assert alias in _KNOWN_PROVIDER_NAMES

View File

@ -84,7 +84,8 @@ class TestXiaomiAutoDetection:
"DASHSCOPE_API_KEY", "XAI_API_KEY", "KIMI_API_KEY",
"MINIMAX_API_KEY", "AI_GATEWAY_API_KEY", "KILOCODE_API_KEY",
"HF_TOKEN", "GLM_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GITHUB_TOKEN", "MINIMAX_CN_API_KEY"):
"GH_TOKEN", "GITHUB_TOKEN", "MINIMAX_CN_API_KEY",
"TOKENHUB_API_KEY", "ARCEEAI_API_KEY"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("XIAOMI_API_KEY", "sk-xiaomi-test-12345678")
provider = resolve_provider("auto")

View File

@ -31,6 +31,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **Alibaba Cloud** | `DASHSCOPE_API_KEY` in `~/.hermes/.env` (provider: `alibaba`, aliases: `dashscope`, `qwen`) |
| **Kilo Code** | `KILOCODE_API_KEY` in `~/.hermes/.env` (provider: `kilocode`) |
| **Xiaomi MiMo** | `XIAOMI_API_KEY` in `~/.hermes/.env` (provider: `xiaomi`, aliases: `mimo`, `xiaomi-mimo`) |
| **Tencent TokenHub** | `TOKENHUB_API_KEY` in `~/.hermes/.env` (provider: `tencent-tokenhub`, aliases: `tencent`, `tokenhub`, `tencentmaas`) |
| **OpenCode Zen** | `OPENCODE_ZEN_API_KEY` in `~/.hermes/.env` (provider: `opencode-zen`) |
| **OpenCode Go** | `OPENCODE_GO_API_KEY` in `~/.hermes/.env` (provider: `opencode-go`) |
| **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
@ -284,6 +285,10 @@ hermes chat --provider alibaba --model qwen3.5-plus
hermes chat --provider xiaomi --model mimo-v2-pro
# Requires: XIAOMI_API_KEY in ~/.hermes/.env
# Tencent TokenHub (Hy3 Preview)
hermes chat --provider tencent-tokenhub --model hy3-preview
# Requires: TOKENHUB_API_KEY in ~/.hermes/.env
# Arcee AI (Trinity models)
hermes chat --provider arcee --model trinity-large-thinking
# Requires: ARCEEAI_API_KEY in ~/.hermes/.env
@ -301,7 +306,7 @@ model:
default: "zai-org/GLM-5.1-FP8"
```
Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, `XIAOMI_BASE_URL`, or `GMI_BASE_URL` environment variables.
Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, `XIAOMI_BASE_URL`, `GMI_BASE_URL`, or `TOKENHUB_BASE_URL` environment variables.
:::note Z.AI Endpoint Auto-Detection
When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoints (global, China, coding variants) to find one that accepts your API key. You don't need to set `GLM_BASE_URL` manually — the working endpoint is detected and cached automatically.
@ -1103,7 +1108,7 @@ You can also select named custom providers from the interactive `hermes model` m
| **Cost optimization** | ClawRouter or OpenRouter with `sort: "price"` |
| **Maximum privacy** | Ollama, vLLM, or llama.cpp (fully local) |
| **Enterprise / Azure** | Azure OpenAI with custom endpoint |
| **Chinese AI models** | z.ai (GLM), Kimi/Moonshot (`kimi-coding` or `kimi-coding-cn`), MiniMax, or Xiaomi MiMo (first-class providers) |
| **Chinese AI models** | z.ai (GLM), Kimi/Moonshot (`kimi-coding` or `kimi-coding-cn`), MiniMax, Xiaomi MiMo, or Tencent TokenHub (first-class providers) |
:::tip
You can switch between providers at any time with `hermes model` — no restart required. Your conversation history, memory, and skills carry over regardless of which provider you use.
@ -1178,7 +1183,7 @@ fallback_model:
When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session.
Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `custom`.
Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `tencent-tokenhub`, `custom`.
:::tip
Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers).

View File

@ -46,6 +46,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
| `KILOCODE_BASE_URL` | Override Kilo Code base URL (default: `https://api.kilo.ai/api/gateway`) |
| `XIAOMI_API_KEY` | Xiaomi MiMo API key ([platform.xiaomimimo.com](https://platform.xiaomimimo.com)) |
| `XIAOMI_BASE_URL` | Override Xiaomi MiMo base URL (default: `https://api.xiaomimimo.com/v1`) |
| `TOKENHUB_API_KEY` | Tencent TokenHub API key ([tokenhub.tencentmaas.com](https://tokenhub.tencentmaas.com)) |
| `TOKENHUB_BASE_URL` | Override Tencent TokenHub base URL (default: `https://tokenhub.tencentmaas.com/v1`) |
| `AZURE_FOUNDRY_API_KEY` | Azure AI Foundry / Azure OpenAI API key ([ai.azure.com](https://ai.azure.com/)) |
| `AZURE_FOUNDRY_BASE_URL` | Azure AI Foundry endpoint URL (e.g. `https://<resource>.openai.azure.com/openai/v1` for OpenAI-style, or `https://<resource>.services.ai.azure.com/anthropic` for Anthropic-style) |
| `AZURE_ANTHROPIC_KEY` | Azure Anthropic API key for `provider: anthropic` + `base_url` pointing at an Azure Foundry Claude deployment (alternative to `ANTHROPIC_API_KEY` when both Anthropic and Azure Anthropic are configured) |

View File

@ -60,6 +60,10 @@
"id": "xiaomi/mimo-v2.5",
"description": ""
},
{
"id": "tencent/hy3-preview:free",
"description": "free"
},
{
"id": "openai/gpt-5.3-codex",
"description": ""