diff --git a/litellm/__init__.py b/litellm/__init__.py index c965968bb39..ac57a9d0fee 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1696,6 +1696,9 @@ if TYPE_CHECKING: from .llms.openai.decisions.transformation import ( OpenAIDecisionsConfig as OpenAIDecisionsConfig, ) + from .llms.azure_ai.decisions.transformation import ( + AzureAIDecisionsConfig as AzureAIDecisionsConfig, + ) from .llms.nvidia_nim.rerank.transformation import ( NvidiaNimRerankConfig as NvidiaNimRerankConfig, ) diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index f70caf8bb0d..9caba6a060a 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -165,6 +165,7 @@ LLM_CONFIG_NAMES: Final = ( "DatabricksDecisionsConfig", "HostedVLLMDecisionsConfig", "OpenAIDecisionsConfig", + "AzureAIDecisionsConfig", "NvidiaNimRerankConfig", "NvidiaNimRankingConfig", "VertexAIRerankConfig", @@ -731,6 +732,7 @@ _LLM_CONFIGS_IMPORT_MAP: Final = { "HostedVLLMDecisionsConfig", ), "OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"), + "AzureAIDecisionsConfig": (".llms.azure_ai.decisions.transformation", "AzureAIDecisionsConfig"), "NvidiaNimRerankConfig": ( ".llms.nvidia_nim.rerank.transformation", "NvidiaNimRerankConfig", diff --git a/litellm/llms/azure_ai/decisions/transformation.py b/litellm/llms/azure_ai/decisions/transformation.py new file mode 100644 index 00000000000..9b10f393c7c --- /dev/null +++ b/litellm/llms/azure_ai/decisions/transformation.py @@ -0,0 +1,24 @@ +import re +from typing import Final + +import httpx + +from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig + +_FOUNDRY_ROUTE_SUFFIX: Final = re.compile(r"(/api/projects/[^/]+)?(/openai/v1|/openai|/models|/v1)?$") + + +class AzureAIDecisionsConfig(BaseDecisionsConfig): + """Microsoft Foundry decision models (Microsoft-Decision-1), where model is the deployment name""" + + path = "/providers/microsoft/v1/systemone" + api_key_env = ("AZURE_AI_API_KEY",) + api_base_env = ("AZURE_AI_API_BASE",) + + def missing_api_base_message(self, custom_llm_provider: str) -> str: + return "Missing AZURE_AI_API_BASE - set AZURE_AI_API_BASE or pass api_base, e.g. https://.services.ai.azure.com" + + def get_complete_url(self, api_base: str, model: str) -> str: + url: Final = httpx.URL(api_base) + resource_path: Final = _FOUNDRY_ROUTE_SUFFIX.sub("", url.path.rstrip("/")) + return str(url.copy_with(path=f"{resource_path}{self.path}", query=None, fragment=None)) diff --git a/litellm/utils.py b/litellm/utils.py index 52209dbfce8..e7a171f56c6 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9021,6 +9021,8 @@ class ProviderConfigManager: return litellm.HostedVLLMDecisionsConfig() if provider == LlmProviders.OPENAI: return litellm.OpenAIDecisionsConfig() + if provider == LlmProviders.AZURE_AI: + return litellm.AzureAIDecisionsConfig() return None @staticmethod diff --git a/tests/integration/providers/test_decisions_wire.py b/tests/integration/providers/test_decisions_wire.py index ac4da38ef37..16c938e3995 100644 --- a/tests/integration/providers/test_decisions_wire.py +++ b/tests/integration/providers/test_decisions_wire.py @@ -108,6 +108,9 @@ _PROVIDERS: Final = ( True, "cloudflare/@cf/cloudflare/clef", ), + _Provider( + "azure_ai", "azure_ai/decision-1", "/providers/microsoft/v1/systemone", "decision-1", _API_KEY, False, None + ), _Provider( "databricks", "databricks/databricks-openjev-qwen35-4b", diff --git a/tests/integration/proxy_config.yaml b/tests/integration/proxy_config.yaml index 79c02b0970d..f1c0c565c54 100644 --- a/tests/integration/proxy_config.yaml +++ b/tests/integration/proxy_config.yaml @@ -356,6 +356,13 @@ model_list: litellm_params: model: hosted_vllm/Qwen/Qwen3-0.6B api_base: http://127.0.0.1:8191 + - model_name: azure_ai/decision-1 + litellm_params: + model: azure_ai/decision-1 + api_base: http://127.0.0.1:8191 + api_key: synthetic-azure-ai-key + model_info: + base_model: azure_ai/Microsoft-Decision-1 general_settings: master_key: os.environ/LITELLM_MASTER_KEY database_url: os.environ/DATABASE_URL diff --git a/tests/integration/translation/decisions/bases/azure_ai.py b/tests/integration/translation/decisions/bases/azure_ai.py new file mode 100644 index 00000000000..27e968c55cb --- /dev/null +++ b/tests/integration/translation/decisions/bases/azure_ai.py @@ -0,0 +1,79 @@ +from typing import Final + +from integration.translation.case import TranslationTestCase + +"""Provider request and reply shape from the Microsoft Foundry Decision playground (POST /providers/microsoft/v1/systemone, model is the deployment name). Mock reply captured live from a Microsoft-Decision-1 deployment on 2026-10-09. +""" +MICROSOFT_DECISION_1_TEST_CASE: Final = TranslationTestCase( + scenario="basic", + litellm_endpoint="/v1/systemone", + litellm_request={ + "model": "azure_ai/decision-1", + "state": "Ticket (billing): The export job hangs at 99% and never finishes", + "questions": { + "defect": {"type": "noul", "instructions": "Is this a defect?"}, + "severity": { + "type": "choice", + "instructions": "How severe is it?", + "criteria": {"low": "cosmetic", "high": "blocks users"}, + }, + "confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]}, + }, + "cache": {"no-cache": True}, + }, + expected_provider_endpoint="/providers/microsoft/v1/systemone", + expected_provider_headers={"authorization": "Bearer synthetic-azure-ai-key", "content-type": "application/json"}, + expected_provider_request={ + "model": "decision-1", + "state": "Ticket (billing): The export job hangs at 99% and never finishes", + "questions": { + "defect": {"type": "noul", "instructions": "Is this a defect?"}, + "severity": { + "type": "choice", + "instructions": "How severe is it?", + "criteria": {"low": "cosmetic", "high": "blocks users"}, + }, + "confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]}, + }, + }, + mock_provider_response={ + "model": "microsoft-decision-1", + "answers": { + "confidence": { + "confidence": 0.6351489346076444, + "legend": {"0": "unsure", "1": "sure"}, + "probabilities": {"0": 0.1824255326961778, "1": 0.8175744673038222}, + "score": 0.8175744673038222, + "type": "score", + }, + "defect": {"noul": 0.982013764002278, "type": "noul"}, + "severity": { + "choice": "high", + "confidence": 0.9866142978108987, + "probabilities": {"high": 0.9933071489054494, "low": 0.006692851094550702}, + "type": "choice", + }, + }, + "usage": {"input_tokens": 87, "output_tokens": 3}, + }, + expected_litellm_response={ + "model": "microsoft-decision-1", + "answers": { + "defect": {"type": "noul", "noul": 0.982013764002278}, + "severity": { + "type": "choice", + "choice": "high", + "confidence": 0.9866142978108987, + "probabilities": {"high": 0.9933071489054494, "low": 0.006692851094550702}, + }, + "confidence": { + "type": "score", + "score": 0.8175744673038222, + "confidence": 0.6351489346076444, + "legend": {"0": "unsure", "1": "sure"}, + "probabilities": {"0": 0.1824255326961778, "1": 0.8175744673038222}, + }, + }, + "usage": {"input_tokens": 87, "output_tokens": 3}, + }, +) diff --git a/tests/integration/translation/decisions/basic/test_decisions_basic_azure_ai.py b/tests/integration/translation/decisions/basic/test_decisions_basic_azure_ai.py new file mode 100644 index 00000000000..1d6b5cc4af1 --- /dev/null +++ b/tests/integration/translation/decisions/basic/test_decisions_basic_azure_ai.py @@ -0,0 +1,11 @@ +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.decisions.bases.azure_ai import MICROSOFT_DECISION_1_TEST_CASE +from integration.translation.runner import assert_translation + + +@pytest.mark.parametrize("case", [MICROSOFT_DECISION_1_TEST_CASE], ids=lambda case: case.id) +def test_decisions_basic_azure_ai(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 99d7d59407f..8f05a492119 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -1260,3 +1260,88 @@ async def test_openrouter_decisions_uses_provider_reported_cost_without_cost_map assert route.called assert get_response_cost_from_hidden_params(response.hidden_params) == cost + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "api_base", + ( + "https://res.services.ai.azure.com", + "https://res.services.ai.azure.com/", + "https://res.services.ai.azure.com/models", + "https://res.services.ai.azure.com/openai/v1", + "https://res.services.ai.azure.com/api/projects/proj", + "https://res.services.ai.azure.com/api/projects/proj/openai/v1", + "https://res.services.ai.azure.com/api/projects/proj/models", + "https://res.services.ai.azure.com/models?api-version=2024-05-01-preview", + ), +) +async def test_azure_ai_decision_posts_deployment_to_foundry_systemone_route( + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + api_base: str, +) -> None: + monkeypatch.delenv("AZURE_AI_API_BASE", raising=False) + monkeypatch.delenv("AZURE_AI_API_KEY", raising=False) + route: Final = respx_mock.post("https://res.services.ai.azure.com/providers/microsoft/v1/systemone").respond( + json={**_RESPONSE, "model": "microsoft-decision-1"} + ) + + response: Final = await litellm.adecisions( + model="azure_ai/decision-1", + state="review", + questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + api_base=api_base, + api_key="foundry-key", + ) + + assert route.called + request: Final = respx_mock.calls[0].request + assert request.headers["authorization"] == "Bearer foundry-key" + assert "api-key" not in request.headers + assert json.loads(request.content) == { + "model": "decision-1", + "state": "review", + "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + } + assert response.answers == {"is_defect": NoulAnswer(type="noul", noul=0.9)} + assert response._hidden_params["model"] == "azure_ai/decision-1" + + +@pytest.mark.asyncio +async def test_azure_ai_decision_reads_foundry_env_and_keeps_gateway_path_prefix( + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + monkeypatch.setenv("AZURE_AI_API_BASE", "https://gateway.example.com/foundry/models") + monkeypatch.setenv("AZURE_AI_API_KEY", "env-foundry-key") + route: Final = respx_mock.post("https://gateway.example.com/foundry/providers/microsoft/v1/systemone").respond( + json=_RESPONSE + ) + + await litellm.adecisions( + model="azure_ai/decision-1", + state="review", + questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + ) + + assert route.called + assert respx_mock.calls[0].request.headers["authorization"] == "Bearer env-foundry-key" + + +@pytest.mark.asyncio +async def test_azure_ai_decision_requires_api_base_before_http( + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + monkeypatch.delenv("AZURE_AI_API_BASE", raising=False) + monkeypatch.setenv("AZURE_AI_API_KEY", "foundry-key") + + with pytest.raises(litellm.BadRequestError, match="Missing AZURE_AI_API_BASE"): + await litellm.adecisions( + model="azure_ai/decision-1", + state="review", + questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + ) + + assert len(respx_mock.calls) == 0