Skip to content

Commit e663837

Browse files
authored
fix(opencode): resolve provider model pricing aliases (#983)
Preserve OpenCode provider IDs in loaded usage entries and try provider-aware pricing candidates when calculating costs. Normalize Claude minor-version model IDs such as claude-opus-4.7 and claude-opus-41 algorithmically so new variants do not require one-off aliases.
1 parent 27cb7f7 commit e663837

2 files changed

Lines changed: 137 additions & 13 deletions

File tree

apps/opencode/src/cost-utils.ts

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';
21
import type { LoadedUsageEntry } from './data-loader.ts';
2+
import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';
33
import { 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(/^(claude-(?:haiku|opus|sonnet)-\d+)\.(\d+)(-.*)?$/u, '$1-$2$3')
27+
.replace(/^(claude-(?:haiku|opus|sonnet)-\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
}

apps/opencode/src/data-loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export type LoadedUsageEntry = {
112112
cacheReadInputTokens: number;
113113
};
114114
model: string;
115+
providerID: string;
115116
costUSD: number | null;
116117
};
117118

@@ -183,6 +184,7 @@ function convertOpenCodeMessageToUsageEntry(
183184
cacheReadInputTokens: message.tokens?.cache?.read ?? 0,
184185
},
185186
model: message.modelID ?? 'unknown',
187+
providerID: message.providerID ?? 'unknown',
186188
costUSD: message.cost ?? null,
187189
};
188190
}
@@ -342,6 +344,7 @@ if (import.meta.vitest != null) {
342344
expect(entry.usage.cacheReadInputTokens).toBe(50);
343345
expect(entry.usage.cacheCreationInputTokens).toBe(25);
344346
expect(entry.model).toBe('claude-sonnet-4-5');
347+
expect(entry.providerID).toBe('anthropic');
345348
});
346349

347350
it('should handle missing optional fields', () => {
@@ -364,6 +367,7 @@ if (import.meta.vitest != null) {
364367
expect(entry.usage.outputTokens).toBe(100);
365368
expect(entry.usage.cacheReadInputTokens).toBe(0);
366369
expect(entry.usage.cacheCreationInputTokens).toBe(0);
370+
expect(entry.providerID).toBe('openai');
367371
expect(entry.costUSD).toBe(null);
368372
});
369373
});

0 commit comments

Comments
 (0)