Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,9 @@ def set_global_gitlab_config(config: Mapping[str, object]) -> None:
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,
)
Expand Down
2 changes: 2 additions & 0 deletions litellm/_lazy_imports_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
"CloudflareDecisionsConfig",
"StrandsDeciderDecisionsConfig",
"OpenAIDecisionsConfig",
"AzureAIDecisionsConfig",
"NvidiaNimRerankConfig",
"NvidiaNimRankingConfig",
"VertexAIRerankConfig",
Expand Down Expand Up @@ -724,6 +725,7 @@
"StrandsDeciderDecisionsConfig",
),
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),
"AzureAIDecisionsConfig": (".llms.azure_ai.decisions.transformation", "AzureAIDecisionsConfig"),
"NvidiaNimRerankConfig": (
".llms.nvidia_nim.rerank.transformation",
"NvidiaNimRerankConfig",
Expand Down
24 changes: 24 additions & 0 deletions litellm/llms/azure_ai/decisions/transformation.py
Original file line number Diff line number Diff line change
@@ -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://<resource>.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))
7 changes: 7 additions & 0 deletions litellm/model_prices_and_context_window_backup.json
Original file line number Diff line number Diff line change
Expand Up @@ -81933,5 +81933,12 @@
"output_cost_per_token": 7.5e-06,
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json",
"supports_video_input": true
},
"azure_ai/Microsoft-Decision-1": {
"input_cost_per_token": 4.2e-08,
"litellm_provider": "azure_ai",
"mode": "evaluation",
"output_cost_per_token": 4.2e-08,
"source": "https://prices.azure.com/api/retail/prices?$filter=productName eq 'Microsoft Decision Models'"
}
}
2 changes: 2 additions & 0 deletions litellm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8991,6 +8991,8 @@ def get_provider_decisions_config(model: str, provider: LlmProviders) -> BaseDec
return litellm.StrandsDeciderDecisionsConfig()
if provider == LlmProviders.OPENAI:
return litellm.OpenAIDecisionsConfig()
if provider == LlmProviders.AZURE_AI:
return litellm.AzureAIDecisionsConfig()
return None

@staticmethod
Expand Down
7 changes: 7 additions & 0 deletions model_prices_and_context_window.json
Original file line number Diff line number Diff line change
Expand Up @@ -81933,5 +81933,12 @@
"output_cost_per_token": 7.5e-06,
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json",
"supports_video_input": true
},
"azure_ai/Microsoft-Decision-1": {
"input_cost_per_token": 4.2e-08,
"litellm_provider": "azure_ai",
"mode": "evaluation",
"output_cost_per_token": 4.2e-08,
"source": "https://prices.azure.com/api/retail/prices?$filter=productName eq 'Microsoft Decision Models'"
}
}
3 changes: 3 additions & 0 deletions tests/integration/providers/test_decisions_wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ class _Provider:
True,
"cloudflare/@cf/cloudflare/clef",
),
_Provider(
"azure_ai", "azure_ai/decision-1", "/providers/microsoft/v1/systemone", "decision-1", _API_KEY, False, None
),
)
_PERPLEXITY: Final = _PROVIDERS[0]
_OPENROUTER: Final = _PROVIDERS[2]
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/proxy_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,13 @@ model_list:
litellm_params:
model: strands_decider/strands-decider-2B-hobson-v19
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
Expand Down
79 changes: 79 additions & 0 deletions tests/integration/translation/decisions/bases/azure_ai.py
Original file line number Diff line number Diff line change
@@ -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},
},
)
Original file line number Diff line number Diff line change
@@ -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)
85 changes: 85 additions & 0 deletions tests/unit/decisions/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -922,3 +922,88 @@ async def test_requests_a_provider_cannot_serve_are_rejected_before_http(
await litellm.adecisions(model=model, api_key="caller-key", **request_kwargs)

assert len(respx_mock.calls) == 0


@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
12 changes: 10 additions & 2 deletions ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isAdminRole } from "@/utils/roles";

export default function ModelHubTablePage() {
const { accessToken, userRole, premiumUser } = useAuthorized();
const { accessToken, userRole, premiumUser, isViewOnly } = useAuthorized();
if (!isAdminRole(userRole)) {
return <PublicModelHub accessToken={accessToken} isEmbedded={true} />;
}
return <ModelHubTable accessToken={accessToken} publicPage={false} premiumUser={premiumUser} userRole={userRole} />;
return (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
userRole={userRole}
canOpenPlayground={!isViewOnly}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock("./panels/ModelRetrySettingsPanel", () => ({ default: () => <div data-te
vi.mock("./panels/ModelGroupAliasPanel", () => ({ default: () => <div data-testid="panel-alias" /> }));
vi.mock("./panels/PriceDataPanel", () => ({ default: () => <div data-testid="panel-price" /> }));
vi.mock("./panels/AccessGroupBudgetsPanel", () => ({ default: () => <div data-testid="panel-budgets" /> }));
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
vi.mock("@/components/molecules/DecisionModelsBanner", () => ({ default: () => null }));
vi.mock("@/components/model_info_view", () => ({
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ vi.mock("./detailNavigation", () => ({
useModelDetailRouting: () => ({ ...detailState, close: vi.fn(), openModel: vi.fn(), openTeam: vi.fn() }),
}));

vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
vi.mock("@/components/molecules/DecisionModelsBanner", () => ({
default: ({ onAddModel }: { onAddModel?: () => void }) => (
<section aria-label="Decision models">
{onAddModel && (
<button type="button" onClick={onAddModel}>
Add a decision model
</button>
)}
</section>
),
}));
vi.mock("@/components/model_info_view", () => ({
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
}));
Expand Down Expand Up @@ -97,6 +107,27 @@ describe("ModelsAndEndpointsPage", () => {
expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument();
});

it("opens the Add Model tab from the decision models banner", async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: "Add a decision model" }));
expect(screen.getByTestId("panel-add")).toBeInTheDocument();
expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument();
});

it("does not offer the banner's Add a decision model to a session that cannot add models", () => {
mockUseAuthorized.mockReturnValue(NON_ADMIN);
renderPage();
expect(screen.getByRole("region", { name: "Decision models" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add a decision model" })).not.toBeInTheDocument();
});

it("does not show a view-only session the decision models banner, whose Playground it cannot open", () => {
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
renderPage();
expect(screen.queryByRole("region", { name: "Decision models" })).not.toBeInTheDocument();
});

it("renders the model detail overlay from the ?model drill-in and hides the tabs", () => {
detailState.modelId = "abc-123";
renderPage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { autoRouterCreationScope, canCreateModels } from "@/utils/modelPermissions";
import BetaBadge from "@/components/BetaBadge";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import DecisionModelsBanner from "@/components/molecules/DecisionModelsBanner";
import ModelInfoView from "@/components/model_info_view";
import TeamInfoView from "@/components/team/TeamInfo";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
Expand Down Expand Up @@ -177,7 +177,9 @@ export default function ModelsAndEndpointsPage() {
</div>
</div>

<CostOptimizationFeedbackBanner />
{!isViewOnly && (
<DecisionModelsBanner onAddModel={canCreate && !modelId ? () => setActiveKey("add") : undefined} />
)}

{modelId ? (
<ModelInfoView
Expand Down
Loading
Loading