From 8d138ec84533880cedf828aea18af5cad33b83bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= <220731063+ClemDNL@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:47:39 +0100 Subject: [PATCH 1/2] fix(decisions): serve the Azure OpenAI SDK decisions paths natively An AzureOpenAI client pointed at the proxy posts to /openai/v1/decisions (older SDK versions use /openai/decisions?api-version=...). Both paths fell through to the /openai/{endpoint:path} pass-through, which needs OPENAI_API_KEY and skips routing, budgets and spend logs. They now reach the native Decisions handler with the same auth, and the decisions lazy slot sits ahead of the pass-through so precedence holds in lazy and eager route modes. Azure Decisions also sent azure/gpt5_series/ as gpt5_series/, so the selector is now stripped from the deployment name the same way chat completions do. --- .../llms/azure/decisions/transformation.py | 4 + litellm/proxy/_lazy_features.py | 11 ++- litellm/proxy/_types.py | 2 + .../auth/managed_authorization.py | 2 + .../proxy/decisions_endpoints/endpoints.py | 14 ++++ litellm/proxy/proxy_server.py | 1 + litellm/types/utils.py | 2 + .../azure/decisions/test_transformation.py | 12 ++- .../decisions_endpoints/test_endpoints.py | 78 +++++++++++++++++++ 9 files changed, 124 insertions(+), 2 deletions(-) diff --git a/litellm/llms/azure/decisions/transformation.py b/litellm/llms/azure/decisions/transformation.py index 3fb40e39702..1397cbefa7b 100644 --- a/litellm/llms/azure/decisions/transformation.py +++ b/litellm/llms/azure/decisions/transformation.py @@ -5,6 +5,7 @@ from typing import Final from pydantic import ValidationError import litellm +from litellm.llms.azure.chat.gpt_reasoning_transformation import AzureOpenAIGPT5Config from litellm.llms.azure.common_utils import ( BaseAzureLLM, cached_azure_ad_token_refresh_provider, @@ -73,5 +74,8 @@ class AzureDecisionsConfig(OpenAIDecisionsConfig): ) -> Mapping[str, str]: return await asyncio.to_thread(self.validate_environment, headers, model, api_key, litellm_params) + def canonical_model(self, model: str) -> str: + return model.removeprefix(AzureOpenAIGPT5Config.GPT5_SERIES_ROUTE) + def get_complete_url(self, api_base: str, model: str) -> str: return BaseAzureLLM.get_base_azure_url(api_base, None, route="/openai/v1/decisions") diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index dc6575f1e1b..384cab26fe8 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -281,7 +281,16 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = ( LazyFeature( name="decisions", module_path="litellm.proxy.decisions_endpoints.endpoints", - path_prefixes=("/v1/decisions", "/decisions", "/v1/systemone", "/systemone", "/v1/extraction", "/extraction"), + path_prefixes=( + "/v1/decisions", + "/decisions", + "/openai/v1/decisions", + "/openai/decisions", + "/v1/systemone", + "/systemone", + "/v1/extraction", + "/extraction", + ), ), LazyFeature( name="claude_code_marketplace", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f165355da5a..0688426c65b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -376,6 +376,8 @@ class LiteLLMRoutes(enum.Enum): "/v1/search/{search_tool_name}", "/decisions", "/v1/decisions", + "/openai/v1/decisions", + "/openai/decisions", "/systemone", "/v1/systemone", "/extraction", diff --git a/litellm/proxy/agent_endpoints/auth/managed_authorization.py b/litellm/proxy/agent_endpoints/auth/managed_authorization.py index cdbbd6029fa..f28640e25f2 100644 --- a/litellm/proxy/agent_endpoints/auth/managed_authorization.py +++ b/litellm/proxy/agent_endpoints/auth/managed_authorization.py @@ -39,6 +39,8 @@ _MANAGED_MODEL_ROUTES: Final = frozenset( ) | frozenset( ( "/openai/v1/responses", + "/openai/v1/decisions", + "/openai/decisions", "/v2/rerank", "/claude_code_gateway/v1/messages", "/claude_code_gateway/v1/messages/count_tokens", diff --git a/litellm/proxy/decisions_endpoints/endpoints.py b/litellm/proxy/decisions_endpoints/endpoints.py index a8684dd9757..6ac25dc9b36 100644 --- a/litellm/proxy/decisions_endpoints/endpoints.py +++ b/litellm/proxy/decisions_endpoints/endpoints.py @@ -248,6 +248,20 @@ async def systemone( ) +@router.post( + "/openai/v1/decisions", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, # pyright: ignore[reportDeprecated] # required endpoint contract + tags=["decisions"], + include_in_schema=False, +) +@router.post( + "/openai/decisions", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, # pyright: ignore[reportDeprecated] # required endpoint contract + tags=["decisions"], + include_in_schema=False, +) @router.post( "/v1/decisions", dependencies=[Depends(user_api_key_auth)], diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f078267c6b2..7723fc64a17 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -21761,6 +21761,7 @@ app.include_router(tracing_endpoints.router) app.include_router(image_router) app.include_router(fine_tuning_router) app.include_router(credential_router) +reserve_lazy_slot(app, "decisions") app.include_router(openai_passthrough_router) app.include_router(batches_router) app.include_router(openai_files_router) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 780c9ef6824..488c461c87f 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -847,6 +847,8 @@ API_ROUTE_TO_CALL_TYPES: Final[Mapping[str, Sequence[CallTypes]]] = { "/v1/search": [CallTypes.asearch, CallTypes.search], "/decisions": [CallTypes.adecisions, CallTypes.decisions], "/v1/decisions": [CallTypes.adecisions, CallTypes.decisions], + "/openai/v1/decisions": [CallTypes.adecisions, CallTypes.decisions], + "/openai/decisions": [CallTypes.adecisions, CallTypes.decisions], "/systemone": [CallTypes.asystemone, CallTypes.systemone], "/v1/systemone": [CallTypes.asystemone, CallTypes.systemone], "/extraction": [CallTypes.aextraction, CallTypes.extraction], diff --git a/tests/unit/llms/azure/decisions/test_transformation.py b/tests/unit/llms/azure/decisions/test_transformation.py index b6de6084b9c..ee1c649e5d0 100644 --- a/tests/unit/llms/azure/decisions/test_transformation.py +++ b/tests/unit/llms/azure/decisions/test_transformation.py @@ -53,9 +53,10 @@ async def call_decisions( systemone: bool, credentials: Mapping[str, object], api_base: str = _BASE, + model: str = "azure/luna-deployment", ) -> DecisionsResponse | OpenAIDecisionResponse: params = { - "model": "azure/luna-deployment", + "model": model, "api_base": api_base, **credentials, } @@ -197,6 +198,15 @@ async def test_azure_credential_lookup_does_not_block_the_event_loop( await call_decisions(True, False, {"azure_ad_token_provider": provider}) +@pytest.mark.parametrize("systemone", (False, True)) +async def test_gpt5_series_selector_is_not_sent_as_part_of_the_deployment_name( + respx_mock: respx.MockRouter, systemone: bool +) -> None: + route = respx_mock.post(_URL).respond(json=_RESPONSE) + await call_decisions(True, systemone, {"api_key": "synthetic-key"}, model="azure/gpt5_series/luna-deployment") + assert json.loads(route.calls.last.request.content)["model"] == "luna-deployment" + + def test_azure_decisions_requires_azure_endpoint( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/proxy/decisions_endpoints/test_endpoints.py b/tests/unit/proxy/decisions_endpoints/test_endpoints.py index c67d1012475..914015b0df0 100644 --- a/tests/unit/proxy/decisions_endpoints/test_endpoints.py +++ b/tests/unit/proxy/decisions_endpoints/test_endpoints.py @@ -765,6 +765,84 @@ def test_openai_format_decisions_reach_an_openai_deployment_unchanged_including_ assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(expected_cost) +_AZURE_SDK_DECISIONS_PATHS: Final = ( + "/openai/v1/decisions", + "/openai/v1/decisions?api-version=preview", + "/openai/decisions?api-version=2025-04-01-preview", +) + + +def _route_to_an_azure_deployment(monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter) -> respx.Route: + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "decider", + "litellm_params": { + "model": "azure/gpt5_series/my-decisions-deployment", + "api_base": "https://my-resource.openai.azure.com", + "api_key": "azure-deployment-key", + }, + } + ] + ), + ) + return respx_mock.post("https://my-resource.openai.azure.com/openai/v1/decisions").respond( + json={ + "model": "gpt-6-luna", + "answers": _OPENAI_FORMAT_ANSWERS, + "usage": { + "input_tokens": _INPUT_TOKENS, + "output_tokens": _OUTPUT_TOKENS, + "total_tokens": _INPUT_TOKENS + _OUTPUT_TOKENS, + }, + } + ) + + +@pytest.mark.parametrize("path", _AZURE_SDK_DECISIONS_PATHS) +def test_azure_sdk_decisions_paths_reach_the_azure_deployment( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + path: str, +) -> None: + upstream: Final = _route_to_an_azure_deployment(monkeypatch, respx_mock) + + response: Final = client.post(path, json=_OPENAI_FORMAT_REQUEST, headers={"api-key": "caller-key"}) + + assert response.status_code == 200, response.text + assert response.json()["answers"] == _OPENAI_FORMAT_ANSWERS + assert upstream.call_count == 1 + sent: Final = upstream.calls[0].request + assert sent.headers["api-key"] == "azure-deployment-key" + assert json.loads(sent.content)["model"] == "my-decisions-deployment" + luna_decisions_cost: Final = apply_decisions_pricing(litellm.model_cost["azure/gpt-6-luna"]) + expected_cost: Final = _INPUT_TOKENS * float(luna_decisions_cost["input_cost_per_token"]) + _OUTPUT_TOKENS * float( + luna_decisions_cost["output_cost_per_token"] + ) + assert expected_cost > 0 + assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(expected_cost) + + +@pytest.mark.parametrize("path", _AZURE_SDK_DECISIONS_PATHS) +def test_azure_sdk_decisions_paths_require_a_virtual_key( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + path: str, +) -> None: + upstream: Final = _route_to_an_azure_deployment(monkeypatch, respx_mock) + monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY) + + response: Final = client.post(path, json=_OPENAI_FORMAT_REQUEST) + + assert response.status_code == 401, response.text + assert not upstream.called + + _CLEF_OMNI_MEDIA: Final[Mapping[str, object]] = { "audio": ["data:audio/wav;base64,YQ==", {"content_type": "audio/webm;codecs=opus", "base64": "Yg=="}], "videos": ["data:video/mp4;base64,Yw=="], From 4fbd6cf3fb1d32e0a9e13807c4a25ff7d7cc90c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= <220731063+ClemDNL@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:25:12 +0100 Subject: [PATCH 2/2] fix(decisions): yield decisions routes to a config pass-through at the same path Reserving the decisions slot ahead of the /openai/{endpoint:path} pass-through also put /v1/decisions ahead of a config pass-through at that path, so the native handler took its requests, including the auth exemption of an auth: false pass-through. The decisions router now uses YieldsToConfiguredPassThroughRoute, as the built-in eu_jev route does, so a configured pass-through keeps its path in lazy and eager route modes. --- .../proxy/decisions_endpoints/endpoints.py | 3 +- .../decisions_endpoints/test_endpoints.py | 81 ++++++++++++++----- 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/decisions_endpoints/endpoints.py b/litellm/proxy/decisions_endpoints/endpoints.py index 6ac25dc9b36..77b9c58b75d 100644 --- a/litellm/proxy/decisions_endpoints/endpoints.py +++ b/litellm/proxy/decisions_endpoints/endpoints.py @@ -10,6 +10,7 @@ from litellm.exceptions import BadRequestError from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.common_utils.custom_openapi_spec import inline_request_body +from litellm.proxy.pass_through_endpoints.pass_through_endpoints import YieldsToConfiguredPassThroughRoute from litellm.types.decisions import ( DecisionsRequest, DecisionsRequestBody, @@ -20,7 +21,7 @@ from litellm.types.decisions import ( ) from litellm.types.extraction import ExtractionRequest, ExtractionRequestBody, ExtractionResponse -router: Final = APIRouter() +router: Final = APIRouter(route_class=YieldsToConfiguredPassThroughRoute) _REQUEST_DATA_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object]) _DECISIONS_REQUEST_BODY_ADAPTER: Final[TypeAdapter[DecisionsRequestBody]] = TypeAdapter(DecisionsRequestBody) _OPENAI_DECISION_REQUEST_BODY_ADAPTER: Final[TypeAdapter[OpenAIDecisionRequestBody]] = TypeAdapter( diff --git a/tests/unit/proxy/decisions_endpoints/test_endpoints.py b/tests/unit/proxy/decisions_endpoints/test_endpoints.py index 914015b0df0..df35f41903e 100644 --- a/tests/unit/proxy/decisions_endpoints/test_endpoints.py +++ b/tests/unit/proxy/decisions_endpoints/test_endpoints.py @@ -6,7 +6,7 @@ import json import random import sys from collections.abc import AsyncGenerator, Callable, Iterator, Mapping -from contextlib import asynccontextmanager +from contextlib import AbstractAsyncContextManager, asynccontextmanager from pathlib import Path from typing import Final, Literal @@ -22,11 +22,15 @@ import litellm from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.decisions_pricing import apply_decisions_pricing -from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature, attach_lazy_features +from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature, attach_lazy_features, reserve_lazy_slot from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.decisions_endpoints.endpoints import decisions, router, systemone -from litellm.proxy.pass_through_endpoints.pass_through_endpoints import SafeRouteAdder +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + _registered_pass_through_routes, +) from litellm.proxy.proxy_server import ( app, cleanup_router_config_variables, @@ -1322,20 +1326,58 @@ def _serving_endpoint(bare: FastAPI, path: str) -> object: ) -def test_a_config_pass_through_at_v1_decisions_keeps_its_route_and_the_native_api_serves_decisions( +@pytest.fixture +def config_pass_through_upstream(monkeypatch: pytest.MonkeyPatch) -> Iterator[respx.Route]: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + saved_registry: Final = dict(_registered_pass_through_routes) + try: + with respx.mock(assert_all_called=False) as upstream: + yield upstream.post("https://operator.example/decide").respond(200, json={"operator": True}) + finally: + _registered_pass_through_routes.clear() + _registered_pass_through_routes.update(saved_registry) + + +def _add_config_pass_through(app_: FastAPI, path: str) -> None: + InitPassThroughEndpointHelpers.add_exact_path_route( + app=app_, + path=path, + target="https://operator.example/decide", + custom_headers={}, + forward_headers=None, + merge_query_params=None, + dependencies=None, + cost_per_request=None, + endpoint_id="decisions-operator", + methods=["POST"], + ) + + +def _app_with_the_proxy_decisions_slot( monkeypatch: pytest.MonkeyPatch, + lifespan: Callable[[FastAPI], AbstractAsyncContextManager[None]] | None = None, +) -> FastAPI: + bare: Final = FastAPI(lifespan=lifespan) + reserve_lazy_slot(bare, "decisions", features=(_decisions_feature(),)) + attach_lazy_features(bare, (_decisions_feature(),)) + monkeypatch.setitem(bare.dependency_overrides, user_api_key_auth, lambda: UserAPIKeyAuth(api_key="sk-virtual")) + return bare + + +@pytest.mark.parametrize("path", ("/v1/decisions", "/openai/v1/decisions")) +def test_a_config_pass_through_at_a_decisions_path_keeps_its_route_and_the_native_api_serves_decisions( + monkeypatch: pytest.MonkeyPatch, + config_pass_through_upstream: respx.Route, + path: str, ) -> None: monkeypatch.delenv("LITELLM_DISABLE_LAZY_ROUTES", raising=False) - async def pass_through() -> dict[str, str]: - return {"served_by": "pass-through"} - - bare: Final = FastAPI() - attach_lazy_features(bare, (_decisions_feature(),)) - SafeRouteAdder.add_api_route_if_not_exists(bare, "/v1/decisions", pass_through, ["POST"]) + bare: Final = _app_with_the_proxy_decisions_slot(monkeypatch) + _add_config_pass_through(bare, path) with TestClient(bare) as client: - assert client.post("/v1/decisions", json={"model": "gpt-6-luna"}).json() == {"served_by": "pass-through"} - assert _serving_endpoint(bare, "/v1/decisions") is pass_through + assert client.post(path, json={"model": "gpt-6-luna"}).json() == {"operator": True} + assert config_pass_through_upstream.call_count == 1 assert _serving_endpoint(bare, "/decisions") is decisions assert _serving_endpoint(bare, "/v1/systemone") is systemone assert _serving_endpoint(bare, "/systemone") is systemone @@ -1343,24 +1385,19 @@ def test_a_config_pass_through_at_v1_decisions_keeps_its_route_and_the_native_ap def test_with_lazy_routes_disabled_a_config_pass_through_at_v1_decisions_still_wins( monkeypatch: pytest.MonkeyPatch, + config_pass_through_upstream: respx.Route, ) -> None: monkeypatch.setenv("LITELLM_DISABLE_LAZY_ROUTES", "true") - async def pass_through() -> dict[str, str]: - return {"served_by": "pass-through"} - @asynccontextmanager async def loads_the_config(app_: FastAPI) -> AsyncGenerator[None]: - assert SafeRouteAdder.add_api_route_if_not_exists(app_, "/v1/decisions", pass_through, ["POST"]), ( - "the native route registered at startup must not block the config pass-through" - ) + _add_config_pass_through(app_, "/v1/decisions") yield - bare: Final = FastAPI(lifespan=loads_the_config) - attach_lazy_features(bare, (_decisions_feature(),)) + bare: Final = _app_with_the_proxy_decisions_slot(monkeypatch, loads_the_config) with TestClient(bare) as client: - assert client.post("/v1/decisions", json={"model": "gpt-6-luna"}).json() == {"served_by": "pass-through"} - assert _serving_endpoint(bare, "/v1/decisions") is pass_through + assert client.post("/v1/decisions", json={"model": "gpt-6-luna"}).json() == {"operator": True} + assert config_pass_through_upstream.call_count == 1 assert _serving_endpoint(bare, "/decisions") is decisions