diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index adefff19240..6326cb81e9d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2003,6 +2003,35 @@ def _read_main_provider() -> str: return "" +def _read_main_api_key() -> str: + """Read the user's main model API key from the runtime override or config. + + Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the + process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by + ``set_runtime_main`` when an AIAgent is active), then falls back to + ``model.api_key`` in config.yaml. + + Used by the ``custom`` provider fallback chain so that auxiliary tasks + configured with an explicit ``base_url`` but empty ``api_key`` inherit + the main model's credentials instead of falling to ``no-key-required`` + (issue #9318). + """ + override = _RUNTIME_MAIN_API_KEY + if isinstance(override, str) and override.strip(): + return override.strip() + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + key = model_cfg.get("api_key", "") + if isinstance(key, str) and key.strip(): + return key.strip() + except Exception: + pass + return "" + + # Process-local override set by AIAgent at session/turn start. Single-threaded # per turn — no lock needed. Cleared by ``clear_runtime_main()``. _RUNTIME_MAIN_PROVIDER: str = "" @@ -4242,6 +4271,7 @@ def resolve_provider_client( custom_key = ( (explicit_api_key or "").strip() or os.getenv("OPENAI_API_KEY", "").strip() + or _read_main_api_key() or "no-key-required" # local servers don't need auth ) if not custom_base: diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index fd501bde940..d793c58ccb5 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -5185,3 +5185,121 @@ class TestCompressionFallbackContextFilter: # Empty / unknown tasks have no minimum assert _task_minimum_context_length("") is None assert _task_minimum_context_length(None) is None + + +class TestCustomEndpointApiKeyInheritance: + """Issue #9318: when an auxiliary task uses provider=custom with an + explicit base_url but empty api_key, the custom_key fallback chain must + inherit ``model.api_key`` from config.yaml before falling to the + ``no-key-required`` placeholder. + + Without this fix, users on self-hosted gateways who share the same + endpoint+credentials for both the main model and auxiliary tasks get 401 + auth errors because the placeholder key is sent instead of the real one. + """ + + def test_inherits_main_api_key_when_aux_key_empty(self, monkeypatch): + """RED→GREEN: explicit_api_key is None, OPENAI_API_KEY unset → + model.api_key from config.yaml must be used.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + fake_config = { + "model": {"api_key": "sk-main-config-key", "default": "main-model"} + } + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "sk-main-config-key", ( + "Custom endpoint with empty api_key should inherit " + "model.api_key from config, got: " + + repr(captured.get("api_key")) + ) + + def test_explicit_api_key_takes_precedence(self, monkeypatch): + """explicit_api_key wins over config model.api_key.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_config = {"model": {"api_key": "sk-main-config-key"}} + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key="sk-explicit", + ) + + assert captured.get("api_key") == "sk-explicit" + + def test_local_server_falls_to_no_key_required(self, monkeypatch): + """When no key is available anywhere (explicit, env, config), fall + back to ``no-key-required`` for local servers (Ollama, etc.).""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_config = {"model": {}} # no api_key configured + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="http://localhost:11434/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "no-key-required" + + def test_runtime_override_key_is_used(self, monkeypatch): + """When _RUNTIME_MAIN_API_KEY is set (by set_runtime_main), it takes + precedence over config.yaml for the custom endpoint key.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch.object(ac, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key"), \ + patch("hermes_cli.config.load_config", return_value={"model": {}}), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "sk-runtime-key"