mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
fix(auxiliary): inherit model.api_key for custom endpoint when per-task key is empty (#9318)
When an auxiliary task is configured with provider=custom and an explicit base_url but an empty api_key, the custom_key fallback chain in resolve_provider_client() jumped straight to the no-key-required placeholder without consulting model.api_key from config.yaml. Users on self-hosted gateways who share the same endpoint and credentials for both the main model and auxiliary tasks got 401 auth errors. Add _read_main_api_key() following the same pattern as _read_main_model() and _read_main_provider(): checks _RUNTIME_MAIN_API_KEY (runtime override) first, then config.yaml model.api_key. Insert it into the fallback chain before no-key-required so real credentials are used when available, while local servers without auth still get the placeholder.
This commit is contained in:
parent
6fad6f1dd8
commit
8e09afda27
2 changed files with 148 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue