diff --git a/tests/tools/test_langchain_client.py b/tests/tools/test_langchain_client.py index 4ab7ac9b1..6dcb32127 100644 --- a/tests/tools/test_langchain_client.py +++ b/tests/tools/test_langchain_client.py @@ -88,6 +88,25 @@ def test_build_chat_client_anthropic_fallback(monkeypatch: pytest.MonkeyPatch) - assert resolved.model == "claude-sonnet-4-6" +def test_build_chat_client_anthropic_without_openai_package( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Claude can run in Anthropic-only environments without langchain_openai.""" + monkeypatch.setitem(sys.modules, "langchain_openai", None) + FakeChatAnthropic = _install_fake_langchain_anthropic(monkeypatch) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv(langchain_client.ENV_ANTHROPIC_KEY, "claude-token") + monkeypatch.delenv(langchain_client.ENV_PROVIDER, raising=False) + + resolved = langchain_client.build_chat_client() + + assert resolved is not None + assert resolved.provider == langchain_client.PROVIDER_ANTHROPIC + assert isinstance(resolved.client, FakeChatAnthropic) + assert resolved.client.kwargs["anthropic_api_key"] == "claude-token" + + def test_build_chat_client_env_provider_override(monkeypatch: pytest.MonkeyPatch) -> None: """Provider override env var should force OpenAI when set.""" FakeChatOpenAI = _install_fake_langchain_openai(monkeypatch) @@ -524,6 +543,24 @@ def __init__(self, **kwargs): assert isinstance(clients[0].client, FakeChatOpenAI) +def test_build_chat_clients_anthropic_without_openai_package( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Multi-client resolution should not make Anthropic depend on OpenAI imports.""" + monkeypatch.setitem(sys.modules, "langchain_openai", None) + FakeChatAnthropic = _install_fake_langchain_anthropic(monkeypatch) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv(langchain_client.ENV_ANTHROPIC_KEY, "claude-token") + monkeypatch.delenv(langchain_client.ENV_PROVIDER, raising=False) + + clients = langchain_client.build_chat_clients() + + assert len(clients) == 1 + assert clients[0].provider == langchain_client.PROVIDER_ANTHROPIC + assert isinstance(clients[0].client, FakeChatAnthropic) + + # --- Reasoning model temperature handling --- diff --git a/tests/tools/test_llm_provider.py b/tests/tools/test_llm_provider.py index 2985268c4..bbd54cfaa 100644 --- a/tests/tools/test_llm_provider.py +++ b/tests/tools/test_llm_provider.py @@ -589,6 +589,18 @@ def test_openai_provider_reports_configured_client_model(self): mock_build.assert_called_once_with(provider="openai", model="gpt-configured") assert result.model_name == "gpt-configured" + def test_openai_provider_preserves_blocked_model_signal(self): + provider = OpenAIProvider() + + with ( + patch("tools.llm_registry.configured_model_for_provider", return_value=""), + patch("tools.langchain_client.build_chat_client") as mock_build, + pytest.raises(RuntimeError, match="LangChain OpenAI not available"), + ): + provider.analyze_completion("output", ["task1"]) + + mock_build.assert_not_called() + def test_anthropic_provider_reports_configured_client_model(self): provider = AnthropicProvider() mock_client = MagicMock() @@ -616,6 +628,18 @@ def test_anthropic_provider_reports_configured_client_model(self): mock_build.assert_called_once_with(provider="anthropic", model="claude-configured") assert result.model_name == "claude-configured" + def test_anthropic_provider_preserves_blocked_model_signal(self): + provider = AnthropicProvider() + + with ( + patch("tools.llm_registry.configured_model_for_provider", return_value=""), + patch("tools.langchain_client.build_chat_client") as mock_build, + pytest.raises(RuntimeError, match="LangChain Anthropic not available"), + ): + provider.analyze_completion("output", ["task1"]) + + mock_build.assert_not_called() + class TestRegexFallbackProvider: """Test regex-based analysis.""" diff --git a/tools/langchain_client.py b/tools/langchain_client.py index e4358e735..52d0de4a6 100644 --- a/tools/langchain_client.py +++ b/tools/langchain_client.py @@ -204,7 +204,9 @@ def build_chat_client( try: from langchain_openai import ChatOpenAI except ImportError: - return None + chat_openai_cls = None + else: + chat_openai_cls = ChatOpenAI try: from langchain_anthropic import ChatAnthropic @@ -231,11 +233,11 @@ def build_chat_client( return None if selected_provider == PROVIDER_GITHUB: - if not github_token: + if not github_token or not chat_openai_cls: return None try: client = _build_github_client( - ChatOpenAI, + chat_openai_cls, model=selected_model, token=github_token, timeout=selected_timeout, @@ -246,11 +248,11 @@ def build_chat_client( return None if selected_provider == PROVIDER_OPENAI: - if not openai_token: + if not openai_token or not chat_openai_cls: return None try: client = _build_openai_client( - ChatOpenAI, + chat_openai_cls, model=selected_model, token=openai_token, timeout=selected_timeout, @@ -292,15 +294,15 @@ def build_chat_client( ( slot.provider == PROVIDER_OPENAI and openai_token, slot.provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls, - slot.provider == PROVIDER_GITHUB and github_token, + slot.provider == PROVIDER_GITHUB and github_token and chat_openai_cls, ) ) if not slot_available: continue - if slot.provider == PROVIDER_OPENAI and openai_token: + if slot.provider == PROVIDER_OPENAI and openai_token and chat_openai_cls: with contextlib.suppress(Exception): client = _build_openai_client( - ChatOpenAI, + chat_openai_cls, model=slot_model, token=openai_token, timeout=selected_timeout, @@ -319,10 +321,10 @@ def build_chat_client( ) used_override = True return ClientInfo(client=client, provider=PROVIDER_ANTHROPIC, model=slot_model) - if slot.provider == PROVIDER_GITHUB and github_token: + if slot.provider == PROVIDER_GITHUB and github_token and chat_openai_cls: with contextlib.suppress(Exception): client = _build_github_client( - ChatOpenAI, + chat_openai_cls, model=slot_model, token=github_token, timeout=selected_timeout, @@ -345,7 +347,9 @@ def build_chat_clients( try: from langchain_openai import ChatOpenAI except ImportError: - return [] + chat_openai_cls = None + else: + chat_openai_cls = ChatOpenAI try: from langchain_anthropic import ChatAnthropic @@ -382,12 +386,12 @@ def build_chat_clients( clients: list[ClientInfo] = [] if selected_provider: - if selected_provider == PROVIDER_GITHUB and github_token: + if selected_provider == PROVIDER_GITHUB and github_token and chat_openai_cls: with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_github_client( - ChatOpenAI, + chat_openai_cls, model=first_model, token=github_token, timeout=selected_timeout, @@ -402,7 +406,7 @@ def build_chat_clients( clients.append( ClientInfo( client=_build_github_client( - ChatOpenAI, + chat_openai_cls, model=second_model, token=github_token, timeout=selected_timeout, @@ -412,12 +416,12 @@ def build_chat_clients( model=second_model, ) ) - elif selected_provider == PROVIDER_OPENAI and openai_token: + elif selected_provider == PROVIDER_OPENAI and openai_token and chat_openai_cls: with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_openai_client( - ChatOpenAI, + chat_openai_cls, model=first_model, token=openai_token, timeout=selected_timeout, @@ -432,7 +436,7 @@ def build_chat_clients( clients.append( ClientInfo( client=_build_openai_client( - ChatOpenAI, + chat_openai_cls, model=second_model, token=openai_token, timeout=selected_timeout, @@ -482,7 +486,7 @@ def build_chat_clients( ( slot.provider == PROVIDER_OPENAI and openai_token, slot.provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls, - slot.provider == PROVIDER_GITHUB and github_token, + slot.provider == PROVIDER_GITHUB and github_token and chat_openai_cls, ) ): candidate_slots.append(slot) @@ -498,12 +502,12 @@ def build_chat_clients( if _is_model_blocked(slot.provider, slot_model, registry=registry): logger.warning("Skipping blocked LLM model override: %s/%s", slot.provider, slot_model) continue - if slot.provider == PROVIDER_OPENAI and openai_token: + if slot.provider == PROVIDER_OPENAI and openai_token and chat_openai_cls: with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_openai_client( - ChatOpenAI, + chat_openai_cls, model=slot_model, token=openai_token, timeout=selected_timeout, @@ -528,12 +532,12 @@ def build_chat_clients( model=slot_model, ) ) - if slot.provider == PROVIDER_GITHUB and github_token: + if slot.provider == PROVIDER_GITHUB and github_token and chat_openai_cls: with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_github_client( - ChatOpenAI, + chat_openai_cls, model=slot_model, token=github_token, timeout=selected_timeout, diff --git a/tools/llm_provider.py b/tools/llm_provider.py index b698780c0..05536d5cf 100644 --- a/tools/llm_provider.py +++ b/tools/llm_provider.py @@ -49,7 +49,8 @@ def _configured_langchain_model(provider: str, *, fallback: str) -> str: from tools.llm_registry import configured_model_for_provider except ImportError: return fallback - return configured_model_for_provider(provider, fallback=fallback) or fallback + configured = configured_model_for_provider(provider, fallback=fallback) + return fallback if configured is None else configured def _setup_langsmith_tracing() -> bool: @@ -601,6 +602,8 @@ def _get_client(self): return None model_name = _configured_langchain_model("openai", fallback=DEFAULT_OPENAI_ANALYSIS_MODEL) + if not model_name: + return None resolved = build_chat_client(provider="openai", model=model_name) if resolved: self._model_name = resolved.model @@ -674,6 +677,8 @@ def _get_client(self): model_name = _configured_langchain_model( "anthropic", fallback=DEFAULT_ANTHROPIC_ANALYSIS_MODEL ) + if not model_name: + return None resolved = build_chat_client(provider="anthropic", model=model_name) if resolved: self._model_name = resolved.model