🩹 fix: Align Client Usage Accounting with Backend Cost Semantics

- classify cache tokens by provider (shared inputTokensIncludesCache from
  data-provider, consumed by both the backend billing path and the client)
  instead of a magnitude heuristic, so Anthropic/Bedrock turns where cache
  is smaller than uncached input no longer under-bill input
- mirror resolveCompletionTokens on the client so Vertex-style hidden
  thinking tokens are reflected in the Output row and session cost
- prefer endpoint pricing over adapter-provider pricing so a custom
  endpoint can price a known model name without built-in rates shadowing it
- carry static cacheRead/cacheWrite overrides through the tokenConfig
  schema and buildTokenConfigMap
This commit is contained in:
Danny Avila 2026-06-13 11:43:14 -04:00
parent f513665ec5
commit d27a3b9a6d
9 changed files with 156 additions and 45 deletions

View file

@ -67,9 +67,12 @@ export default function useTokenUsage({
}
let total = 0;
for (const bucket of Object.values(usageTotals.byRate)) {
/** Endpoint-specific config wins (a custom endpoint may price a known
* model name differently); the adapter provider is the fallback, which
* is what resolves agent runs keyed under the underlying provider */
const rates =
(bucket.provider != null ? tokenConfig?.[bucket.provider]?.[bucket.model] : undefined) ??
tokenConfig?.[bucket.endpoint]?.[bucket.model];
tokenConfig?.[bucket.endpoint]?.[bucket.model] ??
(bucket.provider != null ? tokenConfig?.[bucket.provider]?.[bucket.model] : undefined);
total += costFromUnits(bucket, rates);
}
return total;

View file

@ -115,11 +115,13 @@ export default function useUsageHandler(): UsageHandlers {
const totalsAtom = usageTotalsFamily(convoKey);
const prev = jotai.get(totalsAtom);
const bucket = prev.byRate[bucketKey];
/** Display the same normalized units that drive billing: input is the
* uncached portion, output includes repaired completion tokens */
jotai.set(totalsAtom, {
input: prev.input + (data.input_tokens ?? 0),
output: prev.output + (data.output_tokens ?? 0),
cacheWrite: prev.cacheWrite + (data.input_token_details?.cache_creation ?? 0),
cacheRead: prev.cacheRead + (data.input_token_details?.cache_read ?? 0),
input: prev.input + units.input,
output: prev.output + units.output,
cacheWrite: prev.cacheWrite + units.cacheWrite,
cacheRead: prev.cacheRead + units.cacheRead,
eventCount: prev.eventCount + 1,
byRate: {
...prev.byRate,

View file

@ -1,4 +1,4 @@
import { Constants } from 'librechat-data-provider';
import { Constants, Providers } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import {
buildIndex,
@ -148,6 +148,63 @@ describe('calcUsageCost', () => {
expect(calcUsageCost({ input_tokens: 100 }, { context: 1000 })).toBe(0);
});
it('classifies cache additively for Anthropic even when cache <= input', () => {
/** The magnitude heuristic would wrongly treat this as inclusive and drop
* cache from input; the provider says additive */
const event = {
input_tokens: 900,
output_tokens: 100,
provider: Providers.ANTHROPIC,
input_token_details: { cache_read: 100 },
};
expect(normalizeUsageUnits(event)).toEqual({
input: 900,
output: 100,
cacheWrite: 0,
cacheRead: 100,
});
expect(calcUsageCost(event, rates)).toBeCloseTo((900 * 3 + 100 * 0.3 + 100 * 15) / 1e6);
});
it('keeps subset semantics for OpenAI regardless of magnitude', () => {
const event = {
input_tokens: 900,
output_tokens: 100,
provider: Providers.OPENAI,
input_token_details: { cache_read: 100 },
};
expect(normalizeUsageUnits(event)).toEqual({
input: 800,
output: 100,
cacheWrite: 0,
cacheRead: 100,
});
});
it('repairs under-reported completion tokens (Vertex thinking)', () => {
const event = {
input_tokens: 1000,
output_tokens: 200,
total_tokens: 1500,
provider: Providers.VERTEXAI,
};
/** total - input = 500 recovers the dropped thinking tokens */
expect(normalizeUsageUnits(event).output).toBe(500);
});
it('does not mistake additive cache for missing completion tokens', () => {
/** Anthropic total includes cache; without the cache adjustment the repair
* would falsely inflate completion */
const event = {
input_tokens: 1000,
output_tokens: 200,
total_tokens: 1700,
provider: Providers.ANTHROPIC,
input_token_details: { cache_creation: 300, cache_read: 200 },
};
expect(normalizeUsageUnits(event).output).toBe(200);
});
it('prices summed normalized units identically to per-event costs', () => {
const anthropicEvent = {
input_tokens: 1000,

View file

@ -1,4 +1,4 @@
import { Tools, Constants } from 'librechat-data-provider';
import { Tools, Constants, inputTokensIncludesCache } from 'librechat-data-provider';
import type { TMessage, TTokenUsageEvent, TModelTokenomics } from 'librechat-data-provider';
export interface TokenEntry {
@ -242,20 +242,35 @@ export interface CostUnits {
}
/**
* Normalizes one call's usage into billable units. Mirrors the
* cache-detection heuristic used in token accounting: cache counts are
* additive when they exceed base input (Anthropic), otherwise cache reads
* are included in input tokens (OpenAI) applied per event so units stay
* correct when summed across calls.
* Normalizes one call's usage into billable units, mirroring the backend's
* authoritative `splitUsage`/`resolveCompletionTokens`
* (packages/api/src/agents/usage.ts):
* - cache classification is by provider, not magnitude Anthropic/Bedrock
* keep cache additive (input is uncached-only); subset providers fold
* cache into `input_tokens`. Falls back to a magnitude heuristic only when
* the provider is unknown.
* - completion is repaired for providers (e.g. Vertex) that under-report
* `output_tokens` but carry the gap in `total_tokens`.
* Applied per event so units stay correct when summed across calls.
*/
export function normalizeUsageUnits(usage: TTokenUsageEvent): CostUnits {
const input = usage.input_tokens ?? 0;
const output = usage.output_tokens ?? 0;
const rawInput = usage.input_tokens ?? 0;
const rawOutput = usage.output_tokens ?? 0;
const total = usage.total_tokens ?? 0;
const cacheWrite = usage.input_token_details?.cache_creation ?? 0;
const cacheRead = usage.input_token_details?.cache_read ?? 0;
const cacheIsAdditive = cacheWrite + cacheRead > input;
const includesCache =
usage.provider != null
? inputTokensIncludesCache(usage.provider)
: cacheWrite + cacheRead <= rawInput;
const cacheAdjustment = includesCache ? 0 : cacheRead + cacheWrite;
const output =
total > rawInput + rawOutput + cacheAdjustment ? total - rawInput - cacheAdjustment : rawOutput;
return {
input: cacheIsAdditive ? input : Math.max(0, input - cacheRead - cacheWrite),
input: includesCache ? Math.max(0, rawInput - cacheRead - cacheWrite) : rawInput,
output,
cacheWrite,
cacheRead,

View file

@ -1,5 +1,5 @@
import { logger } from '@librechat/data-schemas';
import { Providers } from 'librechat-data-provider';
import { inputTokensIncludesCache } from 'librechat-data-provider';
import type { TCustomConfig, TTransactionsConfig } from 'librechat-data-provider';
import type {
StructuredTokenUsage,
@ -23,33 +23,6 @@ type SpendStructuredTokensFn = (
tokenUsage: StructuredTokenUsage,
) => Promise<unknown>;
/**
* Providers whose `usage_metadata.input_tokens` ALREADY INCLUDES cached tokens
* (i.e. `input_token_details.cache_*` is a subset, not an additional charge):
*
* - Google / Vertex AI: `input_tokens` = `promptTokenCount` (includes `cachedContentTokenCount`)
* - OpenAI / Azure OpenAI: `input_tokens` = `prompt_tokens` (includes `prompt_tokens_details.cached_tokens`)
* - xAI, DeepSeek, OpenRouter, Moonshot: extend `ChatOpenAI`, same semantics
*
* Anthropic and Bedrock keep cache values separate from `input_tokens`, so they
* must be added back to compute the total prompt size that's the historical
* additive default. Providers not listed here fall through to additive.
*/
const SUBSET_PROVIDERS: ReadonlySet<string> = new Set([
Providers.OPENAI,
Providers.AZURE,
Providers.GOOGLE,
Providers.VERTEXAI,
Providers.XAI,
Providers.DEEPSEEK,
Providers.OPENROUTER,
Providers.MOONSHOT,
]);
function inputTokensIncludesCache(provider?: string): boolean {
return provider != null && SUBSET_PROVIDERS.has(provider);
}
/**
* Resolves `completionTokens` for billing, repairing providers whose
* `usage_metadata.output_tokens` undercounts.

View file

@ -84,6 +84,34 @@ describe('buildTokenConfigMap', () => {
expect(map.MyProxy['gpt-4o-mini'].prompt).toBeGreaterThan(0);
});
it('carries static cache rates from the override', () => {
const override: EndpointTokenConfig = {
'cached-model': {
prompt: 3,
completion: 15,
context: 200000,
cacheRead: 0.3,
cacheWrite: 3.75,
} as EndpointTokenConfig[string],
};
const map = buildTokenConfigMap(
{
modelsConfig: { MyProxy: ['cached-model'] },
endpointTokenConfigs: { MyProxy: override },
includePricing: true,
},
deps,
);
expect(map.MyProxy['cached-model']).toEqual({
context: 200000,
prompt: 3,
completion: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
});
});
it('skips endpoints with empty model lists', () => {
const map = buildTokenConfigMap({ modelsConfig: { [EModelEndpoint.agents]: [] } }, deps);
expect(map[EModelEndpoint.agents]).toBeUndefined();

View file

@ -54,6 +54,14 @@ export function buildTokenConfigMap(
if (overrideRates?.prompt != null || overrideRates?.completion != null) {
tokenomics.prompt = overrideRates.prompt;
tokenomics.completion = overrideRates.completion;
/** Carry admin-configured cache rates; client falls back to the
* prompt rate for whichever cache rate is absent */
if (overrideRates.cacheWrite != null) {
tokenomics.cacheWrite = overrideRates.cacheWrite;
}
if (overrideRates.cacheRead != null) {
tokenomics.cacheRead = overrideRates.cacheRead;
}
} else {
const valueKey = deps.getValueKey(model, endpoint);
tokenomics.prompt = deps.getMultiplier({

View file

@ -853,6 +853,8 @@ export const endpointSchema = baseEndpointSchema.merge(
prompt: z.number(),
completion: z.number(),
context: z.number(),
cacheRead: z.number().optional(),
cacheWrite: z.number().optional(),
}),
)
.optional(),

View file

@ -79,6 +79,29 @@ export const isOpenAILikeProvider = (provider?: string | null): boolean => {
return openAILikeProviders.has(provider ?? '');
};
/**
* Providers whose `usage_metadata.input_tokens` ALREADY INCLUDES cached tokens
* (`input_token_details.cache_*` is a subset, not an additional charge):
* Google/Vertex (`promptTokenCount`), OpenAI/Azure (`prompt_tokens`), and the
* OpenAI-compatible family. Anthropic/Bedrock keep cache values separate and
* additive. Single source of truth shared by the backend billing path
* (`packages/api/src/agents/usage.ts`) and the client usage normalization.
*/
export const cacheSubsetProviders = new Set<string>([
Providers.OPENAI,
Providers.AZURE,
Providers.GOOGLE,
Providers.VERTEXAI,
Providers.XAI,
Providers.DEEPSEEK,
Providers.OPENROUTER,
Providers.MOONSHOT,
]);
export const inputTokensIncludesCache = (provider?: string | null): boolean => {
return cacheSubsetProviders.has(provider ?? '');
};
export const isDocumentSupportedProvider = (provider?: string | null): boolean => {
return documentSupportedProviders.has(provider ?? '');
};