mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
fix(usage): preserve pricing overrides and legacy merges
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user