mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-14 14:26:43 +00:00
Merge pull request #23550 from Sameerlite/litellm_azure-model-router-cost-breakdown
feat(azure): Azure Model Router cost breakdown in UI + additional_costs from hidden_params
This commit is contained in:
@@ -1182,7 +1182,7 @@ def completion_cost( # noqa: PLR0915
|
||||
and _usage["prompt_tokens_details"] != {}
|
||||
and _usage["prompt_tokens_details"]
|
||||
):
|
||||
prompt_tokens_details = _usage.get("prompt_tokens_details", {})
|
||||
prompt_tokens_details = _usage.get("prompt_tokens_details") or {}
|
||||
cache_read_input_tokens = prompt_tokens_details.get(
|
||||
"cached_tokens", 0
|
||||
)
|
||||
@@ -1484,8 +1484,8 @@ def completion_cost( # noqa: PLR0915
|
||||
completion_tokens_cost_usd_dollar,
|
||||
) = cost_per_token(
|
||||
model=model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
prompt_tokens=prompt_tokens or 0,
|
||||
completion_tokens=completion_tokens or 0,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
response_time_ms=total_time,
|
||||
region_name=region_name,
|
||||
@@ -1505,13 +1505,27 @@ def completion_cost( # noqa: PLR0915
|
||||
)
|
||||
|
||||
# Get additional costs from provider (e.g., routing fees, infrastructure costs)
|
||||
# Only azure_ai implements additional costs
|
||||
if custom_llm_provider == "azure_ai":
|
||||
model_for_additional_costs = request_model_for_cost
|
||||
if completion_response is not None:
|
||||
hidden_params = getattr(completion_response, "_hidden_params", None) or {}
|
||||
hidden_model = hidden_params.get("model") or hidden_params.get(
|
||||
"litellm_model_name"
|
||||
)
|
||||
if hidden_model and (
|
||||
"model_router" in (hidden_model or "").lower()
|
||||
or "model-router" in (hidden_model or "").lower()
|
||||
):
|
||||
model_for_additional_costs = hidden_model
|
||||
elif model_for_additional_costs is None:
|
||||
model_for_additional_costs = hidden_model
|
||||
if model_for_additional_costs is None:
|
||||
model_for_additional_costs = model
|
||||
additional_costs = _get_additional_costs(
|
||||
model=model,
|
||||
model=model_for_additional_costs,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
prompt_tokens=prompt_tokens or 0,
|
||||
completion_tokens=completion_tokens or 0,
|
||||
)
|
||||
else:
|
||||
additional_costs = None
|
||||
@@ -1529,8 +1543,9 @@ def completion_cost( # noqa: PLR0915
|
||||
)
|
||||
)
|
||||
_final_cost += cost_for_built_in_tools
|
||||
if additional_costs:
|
||||
_final_cost += sum(additional_costs.values())
|
||||
|
||||
# Apply discount from module-level config if configured
|
||||
original_cost = _final_cost
|
||||
if litellm.cost_discount_config:
|
||||
(
|
||||
|
||||
@@ -382,3 +382,49 @@ class TestAzureModelRouterCostBreakdown:
|
||||
|
||||
print(f"Additional costs in breakdown: {additional_costs}")
|
||||
print(f"Azure Model Router Flat Cost: ${actual_flat_cost:.6f}")
|
||||
|
||||
def test_additional_costs_when_response_has_actual_model_via_hidden_params(self):
|
||||
"""additional_costs populated when response has actual model but request was via model router (hidden_params)."""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.cost_calculator import completion_cost
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
||||
|
||||
logging_obj = Logging(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-123",
|
||||
function_id="test-function",
|
||||
)
|
||||
response = ModelResponse(
|
||||
id="test-123",
|
||||
choices=[Choices(finish_reason="stop", index=0, message=Message(role="assistant", content="Hello"))],
|
||||
created=1234567890,
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
object="chat.completion",
|
||||
usage=Usage(prompt_tokens=5000, completion_tokens=2000, total_tokens=7000),
|
||||
)
|
||||
response._hidden_params = {
|
||||
"custom_llm_provider": "azure_ai",
|
||||
"litellm_model_name": "azure_ai/model-router",
|
||||
}
|
||||
cost = completion_cost(
|
||||
completion_response=response,
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
custom_llm_provider="azure_ai",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
expected_flat_cost = (
|
||||
5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
assert cost >= expected_flat_cost
|
||||
assert logging_obj.cost_breakdown is not None
|
||||
assert "additional_costs" in logging_obj.cost_breakdown
|
||||
assert "Azure Model Router Flat Cost" in logging_obj.cost_breakdown["additional_costs"]
|
||||
assert logging_obj.cost_breakdown["additional_costs"]["Azure Model Router Flat Cost"] == pytest.approx(
|
||||
expected_flat_cost, rel=1e-9
|
||||
)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { CostBreakdownViewer } from "./CostBreakdownViewer";
|
||||
|
||||
async function expandCostBreakdown() {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText("Cost Breakdown"));
|
||||
}
|
||||
|
||||
describe("CostBreakdownViewer", () => {
|
||||
it("renders cost breakdown with input and output costs", async () => {
|
||||
renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={{
|
||||
input_cost: 0.001,
|
||||
output_cost: 0.002,
|
||||
total_cost: 0.003,
|
||||
original_cost: 0.003,
|
||||
}}
|
||||
totalSpend={0.003}
|
||||
promptTokens={100}
|
||||
completionTokens={200}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Cost Breakdown")).toBeInTheDocument();
|
||||
await expandCostBreakdown();
|
||||
expect(screen.getByText("Input Cost:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Cost:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Final Calculated Cost:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows non-null, non-zero additional_costs", async () => {
|
||||
renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={{
|
||||
input_cost: 0.001,
|
||||
output_cost: 0.002,
|
||||
total_cost: 0.00312,
|
||||
original_cost: 0.00312,
|
||||
additional_costs: {
|
||||
"Azure Model Router Flat Cost": 0.00012,
|
||||
"Routing Fee": 0.0005,
|
||||
},
|
||||
}}
|
||||
totalSpend={0.00312}
|
||||
promptTokens={100}
|
||||
completionTokens={200}
|
||||
/>
|
||||
);
|
||||
|
||||
await expandCostBreakdown();
|
||||
expect(screen.getByText("Azure Model Router Flat Cost:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Routing Fee:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out null and zero additional_costs", async () => {
|
||||
renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={{
|
||||
input_cost: 0.001,
|
||||
output_cost: 0.002,
|
||||
total_cost: 0.00312,
|
||||
original_cost: 0.00312,
|
||||
additional_costs: {
|
||||
"Azure Model Router Flat Cost": 0.00012,
|
||||
"Zero Cost": 0,
|
||||
"Null Cost": null as unknown as number,
|
||||
},
|
||||
}}
|
||||
totalSpend={0.00312}
|
||||
promptTokens={100}
|
||||
completionTokens={200}
|
||||
/>
|
||||
);
|
||||
|
||||
await expandCostBreakdown();
|
||||
expect(screen.getByText("Azure Model Router Flat Cost:")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Zero Cost:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Null Cost:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders when only additional_costs exist (no input/output costs)", async () => {
|
||||
const { container } = renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={{
|
||||
total_cost: 0.00012,
|
||||
original_cost: 0.00012,
|
||||
additional_costs: {
|
||||
"Model Router Flat Cost": 0.00012,
|
||||
},
|
||||
}}
|
||||
totalSpend={0.00012}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Cost Breakdown")).toBeInTheDocument();
|
||||
await expandCostBreakdown();
|
||||
expect(screen.getByText("Model Router Flat Cost:")).toBeInTheDocument();
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("returns null when no meaningful data", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={undefined}
|
||||
totalSpend={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("returns null when additional_costs are all null/zero", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={{
|
||||
additional_costs: {
|
||||
"Zero": 0,
|
||||
"Null": null as unknown as number,
|
||||
},
|
||||
}}
|
||||
totalSpend={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("expands to show additional_costs on click", async () => {
|
||||
renderWithProviders(
|
||||
<CostBreakdownViewer
|
||||
costBreakdown={{
|
||||
input_cost: 0.001,
|
||||
output_cost: 0.002,
|
||||
total_cost: 0.00312,
|
||||
original_cost: 0.00312,
|
||||
additional_costs: {
|
||||
"Azure Model Router Flat Cost": 0.00012,
|
||||
},
|
||||
}}
|
||||
totalSpend={0.00312}
|
||||
promptTokens={100}
|
||||
completionTokens={200}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Azure Model Router Flat Cost:")).not.toBeInTheDocument();
|
||||
await expandCostBreakdown();
|
||||
expect(screen.getByText("Azure Model Router Flat Cost:")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -45,9 +45,15 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
||||
const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined;
|
||||
|
||||
const hasCostBreakdown = costBreakdown?.input_cost !== undefined || costBreakdown?.output_cost !== undefined;
|
||||
const hasAdditionalCosts =
|
||||
costBreakdown?.additional_costs &&
|
||||
Object.entries(costBreakdown.additional_costs).some(
|
||||
([, value]) => value != null && value !== 0
|
||||
);
|
||||
const hasMeaningfulData =
|
||||
hasCostBreakdown ||
|
||||
hasTokenCounts ||
|
||||
hasAdditionalCosts ||
|
||||
(costBreakdown &&
|
||||
((costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0) ||
|
||||
(costBreakdown.discount_amount !== undefined && costBreakdown.discount_amount !== 0) ||
|
||||
@@ -127,17 +133,15 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
||||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Additional Costs (free-form) */}
|
||||
{costBreakdown?.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && (
|
||||
<>
|
||||
{Object.entries(costBreakdown.additional_costs).map(([key, value]) => (
|
||||
{costBreakdown?.additional_costs &&
|
||||
Object.entries(costBreakdown.additional_costs)
|
||||
.filter(([, value]) => value != null && value !== 0)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
|
||||
|
||||
Reference in New Issue
Block a user