diff --git a/hermes_cli/models.py b/hermes_cli/models.py index fc70420a09765..7f57b39b3549b 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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. @@ -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, "") diff --git a/hermes_cli/runtime_provider_backends.py b/hermes_cli/runtime_provider_backends.py index 2ce619a3ab21a..9508ee0c6b28c 100644 --- a/hermes_cli/runtime_provider_backends.py +++ b/hermes_cli/runtime_provider_backends.py @@ -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": @@ -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 {} ) diff --git a/hermes_cli/runtime_provider_custom.py b/hermes_cli/runtime_provider_custom.py index b42d6537a4aed..06001346e1680 100644 --- a/hermes_cli/runtime_provider_custom.py +++ b/hermes_cli/runtime_provider_custom.py @@ -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: diff --git a/tests/agent/test_command_token_source.py b/tests/agent/test_command_token_source.py index e7210480ef4ca..ab7f4716be705 100644 --- a/tests/agent/test_command_token_source.py +++ b/tests/agent/test_command_token_source.py @@ -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. @@ -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: diff --git a/tests/hermes_cli/test_azure_foundry_entra.py b/tests/hermes_cli/test_azure_foundry_entra.py index 2205a66cbdda3..20786e8ba24a4 100644 --- a/tests/hermes_cli/test_azure_foundry_entra.py +++ b/tests/hermes_cli/test_azure_foundry_entra.py @@ -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) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index ec3e26cdfede2..aad868b93f0b0 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -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" + diff --git a/tests/tui_gateway/test_make_agent_provider.py b/tests/tui_gateway/test_make_agent_provider.py index ea760e8671a0e..36f77226b469c 100644 --- a/tests/tui_gateway/test_make_agent_provider.py +++ b/tests/tui_gateway/test_make_agent_provider.py @@ -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. diff --git a/tui_gateway/model_switch.py b/tui_gateway/model_switch.py index 770732b6514f4..57bb32c4e4ae4 100644 --- a/tui_gateway/model_switch.py +++ b/tui_gateway/model_switch.py @@ -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} if persist_global: _persist_model_switch(result) return {