diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 8281b42f..6d7a8bfa 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -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; diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 44df5b21..129f2d73 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -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)); } /** diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index b88aa37b..5e6e14dc 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -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(); diff --git a/src/web-server/usage/model-identity.ts b/src/web-server/usage/model-identity.ts index 41198a79..7336ac26 100644 --- a/src/web-server/usage/model-identity.ts +++ b/src/web-server/usage/model-identity.ts @@ -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(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( + items: T[] +): T[] { + const byModel = new Map(); + 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; +} diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index c304e7f1..779dfac9 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -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'); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts index 55fb2739..fb6ab03f 100644 --- a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -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([ [