[Fix] UI Cost Estimator - Fix model dropdown (#19529)

* add cost estimator

* ui fix show errors

* test_estimate_cost_resolves_router_model_alias
This commit is contained in:
Ishaan Jaff
2026-01-21 20:19:40 -08:00
committed by GitHub
parent 815e6fcf76
commit a117509f93
3 changed files with 212 additions and 50 deletions
@@ -10,7 +10,7 @@ PATCH /config/cost_margin_config - Update cost margin configuration
POST /cost/estimate - Estimate cost for a given model and token counts
"""
from typing import Dict, Union
from typing import Dict, Optional, Tuple, Union
from fastapi import APIRouter, Depends, HTTPException
@@ -29,6 +29,52 @@ from litellm.types.utils import LlmProvidersSet
router = APIRouter()
def _resolve_model_for_cost_lookup(model: str) -> Tuple[str, Optional[str]]:
"""
Resolve a model name (which may be a router alias/model_group) to the
underlying litellm model name for cost lookup.
Args:
model: The model name from the request (could be a router alias like 'e-model-router'
or an actual model name like 'azure_ai/gpt-4')
Returns:
Tuple of (resolved_model_name, custom_llm_provider)
- resolved_model_name: The actual model name to use for cost lookup
- custom_llm_provider: The provider if resolved from router, None otherwise
"""
from litellm.proxy.proxy_server import llm_router
custom_llm_provider: Optional[str] = None
# Try to resolve from router if available
if llm_router is not None:
try:
# Get deployments for this model name (handles aliases, wildcards, etc.)
deployments = llm_router.get_model_list(model_name=model)
if deployments and len(deployments) > 0:
# Get the first deployment's litellm model
first_deployment = deployments[0]
litellm_params = first_deployment.get("litellm_params", {})
resolved_model = litellm_params.get("model")
if resolved_model:
verbose_proxy_logger.debug(
f"Resolved model '{model}' to '{resolved_model}' from router"
)
# Extract custom_llm_provider if present
custom_llm_provider = litellm_params.get("custom_llm_provider")
return resolved_model, custom_llm_provider
except Exception as e:
verbose_proxy_logger.debug(
f"Could not resolve model '{model}' from router: {e}"
)
# Return original model if not resolved
return model, custom_llm_provider
def _calculate_period_costs(
num_requests, cost_per_request, input_cost, output_cost, margin_cost
):
@@ -413,12 +459,18 @@ async def estimate_cost(
```
"""
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.utils import Usage
from litellm.utils import ModelResponse
from litellm.types.utils import ModelResponse, Usage
# Resolve model name (handles router aliases like 'e-model-router' -> 'azure_ai/gpt-4')
resolved_model, resolved_provider = _resolve_model_for_cost_lookup(request.model)
verbose_proxy_logger.debug(
f"Cost estimate: request.model='{request.model}' resolved to '{resolved_model}'"
)
# Create a mock response with usage for completion_cost
mock_response = ModelResponse(
model=request.model,
model=resolved_model,
usage=Usage(
prompt_tokens=request.input_tokens,
completion_tokens=request.output_tokens,
@@ -428,7 +480,7 @@ async def estimate_cost(
# Create a logging object to capture cost breakdown
litellm_logging_obj = LiteLLMLoggingObj(
model=request.model,
model=resolved_model,
messages=[],
stream=False,
call_type="completion",
@@ -441,14 +493,14 @@ async def estimate_cost(
try:
cost_per_request = completion_cost(
completion_response=mock_response,
model=request.model,
model=resolved_model,
litellm_logging_obj=litellm_logging_obj,
)
except Exception as e:
raise HTTPException(
status_code=404,
detail={
"error": f"Could not calculate cost for model '{request.model}': {str(e)}"
"error": f"Could not calculate cost for model '{request.model}' (resolved to '{resolved_model}'): {str(e)}"
},
)
@@ -461,7 +513,7 @@ async def estimate_cost(
# Get model info for per-token pricing display
try:
model_info = litellm.get_model_info(model=request.model)
model_info = litellm.get_model_info(model=resolved_model)
input_cost_per_token = model_info.get("input_cost_per_token")
output_cost_per_token = model_info.get("output_cost_per_token")
custom_llm_provider = model_info.get("litellm_provider")
@@ -470,6 +522,10 @@ async def estimate_cost(
output_cost_per_token = None
custom_llm_provider = None
# Use provider from router resolution if not found in model_info
if custom_llm_provider is None and resolved_provider is not None:
custom_llm_provider = resolved_provider
# Calculate daily and monthly costs
daily_cost, daily_input_cost, daily_output_cost, daily_margin_cost = (
_calculate_period_costs(
@@ -73,3 +73,67 @@ class TestCostEstimateEndpoint:
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_estimate_cost_resolves_router_model_alias(self):
"""
Test that estimate_cost resolves router model aliases to underlying models.
When a user selects a model like 'my-gpt4-alias' from the UI (which is a
router model_name), the endpoint should resolve it to the actual model
(e.g., 'azure/gpt-4') for cost calculation.
This prevents the bug where custom model names fail cost lookup because
they aren't in model_prices_and_context_window.json.
"""
request = CostEstimateRequest(
model="my-gpt4-alias", # Router alias, not actual model name
input_tokens=1000,
output_tokens=500,
)
# Mock the router to return deployment info
mock_router = MagicMock()
mock_router.get_model_list.return_value = [
{
"model_name": "my-gpt4-alias",
"litellm_params": {
"model": "azure/gpt-4", # Actual model for pricing
"custom_llm_provider": "azure",
},
}
]
with patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
):
with patch(
"litellm.proxy.management_endpoints.cost_tracking_settings.completion_cost"
) as mock_completion_cost:
mock_completion_cost.return_value = 0.05
with patch("litellm.get_model_info") as mock_get_model_info:
mock_get_model_info.return_value = {
"input_cost_per_token": 0.00003,
"output_cost_per_token": 0.00006,
"litellm_provider": "azure",
}
response = await estimate_cost(
request=request,
user_api_key_dict=MagicMock(),
)
# Verify router was queried for the alias
mock_router.get_model_list.assert_called_with(model_name="my-gpt4-alias")
# Verify completion_cost was called with RESOLVED model, not the alias
call_args = mock_completion_cost.call_args
assert call_args.kwargs["model"] == "azure/gpt-4"
# Verify response contains original model name (for UI display)
assert response.model == "my-gpt4-alias"
assert response.cost_per_request == 0.05
assert response.provider == "azure"
@@ -113,10 +113,13 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
const validEntries = multiResult.entries.filter((e) => e.result !== null);
const loadingEntries = multiResult.entries.filter((e) => e.loading);
const errorEntries = multiResult.entries.filter((e) => e.error !== null);
const hasAnyResult = validEntries.length > 0;
const isAnyLoading = loadingEntries.length > 0;
const hasAnyError = errorEntries.length > 0;
if (!hasAnyResult && !isAnyLoading) {
// Show empty state only if no results, not loading, and no errors
if (!hasAnyResult && !isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center border border-dashed border-gray-300 rounded-lg bg-gray-50">
<Text className="text-gray-500">
@@ -126,7 +129,8 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
);
}
if (!hasAnyResult && isAnyLoading) {
// Show loading state only if loading and no results/errors yet
if (!hasAnyResult && isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center">
<Spin indicator={<LoadingOutlined spin />} />
@@ -135,6 +139,26 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
);
}
// Show errors-only view when there are errors but no valid results
if (!hasAnyResult && hasAnyError) {
return (
<div className="space-y-4">
<Divider className="my-4" />
<div className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
</div>
{/* Error Messages */}
{errorEntries.map((e) => (
<div key={e.entry.id} className="text-sm text-red-600 bg-red-50 p-3 rounded-lg border border-red-200">
<span className="font-medium">{e.entry.model || "Unknown model"}: </span>
{e.error}
</div>
))}
</div>
);
}
const toggleExpanded = (id: string) => {
setExpandedModels((prev) => {
const next = new Set(prev);
@@ -157,13 +181,28 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
title: "Model",
dataIndex: "model",
key: "model",
render: (text: string, record: { id: string; provider?: string | null }) => (
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{text}</span>
{record.provider && (
<Tag color="blue" className="text-xs">
{record.provider}
</Tag>
render: (text: string, record: { id: string; provider?: string | null; error?: string | null; loading?: boolean; hasZeroCost?: boolean }) => (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{text}</span>
{record.provider && (
<Tag color="blue" className="text-xs">
{record.provider}
</Tag>
)}
{record.loading && (
<Spin indicator={<LoadingOutlined spin />} size="small" />
)}
</div>
{record.error && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
{record.error}
</div>
)}
{record.hasZeroCost && !record.error && (
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
No pricing data found for this model. Set base_model in config.
</div>
)}
</div>
),
@@ -173,17 +212,21 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
dataIndex: "cost_per_request",
key: "cost_per_request",
align: "right" as const,
render: (value: number) => <span className="font-mono text-sm">{formatCost(value)}</span>,
render: (value: number | null, record: { error?: string | null }) => (
record.error ? <span className="text-gray-400">-</span> : <span className="font-mono text-sm">{formatCost(value)}</span>
),
},
{
title: "Margin Fee",
dataIndex: "margin_cost_per_request",
key: "margin_cost_per_request",
align: "right" as const,
render: (value: number) => (
<span className={`font-mono text-sm ${value > 0 ? "text-amber-600" : "text-gray-400"}`}>
{formatCost(value)}
</span>
render: (value: number | null, record: { error?: string | null }) => (
record.error ? <span className="text-gray-400">-</span> : (
<span className={`font-mono text-sm ${(value ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}>
{formatCost(value)}
</span>
)
),
},
{
@@ -191,34 +234,43 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
dataIndex: periodCostKey,
key: "period_cost",
align: "right" as const,
render: (value: number | null) => <span className="font-mono text-sm">{formatCost(value)}</span>,
render: (value: number | null, record: { error?: string | null }) => (
record.error ? <span className="text-gray-400">-</span> : <span className="font-mono text-sm">{formatCost(value)}</span>
),
},
{
title: "",
key: "expand",
width: 40,
render: (_: unknown, record: { id: string }) => (
<Button
size="xs"
variant="light"
onClick={() => toggleExpanded(record.id)}
className="text-gray-400 hover:text-gray-600"
>
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
</Button>
render: (_: unknown, record: { id: string; error?: string | null }) => (
record.error ? null : (
<Button
size="xs"
variant="light"
onClick={() => toggleExpanded(record.id)}
className="text-gray-400 hover:text-gray-600"
>
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
</Button>
)
),
},
];
const summaryData = validEntries.map((e) => ({
// Include both valid results and errors in the table data
const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model);
const summaryData = allEntriesWithModels.map((e) => ({
key: e.entry.id,
id: e.entry.id,
model: e.result!.model,
provider: e.result!.provider,
cost_per_request: e.result!.cost_per_request,
margin_cost_per_request: e.result!.margin_cost_per_request,
daily_cost: e.result!.daily_cost,
monthly_cost: e.result!.monthly_cost,
model: e.result?.model || e.entry.model,
provider: e.result?.provider,
cost_per_request: e.result?.cost_per_request ?? null,
margin_cost_per_request: e.result?.margin_cost_per_request ?? null,
daily_cost: e.result?.daily_cost ?? null,
monthly_cost: e.result?.monthly_cost ?? null,
error: e.error,
loading: e.loading,
hasZeroCost: e.result && e.result.cost_per_request === 0,
}));
return (
@@ -268,7 +320,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
</Card>
{/* Per-Model Table */}
{validEntries.length > 0 && (
{summaryData.length > 0 && (
<Table
columns={summaryColumns}
dataSource={summaryData}
@@ -290,16 +342,6 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
}}
/>
)}
{/* Error Messages */}
{multiResult.entries
.filter((e) => e.error)
.map((e) => (
<div key={e.entry.id} className="text-sm text-red-600 bg-red-50 p-3 rounded-lg border border-red-200">
<span className="font-medium">{e.entry.model || "Unknown model"}: </span>
{e.error}
</div>
))}
</div>
);
};