From 4135933ec9fdb2b489477a50cf6d92d77fe43be6 Mon Sep 17 00:00:00 2001 From: Justin Bennington <43761886+somewheresy@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:01:17 -0400 Subject: [PATCH] fix(providers): preserve Actual routing during startup and key reload (E-1047) --- agent/agent_init.py | 9 +++ agent/client_lifecycle.py | 7 +- hermes_cli/model_setup_flows.py | 3 +- tests/agent/test_actual_auxiliary_routing.py | 81 ++++++++++++++++++-- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index dfbb4cae60..aa6574e654 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -845,6 +845,10 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Dict[str, _routed_client, _ = resolve_provider_client( agent.provider or "auto", model=agent.model, raw_codex=True) if _routed_client is not None: + from hermes_cli.providers import is_actual_route, normalize_provider + effective_provider = getattr(_routed_client, "_hermes_aux_effective_provider", "") + if is_actual_route(effective_provider): + agent.provider = normalize_provider(effective_provider) return _client_kwargs_from_routed(_routed_client, _provider_timeout) # No credentials: try the fallback chain BEFORE failing (an exhausted single-entry pool # must not die with a misleading "No LLM provider configured"); only explicitly named @@ -929,6 +933,11 @@ def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_time client_kwargs = _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) else: client_kwargs = _routed_client_kwargs(agent, fallback_model, _provider_timeout) + from hermes_cli.providers import is_actual_route + if is_actual_route(agent.provider, client_kwargs.get("base_url", "")): + agent.api_mode = "chat_completions" + if hasattr(agent, "_transport_cache"): + agent._transport_cache.clear() try: from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs configure_bedrock_openai_client_kwargs(client_kwargs, timeout=_provider_timeout) diff --git a/agent/client_lifecycle.py b/agent/client_lifecycle.py index 1563dcc610..eb2dc19eb8 100644 --- a/agent/client_lifecycle.py +++ b/agent/client_lifecycle.py @@ -677,7 +677,12 @@ class ClientLifecycleMixin: env_url = get_env_prefer_dotenv(url_var).strip().rstrip("/") if url_var else "" default_base = (pconfig.inference_base_url or "").strip().rstrip("/") base_url = env_url or default_base - if self.provider in ("kimi-coding", "zai"): + if self.provider == "actual": + from hermes_cli.auth import normalize_actual_base_url + from hermes_cli.runtime_provider import _config_base_url_for_provider, _get_model_config + configured_base = _config_base_url_for_provider(_get_model_config(), "actual") + base_url = normalize_actual_base_url(configured_base or base_url) + elif self.provider in ("kimi-coding", "zai"): from hermes_cli import auth as _auth resolver = _auth._resolve_kimi_base_url if self.provider == "kimi-coding" else _auth._resolve_zai_base_url base_url = resolver(api_key, pconfig.inference_base_url, env_url).rstrip("/") diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index b97ad50ceb..f6e7108c15 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -933,8 +933,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): effective_base = current_base or pconfig.inference_base_url if provider_id == "actual": + from hermes_cli.providers import normalize_provider model_cfg = config.get("model") or {} - if isinstance(model_cfg, dict) and model_cfg.get("provider") == provider_id: + if isinstance(model_cfg, dict) and normalize_provider(str(model_cfg.get("provider") or "")) == provider_id: effective_base = str(model_cfg.get("base_url") or "").strip() or effective_base if provider_id == "zai": diff --git a/tests/agent/test_actual_auxiliary_routing.py b/tests/agent/test_actual_auxiliary_routing.py index 1cfc06d03c..033bfeabaa 100644 --- a/tests/agent/test_actual_auxiliary_routing.py +++ b/tests/agent/test_actual_auxiliary_routing.py @@ -232,7 +232,17 @@ def test_actual_background_tasks_reach_chat_completions( ], ) @pytest.mark.parametrize( - "entrypoint", ["init", "auto", "switch", "fallback", "restore", "rotation"] + "entrypoint", + [ + "init", + "auto", + "switch", + "fallback", + "restore", + "rotation", + "init_fallback", + "init_auto", + ], ) def test_actual_runtime_transitions_reach_chat_completions( tmp_path, monkeypatch, actual_endpoint, provider, hosted, entrypoint @@ -266,12 +276,20 @@ def test_actual_runtime_transitions_reach_chat_completions( (tmp_path / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") runtime = resolve_runtime_provider(requested=provider) assert runtime["api_mode"] == "chat_completions" + initial_base = base_url.removesuffix("/v1") + initial_provider = provider + initial_key = "actual-test-key" + if entrypoint in {"init_fallback", "init_auto"}: + initial_base = initial_key = None + initial_provider = ( + "missing-provider" if entrypoint == "init_fallback" else "auto" + ) + elif entrypoint == "rotation": + initial_base = base_url.replace("api.actual.inc", "127.0.0.1") agent = AIAgent( - provider=provider, - base_url=base_url.replace("api.actual.inc", "127.0.0.1") - if entrypoint == "rotation" - else base_url.removesuffix("/v1"), - api_key="actual-test-key", + provider=initial_provider, + base_url=initial_base, + api_key=initial_key, api_mode=None if entrypoint == "auto" else "codex_responses", model="primary-model" if entrypoint == "fallback" else "gpt-5.4", enabled_toolsets=[], @@ -386,7 +404,10 @@ def test_actual_auxiliary_fallback_reaches_chat_completions( @pytest.mark.parametrize("override", ["", "http://127.0.0.1:8081", "invalid-url"]) -def test_actual_setup_keeps_provider_settings_in_yaml(tmp_path, monkeypatch, override): +@pytest.mark.parametrize("configured_provider", ["actual", "aci"]) +def test_actual_setup_keeps_provider_settings_in_yaml( + tmp_path, monkeypatch, override, configured_provider +): from hermes_cli import config as config_module from hermes_cli import model_setup_flows as setup from hermes_cli.auth import resolve_api_key_provider_credentials @@ -399,7 +420,7 @@ def test_actual_setup_keeps_provider_settings_in_yaml(tmp_path, monkeypatch, ove configured_url = "http://127.0.0.1:8080" raw = { "model": { - "provider": "actual", + "provider": configured_provider, "default": "old-model", "base_url": configured_url, } @@ -435,3 +456,47 @@ def test_actual_setup_keeps_provider_settings_in_yaml(tmp_path, monkeypatch, ove runtime = resolve_runtime_provider(requested="actual", **kwargs) assert runtime["base_url"] == expected_url + "/v1" assert runtime["api_mode"] == "chat_completions" + + +def test_actual_key_reload_keeps_yaml_endpoint(tmp_path, monkeypatch, actual_endpoint): + from run_agent import AIAgent + + base_url, requests = actual_endpoint + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key") + monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:1") + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({ + "model": {"provider": "aci", "default": "test-model", "base_url": base_url}, + }), + encoding="utf-8", + ) + env_path = tmp_path / ".env" + env_path.write_text("ACTUAL_API_KEY=actual-test-key\n", encoding="utf-8") + agent = AIAgent( + provider="actual", + base_url=base_url, + api_key="actual-test-key", + model="test-model", + enabled_toolsets=[], + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + save_trajectories=False, + ) + try: + assert not agent._try_refresh_env_client_credentials() + env_path.write_text("ACTUAL_API_KEY=actual-rotated-key\n", encoding="utf-8") + assert agent._try_refresh_env_client_credentials() + assert agent.api_key == "actual-rotated-key" + assert agent.base_url == base_url + "/v1" + assert agent.api_mode == "chat_completions" + response = agent._interruptible_api_call( + agent._build_api_kwargs([{"role": "user", "content": "Reply briefly."}], []) + ) + assert response.choices[0].message.content == "The task is complete." + assert [ + path for path, body in requests if body.get("model") == "test-model" + ] == ["/v1/chat/completions"] + finally: + agent.client.close()