From 78e844d4465c4d9f6a2cb2f44e5076d31e1c68db Mon Sep 17 00:00:00 2001 From: webtecnica <75556242+webtecnica@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:29:08 -0300 Subject: [PATCH] fix(agent): validate credential pool after provider auto-detection (#63425) Provider auto-detection (URL-based inference for Anthropic, OpenAI Codex, and xAI endpoints) runs before credential-pool validation in AIAgent init, but #63048 placed the pool validation before auto-detection. When the agent is constructed with provider=None and a recognized endpoint URL, the pool is validated against an empty provider identity and discarded, even though auto-detection correctly resolves the provider moments later. Fix: move the credential-pool validation block to after the URL-based auto-detection chain. The pool is stored on the agent before auto-detection; validation now checks the resolved provider and only nullifies agent._credential_pool when the pool's scoped provider genuinely doesn't match. Regression test covers all three auto-detection paths: - Anthropic (api.anthropic.com) - OpenAI Codex (chatgpt.com/backend-api/codex) - xAI (api.x.ai) Fixes #63425. --- agent/agent_init.py | 30 +-- .../test_63425_credential_pool_auto_detect.py | 178 ++++++++++++++++++ 2 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 tests/run_agent/test_63425_credential_pool_auto_detect.py diff --git a/agent/agent_init.py b/agent/agent_init.py index f9acb3c982ab..0c700c279b98 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -434,18 +434,6 @@ def init_agent( agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" - if credential_pool is not None: - try: - from agent.credential_pool import credential_pool_matches_provider - - if not credential_pool_matches_provider( - credential_pool, - agent.provider, - base_url=agent.base_url, - ): - credential_pool = None - except Exception: - credential_pool = None agent._credential_pool = credential_pool agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) @@ -482,6 +470,24 @@ def init_agent( else: agent.api_mode = "chat_completions" + # Credential-pool validation runs AFTER provider auto-detection so + # a pool scoped to e.g. "anthropic" is not rejected when the agent + # was constructed with provider=None and an anthropic.com URL. + # Regression from #63048 which placed this check before the + # URL-based auto-detection block above (fixed #63425). + if credential_pool is not None: + try: + from agent.credential_pool import credential_pool_matches_provider + + if not credential_pool_matches_provider( + credential_pool, + agent.provider, + base_url=agent.base_url, + ): + agent._credential_pool = None + except Exception: + agent._credential_pool = None + # Eagerly warm the transport cache so import errors surface at init, # not mid-conversation. Also validates the api_mode is registered. try: diff --git a/tests/run_agent/test_63425_credential_pool_auto_detect.py b/tests/run_agent/test_63425_credential_pool_auto_detect.py new file mode 100644 index 000000000000..82be64845152 --- /dev/null +++ b/tests/run_agent/test_63425_credential_pool_auto_detect.py @@ -0,0 +1,178 @@ +"""Reproduction test for issue #63425: Provider auto-detection discards credential pools. + +When AIAgent is constructed with provider=None and a recognized endpoint URL, +the provider auto-detection works but the credential pool is discarded because +pool validation runs before URL-based provider inference. +""" +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +import pytest + + +def _mock_client(api_key="test-key", base_url="https://api.anthropic.com"): + c = MagicMock() + c.api_key = api_key + c.base_url = base_url + c._default_headers = None + return c + + +class TestCredentialPoolPreservedOnAutoDetect: + """Issue #63425: credential pool should survive provider auto-detection.""" + + def test_anthropic_pool_preserved_with_url_auto_detect(self): + """When provider=None and base_url=api.anthropic.com, the passed + credential_pool should remain attached after auto-detection.""" + from agent.agent_init import init_agent + + # Build a minimal agent-like object (like tests use object.__new__) + from run_agent import AIAgent + agent = object.__new__(AIAgent) + agent._base_url = "" + agent._base_url_lower = "" + agent._base_url_hostname = "" + + pool = SimpleNamespace(provider="anthropic") + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(None, None)), \ + patch("run_agent.get_tool_definitions", return_value=[]), \ + patch('agent.anthropic_adapter.build_anthropic_client', return_value=MagicMock()), \ + patch('agent.anthropic_adapter.resolve_anthropic_token', return_value=''), \ + patch('agent.anthropic_adapter._is_oauth_token', return_value=False), \ + patch('agent.azure_identity_adapter.is_token_provider', return_value=False), \ + patch('hermes_cli.model_normalize.normalize_model_for_provider', return_value='test-model'), \ + patch('agent.credential_pool.load_pool', return_value=MagicMock()), \ + patch('hermes_cli.config.load_config', return_value={}), \ + patch('hermes_cli.config.get_compatible_custom_providers', return_value=[]), \ + patch('agent.iteration_budget.IterationBudget'), \ + patch('hermes_cli.config.cfg_get', return_value=None): + + init_agent( + agent, + base_url="https://api.anthropic.com", + api_key="test-key", + provider=None, + model="test-model", + credential_pool=pool, + skip_context_files=True, + skip_memory=True, + quiet_mode=True, + ) + + print(f"agent.provider = {agent.provider!r}") + print(f"agent.api_mode = {agent.api_mode!r}") + print(f"agent._credential_pool is pool = {agent._credential_pool is pool}") + + assert agent.provider == "anthropic", ( + f"Provider should be auto-detected as 'anthropic', got {agent.provider!r}" + ) + assert agent.api_mode == "anthropic_messages", ( + f"api_mode should be 'anthropic_messages', got {agent.api_mode!r}" + ) + assert agent._credential_pool is pool, ( + "Credential pool was discarded! agent._credential_pool is NOT the " + "same object that was passed to AIAgent().\n" + f" Expected: {id(pool)}\n" + f" Got: {id(agent._credential_pool)}" + ) + + def test_codex_pool_preserved_with_url_auto_detect(self): + """When provider=None and base_url=chatgpt.com/backend-api/codex, the + passed credential_pool should remain attached.""" + from agent.agent_init import init_agent + from run_agent import AIAgent + agent = object.__new__(AIAgent) + agent._base_url = "" + agent._base_url_lower = "" + agent._base_url_hostname = "" + + pool = SimpleNamespace(provider="openai-codex") + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(_mock_client("key", "https://chatgpt.com/backend-api/codex"), None)), \ + patch("run_agent.get_tool_definitions", return_value=[]), \ + patch("run_agent.OpenAI", return_value=MagicMock()), \ + patch('agent.anthropic_adapter.build_anthropic_client', return_value=MagicMock()), \ + patch('agent.anthropic_adapter.resolve_anthropic_token', return_value=''), \ + patch('agent.anthropic_adapter._is_oauth_token', return_value=False), \ + patch('agent.azure_identity_adapter.is_token_provider', return_value=False), \ + patch('hermes_cli.model_normalize.normalize_model_for_provider', return_value='test-model'), \ + patch('agent.credential_pool.load_pool', return_value=MagicMock()), \ + patch('hermes_cli.config.load_config', return_value={}), \ + patch('hermes_cli.config.get_compatible_custom_providers', return_value=[]), \ + patch('agent.iteration_budget.IterationBudget'), \ + patch('hermes_cli.config.cfg_get', return_value=None): + + init_agent( + agent, + base_url="https://chatgpt.com/backend-api/codex", + api_key="test-key", + provider=None, + model="gpt-5.5", + credential_pool=pool, + skip_context_files=True, + skip_memory=True, + quiet_mode=True, + ) + + print(f"\nagent.provider = {agent.provider!r}") + print(f"agent.api_mode = {agent.api_mode!r}") + print(f"agent._credential_pool is pool = {agent._credential_pool is pool}") + + assert agent.provider == "openai-codex", ( + f"Provider should be auto-detected as 'openai-codex', got {agent.provider!r}" + ) + assert agent._credential_pool is pool, ( + "Credential pool was discarded! agent._credential_pool is NOT the " + f"same object. Expected: {id(pool)}, Got: {id(agent._credential_pool)}" + ) + + def test_xai_pool_preserved_with_url_auto_detect(self): + """When provider=None and base_url=api.x.ai, the passed + credential_pool should remain attached.""" + from agent.agent_init import init_agent + from run_agent import AIAgent + agent = object.__new__(AIAgent) + agent._base_url = "" + agent._base_url_lower = "" + agent._base_url_hostname = "" + + pool = SimpleNamespace(provider="xai") + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(_mock_client("key", "https://api.x.ai"), None)), \ + patch("run_agent.get_tool_definitions", return_value=[]), \ + patch("run_agent.OpenAI", return_value=MagicMock()), \ + patch('agent.anthropic_adapter.build_anthropic_client', return_value=MagicMock()), \ + patch('agent.anthropic_adapter.resolve_anthropic_token', return_value=''), \ + patch('agent.anthropic_adapter._is_oauth_token', return_value=False), \ + patch('agent.azure_identity_adapter.is_token_provider', return_value=False), \ + patch('hermes_cli.model_normalize.normalize_model_for_provider', return_value='test-model'), \ + patch('agent.credential_pool.load_pool', return_value=MagicMock()), \ + patch('hermes_cli.config.load_config', return_value={}), \ + patch('hermes_cli.config.get_compatible_custom_providers', return_value=[]), \ + patch('agent.iteration_budget.IterationBudget'), \ + patch('hermes_cli.config.cfg_get', return_value=None): + + init_agent( + agent, + base_url="https://api.x.ai", + api_key="test-key", + provider=None, + model="grok-5", + credential_pool=pool, + skip_context_files=True, + skip_memory=True, + quiet_mode=True, + ) + + print(f"\nagent.provider = {agent.provider!r}") + print(f"agent.api_mode = {agent.api_mode!r}") + print(f"agent._credential_pool is pool = {agent._credential_pool is pool}") + + assert agent.provider == "xai", ( + f"Provider should be auto-detected as 'xai', got {agent.provider!r}" + ) + assert agent._credential_pool is pool, ( + "Credential pool was discarded! agent._credential_pool is NOT the " + f"same object. Expected: {id(pool)}, Got: {id(agent._credential_pool)}" + )