diff --git a/litellm/decisions/main.py b/litellm/decisions/main.py index 8d98cf5fa52..b83ad4d8bed 100644 --- a/litellm/decisions/main.py +++ b/litellm/decisions/main.py @@ -25,7 +25,6 @@ from litellm.llms.openrouter.decisions.transformation import OPENROUTER_DECISION from litellm.llms.perplexity.decisions.transformation import PERPLEXITY_DECISIONS_ENDPOINT from litellm.llms.strands_decider.decisions.transformation import STRANDS_DECIDER_DECISIONS_ENDPOINT from litellm.llms.typesafe.decisions.transformation import TYPESAFE_DECISIONS_ENDPOINT -from litellm.secret_managers.main import get_secret_str from litellm.types.decisions import ( DecisionQuestion, DecisionsIRRequest, @@ -103,21 +102,16 @@ def _resolve_api_key( ) -> str | None: if api_key is not None: return api_key - - server_api_key: Final = next( - (key for key in (get_secret_str(name) for name in endpoint.api_key_env) if key), - None, + configured_api_key: Final = endpoint.configured_api_key() + if configured_api_key: + return configured_api_key + if not endpoint.api_key_required: + return None + raise litellm.AuthenticationError( + message=f"Missing API key for Decisions provider '{provider}'", + model=model, + llm_provider=provider, ) - if server_api_key is None: - if not endpoint.api_key_required: - return None - raise litellm.AuthenticationError( - message=f"Missing API key for Decisions provider '{provider}'", - model=model, - llm_provider=provider, - ) - - return server_api_key def _validate_request( @@ -175,9 +169,7 @@ def _prepare_request( ) from error endpoint: Final = DECISIONS_ENDPOINTS[provider] - env_api_base: Final = get_secret_str(endpoint.api_base_env) - default_api_base: Final = endpoint.default_api_base() - resolved_api_base: Final = api_base or env_api_base or default_api_base + resolved_api_base: Final = api_base or endpoint.configured_api_base() if resolved_api_base is None: raise litellm.BadRequestError( message=endpoint.missing_api_base_message(provider), diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index ab827ae5dcf..14a955053da 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -7,6 +7,7 @@ from typing import Final, Protocol from pydantic import TypeAdapter from typing_extensions import assert_never +from litellm.secret_managers.main import get_secret_str from litellm.types.decisions import ( ChoiceAnswer, ChoiceQuestion, @@ -299,8 +300,11 @@ class JevCompatibleDecisionsEndpoint: api_base_env: str api_key_required: bool = True - def default_api_base(self) -> str | None: - return self.default_api_base_value + def configured_api_key(self) -> str | None: + return next((key for key in (get_secret_str(name) for name in self.api_key_env) if key), None) + + def configured_api_base(self) -> str | None: + return get_secret_str(self.api_base_env) or self.default_api_base_value def missing_api_base_message(self, provider: str) -> str: return f"api_base is required for Decisions provider '{provider}'" @@ -324,16 +328,12 @@ class JevCompatibleDecisionsEndpoint: class DecisionsProviderConfig(Protocol): - @property - def api_key_env(self) -> tuple[str, ...]: ... - - @property - def api_base_env(self) -> str: ... - @property def api_key_required(self) -> bool: ... - def default_api_base(self) -> str | None: ... + def configured_api_key(self) -> str | None: ... + + def configured_api_base(self) -> str | None: ... def missing_api_base_message(self, provider: str) -> str: ... diff --git a/litellm/llms/cloudflare/decisions/transformation.py b/litellm/llms/cloudflare/decisions/transformation.py index a325689b0c9..83805e9d931 100644 --- a/litellm/llms/cloudflare/decisions/transformation.py +++ b/litellm/llms/cloudflare/decisions/transformation.py @@ -16,11 +16,15 @@ _RESPONSE_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapte @dataclass(frozen=True, slots=True) class CloudflareDecisionsEndpoint: - api_key_env: tuple[str, ...] = ("CLOUDFLARE_API_KEY",) - api_base_env: str = "CLOUDFLARE_API_BASE" api_key_required: bool = True - def default_api_base(self) -> str | None: + def configured_api_key(self) -> str | None: + return get_secret_str("CLOUDFLARE_API_KEY") or None + + def configured_api_base(self) -> str | None: + env_api_base: Final = get_secret_str("CLOUDFLARE_API_BASE") + if env_api_base: + return env_api_base account_id: Final = normalize_nonempty_secret_str(get_secret_str("CLOUDFLARE_ACCOUNT_ID")) if account_id is None: return None diff --git a/litellm/llms/openai/decisions/transformation.py b/litellm/llms/openai/decisions/transformation.py index b7f5a95732c..fcae4bcc49c 100644 --- a/litellm/llms/openai/decisions/transformation.py +++ b/litellm/llms/openai/decisions/transformation.py @@ -5,7 +5,9 @@ from typing import Final from pydantic import TypeAdapter from typing_extensions import assert_never +import litellm from litellm.llms.base_llm.decisions.transformation import decisions_text +from litellm.secret_managers.main import get_secret_str from litellm.types.decisions import ( DecisionsIRAnswer, DecisionsIRChoiceAnswer, @@ -271,12 +273,18 @@ def ir_to_openai_response( @dataclass(frozen=True, slots=True) class OpenAIDecisionsEndpoint: - api_key_env: tuple[str, ...] = ("OPENAI_API_KEY",) - api_base_env: str = "OPENAI_BASE_URL" api_key_required: bool = True - def default_api_base(self) -> str | None: - return "https://api.openai.com" + def configured_api_key(self) -> str | None: + return litellm.api_key or litellm.openai_key or get_secret_str("OPENAI_API_KEY") + + def configured_api_base(self) -> str | None: + return ( + litellm.api_base + or get_secret_str("OPENAI_BASE_URL") + or get_secret_str("OPENAI_API_BASE") + or "https://api.openai.com" + ) def missing_api_base_message(self, provider: str) -> str: return f"api_base is required for Decisions provider '{provider}'" diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index d915b1b4c01..d8375935d46 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -673,7 +673,9 @@ async def test_openai_decisions_translate_systemone_to_the_openai_wire_contract_ monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, ) -> None: + monkeypatch.setattr(litellm, "api_base", None) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) route: Final = respx_mock.post(url).respond(json=_OPENAI_RESPONSE) response: Final = await litellm.adecisions( @@ -720,6 +722,57 @@ async def test_openai_decisions_translate_systemone_to_the_openai_wire_contract_ assert litellm.completion_cost(completion_response=response) == pytest.approx(expected_cost) +@pytest.mark.parametrize( + ("settings", "env", "url", "authorization"), + ( + ({"openai_key": "sdk-key"}, {}, "https://api.openai.com/v1/decisions", "Bearer sdk-key"), + ( + {"api_key": "global-key", "openai_key": "sdk-key"}, + {"OPENAI_API_KEY": "env-key"}, + "https://api.openai.com/v1/decisions", + "Bearer global-key", + ), + ( + {}, + {"OPENAI_API_KEY": "env-key", "OPENAI_API_BASE": "https://legacy.example/v1"}, + "https://legacy.example/v1/decisions", + "Bearer env-key", + ), + ( + {"api_base": "https://sdk.example"}, + {"OPENAI_API_KEY": "env-key", "OPENAI_BASE_URL": "https://env.example"}, + "https://sdk.example/v1/decisions", + "Bearer env-key", + ), + ), + ids=("openai_key", "api_key_before_env", "openai_api_base_env", "api_base_before_env"), +) +def test_openai_decisions_use_the_same_settings_as_other_openai_calls( + settings: Mapping[str, str], + env: Mapping[str, str], + url: str, + authorization: str, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + for name in ("api_key", "openai_key", "api_base"): + monkeypatch.setattr(litellm, name, settings.get(name)) + for name in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE"): + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + route: Final = respx_mock.post(url).respond(json=_OPENAI_RESPONSE) + + litellm.decisions( + model="openai/gpt-6-luna", + state="review", + questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + ) + + assert route.call_count == 1 + assert route.calls[0].request.headers["authorization"] == authorization + + @pytest.mark.asyncio async def test_openai_format_calls_to_a_systemone_provider_get_openai_format_answers( respx_mock: respx.MockRouter,