diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 940bdfd45..d21b96240 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -857,7 +857,7 @@ def _read_main_provider() -> str: return "" -def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]: +def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]: """Resolve the active custom/main endpoint the same way the main CLI does. This covers both env-driven OPENAI_BASE_URL setups and config-saved custom @@ -870,18 +870,29 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]: runtime = resolve_runtime_provider(requested="custom") except Exception as exc: logger.debug("Auxiliary client: custom runtime resolution failed: %s", exc) - return None, None + runtime = None + + if not isinstance(runtime, dict): + openai_base = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") + openai_key = os.getenv("OPENAI_API_KEY", "").strip() + if not openai_base: + return None, None, None + runtime = { + "base_url": openai_base, + "api_key": openai_key, + } custom_base = runtime.get("base_url") custom_key = runtime.get("api_key") + custom_mode = runtime.get("api_mode") if not isinstance(custom_base, str) or not custom_base.strip(): - return None, None + return None, None, None custom_base = custom_base.strip().rstrip("/") if "openrouter.ai" in custom_base.lower(): # requested='custom' falls back to OpenRouter when no custom endpoint is # configured. Treat that as "no custom endpoint" for auxiliary routing. - return None, None + return None, None, None # Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth. # Use a placeholder key — the OpenAI SDK requires a non-empty string but @@ -890,20 +901,33 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]: if not isinstance(custom_key, str) or not custom_key.strip(): custom_key = "no-key-required" - return custom_base, custom_key.strip() + if not isinstance(custom_mode, str) or not custom_mode.strip(): + custom_mode = None + + return custom_base, custom_key.strip(), custom_mode def _current_custom_base_url() -> str: - custom_base, _ = _resolve_custom_runtime() + custom_base, _, _ = _resolve_custom_runtime() return custom_base or "" def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]: - custom_base, custom_key = _resolve_custom_runtime() + runtime = _resolve_custom_runtime() + if len(runtime) == 2: + custom_base, custom_key = runtime + custom_mode = None + else: + custom_base, custom_key, custom_mode = runtime if not custom_base or not custom_key: return None, None + if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()): + return None, None model = _read_main_model() or "gpt-4o-mini" - logger.debug("Auxiliary client: custom endpoint (%s)", model) + logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions") + if custom_mode == "codex_responses": + real_client = OpenAI(api_key=custom_key, base_url=custom_base) + return CodexAuxiliaryClient(real_client, model), model return OpenAI(api_key=custom_key, base_url=custom_base), model diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 17f4dc3c8..547224892 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -658,6 +658,19 @@ class TestGetTextAuxiliaryClient: assert client is None assert model is None + def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self): + with patch("agent.auxiliary_client._resolve_custom_runtime", + return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \ + patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = get_text_auxiliary_client() + + from agent.auxiliary_client import CodexAuxiliaryClient + assert isinstance(client, CodexAuxiliaryClient) + assert model == "gpt-5.3-codex" + assert mock_openai.call_args.kwargs["base_url"] == "https://api.openai.com/v1" + assert mock_openai.call_args.kwargs["api_key"] == "sk-test" + class TestVisionClientFallback: """Vision client auto mode resolves known-good multimodal backends.""" @@ -838,6 +851,123 @@ class TestGetAuxiliaryProvider: assert _get_auxiliary_provider("web_extract") == "main" +class TestResolveForcedProvider: + """Tests for _resolve_forced_provider with explicit provider selection.""" + + def test_forced_openrouter(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = _resolve_forced_provider("openrouter") + assert model == "google/gemini-3-flash-preview" + assert client is not None + + def test_forced_openrouter_no_key(self, monkeypatch): + with patch("agent.auxiliary_client._read_nous_auth", return_value=None): + client, model = _resolve_forced_provider("openrouter") + assert client is None + assert model is None + + def test_forced_nous(self, monkeypatch): + with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \ + patch("agent.auxiliary_client.OpenAI"): + mock_nous.return_value = {"access_token": "nous-tok"} + client, model = _resolve_forced_provider("nous") + assert model == "google/gemini-3-flash-preview" + assert client is not None + + def test_forced_nous_not_configured(self, monkeypatch): + with patch("agent.auxiliary_client._read_nous_auth", return_value=None): + client, model = _resolve_forced_provider("nous") + assert client is None + assert model is None + + def test_forced_main_uses_custom(self, monkeypatch): + config = { + "model": { + "provider": "custom", + "base_url": "http://local:8080/v1", + "default": "my-local-model", + } + } + monkeypatch.setenv("OPENAI_API_KEY", "local-key") + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config) + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = _resolve_forced_provider("main") + assert model == "my-local-model" + + def test_forced_main_uses_config_saved_custom_endpoint(self, monkeypatch): + config = { + "model": { + "provider": "custom", + "base_url": "http://local:8080/v1", + "default": "my-local-model", + } + } + monkeypatch.setenv("OPENAI_API_KEY", "local-key") + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config) + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \ + patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)), \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = _resolve_forced_provider("main") + assert client is not None + assert model == "my-local-model" + call_kwargs = mock_openai.call_args + assert call_kwargs.kwargs["base_url"] == "http://local:8080/v1" + + def test_forced_main_skips_openrouter_nous(self, monkeypatch): + """Even if OpenRouter key is set, 'main' skips it.""" + config = { + "model": { + "provider": "custom", + "base_url": "http://local:8080/v1", + "default": "my-local-model", + } + } + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("OPENAI_API_KEY", "local-key") + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config) + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = _resolve_forced_provider("main") + # Should use custom endpoint, not OpenRouter + assert model == "my-local-model" + + def test_forced_main_falls_to_codex(self, codex_auth_dir, monkeypatch): + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._resolve_custom_runtime", return_value=(None, None, None)), \ + patch("agent.auxiliary_client.OpenAI"): + client, model = _resolve_forced_provider("main") + from agent.auxiliary_client import CodexAuxiliaryClient + assert isinstance(client, CodexAuxiliaryClient) + assert model == "gpt-5.2-codex" + + def test_forced_codex(self, codex_auth_dir, monkeypatch): + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client.OpenAI"): + client, model = _resolve_forced_provider("codex") + from agent.auxiliary_client import CodexAuxiliaryClient + assert isinstance(client, CodexAuxiliaryClient) + assert model == "gpt-5.2-codex" + + def test_forced_codex_no_token(self, monkeypatch): + with patch("agent.auxiliary_client._read_codex_access_token", return_value=None): + client, model = _resolve_forced_provider("codex") + assert client is None + assert model is None + + def test_forced_unknown_returns_none(self, monkeypatch): + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._read_codex_access_token", return_value=None): + client, model = _resolve_forced_provider("invalid-provider") + assert client is None + assert model is None + + class TestTaskSpecificOverrides: """Integration tests for per-task provider routing via get_text_auxiliary_client(task=...)."""