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.