ui : account for cache tokens on external backends

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 19:22:47 +02:00
parent 69870519b7
commit ee41ebc15d
3 changed files with 39 additions and 6 deletions
@@ -224,10 +224,18 @@ function usageOf(raw: Record<string, unknown> | undefined): ApiChatCompletionUsa
if (!raw) return undefined;
const usage: ApiChatCompletionUsage = {};
const fields = [
'cache_creation_input_tokens',
'cache_read_input_tokens',
'input_tokens',
'output_tokens'
] as const;
if (typeof raw.input_tokens === 'number') usage.input_tokens = raw.input_tokens;
for (const field of fields) {
const value = raw[field];
if (typeof raw.output_tokens === 'number') usage.output_tokens = raw.output_tokens;
if (typeof value === 'number') usage[field] = value;
}
return Object.keys(usage).length > 0 ? usage : undefined;
}
+5
View File
@@ -379,10 +379,15 @@ export interface ApiChatCompletionStreamChunk {
}
export interface ApiChatCompletionUsage {
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
cached_tokens?: number;
completion_tokens?: number;
input_tokens?: number;
output_tokens?: number;
prompt_cache_hit_tokens?: number;
prompt_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
total_tokens?: number;
}
+24 -4
View File
@@ -17,14 +17,33 @@ export interface StreamClock {
lastTokenAt: number | null;
}
/** Prompt/output token counts, accepting OpenAI and Anthropic usage fields. */
/**
* Prompt/output/cache token counts, accepting OpenAI and Anthropic usage
* fields. `promptTokens` excludes the cache read tokens, which are returned
* separately as `cacheTokens`, so the two always add up to the prompt size.
*/
export function usageTokenCounts(usage: ApiChatCompletionUsage | undefined): {
promptTokens: number;
cacheTokens: number;
completionTokens: number;
promptTokens: number;
} {
// Anthropic reports the input excluding cache tokens and splits reads from
// writes; OpenAI-compatible servers report a total that includes the reads
const isAnthropicStyle = usage?.input_tokens !== undefined;
const cacheTokens = isAnthropicStyle
? (usage?.cache_read_input_tokens ?? 0)
: (usage?.prompt_tokens_details?.cached_tokens ??
usage?.prompt_cache_hit_tokens ??
usage?.cached_tokens ??
0);
const promptTotal = isAnthropicStyle
? (usage?.input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0)
: (usage?.prompt_tokens ?? 0);
return {
cacheTokens,
completionTokens: usage?.completion_tokens ?? usage?.output_tokens ?? 0,
promptTokens: usage?.prompt_tokens ?? usage?.input_tokens ?? 0
promptTokens: isAnthropicStyle ? promptTotal : Math.max(0, promptTotal - cacheTokens)
};
}
@@ -33,7 +52,7 @@ export function buildTimingsFromUsage(
clock: StreamClock,
fallbackTokens = 0
): ChatMessageTimings | null {
const { completionTokens, promptTokens } = usageTokenCounts(usage);
const { cacheTokens, completionTokens, promptTokens } = usageTokenCounts(usage);
const predictedN = completionTokens || fallbackTokens;
if (promptTokens === 0 && predictedN === 0) return null;
@@ -42,6 +61,7 @@ export function buildTimingsFromUsage(
const lastTokenAt = clock.lastTokenAt ?? firstTokenAt;
return {
cache_n: cacheTokens,
// clamp so a one-token reply still reports a positive duration
predicted_ms: firstTokenAt && lastTokenAt ? Math.max(1, lastTokenAt - firstTokenAt) : undefined,
predicted_n: predictedN,