mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #25735 from BerriAI/litellm_bedrock_cache_cost_breakdown
fix(bedrock/anthropic): accurate cache token cost breakdown in UI and SpendLogs
This commit is contained in:
commit
645e0a7831
10 changed files with 175 additions and 21 deletions
|
|
@ -966,6 +966,8 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
margin_percent: Optional[float] = None,
|
||||
margin_fixed_amount: Optional[float] = None,
|
||||
margin_total_amount: Optional[float] = None,
|
||||
cache_read_cost: Optional[float] = None,
|
||||
cache_creation_cost: Optional[float] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Helper function to store cost breakdown in the logging object.
|
||||
|
|
@ -1001,6 +1003,8 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
margin_percent=margin_percent,
|
||||
margin_fixed_amount=margin_fixed_amount,
|
||||
margin_total_amount=margin_total_amount,
|
||||
cache_read_cost=cache_read_cost,
|
||||
cache_creation_cost=cache_creation_cost,
|
||||
)
|
||||
|
||||
except Exception as breakdown_error:
|
||||
|
|
@ -1599,6 +1603,22 @@ def completion_cost( # noqa: PLR0915
|
|||
|
||||
# Store cost breakdown in logging object if available
|
||||
if litellm_logging_obj is not None:
|
||||
_cache_read_cost: Optional[float] = None
|
||||
_cache_creation_cost: Optional[float] = None
|
||||
if cost_per_token_usage_object is not None:
|
||||
_cr = getattr(cost_per_token_usage_object, "cache_read_input_tokens", None) or (cost_per_token_usage_object.model_extra or {}).get("cache_read_input_tokens")
|
||||
_cc = getattr(cost_per_token_usage_object, "cache_creation_input_tokens", None) or (cost_per_token_usage_object.model_extra or {}).get("cache_creation_input_tokens")
|
||||
if (_cr or _cc) and model:
|
||||
try:
|
||||
_mi = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
_cr_rate = _mi.get("cache_read_input_token_cost")
|
||||
if _cr and _cr_rate is not None:
|
||||
_cache_read_cost = float(_cr) * float(_cr_rate)
|
||||
_cc_rate = _mi.get("cache_creation_input_token_cost")
|
||||
if _cc and _cc_rate is not None:
|
||||
_cache_creation_cost = float(_cc) * float(_cc_rate)
|
||||
except Exception:
|
||||
pass
|
||||
_store_cost_breakdown_in_logging_obj(
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
prompt_tokens_cost_usd_dollar=prompt_tokens_cost_usd_dollar,
|
||||
|
|
@ -1612,6 +1632,8 @@ def completion_cost( # noqa: PLR0915
|
|||
margin_percent=margin_percent,
|
||||
margin_fixed_amount=margin_fixed_amount,
|
||||
margin_total_amount=margin_total_amount,
|
||||
cache_read_cost=_cache_read_cost,
|
||||
cache_creation_cost=_cache_creation_cost,
|
||||
)
|
||||
|
||||
return _final_cost
|
||||
|
|
|
|||
|
|
@ -1387,6 +1387,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
margin_percent: Optional[float] = None,
|
||||
margin_fixed_amount: Optional[float] = None,
|
||||
margin_total_amount: Optional[float] = None,
|
||||
cache_read_cost: Optional[float] = None,
|
||||
cache_creation_cost: Optional[float] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Helper method to store cost breakdown in the logging object.
|
||||
|
|
@ -1411,6 +1413,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
total_cost=total_cost,
|
||||
tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar,
|
||||
)
|
||||
if cache_read_cost is not None and cache_read_cost > 0:
|
||||
self.cost_breakdown["cache_read_cost"] = cache_read_cost
|
||||
if cache_creation_cost is not None and cache_creation_cost > 0:
|
||||
self.cost_breakdown["cache_creation_cost"] = cache_creation_cost
|
||||
|
||||
# Store additional costs if provided (free-form dict for extensibility)
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1702,10 +1702,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
),
|
||||
)
|
||||
|
||||
raw_input_tokens = usage_object.get("input_tokens", 0) or 0
|
||||
prompt_tokens_details = PromptTokensDetailsWrapper(
|
||||
cached_tokens=cache_read_input_tokens,
|
||||
cache_creation_tokens=cache_creation_input_tokens,
|
||||
cache_creation_token_details=cache_creation_token_details,
|
||||
text_tokens=raw_input_tokens,
|
||||
)
|
||||
# Always populate completion_token_details, not just when there's reasoning_content
|
||||
reasoning_tokens = (
|
||||
|
|
|
|||
|
|
@ -1651,6 +1651,7 @@ class AmazonConverseConfig(BaseConfig):
|
|||
cache_creation_input_tokens: int = 0
|
||||
cache_read_input_tokens: int = 0
|
||||
|
||||
raw_input_tokens = input_tokens # capture before inflation
|
||||
if "cacheReadInputTokens" in usage:
|
||||
cache_read_input_tokens = usage["cacheReadInputTokens"]
|
||||
input_tokens += cache_read_input_tokens
|
||||
|
|
@ -1659,7 +1660,9 @@ class AmazonConverseConfig(BaseConfig):
|
|||
input_tokens += cache_creation_input_tokens
|
||||
|
||||
prompt_tokens_details = PromptTokensDetailsWrapper(
|
||||
cached_tokens=cache_read_input_tokens
|
||||
cached_tokens=cache_read_input_tokens,
|
||||
cache_creation_tokens=cache_creation_input_tokens,
|
||||
text_tokens=raw_input_tokens,
|
||||
)
|
||||
reasoning_tokens = (
|
||||
token_counter(text=reasoning_content, count_response_tokens=True)
|
||||
|
|
|
|||
|
|
@ -2798,7 +2798,9 @@ class CostBreakdown(TypedDict, total=False):
|
|||
Detailed cost breakdown for a request
|
||||
"""
|
||||
|
||||
input_cost: float # Cost of input/prompt tokens
|
||||
input_cost: float # Cost of raw (non-cached) input tokens only
|
||||
cache_read_cost: float # Cost of cache-read tokens (discounted rate)
|
||||
cache_creation_cost: float # Cost of cache-write tokens (premium rate)
|
||||
output_cost: (
|
||||
float # Cost of output/completion tokens (includes reasoning if applicable)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -641,7 +641,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<Card>
|
||||
<Title>Input Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-blue-600">
|
||||
{userSpendData.metadata?.total_prompt_tokens?.toLocaleString() || 0}
|
||||
{Math.max(
|
||||
0,
|
||||
(userSpendData.metadata?.total_prompt_tokens || 0) -
|
||||
(userSpendData.metadata?.total_cache_read_input_tokens || 0) -
|
||||
(userSpendData.metadata?.total_cache_creation_input_tokens || 0)
|
||||
).toLocaleString()}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
|
|||
|
||||
export interface CostBreakdown {
|
||||
input_cost?: number;
|
||||
cache_read_cost?: number;
|
||||
cache_creation_cost?: number;
|
||||
output_cost?: number;
|
||||
total_cost?: number;
|
||||
tool_usage_cost?: number;
|
||||
|
|
@ -22,6 +24,9 @@ interface CostBreakdownViewerProps {
|
|||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
cacheHit?: string;
|
||||
rawInputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
}
|
||||
|
||||
const formatCost = (cost: number | undefined): string => {
|
||||
|
|
@ -40,6 +45,9 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
promptTokens,
|
||||
completionTokens,
|
||||
cacheHit,
|
||||
rawInputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
}) => {
|
||||
const isCached = cacheHit?.toLowerCase() === "true";
|
||||
const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined;
|
||||
|
|
@ -105,17 +113,63 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
{(() => {
|
||||
const hasCacheBreakdown =
|
||||
costBreakdown?.cache_read_cost !== undefined ||
|
||||
costBreakdown?.cache_creation_cost !== undefined;
|
||||
if (hasCacheBreakdown) {
|
||||
// Separate line items: Input / Cache Read / Cache Write
|
||||
const rawCost = isCached ? 0 : (inputCost ?? 0) - (costBreakdown?.cache_read_cost ?? 0) - (costBreakdown?.cache_creation_cost ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(rawCost)}
|
||||
{rawInputTokens !== undefined && rawInputTokens !== null && (
|
||||
<span className="text-gray-500 font-normal ml-1">({rawInputTokens.toLocaleString()} tokens)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Cache Read Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
|
||||
{(cacheReadTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">({(cacheReadTokens ?? 0).toLocaleString()} tokens)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Cache Write Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
|
||||
{(cacheCreationTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">({(cacheCreationTokens ?? 0).toLocaleString()} tokens)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
|
|
|
|||
|
|
@ -113,6 +113,35 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getAllByText("$0.00200000").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should show Input Tokens and Output Tokens for anthropic_messages when uncached text_tokens exist", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
logEntry={createLogEntry({
|
||||
call_type: "anthropic_messages",
|
||||
prompt_tokens: 34548,
|
||||
completion_tokens: 28,
|
||||
total_tokens: 34576,
|
||||
spend: 0.01107885,
|
||||
metadata: {
|
||||
status: "success",
|
||||
additional_usage_values: {
|
||||
prompt_tokens_details: { text_tokens: 3 },
|
||||
cache_read_input_tokens: 34462,
|
||||
cache_creation_input_tokens: 83,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Input Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("28")).toBeInTheDocument();
|
||||
// Combined TokenFlow line should not appear (would include "prompt tokens")
|
||||
expect(screen.queryByText(/prompt tokens \+ .* completion tokens/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display ConfigInfoMessage when no messages, response, or error and not loading", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
|
|||
promptTokens={logEntry.prompt_tokens}
|
||||
completionTokens={logEntry.completion_tokens}
|
||||
cacheHit={logEntry.cache_hit}
|
||||
rawInputTokens={metadata?.additional_usage_values?.prompt_tokens_details?.text_tokens}
|
||||
cacheReadTokens={metadata?.additional_usage_values?.cache_read_input_tokens}
|
||||
cacheCreationTokens={metadata?.additional_usage_values?.cache_creation_input_tokens}
|
||||
/>
|
||||
|
||||
{/* Tools */}
|
||||
|
|
@ -257,6 +260,19 @@ function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: nu
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncached input token count (billable non-cache prompt text), aligned with Cost Breakdown "Input".
|
||||
* Same sources as CostBreakdownViewer rawInputTokens.
|
||||
*/
|
||||
function getUncachedInputTextTokens(metadata: Record<string, any>): number | undefined {
|
||||
const raw =
|
||||
metadata?.additional_usage_values?.prompt_tokens_details?.text_tokens ??
|
||||
metadata?.usage_object?.prompt_tokens_details?.text_tokens;
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, any> }) {
|
||||
const completionStartTime = logEntry.completionStartTime;
|
||||
const ttftMs =
|
||||
|
|
@ -277,17 +293,32 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
? "red"
|
||||
: "default";
|
||||
|
||||
const uncachedInputTokens = getUncachedInputTextTokens(metadata);
|
||||
const showAnthropicMessagesInputOutput =
|
||||
logEntry.call_type === "anthropic_messages" && uncachedInputTokens !== undefined;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Metrics" size="small" style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
{showAnthropicMessagesInputOutput ? (
|
||||
<>
|
||||
<Descriptions.Item label="Input Tokens">
|
||||
{formatNumberWithCommas(uncachedInputTokens)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Output Tokens">
|
||||
{formatNumberWithCommas(logEntry.completion_tokens)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
) : (
|
||||
<Descriptions.Item label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Duration">{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s</Descriptions.Item>
|
||||
{ttftMs != null && ttftMs > 0 && (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue