diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b2027cd42b..74fede3e6f 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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. diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 6ea1603565..44135e2e65 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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", } diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 34d5caa88a..0e497c9ec3 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -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] = {} diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index fb6a79d1ff..700aa7a2e9 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index dc346ac9b2..64ac4915f2 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -57,6 +57,7 @@ _PROVIDER_ENV_HINTS = ( "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", "XIAOMI_API_KEY", + "TOKENHUB_API_KEY", ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1f96732dc5..2e2a2f8f4d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 21fa3b74f3..204c688cb2 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index c526682809..32c6e3fe8c 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -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", diff --git a/run_agent.py b/run_agent.py index 4976861b83..568a7f7ac0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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) diff --git a/tests/hermes_cli/test_arcee_provider.py b/tests/hermes_cli/test_arcee_provider.py index e9eea77f93..ac703153fa 100644 --- a/tests/hermes_cli/test_arcee_provider.py +++ b/tests/hermes_cli/test_arcee_provider.py @@ -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", ) diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 03c6fec339..4cff16e5af 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -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" + diff --git a/tests/hermes_cli/test_tencent_tokenhub_provider.py b/tests/hermes_cli/test_tencent_tokenhub_provider.py new file mode 100644 index 0000000000..b84666e83f --- /dev/null +++ b/tests/hermes_cli/test_tencent_tokenhub_provider.py @@ -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 + diff --git a/tests/hermes_cli/test_xiaomi_provider.py b/tests/hermes_cli/test_xiaomi_provider.py index aa82bd48a5..7343333896 100644 --- a/tests/hermes_cli/test_xiaomi_provider.py +++ b/tests/hermes_cli/test_xiaomi_provider.py @@ -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") diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index c91bf6e007..b4e4148cfa 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -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). diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 6bcfff8cc5..3bb4e170d5 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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://.openai.azure.com/openai/v1` for OpenAI-style, or `https://.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) | diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index e22cd90b87..b874ac06a3 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -60,6 +60,10 @@ "id": "xiaomi/mimo-v2.5", "description": "" }, + { + "id": "tencent/hy3-preview:free", + "description": "free" + }, { "id": "openai/gpt-5.3-codex", "description": ""