diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index c1daa109c7..a3ef7b264e 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -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: ( diff --git a/tests/test_litellm/llms/azure_ai/test_azure_ai_cost_calculator.py b/tests/test_litellm/llms/azure_ai/test_azure_ai_cost_calculator.py index ec1d4e4b3c..2b00c25049 100644 --- a/tests/test_litellm/llms/azure_ai/test_azure_ai_cost_calculator.py +++ b/tests/test_litellm/llms/azure_ai/test_azure_ai_cost_calculator.py @@ -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 + ) diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx new file mode 100644 index 0000000000..983e78aa98 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("returns null when additional_costs are all null/zero", () => { + const { container } = renderWithProviders( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("expands to show additional_costs on click", async () => { + renderWithProviders( + + ); + + expect(screen.queryByText("Azure Model Router Flat Cost:")).not.toBeInTheDocument(); + await expandCostBreakdown(); + expect(screen.getByText("Azure Model Router Flat Cost:")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index 087863e947..61699cb2ef 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -45,9 +45,15 @@ export const CostBreakdownViewer: React.FC = ({ 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 = ({ {formatCost(costBreakdown.tool_usage_cost)} )} - {/* 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]) => (
{key}: {formatCost(value)}
))} - - )} {/* Subtotal / Original Cost - hide when cached since it would be $0 */}