fix(azure_ai): add passthrough config so router-model relays reach the deployment's own endpoint

Every /azure_ai/<router model>/<native path> relay failed with HTTP 500 because
azure_ai had no passthrough config. The new AzureAIPassthroughConfig strips the
router-model prefix from the relayed path, forwards to the deployment's api_base
with its own credential (api-key on Foundry and Azure OpenAI hosts, Bearer
elsewhere, Entra as the fallback), and delegates chat/completions cost logging
to the Azure passthrough config.

The router's provider inference now receives the deployment's api_base so an
OpenAI-family model on a Foundry resource stays azure_ai instead of flipping to
azure through the AZURE_AI_API_BASE env var.
This commit is contained in:
mateo-berri 2026-09-04 21:30:48 -07:00
parent 2151dcbd73
commit 512e730f2f
11 changed files with 423 additions and 13 deletions

View file

@ -57,7 +57,7 @@
"limit": 5601
},
"reportMissingTypeArgument": {
"limit": 15284
"limit": 15283
},
"reportMissingTypeStubs": {
"limit": 40
@ -105,10 +105,10 @@
"limit": 109
},
"reportUnknownMemberType": {
"limit": 38309
"limit": 38308
},
"reportUnknownParameterType": {
"limit": 19621
"limit": 19620
},
"reportUnknownVariableType": {
"limit": 29844

View file

@ -2,7 +2,6 @@ import copy
import enum
import re
from typing import TYPE_CHECKING, Final, cast
from urllib.parse import urlparse
import httpx
from httpx import Response
@ -15,7 +14,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
filter_value_from_dict,
)
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.azure_ai.common_utils import is_foundry_model_inference_base
from litellm.llms.azure_ai.common_utils import (
api_key_header_for_base,
is_foundry_model_inference_base,
)
from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj
from litellm.llms.openai.common_utils import drop_params_from_unprocessable_entity_error
from litellm.llms.openai.openai import OpenAIConfig
@ -99,11 +101,7 @@ class AzureAIStudioConfig(OpenAIConfig):
"""
Returns True if the request should use `api-key` header for authentication.
"""
parsed_url: Final = urlparse(api_base)
host: Final = parsed_url.hostname
if host and (host.endswith(".services.ai.azure.com") or host.endswith(".openai.azure.com")):
return True
return False
return api_key_header_for_base(api_base) == "api-key"
def get_complete_url(
self,

View file

@ -19,6 +19,13 @@ def is_foundry_model_inference_base(api_base: str) -> bool:
return "/openai/deployments" not in parsed.path
def api_key_header_for_base(api_base: str | None) -> AzureAIApiKeyHeader:
host: Final = urlparse(api_base).hostname if api_base else None
if host and (host.endswith(".services.ai.azure.com") or host.endswith(".openai.azure.com")):
return "api-key"
return "Authorization"
def get_azure_ai_entra_token(litellm_params: Mapping[str, object] | None = None) -> str | None:
"""
Resolve an Entra ID / OAuth access token for an Azure AI Foundry deployment.

View file

@ -0,0 +1,106 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm.llms.azure_ai.common_utils import (
AzureFoundryModelInfo,
api_key_header_for_base,
get_azure_ai_auth_headers,
)
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
from litellm.types.llms.openai import AllMessageValues
if TYPE_CHECKING:
from httpx import URL, Response
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import CostResponseTypes
def strip_leading_model_segment(endpoint: str, model_names: tuple[str, ...]) -> str:
path: Final = endpoint.lstrip("/")
for model_name in model_names:
if not model_name:
continue
if path == model_name:
return ""
if path.startswith(f"{model_name}/"):
return path[len(model_name) + 1 :]
return path
class PassthroughMetadata(BaseModel):
model_config = ConfigDict(extra="ignore")
model_group: str = ""
def model_group_from(litellm_params: Mapping[str, object]) -> str:
try:
return PassthroughMetadata.model_validate(litellm_params.get("litellm_metadata")).model_group
except ValidationError:
return ""
class AzureAIPassthroughConfig(AzureFoundryModelInfo, BasePassthroughConfig):
def is_streaming_request(self, endpoint: str, request_data: Mapping[str, object]) -> bool:
return request_data.get("stream") is True
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
endpoint: str,
request_query_params: Mapping[str, object] | None,
litellm_params: Mapping[str, object],
) -> tuple[URL, str]:
base_target_url: Final = self.get_api_base(api_base)
if base_target_url is None:
raise ValueError("Azure AI api base not found: set `api_base` on the deployment or AZURE_AI_API_BASE")
native_endpoint: Final = strip_leading_model_segment(endpoint, (model, model_group_from(litellm_params)))
return (
self.format_url(native_endpoint, base_target_url, request_query_params),
base_target_url,
)
def validate_environment(
self,
headers: Mapping[str, str],
model: str,
messages: Sequence[AllMessageValues],
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
) -> dict[str, str]: # mutable-ok: base class contract returns dict for httpx
auth_headers: Final = get_azure_ai_auth_headers(
api_key=api_key,
litellm_params=litellm_params,
api_key_header=api_key_header_for_base(api_base),
)
return {**headers, **auth_headers} # mutable-ok: base class contract returns dict for httpx
def logging_non_streaming_response(
self,
model: str,
custom_llm_provider: str,
httpx_response: Response,
request_data: Mapping[str, object],
logging_obj: Logging,
endpoint: str,
) -> CostResponseTypes | None:
from litellm.llms.azure.passthrough.transformation import AzurePassthroughConfig
return AzurePassthroughConfig().logging_non_streaming_response( # pyright: ignore[reportUnknownMemberType] # the Azure config still types request_data as a bare dict
model=model,
custom_llm_provider=custom_llm_provider,
httpx_response=httpx_response,
request_data=dict(request_data), # mutable-ok: AzurePassthroughConfig wants a dict
logging_obj=logging_obj,
endpoint=endpoint,
)

View file

@ -1,4 +1,5 @@
from abc import abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, Optional, Union
from ..base_utils import BaseLLMModelInfo
@ -23,7 +24,7 @@ class BasePassthroughConfig(BaseLLMModelInfo):
self,
endpoint: str,
base_target_url: str,
request_query_params: dict | None,
request_query_params: Mapping[str, object] | None,
) -> "URL":
"""
Helper function to add query params to the url

View file

@ -5051,6 +5051,7 @@ class Router:
_, inferred_custom_llm_provider, _, _ = get_llm_provider(
model=data["model"],
custom_llm_provider=custom_llm_provider,
api_base=data.get("api_base"),
)
custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
except Exception:
@ -5570,6 +5571,7 @@ class Router:
_, inferred_custom_llm_provider, _, _ = get_llm_provider(
model=data["model"],
custom_llm_provider=custom_llm_provider,
api_base=data.get("api_base"),
)
custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
except Exception:

View file

@ -8831,6 +8831,12 @@ class ProviderConfigManager:
)
return AzurePassthroughConfig()
elif LlmProviders.AZURE_AI == provider:
from litellm.llms.azure_ai.passthrough.transformation import (
AzureAIPassthroughConfig,
)
return AzureAIPassthroughConfig()
elif LlmProviders.GIGACHAT == provider:
from litellm.llms.gigachat.passthrough.transformation import (
GigaChatPassthroughConfig,

View file

@ -201,7 +201,7 @@
"limit": 310
},
"SIM103": {
"limit": 119
"limit": 118
},
"SIM113": {
"limit": 3

View file

@ -0,0 +1,175 @@
import json
from unittest.mock import MagicMock
import httpx
import pytest
import litellm
from litellm.llms.azure_ai.passthrough.transformation import AzureAIPassthroughConfig
from litellm.types.utils import LlmProviders, ModelResponse
from litellm.utils import ProviderConfigManager
FOUNDRY_BASE = "https://my-resource.services.ai.azure.com"
@pytest.fixture(autouse=True)
def clear_azure_ai_env(monkeypatch):
for env_var in ("AZURE_AI_API_BASE", "AZURE_AI_API_KEY", "AZURE_AD_TOKEN", "AZURE_API_KEY"):
monkeypatch.delenv(env_var, raising=False)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "api_key", None)
def test_provider_config_manager_resolves_azure_ai_passthrough_config():
config = ProviderConfigManager.get_provider_passthrough_config(model="Cohere-parse-v5", provider=LlmProviders.AZURE_AI)
assert isinstance(config, AzureAIPassthroughConfig)
def test_router_model_prefix_is_stripped_and_native_path_kept_verbatim():
url, base = AzureAIPassthroughConfig().get_complete_url(
api_base=FOUNDRY_BASE,
api_key=None,
model="Cohere-parse-v5",
endpoint="Cohere-parse-v5/providers/cohere/v2/parse",
request_query_params=None,
litellm_params={},
)
assert str(url) == f"{FOUNDRY_BASE}/providers/cohere/v2/parse"
assert base == FOUNDRY_BASE
def test_model_group_prefix_is_stripped_when_router_metadata_names_it():
url, _ = AzureAIPassthroughConfig().get_complete_url(
api_base=FOUNDRY_BASE,
api_key=None,
model="Cohere-parse-v5",
endpoint="/parse-alias/providers/cohere/v2/parse",
request_query_params=None,
litellm_params={"litellm_metadata": {"model_group": "parse-alias"}},
)
assert str(url) == f"{FOUNDRY_BASE}/providers/cohere/v2/parse"
def test_model_inside_the_path_stays_and_query_params_are_forwarded():
url, _ = AzureAIPassthroughConfig().get_complete_url(
api_base=f"{FOUNDRY_BASE}/",
api_key=None,
model="gpt-5.4-mini",
endpoint="openai/deployments/gpt-5.4-mini/chat/completions",
request_query_params={"api-version": "2024-10-21"},
litellm_params={},
)
assert str(url) == f"{FOUNDRY_BASE}/openai/deployments/gpt-5.4-mini/chat/completions?api-version=2024-10-21"
def test_missing_api_base_raises_instead_of_building_a_relative_url():
with pytest.raises(ValueError, match="AZURE_AI_API_BASE"):
AzureAIPassthroughConfig().get_complete_url(
api_base=None,
api_key=None,
model="Cohere-parse-v5",
endpoint="Cohere-parse-v5/providers/cohere/v2/parse",
request_query_params=None,
litellm_params={},
)
def _auth_headers(api_key: str | None, api_base: str, litellm_params: dict | None = None) -> dict:
return AzureAIPassthroughConfig().validate_environment(
headers={"content-type": "application/json"},
model="Cohere-parse-v5",
messages=[],
optional_params={},
litellm_params=litellm_params or {},
api_key=api_key,
api_base=api_base,
)
def test_foundry_host_gets_the_api_key_header():
headers = _auth_headers(api_key="deployment-key", api_base=FOUNDRY_BASE)
assert headers == {"content-type": "application/json", "api-key": "deployment-key"}
def test_serverless_host_gets_a_bearer_token():
headers = _auth_headers(api_key="deployment-key", api_base="https://cohere-parse.eastus.models.ai.azure.com")
assert headers["Authorization"] == "Bearer deployment-key"
assert "api-key" not in headers
def test_entra_token_is_used_when_the_deployment_has_no_api_key():
headers = _auth_headers(api_key=None, api_base=FOUNDRY_BASE, litellm_params={"azure_ad_token": "entra-token"})
assert headers["Authorization"] == "Bearer entra-token"
def test_no_credentials_at_all_raises():
with pytest.raises(ValueError, match="Missing Azure AI credentials"):
_auth_headers(api_key=None, api_base=FOUNDRY_BASE)
@pytest.mark.parametrize(
"request_data, expected",
[({"stream": True}, True), ({"stream": False}, False), ({}, False)],
)
def test_is_streaming_request_reads_the_stream_flag(request_data, expected):
assert AzureAIPassthroughConfig().is_streaming_request(endpoint="models/chat/completions", request_data=request_data) is expected
def _chat_completion_response() -> httpx.Response:
body = {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-5.4-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
}
return httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=json.dumps(body).encode("utf-8"),
request=httpx.Request("POST", f"{FOUNDRY_BASE}/models/chat/completions"),
)
def test_chat_completions_relay_yields_a_model_response_for_cost_tracking():
result = AzureAIPassthroughConfig().logging_non_streaming_response(
model="gpt-5.4-mini",
custom_llm_provider="azure_ai",
httpx_response=_chat_completion_response(),
request_data={"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "hi"}]},
logging_obj=MagicMock(),
endpoint="models/chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "hi"
assert result.usage.prompt_tokens == 10
assert result.usage.completion_tokens == 8
def test_non_chat_relay_yields_no_cost_response():
parse_response = httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=b'{"id":"parse-1","pages":[]}',
request=httpx.Request("POST", f"{FOUNDRY_BASE}/providers/cohere/v2/parse"),
)
result = AzureAIPassthroughConfig().logging_non_streaming_response(
model="Cohere-parse-v5",
custom_llm_provider="azure_ai",
httpx_response=parse_response,
request_data={"model": "Cohere-parse-v5"},
logging_obj=MagicMock(),
endpoint="providers/cohere/v2/parse",
)
assert result is None

View file

@ -873,3 +873,118 @@ def test_llm_passthrough_route_propagates_allm_passthrough_route_to_logging_obj(
assert captured_litellm_params.get("allm_passthrough_route") is True
assert LitellmLogging._is_sync_litellm_request(captured_litellm_params) is False
FOUNDRY_BASE = "https://my-resource.services.ai.azure.com"
def _foundry_parse_response() -> httpx.Response:
return httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=b'{"id":"parse-1","pages":[]}',
request=httpx.Request("POST", f"{FOUNDRY_BASE}/providers/cohere/v2/parse"),
)
def test_azure_ai_relay_reaches_the_deployment_with_its_own_credential():
"""
Regression for LIT-7022: azure_ai had no passthrough config, so every
/azure_ai/<router-model>/<native-path> relay raised "Provider azure_ai not found"
before a request was built.
"""
client = HTTPHandler()
with patch.object(client.client, "send", return_value=_foundry_parse_response()) as mock_send:
response = llm_passthrough_route(
model="azure_ai/Cohere-parse-v5",
endpoint="Cohere-parse-v5/providers/cohere/v2/parse",
method="POST",
custom_llm_provider="azure_ai",
api_base=FOUNDRY_BASE,
api_key="deployment-key",
json={"model": "Cohere-parse-v5", "document": {"type": "image_url", "image_url": "https://x/y.png"}},
client=client,
litellm_logging_obj=MagicMock(),
)
sent = mock_send.call_args.kwargs["request"]
assert str(sent.url) == f"{FOUNDRY_BASE}/providers/cohere/v2/parse"
assert sent.headers["api-key"] == "deployment-key"
assert json.loads(sent.content)["model"] == "Cohere-parse-v5"
assert response.status_code == 200
@pytest.mark.asyncio
async def test_router_relays_azure_ai_model_through_the_deployment_api_base():
router = litellm.Router(
model_list=[
{
"model_name": "foundry-parse",
"litellm_params": {
"model": "azure_ai/Cohere-parse-v5",
"api_base": FOUNDRY_BASE,
"api_key": "deployment-key",
},
}
]
)
async_client = AsyncHTTPHandler()
with patch.object(async_client.client, "send", AsyncMock(return_value=_foundry_parse_response())) as mock_send:
response = await router.allm_passthrough_route(
model="foundry-parse",
method="POST",
endpoint="foundry-parse/providers/cohere/v2/parse",
json={"model": "foundry-parse", "document": {"type": "image_url", "image_url": "https://x/y.png"}},
client=async_client,
)
sent = mock_send.call_args.kwargs["request"]
assert str(sent.url) == f"{FOUNDRY_BASE}/providers/cohere/v2/parse"
assert sent.headers["api-key"] == "deployment-key"
assert json.loads(sent.content)["model"] == "Cohere-parse-v5"
assert response.status_code == 200
@pytest.mark.asyncio
async def test_router_relays_an_openai_model_on_a_foundry_base_as_azure_ai(monkeypatch):
monkeypatch.setenv("AZURE_AI_API_BASE", "https://unrelated.openai.azure.com")
router = litellm.Router(
model_list=[
{
"model_name": "foundry-gpt",
"litellm_params": {
"model": "azure_ai/gpt-5.4-mini",
"api_base": FOUNDRY_BASE,
"api_key": "deployment-key",
},
}
]
)
async_client = AsyncHTTPHandler()
upstream = httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=(
b'{"id":"chatcmpl-1","object":"chat.completion","model":"gpt-5.4-mini",'
b'"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],'
b'"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}'
),
request=httpx.Request("POST", f"{FOUNDRY_BASE}/models/chat/completions"),
)
with patch.object(async_client.client, "send", AsyncMock(return_value=upstream)) as mock_send:
await router.allm_passthrough_route(
model="foundry-gpt",
method="POST",
endpoint="foundry-gpt/models/chat/completions",
request_query_params={"api-version": "2024-05-01-preview"},
json={"model": "foundry-gpt", "messages": [{"role": "user", "content": "hi"}]},
client=async_client,
)
sent = mock_send.call_args.kwargs["request"]
assert str(sent.url) == f"{FOUNDRY_BASE}/models/chat/completions?api-version=2024-05-01-preview"
assert sent.headers["api-key"] == "deployment-key"
assert json.loads(sent.content)["model"] == "gpt-5.4-mini"

View file

@ -1,6 +1,6 @@
{
"LIT001": {
"limit": 22326
"limit": 22325
},
"LIT002": {
"limit": 26748