diff --git a/strix/config/models.py b/strix/config/models.py index e632bb06a..87b34ed25 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -562,7 +562,26 @@ def configure_sdk_model_defaults(settings: Settings) -> None: _configure_openrouter_attribution(llm.model) if llm.api_key: set_default_openai_key(llm.api_key, use_for_tracing=False) - _configure_litellm_default("api_key", llm.api_key) + # Do NOT set litellm.api_key as a module-level global when a custom + # api_base is configured. When api_base is active the model resolves + # through openai-agents' LitellmModel, which always forwards + # api_key=self.api_key (None by default, because Strix never populates + # it on the instance) as an *explicit* kwarg in every + # litellm.acompletion() call. litellm's internal dispatch then merges + # the module-level global into the same kwargs dict that already + # carries the explicit kwarg, producing: + # + # TypeError: acompletion() got multiple values for keyword argument + # 'api_key' (anthropic/ prefix path) + # TypeError: AsyncCompletions.create() got an unexpected keyword + # argument 'api_key' (openai/ prefix path) + # + # The key is still delivered to the provider via + # _mirror_api_key_to_provider_env (sets the provider-specific + # *_API_KEY env var, e.g. ANTHROPIC_API_KEY) and set_default_openai_key + # above, so skipping the global default here is safe. + if not llm.api_base: + _configure_litellm_default("api_key", llm.api_key) _mirror_api_key_to_provider_env(llm.model, llm.api_key) if llm.api_base: os.environ["OPENAI_BASE_URL"] = llm.api_base diff --git a/tests/test_llm_api_base_no_duplicate_key.py b/tests/test_llm_api_base_no_duplicate_key.py new file mode 100644 index 000000000..be5c94c68 --- /dev/null +++ b/tests/test_llm_api_base_no_duplicate_key.py @@ -0,0 +1,129 @@ +"""Regression tests for issue #1095. + +LLM CONNECTION FAILED — duplicate api_key keyword argument when LLM_API_BASE +is set to a custom endpoint. + +When LLM_API_BASE is configured the model resolves through openai-agents' +LitellmModel, which always forwards api_key=self.api_key (None by default) as +an *explicit* kwarg on every litellm.acompletion() call. If litellm.api_key +is also set as a module-level global by configure_sdk_model_defaults, litellm's +internal dispatch merges the global into the same kwargs dict that already +carries the explicit kwarg and raises: + + TypeError: acompletion() got multiple values for keyword argument 'api_key' + (anthropic/ / litellm/ prefix path) + TypeError: AsyncCompletions.create() got an unexpected keyword argument + 'api_key' (openai/ prefix path) + +The fix: skip _configure_litellm_default("api_key", ...) when api_base is set. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import litellm +import pytest + +from strix.config import loader +from strix.config.loader import load_settings +from strix.config.models import configure_sdk_model_defaults + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_ENV_KEYS = [ + # Primary Strix settings + "STRIX_LLM", + "LLM_API_KEY", + "LLM_API_BASE", + # All aliases that LlmSettings maps to api_base (settings.py AliasChoices), + # so configure_sdk_model_defaults()'s os.environ["OPENAI_BASE_URL"] write + # in one test does not bleed into the next test's load_settings() call. + "OPENAI_API_BASE", + "OPENAI_BASE_URL", + "LITELLM_BASE_URL", + "OLLAMA_API_BASE", +] + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Isolate litellm.api_key and the settings cache between tests.""" + for key in _ENV_KEYS: + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + + saved_api_key = litellm.api_key + litellm.api_key = None + try: + yield + finally: + litellm.api_key = saved_api_key + + + +# --------------------------------------------------------------------------- +# Core regression: litellm.api_key must NOT be set when api_base is active +# --------------------------------------------------------------------------- + + +def test_litellm_api_key_global_not_set_when_api_base_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Issue #1095 (anthropic/ path): litellm.api_key must remain None when + LLM_API_BASE is set, so that LitellmModel's explicit api_key=None kwarg + does not collide with a module-level global.""" + monkeypatch.setenv("STRIX_LLM", "anthropic/deepseek-main") + monkeypatch.setenv("LLM_API_KEY", "placeholder") + monkeypatch.setenv("LLM_API_BASE", "http://192.168.1.18:8081") + + configure_sdk_model_defaults(load_settings()) + + # The module-level litellm.api_key must NOT be set; the key reaches the + # provider through provider-specific env vars (_mirror_api_key_to_provider_env). + assert litellm.api_key is None, ( + "litellm.api_key was set as a module-level global even though " + "LLM_API_BASE is configured. This causes " + "'acompletion() got multiple values for keyword argument api_key' " + "(issue #1095)." + ) + + +def test_litellm_api_key_global_not_set_for_openai_prefix_with_custom_base( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Issue #1095 (openai/ path): same guard applies regardless of the + STRIX_LLM prefix used.""" + monkeypatch.setenv("STRIX_LLM", "openai/deepseek-main") + monkeypatch.setenv("LLM_API_KEY", "placeholder") + monkeypatch.setenv("LLM_API_BASE", "http://192.168.1.18:8081/v1") + + configure_sdk_model_defaults(load_settings()) + + assert litellm.api_key is None, ( + "litellm.api_key was set as a module-level global even though " + "LLM_API_BASE is configured (openai/ prefix path). This causes " + "'AsyncCompletions.create() got an unexpected keyword argument api_key'" + " (issue #1095)." + ) + + +def test_litellm_api_key_global_set_when_no_api_base( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Positive case: litellm.api_key IS set when no custom api_base is given + (standard direct-to-provider path).""" + monkeypatch.setenv("STRIX_LLM", "openai/gpt-5.4") + monkeypatch.setenv("LLM_API_KEY", "sk-real-key") + # LLM_API_BASE is intentionally absent + + configure_sdk_model_defaults(load_settings()) + + assert litellm.api_key == "sk-real-key", ( + "litellm.api_key should be set globally when no custom api_base is " + "configured (standard direct-to-provider path)." + )