fix(usage): preserve pricing overrides and legacy merges

This commit is contained in:
Tam Nhu Tran
2026-04-28 16:51:06 -04:00
parent 42fc5281a1
commit 30f350a74f
6 changed files with 292 additions and 24 deletions
+29
View File
@@ -858,6 +858,30 @@ function getCcsStaticPricing(model: string): ModelPricing | undefined {
return undefined;
}
function getCcsPolicyOverridePricing(model: string): ModelPricing | undefined {
const providerlessModel = stripProviderPrefix(model);
const normalized = normalizeModelName(providerlessModel);
for (const candidate of getLookupCandidates(providerlessModel)) {
const alias = MODEL_PRICING_ALIASES[candidate];
if (alias !== undefined) {
const aliasPricing = NORMALIZED_PRICING_REGISTRY[alias];
if (aliasPricing !== undefined) {
return aliasPricing;
}
}
if (candidate !== normalized) {
const variantPricing = NORMALIZED_PRICING_REGISTRY[candidate];
if (variantPricing !== undefined) {
return variantPricing;
}
}
}
return undefined;
}
function hasProviderContext(model: string, options: PricingLookupOptions): boolean {
return Boolean(options.provider || /^[^/]+\//.test(model.trim()));
}
@@ -869,6 +893,11 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole
*/
export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing {
if (hasProviderContext(model, options)) {
const ccsOverridePricing = getCcsPolicyOverridePricing(model);
if (ccsOverridePricing !== undefined) {
return ccsOverridePricing;
}
const providerPricing = resolveModelsDevPricing(model, options);
if (providerPricing !== undefined) {
return providerPricing.pricing;
+41 -7
View File
@@ -35,7 +35,11 @@ import {
import { scanCodexNativeUsageEntries } from './codex-native-usage-collector';
import { scanDroidNativeUsageEntries } from './droid-native-usage-collector';
import { startModelsDevRegistryRefresh } from '../models-dev/registry-cache';
import { getModelsUsed, getProviderModelKey } from './model-identity';
import {
coalesceLegacyProviderlessBreakdowns,
getModelsUsed,
getProviderModelKey,
} from './model-identity';
// ============================================================================
// Multi-Instance Support - Aggregate usage from CCS profiles
@@ -113,6 +117,33 @@ function getHourlyRequestCount(hour: HourlyUsage): number {
return hour.requestCount ?? hour.modelBreakdowns.length;
}
function finalizeDailyUsage(day: DailyUsage): DailyUsage {
const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(day.modelBreakdowns);
return {
...day,
modelsUsed: getModelsUsed(modelBreakdowns),
modelBreakdowns,
};
}
function finalizeMonthlyUsage(month: MonthlyUsage): MonthlyUsage {
const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(month.modelBreakdowns);
return {
...month,
modelsUsed: getModelsUsed(modelBreakdowns),
modelBreakdowns,
};
}
function finalizeHourlyUsage(hour: HourlyUsage): HourlyUsage {
const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(hour.modelBreakdowns);
return {
...hour,
modelsUsed: getModelsUsed(modelBreakdowns),
modelBreakdowns,
};
}
/**
* Merge daily usage data from multiple sources
* Combines entries with same date by aggregating tokens
@@ -146,7 +177,6 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
existing.modelBreakdowns.push({ ...breakdown });
}
}
existing.modelsUsed = getModelsUsed(existing.modelBreakdowns);
} else {
// Clone to avoid mutating original
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
@@ -159,7 +189,9 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
}
}
return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
return Array.from(dateMap.values())
.map(finalizeDailyUsage)
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
@@ -192,7 +224,6 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
existing.modelBreakdowns.push({ ...breakdown });
}
}
existing.modelsUsed = getModelsUsed(existing.modelBreakdowns);
} else {
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
monthMap.set(month.month, {
@@ -204,7 +235,9 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
}
}
return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
return Array.from(monthMap.values())
.map(finalizeMonthlyUsage)
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
@@ -240,7 +273,6 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
existing.modelBreakdowns.push({ ...breakdown });
}
}
existing.modelsUsed = getModelsUsed(existing.modelBreakdowns);
} else {
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
hourMap.set(hour.hour, {
@@ -253,7 +285,9 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
}
}
return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour));
return Array.from(hourMap.values())
.map(finalizeHourlyUsage)
.sort((a, b) => a.hour.localeCompare(b.hour));
}
/**
+22 -17
View File
@@ -17,7 +17,11 @@ import {
getLastFetchTimestamp,
refreshUsageCache,
} from './aggregator';
import { getModelsUsed } from './model-identity';
import {
coalesceLegacyProviderlessBreakdowns,
getModelsUsed,
getProviderModelKey,
} from './model-identity';
// ============================================================================
// Types
@@ -142,10 +146,6 @@ function calculateUsageTotalTokens(
return input + output + cacheCreation + cacheRead;
}
function getBreakdownKey(breakdown: { modelName: string; provider?: string }): string {
return `${breakdown.provider ?? ''}\u0000${breakdown.modelName}`;
}
function parseDateKey(dateString: string): Date {
return new Date(
Date.UTC(
@@ -557,7 +557,7 @@ export async function handleModels(
for (const day of filtered) {
for (const breakdown of day.modelBreakdowns) {
const modelKey = getBreakdownKey(breakdown);
const modelKey = getProviderModelKey(breakdown);
const existing = modelMap.get(modelKey) || {
model: breakdown.modelName,
provider: breakdown.provider,
@@ -750,7 +750,7 @@ export async function handleMonthly(
existing.cacheReadTokens += day.cacheReadTokens;
existing.totalCost += day.totalCost;
for (const breakdown of day.modelBreakdowns) {
const breakdownKey = getBreakdownKey(breakdown);
const breakdownKey = getProviderModelKey(breakdown);
const existingBreakdown = existing.modelBreakdowns.get(breakdownKey) ?? {
modelName: breakdown.modelName,
provider: breakdown.provider,
@@ -772,16 +772,21 @@ export async function handleMonthly(
}
filtered = Array.from(monthMap.values())
.map((month) => ({
month: month.month,
inputTokens: month.inputTokens,
outputTokens: month.outputTokens,
cacheCreationTokens: month.cacheCreationTokens,
cacheReadTokens: month.cacheReadTokens,
totalCost: month.totalCost,
modelBreakdowns: Array.from(month.modelBreakdowns.values()),
modelsUsed: getModelsUsed(Array.from(month.modelBreakdowns.values())),
}))
.map((month) => {
const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(
Array.from(month.modelBreakdowns.values())
);
return {
month: month.month,
inputTokens: month.inputTokens,
outputTokens: month.outputTokens,
cacheCreationTokens: month.cacheCreationTokens,
cacheReadTokens: month.cacheReadTokens,
totalCost: month.totalCost,
modelBreakdowns,
modelsUsed: getModelsUsed(modelBreakdowns),
};
})
.sort((a, b) => a.month.localeCompare(b.month));
} else {
filtered = await getCachedMonthlyData();
+51
View File
@@ -5,6 +5,14 @@ export interface ProviderModelIdentity {
provider?: string;
}
export interface MergeableProviderModelBreakdown extends ProviderModelIdentity {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
cost: number;
}
export function normalizeUsageProvider(provider: string | undefined): string | undefined {
return normalizeModelsDevProviderId(provider);
}
@@ -41,3 +49,46 @@ export function getModelsUsed(items: ProviderModelIdentity[]): string[] {
return [...new Set(items.map((item) => getModelUsageLabel(item, ambiguousModelNames)))];
}
function addBreakdownTokens<T extends MergeableProviderModelBreakdown>(target: T, source: T): void {
target.inputTokens += source.inputTokens;
target.outputTokens += source.outputTokens;
target.cacheCreationTokens += source.cacheCreationTokens;
target.cacheReadTokens += source.cacheReadTokens;
target.cost += source.cost;
}
export function coalesceLegacyProviderlessBreakdowns<T extends MergeableProviderModelBreakdown>(
items: T[]
): T[] {
const byModel = new Map<string, T[]>();
for (const item of items) {
const existing = byModel.get(item.modelName) ?? [];
existing.push(item);
byModel.set(item.modelName, existing);
}
const coalesced: T[] = [];
for (const group of byModel.values()) {
const providerBreakdowns = group.filter((item) => getProviderKey(item.provider));
const legacyBreakdowns = group.filter((item) => !getProviderKey(item.provider));
const providerKeys = new Set(providerBreakdowns.map((item) => getProviderKey(item.provider)));
if (legacyBreakdowns.length > 0 && providerKeys.size === 1 && providerBreakdowns.length > 0) {
const provider = Array.from(providerKeys)[0];
const [firstProviderBreakdown, ...remainingProviderBreakdowns] = providerBreakdowns;
const merged = { ...firstProviderBreakdown, provider } as T;
for (const breakdown of [...remainingProviderBreakdowns, ...legacyBreakdowns]) {
addBreakdownTokens(merged, breakdown);
}
coalesced.push(merged);
continue;
}
coalesced.push(...group.map((item) => ({ ...item })));
}
return coalesced;
}
+19
View File
@@ -355,6 +355,17 @@ describe('model-pricing', () => {
},
},
},
google: {
id: 'google',
name: 'Google',
models: {
'gemini-3-flash-preview': {
id: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash Preview',
cost: { input: 99, output: 99 },
},
},
},
});
});
@@ -388,6 +399,14 @@ describe('model-pricing', () => {
expect(getModelPricing('gpt-4o').inputPerMillion).toBe(2.5);
});
it('keeps CCS compatibility aliases ahead of provider-aware models.dev matches', () => {
const pricing = getModelPricing('gemini-3-flash-preview', { provider: 'google' });
const canonical = getModelPricing('gemini-2.5-flash');
expect(pricing).toEqual(canonical);
expect(pricing.inputPerMillion).not.toBe(99);
});
it('falls back to CCS static pricing when provider-aware models.dev lookup misses a known model', () => {
const staticPricing = getModelPricing('claude-sonnet-4-5');
@@ -330,6 +330,136 @@ describe('usage aggregator cliproxy integration', () => {
expect(result[0].modelBreakdowns).toHaveLength(2);
});
it('coalesces legacy providerless breakdowns into the only known provider', () => {
const result = aggregator.mergeDailyData([
[
{
date: '2026-03-02',
source: 'legacy-cache',
inputTokens: 10,
outputTokens: 1,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 1,
totalCost: 1,
modelsUsed: ['gpt-5.5'],
modelBreakdowns: [
{
modelName: 'gpt-5.5',
inputTokens: 10,
outputTokens: 1,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 1,
},
],
},
],
[
{
date: '2026-03-02',
source: 'cliproxy',
inputTokens: 20,
outputTokens: 2,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 2,
totalCost: 2,
modelsUsed: ['gpt-5.5'],
modelBreakdowns: [
{
modelName: 'gpt-5.5',
provider: 'openai',
inputTokens: 20,
outputTokens: 2,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 2,
},
],
},
],
]);
expect(result[0].modelBreakdowns).toHaveLength(1);
expect(result[0].modelBreakdowns[0]).toMatchObject({
modelName: 'gpt-5.5',
provider: 'openai',
inputTokens: 30,
outputTokens: 3,
cost: 3,
});
expect(result[0].modelsUsed).toEqual(['gpt-5.5']);
});
it('keeps legacy providerless breakdowns separate when providers are ambiguous', () => {
const result = aggregator.mergeHourlyData([
[
{
hour: '2026-03-02 10:00',
source: 'legacy-cache',
inputTokens: 10,
outputTokens: 1,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 1,
totalCost: 1,
modelsUsed: ['gpt-5.5'],
modelBreakdowns: [
{
modelName: 'gpt-5.5',
inputTokens: 10,
outputTokens: 1,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 1,
},
],
},
],
[
{
hour: '2026-03-02 10:00',
source: 'providers',
inputTokens: 50,
outputTokens: 5,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 5,
totalCost: 5,
modelsUsed: ['openai/gpt-5.5', 'github-copilot/gpt-5.5'],
modelBreakdowns: [
{
modelName: 'gpt-5.5',
provider: 'openai',
inputTokens: 20,
outputTokens: 2,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 2,
},
{
modelName: 'gpt-5.5',
provider: 'github-copilot',
inputTokens: 30,
outputTokens: 3,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 3,
},
],
},
],
]);
expect(result[0].modelBreakdowns).toHaveLength(3);
expect(result[0].modelsUsed).toEqual([
'gpt-5.5',
'openai/gpt-5.5',
'github-copilot/gpt-5.5',
]);
});
it('falls back to model cardinality when merging legacy hourly buckets without requestCount', () => {
const result = aggregator.mergeHourlyData([
[