diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index e7c7f0c1..4434bb71 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -845,11 +845,6 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole * first known family tier that happens to share a prefix. */ export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing { - const exactPricing = PRICING_REGISTRY[model]; - if (exactPricing !== undefined) { - return exactPricing; - } - if (hasProviderContext(model, options)) { const providerPricing = resolveModelsDevPricing(model, options); if (providerPricing !== undefined) { @@ -857,6 +852,11 @@ export function getModelPricing(model: string, options: PricingLookupOptions = { } } + const exactPricing = PRICING_REGISTRY[model]; + if (exactPricing !== undefined) { + return exactPricing; + } + const directOrAliasPricing = getDirectOrAliasPricing(model); if (directOrAliasPricing !== undefined) { return directOrAliasPricing; diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 8ee7ac21..92ddbfbb 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -8,7 +8,7 @@ import type { CliproxyUsageApiResponse, CliproxyRequestDetail } from '../../cliproxy/stats-fetcher'; import { calculateCost } from '../model-pricing'; import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './types'; -import { getModelsUsed } from './model-identity'; +import { getModelsUsed, normalizeUsageProvider } from './model-identity'; // ============================================================================ // INTERNAL HELPERS @@ -60,7 +60,7 @@ function createHistoryDetail( model: string, detail: CliproxyRequestDetail ): CliproxyUsageHistoryDetail { - const pricingProvider = provider.trim().toLowerCase(); + const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase(); return { model, provider: pricingProvider, diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index baa7e11a..62d3a523 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -327,6 +327,11 @@ describe('model-pricing', () => { name: 'GPT-5.5', cost: { input: 5, output: 30, cache_read: 0.5 }, }, + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + cost: { input: 2.5, output: 10, cache_read: 1.25 }, + }, 'openai-exclusive-model': { id: 'openai-exclusive-model', name: 'OpenAI Exclusive Model', @@ -343,6 +348,11 @@ describe('model-pricing', () => { name: 'GPT-5.5', cost: { input: 0, output: 0 }, }, + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + cost: { input: 0, output: 0 }, + }, }, }, }); @@ -371,6 +381,13 @@ describe('model-pricing', () => { expect(pricing.outputPerMillion).toBe(0); }); + it('prefers provider-aware models.dev pricing over exact static table matches', () => { + const pricing = getModelPricing('gpt-4o', { provider: 'github-copilot' }); + expect(pricing.inputPerMillion).toBe(0); + expect(pricing.outputPerMillion).toBe(0); + expect(getModelPricing('gpt-4o').inputPerMillion).toBe(2.5); + }); + it('does not use ambiguous model-only models.dev matches', () => { const pricing = getModelPricing('gpt-5.5'); expect(pricing).toEqual(getModelPricing('unknown-model-xyz')); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index 8bfd1049..5c937023 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -108,7 +108,7 @@ describe('cliproxy usage transformer', () => { it('retains failed requests when they carry usage and skips zero-usage failures', () => { const flat = extractCliproxyUsageHistoryDetails(sampleResponse); expect(flat).toHaveLength(4); - expect(flat[0].provider).toBe('gemini'); + expect(flat[0].provider).toBe('google'); expect( flat.some( (entry) => @@ -296,5 +296,71 @@ describe('cliproxy usage transformer', () => { expect(paid?.cost).toBe(35.5); expect(subscription?.cost).toBe(0); }); + + it('canonicalizes CLIProxy provider aliases before grouping history details', () => { + const response: CliproxyUsageApiResponse = { + usage: { + apis: { + ghcp: { + models: { + 'gpt-5.5': { + details: [ + { + timestamp: '2026-03-03T10:00:00.000Z', + source: 'copilot-alias', + auth_index: 0, + tokens: { + input_tokens: 1_000_000, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 1_000_000, + }, + failed: false, + }, + ], + }, + }, + }, + 'github-copilot': { + models: { + 'gpt-5.5': { + details: [ + { + timestamp: '2026-03-03T11:00:00.000Z', + source: 'copilot-canonical', + auth_index: 1, + tokens: { + input_tokens: 2_000_000, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 2_000_000, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }; + + const details = extractCliproxyUsageHistoryDetails(response); + expect(details.map((detail) => detail.provider)).toEqual([ + 'github-copilot', + 'github-copilot', + ]); + + const [daily] = transformCliproxyToDailyUsage(response); + expect(daily.modelBreakdowns).toHaveLength(1); + expect(daily.modelBreakdowns[0]).toMatchObject({ + modelName: 'gpt-5.5', + provider: 'github-copilot', + inputTokens: 3_000_000, + }); + expect(daily.modelsUsed).toEqual(['gpt-5.5']); + }); }); });