"""Tests for agent.auxiliary_client resolution chain, provider overrides, and model overrides.""" import json import os from pathlib import Path from unittest.mock import patch, MagicMock import pytest from agent.auxiliary_client import ( get_text_auxiliary_client, get_vision_auxiliary_client, get_available_vision_backends, resolve_vision_provider_client, resolve_provider_client, auxiliary_max_tokens_param, _read_codex_access_token, _get_auxiliary_provider, _resolve_forced_provider, _resolve_auto, ) @pytest.fixture(autouse=True) def _clean_env(monkeypatch): """Strip provider env vars so each test starts clean.""" for key in ( "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", # Per-task provider/model/direct-endpoint overrides "AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL", "AUXILIARY_VISION_BASE_URL", "AUXILIARY_VISION_API_KEY", "AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL", "AUXILIARY_WEB_EXTRACT_BASE_URL", "AUXILIARY_WEB_EXTRACT_API_KEY", "CONTEXT_COMPRESSION_PROVIDER", "CONTEXT_COMPRESSION_MODEL", ): monkeypatch.delenv(key, raising=False) @pytest.fixture def codex_auth_dir(tmp_path, monkeypatch): """Provide a writable ~/.codex/ directory with a valid auth.json.""" codex_dir = tmp_path / ".codex" codex_dir.mkdir() auth_file = codex_dir / "auth.json" auth_file.write_text(json.dumps({ "tokens": { "access_token": "codex-test-token-abc123", "refresh_token": "codex-refresh-xyz", } })) monkeypatch.setattr( "agent.auxiliary_client._read_codex_access_token", lambda: "codex-test-token-abc123", ) return codex_dir class TestReadCodexAccessToken: def test_valid_auth_store(self, tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": "tok-123", "refresh_token": "r-456"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result == "tok-123" def test_missing_returns_none(self, tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result is None def test_empty_token_returns_none(self, tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": " ", "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result is None def test_malformed_json_returns_none(self, tmp_path): codex_dir = tmp_path / ".codex" codex_dir.mkdir() (codex_dir / "auth.json").write_text("{bad json") with patch("agent.auxiliary_client.Path.home", return_value=tmp_path): result = _read_codex_access_token() assert result is None def test_missing_tokens_key_returns_none(self, tmp_path): codex_dir = tmp_path / ".codex" codex_dir.mkdir() (codex_dir / "auth.json").write_text(json.dumps({"other": "data"})) with patch("agent.auxiliary_client.Path.home", return_value=tmp_path): result = _read_codex_access_token() assert result is None def test_expired_jwt_returns_none(self, tmp_path, monkeypatch): """Expired JWT tokens should be skipped so auto chain continues.""" import base64 import time as _time # Build a JWT with exp in the past header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() expired_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result is None, "Expired JWT should return None" def test_valid_jwt_returns_token(self, tmp_path, monkeypatch): """Non-expired JWT tokens should be returned.""" import base64 import time as _time header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() payload_data = json.dumps({"exp": int(_time.time()) + 3600}).encode() payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() valid_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": valid_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result == valid_jwt def test_non_jwt_token_passes_through(self, tmp_path, monkeypatch): """Non-JWT tokens (no dots) should be returned as-is.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": "plain-token-no-jwt", "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result == "plain-token-no-jwt" class TestAnthropicOAuthFlag: """Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client.""" def test_oauth_token_sets_flag(self, monkeypatch): """OAuth tokens (sk-ant-oat01-*) should create client with is_oauth=True.""" monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-token") with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient client, model = _try_anthropic() assert client is not None assert isinstance(client, AnthropicAuxiliaryClient) # The adapter inside should have is_oauth=True adapter = client.chat.completions assert adapter._is_oauth is True def test_api_key_no_oauth_flag(self, monkeypatch): """Regular API keys (sk-ant-api-*) should create client with is_oauth=False.""" with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-testkey1234"), \ patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient client, model = _try_anthropic() assert client is not None assert isinstance(client, AnthropicAuxiliaryClient) adapter = client.chat.completions assert adapter._is_oauth is False class TestExpiredCodexFallback: """Test that expired Codex tokens don't block the auto chain.""" def test_expired_codex_falls_through_to_next(self, tmp_path, monkeypatch): """When Codex token is expired, auto chain should skip it and try next provider.""" import base64 import time as _time # Expired Codex JWT header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() expired_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) # Set up Anthropic as fallback monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-fallback") with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() from agent.auxiliary_client import _resolve_auto, AnthropicAuxiliaryClient client, model = _resolve_auto() # Should NOT be Codex, should be Anthropic (or another available provider) assert not isinstance(client, type(None)), "Should find a provider after expired Codex" def test_expired_codex_openrouter_wins(self, tmp_path, monkeypatch): """With expired Codex + OpenRouter key, OpenRouter should win (1st in chain).""" import base64 import time as _time header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() expired_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() from agent.auxiliary_client import _resolve_auto client, model = _resolve_auto() assert client is not None # OpenRouter is 1st in chain, should win mock_openai.assert_called() def test_expired_codex_custom_endpoint_wins(self, tmp_path, monkeypatch): """With expired Codex + custom endpoint (Ollama), custom should win (3rd in chain).""" import base64 import time as _time header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() expired_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) # Simulate Ollama or custom endpoint with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("http://localhost:11434/v1", "sk-dummy")): with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() from agent.auxiliary_client import _resolve_auto client, model = _resolve_auto() assert client is not None def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch): """OAuth-style tokens should get is_oauth=True (token is not sk-ant-api-*).""" # Mock resolve_anthropic_token to return an OAuth-style token with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="hermes-oauth-jwt-token"), \ patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient client, model = _try_anthropic() assert client is not None, "Should resolve token" adapter = client.chat.completions assert adapter._is_oauth is True, "Non-sk-ant-api token should set is_oauth=True" def test_jwt_missing_exp_passes_through(self, tmp_path, monkeypatch): """JWT with valid JSON but no exp claim should pass through.""" import base64 header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() payload_data = json.dumps({"sub": "user123"}).encode() # no exp payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() no_exp_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": no_exp_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result == no_exp_jwt, "JWT without exp should pass through" def test_jwt_invalid_json_payload_passes_through(self, tmp_path, monkeypatch): """JWT with valid base64 but invalid JSON payload should pass through.""" import base64 header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() payload = base64.urlsafe_b64encode(b"not-json-content").rstrip(b"=").decode() bad_jwt = f"{header}.{payload}.fakesig" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": { "openai-codex": { "tokens": {"access_token": bad_jwt, "refresh_token": "r"}, }, }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = _read_codex_access_token() assert result == bad_jwt, "JWT with invalid JSON payload should pass through" def test_claude_code_oauth_env_sets_flag(self, monkeypatch): """CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True.""" monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "cc-oauth-token-test") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient client, model = _try_anthropic() assert client is not None adapter = client.chat.completions assert adapter._is_oauth is True class TestExplicitProviderRouting: """Test explicit provider selection bypasses auto chain correctly.""" def test_explicit_anthropic_oauth(self, monkeypatch): """provider='anthropic' + OAuth token should work with is_oauth=True.""" monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-explicit-test") with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() client, model = resolve_provider_client("anthropic") assert client is not None # Verify OAuth flag propagated adapter = client.chat.completions assert adapter._is_oauth is True def test_explicit_anthropic_api_key(self, monkeypatch): """provider='anthropic' + regular API key should work with is_oauth=False.""" with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api-regular-key"), \ patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() client, model = resolve_provider_client("anthropic") assert client is not None adapter = client.chat.completions assert adapter._is_oauth is False def test_explicit_openrouter(self, monkeypatch): """provider='openrouter' should use OPENROUTER_API_KEY.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-explicit") with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() client, model = resolve_provider_client("openrouter") assert client is not None def test_explicit_kimi(self, monkeypatch): """provider='kimi-coding' should use KIMI_API_KEY.""" monkeypatch.setenv("KIMI_API_KEY", "kimi-test-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() client, model = resolve_provider_client("kimi-coding") assert client is not None def test_explicit_minimax(self, monkeypatch): """provider='minimax' should use MINIMAX_API_KEY.""" monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() client, model = resolve_provider_client("minimax") assert client is not None def test_explicit_deepseek(self, monkeypatch): """provider='deepseek' should use DEEPSEEK_API_KEY.""" monkeypatch.setenv("DEEPSEEK_API_KEY", "ds-test-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() client, model = resolve_provider_client("deepseek") assert client is not None def test_explicit_zai(self, monkeypatch): """provider='zai' should use GLM_API_KEY.""" monkeypatch.setenv("GLM_API_KEY", "zai-test-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() client, model = resolve_provider_client("zai") assert client is not None def test_explicit_unknown_returns_none(self, monkeypatch): """Unknown provider should return None.""" client, model = resolve_provider_client("nonexistent-provider") assert client is None class TestGetTextAuxiliaryClient: """Test the full resolution chain for get_text_auxiliary_client.""" def test_openrouter_takes_priority(self, monkeypatch, codex_auth_dir): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "google/gemini-3-flash-preview" mock_openai.assert_called_once() call_kwargs = mock_openai.call_args assert call_kwargs.kwargs["api_key"] == "or-key" def test_nous_takes_priority_over_codex(self, monkeypatch, codex_auth_dir): with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \ patch("agent.auxiliary_client.OpenAI") as mock_openai: mock_nous.return_value = {"access_token": "nous-tok"} client, model = get_text_auxiliary_client() assert model == "google/gemini-3-flash-preview" def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir): monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1") monkeypatch.setenv("OPENAI_API_KEY", "lm-studio-key") monkeypatch.setenv("OPENAI_MODEL", "my-local-model") # Override the autouse monkeypatch for codex monkeypatch.setattr( "agent.auxiliary_client._read_codex_access_token", lambda: "codex-test-token-abc123", ) with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "my-local-model" call_kwargs = mock_openai.call_args assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1" def test_task_direct_endpoint_override(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1") monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_API_KEY", "task-key") monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client("web_extract") assert model == "task-model" assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1" assert mock_openai.call_args.kwargs["api_key"] == "task-key" def test_task_direct_endpoint_without_openai_key_uses_placeholder(self, monkeypatch): """Local endpoints without an API key should use 'no-key-required' placeholder.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1") monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client("web_extract") assert client is not None assert model == "task-model" assert mock_openai.call_args.kwargs["api_key"] == "no-key-required" assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1" def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch): config = { "model": { "provider": "custom", "base_url": "http://localhost:1234/v1", "default": "my-local-model", } } monkeypatch.setenv("OPENAI_API_KEY", "lm-studio-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 = get_text_auxiliary_client() assert client is not None assert model == "my-local-model" call_kwargs = mock_openai.call_args assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1" def test_codex_fallback_when_nothing_else(self, codex_auth_dir): with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "gpt-5.2-codex" # Returns a CodexAuxiliaryClient wrapper, not a raw OpenAI client from agent.auxiliary_client import CodexAuxiliaryClient assert isinstance(client, CodexAuxiliaryClient) def test_returns_none_when_nothing_available(self, monkeypatch): monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) 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)): client, model = get_text_auxiliary_client() assert client is None assert model is None class TestVisionClientFallback: """Vision client auto mode resolves known-good multimodal backends.""" def test_vision_returns_none_without_any_credentials(self): with ( patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.auxiliary_client._try_anthropic", return_value=(None, None)), ): client, model = get_vision_auxiliary_client() assert client is None assert model is None def test_vision_auto_includes_anthropic_when_configured(self, monkeypatch): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") with ( patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), ): backends = get_available_vision_backends() assert "anthropic" in backends def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") with ( patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), ): client, model = resolve_provider_client("anthropic") assert client is not None assert client.__class__.__name__ == "AnthropicAuxiliaryClient" assert model == "claude-haiku-4-5-20251001" def test_resolve_provider_client_copilot_uses_runtime_credentials(self, monkeypatch): monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.delenv("GH_TOKEN", raising=False) with ( patch( "hermes_cli.auth.resolve_api_key_provider_credentials", return_value={ "provider": "copilot", "api_key": "gh-cli-token", "base_url": "https://api.githubcopilot.com", "source": "gh auth token", }, ), patch("agent.auxiliary_client.OpenAI") as mock_openai, ): client, model = resolve_provider_client("copilot", model="gpt-5.4") assert client is not None assert model == "gpt-5.4" call_kwargs = mock_openai.call_args.kwargs assert call_kwargs["api_key"] == "gh-cli-token" assert call_kwargs["base_url"] == "https://api.githubcopilot.com" assert call_kwargs["default_headers"]["Editor-Version"] def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") with ( patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), ): client, model = get_vision_auxiliary_client() assert client is not None assert client.__class__.__name__ == "AnthropicAuxiliaryClient" assert model == "claude-haiku-4-5-20251001" def test_selected_anthropic_provider_is_preferred_for_vision_auto(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") def fake_load_config(): return {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}} with ( patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), patch("agent.auxiliary_client.OpenAI") as mock_openai, patch("hermes_cli.config.load_config", fake_load_config), ): client, model = get_vision_auxiliary_client() assert client is not None assert client.__class__.__name__ == "AnthropicAuxiliaryClient" assert model == "claude-haiku-4-5-20251001" def test_selected_codex_provider_short_circuits_vision_auto(self, monkeypatch): def fake_load_config(): return {"model": {"provider": "openai-codex", "default": "gpt-5.2-codex"}} codex_client = MagicMock() with ( patch("hermes_cli.config.load_config", fake_load_config), patch("agent.auxiliary_client._try_codex", return_value=(codex_client, "gpt-5.2-codex")) as mock_codex, patch("agent.auxiliary_client._try_openrouter") as mock_openrouter, patch("agent.auxiliary_client._try_nous") as mock_nous, patch("agent.auxiliary_client._try_anthropic") as mock_anthropic, patch("agent.auxiliary_client._try_custom_endpoint") as mock_custom, ): provider, client, model = resolve_vision_provider_client() assert provider == "openai-codex" assert client is codex_client assert model == "gpt-5.2-codex" mock_codex.assert_called_once() mock_openrouter.assert_not_called() mock_nous.assert_not_called() mock_anthropic.assert_not_called() mock_custom.assert_not_called() def test_vision_auto_includes_codex(self, codex_auth_dir): """Codex supports vision (gpt-5.3-codex), so auto mode should use it.""" with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI"): client, model = get_vision_auxiliary_client() from agent.auxiliary_client import CodexAuxiliaryClient assert isinstance(client, CodexAuxiliaryClient) assert model == "gpt-5.2-codex" def test_vision_auto_falls_back_to_custom_endpoint(self, monkeypatch): """Custom endpoint is used as fallback in vision auto mode. Many local models (Qwen-VL, LLaVA, etc.) support vision. When no OpenRouter/Nous/Codex is available, try the custom endpoint. """ monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1") monkeypatch.setenv("OPENAI_API_KEY", "local-key") with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_vision_auxiliary_client() assert client is not None # Custom endpoint picked up as fallback def test_vision_direct_endpoint_override(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1") monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "vision-key") monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_vision_auxiliary_client() assert model == "vision-model" assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:4567/v1" assert mock_openai.call_args.kwargs["api_key"] == "vision-key" def test_vision_direct_endpoint_without_key_uses_placeholder(self, monkeypatch): """Vision endpoint without API key should use 'no-key-required' placeholder.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1") monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_vision_auxiliary_client() assert client is not None assert model == "vision-model" assert mock_openai.call_args.kwargs["api_key"] == "no-key-required" def test_vision_uses_openrouter_when_available(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_vision_auxiliary_client() assert model == "google/gemini-3-flash-preview" assert client is not None def test_vision_uses_nous_when_available(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 = get_vision_auxiliary_client() assert model == "google/gemini-3-flash-preview" assert client is not None def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch): """When explicitly forced to 'main', vision CAN use custom endpoint.""" monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main") monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1") monkeypatch.setenv("OPENAI_API_KEY", "local-key") monkeypatch.setenv("OPENAI_MODEL", "my-local-model") with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_vision_auxiliary_client() assert client is not None assert model == "my-local-model" def test_vision_forced_main_returns_none_without_creds(self, monkeypatch): """Forced main with no credentials still returns None.""" monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main") monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) 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)): client, model = get_vision_auxiliary_client() assert client is None assert model is None def test_vision_forced_codex(self, monkeypatch, codex_auth_dir): """When forced to 'codex', vision uses Codex OAuth.""" monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "codex") with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI"): client, model = get_vision_auxiliary_client() from agent.auxiliary_client import CodexAuxiliaryClient assert isinstance(client, CodexAuxiliaryClient) assert model == "gpt-5.2-codex" class TestGetAuxiliaryProvider: """Tests for _get_auxiliary_provider env var resolution.""" def test_no_task_returns_auto(self): assert _get_auxiliary_provider() == "auto" assert _get_auxiliary_provider("") == "auto" def test_auxiliary_prefix_takes_priority(self, monkeypatch): monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "openrouter") assert _get_auxiliary_provider("vision") == "openrouter" def test_context_prefix_fallback(self, monkeypatch): monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous") assert _get_auxiliary_provider("compression") == "nous" def test_auxiliary_prefix_over_context_prefix(self, monkeypatch): monkeypatch.setenv("AUXILIARY_COMPRESSION_PROVIDER", "openrouter") monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous") assert _get_auxiliary_provider("compression") == "openrouter" def test_auto_value_treated_as_auto(self, monkeypatch): monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "auto") assert _get_auxiliary_provider("vision") == "auto" def test_whitespace_stripped(self, monkeypatch): monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", " openrouter ") assert _get_auxiliary_provider("vision") == "openrouter" def test_case_insensitive(self, monkeypatch): monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "OpenRouter") assert _get_auxiliary_provider("vision") == "openrouter" def test_main_provider(self, monkeypatch): monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_PROVIDER", "main") 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): monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1") monkeypatch.setenv("OPENAI_API_KEY", "local-key") monkeypatch.setenv("OPENAI_MODEL", "my-local-model") 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.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1") monkeypatch.setenv("OPENAI_API_KEY", "local-key") monkeypatch.setenv("OPENAI_MODEL", "my-local-model") 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.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=...).""" def test_text_with_vision_provider_override(self, monkeypatch): """AUXILIARY_VISION_PROVIDER should not affect text tasks.""" monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "nous") monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") with patch("agent.auxiliary_client.OpenAI"): client, model = get_text_auxiliary_client() # no task → auto assert model == "google/gemini-3-flash-preview" # OpenRouter, not Nous def test_compression_task_reads_context_prefix(self, monkeypatch): """Compression task should check CONTEXT_COMPRESSION_PROVIDER env var.""" monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous") monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") # would win in auto with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \ patch("agent.auxiliary_client.OpenAI"): mock_nous.return_value = {"access_token": "***"} client, model = get_text_auxiliary_client("compression") # Config-first: model comes from config.yaml summary_model default, # but provider is forced to Nous via env var assert client is not None def test_web_extract_task_override(self, monkeypatch): monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_PROVIDER", "openrouter") monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") with patch("agent.auxiliary_client.OpenAI"): client, model = get_text_auxiliary_client("web_extract") assert model == "google/gemini-3-flash-preview" def test_task_direct_endpoint_from_config(self, monkeypatch, tmp_path): hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "config.yaml").write_text( """auxiliary: web_extract: base_url: http://localhost:3456/v1 api_key: config-key model: config-model """ ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client("web_extract") assert model == "config-model" assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:3456/v1" assert mock_openai.call_args.kwargs["api_key"] == "config-key" def test_task_without_override_uses_auto(self, monkeypatch): """A task with no provider env var falls through to auto chain.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") with patch("agent.auxiliary_client.OpenAI"): client, model = get_text_auxiliary_client("compression") assert model == "google/gemini-3-flash-preview" # auto → OpenRouter def test_compression_summary_base_url_from_config(self, monkeypatch, tmp_path): """compression.summary_base_url should produce a custom-endpoint client.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "config.yaml").write_text( """compression: summary_provider: custom summary_model: glm-4.7 summary_base_url: https://api.z.ai/api/coding/paas/v4 """ ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) # Custom endpoints need an API key to build the client monkeypatch.setenv("OPENAI_API_KEY", "test-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client("compression") assert model == "glm-4.7" assert mock_openai.call_args.kwargs["base_url"] == "https://api.z.ai/api/coding/paas/v4" class TestAuxiliaryMaxTokensParam: def test_codex_fallback_uses_max_tokens(self, monkeypatch): """Codex adapter translates max_tokens internally, so we return max_tokens.""" with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client._read_codex_access_token", return_value="tok"): result = auxiliary_max_tokens_param(1024) assert result == {"max_tokens": 1024} def test_openrouter_uses_max_tokens(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") result = auxiliary_max_tokens_param(1024) assert result == {"max_tokens": 1024} def test_no_provider_uses_max_tokens(self): with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client._read_codex_access_token", return_value=None): result = auxiliary_max_tokens_param(1024) assert result == {"max_tokens": 1024}