1- import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing' ;
21import type { LoadedUsageEntry } from './data-loader.ts' ;
2+ import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing' ;
33import { Result } from '@praha/byethrow' ;
44
55/**
@@ -15,6 +15,32 @@ function resolveModelName(modelName: string): string {
1515 return MODEL_ALIASES [ modelName ] ?? modelName ;
1616}
1717
18+ function normalizeOpenCodeProviderID ( providerID : string ) : string {
19+ return providerID . replaceAll ( '-' , '_' ) ;
20+ }
21+
22+ function normalizeOpenCodeModelName ( modelName : string ) : string {
23+ const resolved = resolveModelName ( modelName ) ;
24+
25+ return resolved
26+ . replace ( / ^ ( c l a u d e - (?: h a i k u | o p u s | s o n n e t ) - \d + ) \. ( \d + ) ( - .* ) ? $ / u, '$1-$2$3' )
27+ . replace ( / ^ ( c l a u d e - (?: h a i k u | o p u s | s o n n e t ) - \d ) ( \d ) ( - .* ) ? $ / u, '$1-$2$3' ) ;
28+ }
29+
30+ function createModelCandidates ( entry : LoadedUsageEntry ) : string [ ] {
31+ const resolved = resolveModelName ( entry . model ) ;
32+ const normalized = normalizeOpenCodeModelName ( resolved ) ;
33+ const baseCandidates = normalized === resolved ? [ resolved ] : [ normalized ] ;
34+ const candidates = [ ...baseCandidates ] ;
35+
36+ if ( entry . providerID !== 'unknown' ) {
37+ const providerPrefix = normalizeOpenCodeProviderID ( entry . providerID ) ;
38+ candidates . push ( ...baseCandidates . map ( ( candidate ) => `${ providerPrefix } /${ candidate } ` ) ) ;
39+ }
40+
41+ return Array . from ( new Set ( candidates ) ) ;
42+ }
43+
1844/**
1945 * Calculate cost for a single usage entry
2046 * Uses pre-calculated cost if available, otherwise calculates from tokens
@@ -27,16 +53,110 @@ export async function calculateCostForEntry(
2753 return entry . costUSD ;
2854 }
2955
30- const resolvedModel = resolveModelName ( entry . model ) ;
31- const result = await fetcher . calculateCostFromTokens (
32- {
33- input_tokens : entry . usage . inputTokens ,
34- output_tokens : entry . usage . outputTokens ,
35- cache_creation_input_tokens : entry . usage . cacheCreationInputTokens ,
36- cache_read_input_tokens : entry . usage . cacheReadInputTokens ,
37- } ,
38- resolvedModel ,
39- ) ;
40-
41- return Result . unwrap ( result , 0 ) ;
56+ const tokens = {
57+ input_tokens : entry . usage . inputTokens ,
58+ output_tokens : entry . usage . outputTokens ,
59+ cache_creation_input_tokens : entry . usage . cacheCreationInputTokens ,
60+ cache_read_input_tokens : entry . usage . cacheReadInputTokens ,
61+ } ;
62+
63+ for ( const candidate of createModelCandidates ( entry ) ) {
64+ const result = await fetcher . calculateCostFromTokens ( tokens , candidate ) ;
65+ if ( Result . isSuccess ( result ) && result . value > 0 ) {
66+ return result . value ;
67+ }
68+ }
69+
70+ return 0 ;
71+ }
72+
73+ if ( import . meta. vitest != null ) {
74+ const { describe, expect, it } = import . meta. vitest ;
75+
76+ function createEntry ( model : string , providerID = 'github-copilot' ) : LoadedUsageEntry {
77+ return {
78+ timestamp : new Date ( '2026-01-01T00:00:00Z' ) ,
79+ sessionID : 'session' ,
80+ usage : {
81+ inputTokens : 1000 ,
82+ outputTokens : 100 ,
83+ cacheCreationInputTokens : 0 ,
84+ cacheReadInputTokens : 0 ,
85+ } ,
86+ model,
87+ providerID,
88+ costUSD : null ,
89+ } ;
90+ }
91+
92+ describe ( 'calculateCostForEntry' , ( ) => {
93+ it ( 'normalizes OpenCode Claude dot notation without hard-coded aliases' , async ( ) => {
94+ using fetcher = new LiteLLMPricingFetcher ( {
95+ offline : true ,
96+ offlineLoader : async ( ) => ( {
97+ 'github_copilot/claude-opus-4.7' : { } ,
98+ 'claude-opus-4-1' : {
99+ input_cost_per_token : 99 ,
100+ output_cost_per_token : 99 ,
101+ } ,
102+ 'claude-opus-4-7' : {
103+ input_cost_per_token : 1e-6 ,
104+ output_cost_per_token : 2e-6 ,
105+ } ,
106+ } ) ,
107+ } ) ;
108+
109+ await expect (
110+ calculateCostForEntry ( createEntry ( 'claude-opus-4.7' ) , fetcher ) ,
111+ ) . resolves . toBeCloseTo ( 0.0012 ) ;
112+ } ) ;
113+
114+ it ( 'normalizes compact Claude minor versions' , async ( ) => {
115+ using fetcher = new LiteLLMPricingFetcher ( {
116+ offline : true ,
117+ offlineLoader : async ( ) => ( {
118+ 'claude-opus-4-1' : {
119+ input_cost_per_token : 1e-6 ,
120+ output_cost_per_token : 2e-6 ,
121+ } ,
122+ } ) ,
123+ } ) ;
124+
125+ await expect (
126+ calculateCostForEntry ( createEntry ( 'claude-opus-41' ) , fetcher ) ,
127+ ) . resolves . toBeCloseTo ( 0.0012 ) ;
128+ } ) ;
129+
130+ it ( 'normalizes future Claude generations' , async ( ) => {
131+ using fetcher = new LiteLLMPricingFetcher ( {
132+ offline : true ,
133+ offlineLoader : async ( ) => ( {
134+ 'claude-opus-5-1' : {
135+ input_cost_per_token : 1e-6 ,
136+ output_cost_per_token : 2e-6 ,
137+ } ,
138+ } ) ,
139+ } ) ;
140+
141+ await expect (
142+ calculateCostForEntry ( createEntry ( 'claude-opus-5.1' ) , fetcher ) ,
143+ ) . resolves . toBeCloseTo ( 0.0012 ) ;
144+ } ) ;
145+
146+ it ( 'uses provider-prefixed pricing candidates after base model candidates' , async ( ) => {
147+ using fetcher = new LiteLLMPricingFetcher ( {
148+ offline : true ,
149+ offlineLoader : async ( ) => ( {
150+ 'xai/grok-code-fast-1' : {
151+ input_cost_per_token : 1e-6 ,
152+ output_cost_per_token : 2e-6 ,
153+ } ,
154+ } ) ,
155+ } ) ;
156+
157+ await expect (
158+ calculateCostForEntry ( createEntry ( 'grok-code-fast-1' , 'xai' ) , fetcher ) ,
159+ ) . resolves . toBeCloseTo ( 0.0012 ) ;
160+ } ) ;
161+ } ) ;
42162}
0 commit comments