Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,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 = ""
Expand Down Expand Up @@ -4110,6 +4139,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
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:
Expand Down
118 changes: 118 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4919,3 +4919,121 @@ def test_compression_task_uses_minimum_context_constant(self):
# 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"
Loading