From cbf1f15cfedfca3fd5130b5532fb9b7f8421946b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:59:47 -0700 Subject: [PATCH] fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing (#5978) * fix(telegram): replace substring caption check with exact line-by-line match Captions in photo bursts and media group albums were silently dropped when a shorter caption happened to be a substring of an existing one (e.g. "Meeting" lost inside "Meeting agenda"). Extract a shared _merge_caption static helper that splits on "\n\n" and uses exact match with whitespace normalisation, then use it in both _enqueue_photo_event and _queue_media_group_event. Adds 13 unit tests covering the fixed bug scenarios. Cherry-picked from PR #2671 by Dilee. * fix: extend caption substring fix to all platforms Move _merge_caption helper from TelegramAdapter to BasePlatformAdapter so all adapters inherit it. Fix the same substring-containment bug in: - gateway/platforms/base.py (photo burst merging) - gateway/run.py (priority photo follow-up merging) - gateway/platforms/feishu.py (media batch merging) The original fix only covered telegram.py. The same bug existed in base.py and run.py (pure substring check) and feishu.py (list membership without whitespace normalization). * fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing Two bugs caused auxiliary tasks (vision, compression, etc.) to fail when using named custom providers defined in config.yaml: 1. 'provider: main' was hardcoded to 'custom', which only checks legacy OPENAI_BASE_URL env vars. Now reads _read_main_provider() to resolve to the actual provider (e.g., 'custom:beans', 'openrouter', 'deepseek'). 2. Named custom provider names (e.g., 'beans') fell through to PROVIDER_REGISTRY which doesn't know about config.yaml entries. Now checks _get_named_custom_provider() before the registry fallback. Fixes both resolve_provider_client() and _normalize_vision_provider() so the fix covers all auxiliary tasks (vision, compression, web_extract, session_search, etc.). Adds 13 unit tests. Reported by Laura via Discord. --------- Co-authored-by: Dilee --- agent/auxiliary_client.py | 35 +++- .../test_auxiliary_named_custom_providers.py | 151 ++++++++++++++++++ website/docs/user-guide/configuration.md | 4 +- 3 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 tests/agent/test_auxiliary_named_custom_providers.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 35ba3c7b..49a78458 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1142,7 +1142,13 @@ def resolve_provider_client( if provider == "codex": provider = "openai-codex" if provider == "main": - provider = "custom" + # Resolve to the user's actual main provider so named custom providers + # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly. + main_prov = _read_main_provider() + if main_prov and main_prov not in ("auto", "main", ""): + provider = main_prov + else: + provider = "custom" # ── Auto: try all providers in priority order ──────────────────── if provider == "auto": @@ -1238,6 +1244,28 @@ def resolve_provider_client( "but no endpoint credentials found") return None, None + # ── Named custom providers (config.yaml custom_providers list) ─── + try: + from hermes_cli.runtime_provider import _get_named_custom_provider + custom_entry = _get_named_custom_provider(provider) + if custom_entry: + custom_base = custom_entry.get("base_url", "").strip() + custom_key = custom_entry.get("api_key", "").strip() or "no-key-required" + if custom_base: + final_model = model or _read_main_model() or "gpt-4o-mini" + client = OpenAI(api_key=custom_key, base_url=custom_base) + logger.debug( + "resolve_provider_client: named custom provider %r (%s)", + provider, final_model) + return (_to_async_client(client, final_model) if async_mode + else (client, final_model)) + logger.warning( + "resolve_provider_client: named custom provider %r has no base_url", + provider) + return None, None + except ImportError: + pass + # ── API-key providers from PROVIDER_REGISTRY ───────────────────── try: from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials @@ -1358,6 +1386,11 @@ def _normalize_vision_provider(provider: Optional[str]) -> str: if provider == "codex": return "openai-codex" if provider == "main": + # Resolve to actual main provider — named custom providers and + # non-aggregator providers need to pass through as their real name. + main_prov = _read_main_provider() + if main_prov and main_prov not in ("auto", "main", ""): + return main_prov return "custom" return provider diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py new file mode 100644 index 00000000..9ca0c5e5 --- /dev/null +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -0,0 +1,151 @@ +"""Tests for named custom provider and 'main' alias resolution in auxiliary_client.""" + +import os +from unittest.mock import patch, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Redirect HERMES_HOME and clear module caches.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Write a minimal config so load_config doesn't fail + (hermes_home / "config.yaml").write_text("model:\n default: test-model\n") + + +def _write_config(tmp_path, config_dict): + """Write a config.yaml to the test HERMES_HOME.""" + import yaml + config_path = tmp_path / ".hermes" / "config.yaml" + config_path.write_text(yaml.dump(config_dict)) + + +class TestNormalizeVisionProvider: + """_normalize_vision_provider should resolve 'main' to actual main provider.""" + + def test_main_resolves_to_named_custom(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "my-model", "provider": "custom:beans"}, + "custom_providers": [{"name": "beans", "base_url": "http://localhost/v1"}], + }) + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("main") == "custom:beans" + + def test_main_resolves_to_openrouter(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "anthropic/claude-sonnet-4", "provider": "openrouter"}, + }) + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("main") == "openrouter" + + def test_main_resolves_to_deepseek(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "deepseek-chat", "provider": "deepseek"}, + }) + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("main") == "deepseek" + + def test_main_falls_back_to_custom_when_no_provider(self, tmp_path): + _write_config(tmp_path, {"model": {"default": "gpt-4o"}}) + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("main") == "custom" + + def test_bare_provider_name_unchanged(self): + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("beans") == "beans" + assert _normalize_vision_provider("deepseek") == "deepseek" + + def test_codex_alias_still_works(self): + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("codex") == "openai-codex" + + def test_auto_unchanged(self): + from agent.auxiliary_client import _normalize_vision_provider + assert _normalize_vision_provider("auto") == "auto" + assert _normalize_vision_provider(None) == "auto" + + +class TestResolveProviderClientMainAlias: + """resolve_provider_client('main', ...) should resolve to actual main provider.""" + + def test_main_resolves_to_named_custom_provider(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "my-model", "provider": "beans"}, + "custom_providers": [ + {"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"}, + ], + }) + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("main", "override-model") + assert client is not None + assert model == "override-model" + assert "beans.local" in str(client.base_url) + + def test_main_with_custom_colon_prefix(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "my-model", "provider": "custom:beans"}, + "custom_providers": [ + {"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"}, + ], + }) + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("main", "test") + assert client is not None + assert "beans.local" in str(client.base_url) + + +class TestResolveProviderClientNamedCustom: + """resolve_provider_client should resolve named custom providers directly.""" + + def test_named_custom_provider(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "test-model"}, + "custom_providers": [ + {"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"}, + ], + }) + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("beans", "my-model") + assert client is not None + assert model == "my-model" + assert "beans.local" in str(client.base_url) + + def test_named_custom_provider_default_model(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "main-model"}, + "custom_providers": [ + {"name": "beans", "base_url": "http://beans.local/v1", "api_key": "k"}, + ], + }) + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("beans") + assert client is not None + # Should use _read_main_model() fallback + assert model == "main-model" + + def test_named_custom_no_api_key_uses_fallback(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "test"}, + "custom_providers": [ + {"name": "local", "base_url": "http://localhost:8080/v1"}, + ], + }) + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("local", "test") + assert client is not None + # no-key-required should be used + + def test_nonexistent_named_custom_falls_through(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "test"}, + "custom_providers": [ + {"name": "beans", "base_url": "http://beans.local/v1"}, + ], + }) + from agent.auxiliary_client import resolve_provider_client + # "coffee" doesn't exist in custom_providers + client, model = resolve_provider_client("coffee", "test") + assert client is None diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 2e26a9f6..468806b8 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -553,7 +553,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. -Available providers: `auto`, `openrouter`, `nous`, `codex`, `copilot`, `anthropic`, `main`, `zai`, `kimi-coding`, `minimax`, and any provider registered in the [provider registry](/docs/reference/environment-variables). +Available providers: `auto`, `openrouter`, `nous`, `codex`, `copilot`, `anthropic`, `main`, `zai`, `kimi-coding`, `minimax`, any provider registered in the [provider registry](/docs/reference/environment-variables), or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). ### Full auxiliary config reference @@ -704,7 +704,7 @@ auxiliary: model: "my-local-model" ``` -`provider: "main"` follows the same custom endpoint Hermes uses for normal chat. That endpoint can be set directly with `OPENAI_BASE_URL`, or saved once through `hermes model` and persisted in `config.yaml`. +`provider: "main"` uses whatever provider Hermes uses for normal chat — whether that's a named custom provider (e.g. `beans`), a built-in provider like `openrouter`, or a legacy `OPENAI_BASE_URL` endpoint. :::tip If you use Codex OAuth as your main model provider, vision works automatically — no extra configuration needed. Codex is included in the auto-detection chain for vision.