fix(decisions): honor litellm OpenAI key and base settings for the openai provider

Backport of #45214 to stable/1.104.x.
Cherry-picked from e465919004 (litellm_openai_decisions_provider).
This commit is contained in:
mateo-berri 2026-10-07 19:49:50 -07:00
parent 7df9c0620f
commit c65d369958
5 changed files with 91 additions and 34 deletions

View file

@ -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),

View file

@ -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: ...

View file

@ -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

View file

@ -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}'"

View file

@ -664,7 +664,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(
@ -711,6 +713,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,