Skip to content
Closed
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
5 changes: 4 additions & 1 deletion hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1714,7 +1714,8 @@ def _fetch_anthropic_models(
return None

resolved_base_url = base_url
token = (api_key or "").strip() or resolve_anthropic_token()
from agent.command_token_source import materialize_probe_api_key
token = materialize_probe_api_key(api_key) if api_key else resolve_anthropic_token()
if not token:
# A pool credential and its endpoint are one security boundary — never pair the pool key
# with a caller-provided endpoint.
Expand Down Expand Up @@ -2236,6 +2237,8 @@ def probe_api_models(
"""Probe a ``/models`` endpoint with light URL heuristics (``base`` then ``base±/v1``).
``anthropic_messages`` mode sends ``x-api-key`` + ``anthropic-version`` instead of a bearer; the
``data[].id`` response shape is identical. ``models`` is None when no candidate answered."""
from agent.command_token_source import materialize_probe_api_key
api_key = materialize_probe_api_key(api_key)
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return _probe_result(None, None, "")
Expand Down
11 changes: 8 additions & 3 deletions hermes_cli/runtime_provider_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ def _resolve_azure_foundry_runtime(*, requested_provider: str, model_cfg: Dict[s
``.env``/env or a per-request Entra ID token, trailing ``/v1`` stripped for Anthropic-style
endpoints (the Anthropic SDK appends /v1/messages itself)."""
rp = _rp()
explicit_api_key = str(explicit_api_key or "").strip()
explicit_key_is_callable = callable(explicit_api_key) and not isinstance(explicit_api_key, str)
explicit_api_key = explicit_api_key if explicit_key_is_callable else str(explicit_api_key or "").strip()
explicit_base_url_clean = str(explicit_base_url or "").strip().rstrip("/")
cfg_base_url, cfg_api_mode, cfg_auth_mode, cfg_entra = "", "chat_completions", "api_key", {}
if rp._cfg_provider(model_cfg) == "azure-foundry":
Expand All @@ -90,12 +91,16 @@ def _resolve_azure_foundry_runtime(*, requested_provider: str, model_cfg: Dict[s
if cfg_api_mode == "anthropic_messages":
base_url = re.sub(r"/v1/?$", "", base_url)
if cfg_auth_mode == "entra_id":
scope = str(cfg_entra.get("scope") or "").strip()
# --api-key on the CLI while config says entra_id: honour the explicit string (escape hatch
# for one-off testing).
if explicit_api_key:
if explicit_key_is_callable:
api_key, source, auth_mode, entra = explicit_api_key, "entra_id", "entra_id", (
{"scope": scope} if scope else {}
)
elif explicit_api_key:
api_key, source, auth_mode, entra = explicit_api_key, "explicit", "api_key", {}
else:
scope = str(cfg_entra.get("scope") or "").strip()
api_key, source, auth_mode, entra = _azure_entra_credentials(cfg_entra), "entra_id", "entra_id", (
{"scope": scope} if scope else {}
)
Expand Down
9 changes: 6 additions & 3 deletions hermes_cli/runtime_provider_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,19 +478,22 @@ def _resolve_named_custom_runtime(*, requested_provider: str, explicit_api_key:
# The pool doesn't know the custom_providers fields — propagate them here too.
_apply_custom_provider_extras(custom_provider, target_model, pool_result)
return pool_result
explicit_key = (explicit_api_key or "").strip()
explicit_key_is_callable = callable(explicit_api_key) and not isinstance(explicit_api_key, str)
explicit_key = explicit_api_key if explicit_key_is_callable else str(explicit_api_key or "").strip()
candidates = [
explicit_key,
_clean(custom_provider.get("api_key", "")),
rp._getenv(_clean(custom_provider.get("key_env", "")), "").strip(),
*rp._host_gated_env_key_candidates(base_url, ollama=False),
]
api_key: Any = next((c for c in candidates if rp.has_usable_secret(c)), "")
api_key: Any = explicit_key if explicit_key_is_callable else next(
(c for c in candidates if rp.has_usable_secret(c)), ""
)
# ``key_cmd`` credentials are minted per request (short-lived bearers would go stale
# mid-session); both wire clients accept a callable api_key (the Entra ID contract). An
# explicit --api-key still wins as the one-off recovery escape hatch.
key_cmd = _clean(custom_provider.get("key_cmd", ""))
if key_cmd and not rp.has_usable_secret(explicit_key):
if key_cmd and not explicit_key_is_callable and not rp.has_usable_secret(explicit_key):
from agent.command_token_source import build_command_token_provider
token_provider = build_command_token_provider(key_cmd, str(custom_provider.get("name", requested_provider) or "custom"))
if token_provider is not None:
Expand Down
29 changes: 28 additions & 1 deletion tests/agent/test_command_token_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,33 @@ def test_explicit_api_key_still_wins(self, monkeypatch):
)
assert runtime["api_key"] == "sk-explicit-override"

def test_explicit_callable_survives_named_custom_reresolution(self, monkeypatch):
"""A live key_cmd source passed back through the main resolver must
remain callable instead of raising or becoming an object repr."""
from hermes_cli import runtime_provider as rp

config = {
"providers": {
"dbx": {
"base_url": "https://example.invalid/v1",
"api_mode": "chat_completions",
"model": "m1",
"key_cmd": "printf minted-token",
}
}
}
monkeypatch.setattr(rp, "load_config", lambda *a, **k: config)
monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: config)

token_provider = CommandTokenSource("printf forwarded-token", "dbx")
runtime = rp.resolve_runtime_provider(
requested="custom:dbx",
explicit_api_key=token_provider,
target_model="m1",
)

assert runtime["api_key"] is token_provider


class TestCallableKeyGetsBearerAuth:
"""A callable api_key must reach the Anthropic bearer-hook client path.
Expand Down Expand Up @@ -291,7 +318,7 @@ def test_the_token_actually_gets_re_minted(self, tmp_path):
assert src._expires_at is not None, "cache must carry a deadline"
src._expires_at = time.monotonic() - 1 # simulate crossing it
src()
assert len(counter.read_text()) == 2, "expired cache must re-run the helper"
assert len(counter.read_text(encoding="utf-8")) == 2, "expired cache must re-run the helper"


class TestAuxiliaryResolverHonoursKeyCmd:
Expand Down
24 changes: 24 additions & 0 deletions tests/hermes_cli/test_azure_foundry_entra.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_i
assert runtime["auth_mode"] == "api_key"
assert runtime["source"] == "explicit"

def test_forwarded_entra_callable_preserves_identity_and_metadata(self, fake_azure_identity):
"""A live provider re-resolution must not stringify its token source or
relabel Entra authentication as a static-key override."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime

token_provider = lambda: "forwarded-jwt"
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {"scope": "https://ai.azure.com/.default"},
"default": "gpt-4o",
},
explicit_api_key=token_provider,
)

assert runtime["api_key"] is token_provider
assert runtime["auth_mode"] == "entra_id"
assert runtime["source"] == "entra_id"
assert runtime["entra"] == {"scope": "https://ai.azure.com/.default"}


# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: legacy api_key branch (regression)
Expand Down
16 changes: 16 additions & 0 deletions tests/hermes_cli/test_model_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,22 @@ def test_probe_user_agent_sent_without_api_key(self):
# No Authorization was set, but UA must still be present.
assert req.get_header("Authorization") is None

def test_probe_materializes_callable_credentials(self):
"""Capability probes must mint a callable before constructing auth headers."""
from unittest.mock import patch

body = b'{"data":[{"id":"gpt-4o"}]}'
token_provider = lambda: "minted-probe-token"
with patch(
"hermes_cli.models._urlopen_model_catalog_request",
return_value=self._make_mock_response(body),
) as mock_urlopen:
result = probe_api_models(token_provider, "https://example.com/v1")

assert result["models"] == ["gpt-4o"]
req = mock_urlopen.call_args[0][0]
assert req.get_header("Authorization") == "Bearer minted-probe-token"




Expand Down
1 change: 1 addition & 0 deletions tests/tui_gateway/test_make_agent_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def switch_model(self, **kw):
# Target session recorded a per-session override.
assert sess_b["model_override"]["model"] == "zai/glm-5.1"
assert sess_b["model_override"]["provider"] == "zai"
assert "api_key" not in sess_b["model_override"]
# The switched agent mutated in place.
assert sess_b["agent"].model == "zai/glm-5.1"
# Sibling session is completely untouched.
Expand Down
2 changes: 1 addition & 1 deletion tui_gateway/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def _apply_model_switch(
if pin_session_override and isinstance(session, dict) and not one_turn:
session["model_override"] = {
"model": result.new_model, "provider": result.target_provider,
"base_url": result.base_url, "api_key": result.api_key, "api_mode": result.api_mode}
"base_url": result.base_url, "api_mode": result.api_mode}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing api_key here breaks rebuilds after switching to a credential-bearing model_aliases entry. I reproduced this on this head: the initial /model private-alias switch uses the configured alias key, but _resolve_agent_model_runtime(session["model_override"], None) then receives only provider: custom and the base URL and returns no-key-required. The recorded base keeps the in-memory key and rebuilds correctly. sanitize_model_override() already removes api_key before sessions.json persistence, so please retain it in the live override or persist non-secret alias identity and re-resolve it during rebuild.

if persist_global:
_persist_model_switch(result)
return {
Expand Down