Skip to content

Commit d2d5808

Browse files
committed
wip
1 parent eb74cf0 commit d2d5808

File tree

4 files changed

+375
-108
lines changed

4 files changed

+375
-108
lines changed

lib/pricing.test.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
_generateLookupCandidates as generateLookupCandidates,
4+
calculateCost,
5+
formatCost,
6+
formatMTokCost,
7+
getModelPricingDisplay,
8+
type ModelPricing,
9+
} from "./pricing.ts";
10+
11+
describe("generateLookupCandidates", () => {
12+
describe("openrouter provider", () => {
13+
it("should generate candidates for openrouter/anthropic/model", () => {
14+
const candidates = generateLookupCandidates(
15+
"openrouter/anthropic/claude-sonnet-4",
16+
);
17+
expect(candidates).toEqual([
18+
"openrouter/anthropic/claude-sonnet-4",
19+
"anthropic/claude-sonnet-4",
20+
"claude-sonnet-4",
21+
]);
22+
});
23+
24+
it("should generate candidates for openrouter/openai/model", () => {
25+
const candidates = generateLookupCandidates("openrouter/openai/gpt-4o");
26+
expect(candidates).toEqual([
27+
"openrouter/openai/gpt-4o",
28+
"openai/gpt-4o",
29+
"gpt-4o",
30+
]);
31+
});
32+
33+
it("should handle openrouter with simple model name", () => {
34+
const candidates = generateLookupCandidates("openrouter/some-model");
35+
expect(candidates).toEqual(["openrouter/some-model", "some-model"]);
36+
});
37+
});
38+
39+
describe("anthropic provider", () => {
40+
it("should generate candidates for anthropic/model", () => {
41+
const candidates = generateLookupCandidates("anthropic/claude-haiku-4-5");
42+
expect(candidates).toEqual([
43+
"anthropic/claude-haiku-4-5",
44+
"claude-haiku-4-5",
45+
]);
46+
});
47+
});
48+
49+
describe("openai provider", () => {
50+
it("should generate candidates for openai/model", () => {
51+
const candidates = generateLookupCandidates("openai/gpt-4o");
52+
expect(candidates).toEqual(["openai/gpt-4o", "gpt-4o"]);
53+
});
54+
55+
it("should handle openai/gpt-4o-mini", () => {
56+
const candidates = generateLookupCandidates("openai/gpt-4o-mini");
57+
expect(candidates).toEqual(["openai/gpt-4o-mini", "gpt-4o-mini"]);
58+
});
59+
});
60+
61+
describe("lmstudio provider", () => {
62+
it("should generate candidates for lmstudio/model", () => {
63+
const candidates = generateLookupCandidates("lmstudio/llama-3-8b");
64+
expect(candidates).toEqual(["lmstudio/llama-3-8b", "llama-3-8b"]);
65+
});
66+
});
67+
68+
describe("unknown provider", () => {
69+
it("should return only the original string for unknown provider", () => {
70+
const candidates = generateLookupCandidates("unknown/some-model");
71+
expect(candidates).toEqual(["unknown/some-model"]);
72+
});
73+
});
74+
75+
describe("no provider prefix", () => {
76+
it("should return only the original string when no slash", () => {
77+
const candidates = generateLookupCandidates("claude-sonnet-4");
78+
expect(candidates).toEqual(["claude-sonnet-4"]);
79+
});
80+
});
81+
});
82+
83+
describe("calculateCost", () => {
84+
const basePricing: ModelPricing = {
85+
inputCostPerToken: 0.000003, // $3 per MTok
86+
outputCostPerToken: 0.000015, // $15 per MTok
87+
};
88+
89+
const pricingWithCache: ModelPricing = {
90+
...basePricing,
91+
cacheReadInputTokenCost: 0.0000003, // $0.30 per MTok (10% of input)
92+
};
93+
94+
describe("basic cost calculation", () => {
95+
it("should calculate cost with no cached tokens", () => {
96+
const result = calculateCost(basePricing, 1000, 500, 0);
97+
98+
expect(result.inputTokens).toBe(1000);
99+
expect(result.outputTokens).toBe(500);
100+
expect(result.cachedInputTokens).toBe(0);
101+
expect(result.inputCost).toBeCloseTo(0.003); // 1000 * $3/MTok
102+
expect(result.outputCost).toBeCloseTo(0.0075); // 500 * $15/MTok
103+
expect(result.cacheReadCost).toBe(0);
104+
expect(result.totalCost).toBeCloseTo(0.0105);
105+
});
106+
107+
it("should default cachedInputTokens to 0", () => {
108+
const result = calculateCost(basePricing, 1000, 500);
109+
110+
expect(result.cachedInputTokens).toBe(0);
111+
expect(result.inputCost).toBeCloseTo(0.003);
112+
});
113+
});
114+
115+
describe("cached token billing", () => {
116+
it("should bill cached tokens at reduced rate", () => {
117+
// 1000 input tokens, 800 are cached
118+
const result = calculateCost(pricingWithCache, 1000, 500, 800);
119+
120+
expect(result.inputTokens).toBe(1000);
121+
expect(result.cachedInputTokens).toBe(800);
122+
// Uncached: 200 tokens * $3/MTok = $0.0006
123+
expect(result.inputCost).toBeCloseTo(0.0006);
124+
// Cached: 800 tokens * $0.30/MTok = $0.00024
125+
expect(result.cacheReadCost).toBeCloseTo(0.00024);
126+
// Output: 500 * $15/MTok = $0.0075
127+
expect(result.outputCost).toBeCloseTo(0.0075);
128+
expect(result.totalCost).toBeCloseTo(0.00834);
129+
});
130+
131+
it("should treat cached tokens as free when no cache rate specified", () => {
132+
// Using basePricing which has no cacheReadInputTokenCost
133+
const result = calculateCost(basePricing, 1000, 500, 800);
134+
135+
// Only 200 uncached tokens should be billed
136+
expect(result.inputCost).toBeCloseTo(0.0006);
137+
expect(result.cacheReadCost).toBe(0);
138+
});
139+
140+
it("should handle all tokens being cached", () => {
141+
const result = calculateCost(pricingWithCache, 1000, 500, 1000);
142+
143+
expect(result.inputCost).toBe(0);
144+
expect(result.cacheReadCost).toBeCloseTo(0.0003); // 1000 * $0.30/MTok
145+
});
146+
});
147+
148+
describe("edge cases", () => {
149+
it("should handle zero tokens", () => {
150+
const result = calculateCost(basePricing, 0, 0, 0);
151+
152+
expect(result.inputCost).toBe(0);
153+
expect(result.outputCost).toBe(0);
154+
expect(result.cacheReadCost).toBe(0);
155+
expect(result.totalCost).toBe(0);
156+
});
157+
158+
it("should handle large token counts", () => {
159+
const result = calculateCost(basePricing, 1_000_000, 500_000, 0);
160+
161+
expect(result.inputCost).toBeCloseTo(3); // 1M * $3/MTok
162+
expect(result.outputCost).toBeCloseTo(7.5); // 500K * $15/MTok
163+
expect(result.totalCost).toBeCloseTo(10.5);
164+
});
165+
166+
it("should handle pricing with zero costs", () => {
167+
const freePricing: ModelPricing = {
168+
inputCostPerToken: 0,
169+
outputCostPerToken: 0,
170+
};
171+
const result = calculateCost(freePricing, 1000, 500, 0);
172+
173+
expect(result.totalCost).toBe(0);
174+
});
175+
});
176+
});
177+
178+
describe("formatCost", () => {
179+
it('should format zero as "$0.00"', () => {
180+
expect(formatCost(0)).toBe("$0.00");
181+
});
182+
183+
it("should format very small costs with 6 decimal places", () => {
184+
expect(formatCost(0.000123)).toBe("$0.000123");
185+
expect(formatCost(0.001)).toBe("$0.001000");
186+
expect(formatCost(0.0099)).toBe("$0.009900");
187+
});
188+
189+
it("should format small costs with 4 decimal places", () => {
190+
expect(formatCost(0.01)).toBe("$0.0100");
191+
expect(formatCost(0.1234)).toBe("$0.1234");
192+
expect(formatCost(0.99)).toBe("$0.9900");
193+
});
194+
195+
it("should format costs >= $1 with 2 decimal places", () => {
196+
expect(formatCost(1)).toBe("$1.00");
197+
expect(formatCost(1.234)).toBe("$1.23");
198+
expect(formatCost(10.5)).toBe("$10.50");
199+
expect(formatCost(100)).toBe("$100.00");
200+
});
201+
});
202+
203+
describe("formatMTokCost", () => {
204+
it('should format zero as "$0"', () => {
205+
expect(formatMTokCost(0)).toBe("$0");
206+
});
207+
208+
it("should format very small per-MTok costs with 4 decimal places", () => {
209+
expect(formatMTokCost(0.001)).toBe("$0.0010");
210+
expect(formatMTokCost(0.0099)).toBe("$0.0099");
211+
});
212+
213+
it("should format per-MTok costs >= $0.01 with 2 decimal places", () => {
214+
expect(formatMTokCost(0.01)).toBe("$0.01");
215+
expect(formatMTokCost(0.30)).toBe("$0.30");
216+
expect(formatMTokCost(3)).toBe("$3.00");
217+
expect(formatMTokCost(15)).toBe("$15.00");
218+
});
219+
});
220+
221+
describe("getModelPricingDisplay", () => {
222+
it("should convert per-token costs to per-MTok", () => {
223+
const pricing: ModelPricing = {
224+
inputCostPerToken: 0.000003, // $3 per MTok
225+
outputCostPerToken: 0.000015, // $15 per MTok
226+
};
227+
228+
const display = getModelPricingDisplay(pricing);
229+
230+
expect(display.inputCostPerMTok).toBe(3);
231+
expect(display.outputCostPerMTok).toBe(15);
232+
expect(display.cacheReadCostPerMTok).toBeUndefined();
233+
});
234+
235+
it("should include cache read cost when available", () => {
236+
const pricing: ModelPricing = {
237+
inputCostPerToken: 0.000003,
238+
outputCostPerToken: 0.000015,
239+
cacheReadInputTokenCost: 0.0000003, // $0.30 per MTok
240+
};
241+
242+
const display = getModelPricingDisplay(pricing);
243+
244+
expect(display.inputCostPerMTok).toBe(3);
245+
expect(display.outputCostPerMTok).toBe(15);
246+
expect(display.cacheReadCostPerMTok).toBeCloseTo(0.3);
247+
});
248+
249+
it("should handle zero costs", () => {
250+
const pricing: ModelPricing = {
251+
inputCostPerToken: 0,
252+
outputCostPerToken: 0,
253+
};
254+
255+
const display = getModelPricingDisplay(pricing);
256+
257+
expect(display.inputCostPerMTok).toBe(0);
258+
expect(display.outputCostPerMTok).toBe(0);
259+
});
260+
});

0 commit comments

Comments
 (0)