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:
ishaan-berri 2026-04-15 10:44:37 -07:00 • committed by GitHub
commit 645e0a7831
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 175 additions and 21 deletions

View file

@ -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

View file

@ -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 (

View file

@ -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 = (

View file

@ -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)

View file

@ -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)
)

View file

@ -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>

View file

@ -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">

View file

@ -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

View file

@ -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 && (

View file

@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{