Skip to content

Commit c0dbf8a

Browse files
committed
fix: correct XAI pricing conversion from fractional cents to dollars
- Fixed XAI API price conversion: divide by 10,000 instead of 100 - XAI API returns fractional cents (basis points), not regular cents - This fixes pricing display showing 0.00 instead of /bin/sh.20 - Fixed parseApiPrice to handle zero values correctly (for free models) - Added comprehensive test suite for cost utilities - Updated XAI fetcher tests to reflect correct pricing scale
1 parent ba6a37b commit c0dbf8a

File tree

4 files changed

+132
-13
lines changed

4 files changed

+132
-13
lines changed

src/api/providers/fetchers/__tests__/xai.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ describe("getXaiModels", () => {
2121
id: "grok-3",
2222
input_modalities: ["text"],
2323
output_modalities: ["text"],
24-
prompt_text_token_price: 30000,
25-
cached_prompt_text_token_price: 7500,
26-
completion_text_token_price: 150000,
24+
prompt_text_token_price: 2000, // 2000 fractional cents = $0.20 per 1M tokens
25+
cached_prompt_text_token_price: 500, // 500 fractional cents = $0.05 per 1M tokens
26+
completion_text_token_price: 10000, // 10000 fractional cents = $1.00 per 1M tokens
2727
aliases: ["grok-3-latest"],
2828
},
2929
],
@@ -33,9 +33,9 @@ describe("getXaiModels", () => {
3333
const result = await getXaiModels("key", "https://api.x.ai/v1")
3434
expect(result["grok-3"]).toBeDefined()
3535
expect(result["grok-3"]?.supportsImages).toBe(false)
36-
expect(result["grok-3"]?.inputPrice).toBeCloseTo(300) // $300 per 1M (cents->dollars)
37-
expect(result["grok-3"]?.outputPrice).toBeCloseTo(1500)
38-
expect(result["grok-3"]?.cacheReadsPrice).toBeCloseTo(75)
36+
expect(result["grok-3"]?.inputPrice).toBeCloseTo(0.2) // $0.20 per 1M tokens
37+
expect(result["grok-3"]?.outputPrice).toBeCloseTo(1.0) // $1.00 per 1M tokens
38+
expect(result["grok-3"]?.cacheReadsPrice).toBeCloseTo(0.05) // $0.05 per 1M tokens
3939
// aliases are not added to avoid UI duplication
4040
expect(result["grok-3-latest"]).toBeUndefined()
4141
})

src/api/providers/fetchers/xai.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ const xaiLanguageModelSchema = z.object({
1212
id: z.string(),
1313
input_modalities: z.array(z.string()).optional(),
1414
output_modalities: z.array(z.string()).optional(),
15-
prompt_text_token_price: z.number().optional(), // cents per 1M tokens
16-
cached_prompt_text_token_price: z.number().optional(), // cents per 1M tokens
17-
prompt_image_token_price: z.number().optional(), // cents per 1M tokens
18-
completion_text_token_price: z.number().optional(), // cents per 1M tokens
15+
prompt_text_token_price: z.number().optional(), // fractional cents (basis points) per 1M tokens
16+
cached_prompt_text_token_price: z.number().optional(), // fractional cents per 1M tokens
17+
prompt_image_token_price: z.number().optional(), // fractional cents per 1M tokens
18+
completion_text_token_price: z.number().optional(), // fractional cents per 1M tokens
1919
search_price: z.number().optional(),
2020
aliases: z.array(z.string()).optional(),
2121
})
@@ -56,8 +56,9 @@ export async function getXaiModels(apiKey?: string, baseUrl?: string): Promise<R
5656
console.error("xAI language models response validation failed", parsed.error?.format?.() ?? parsed.error)
5757
}
5858

59-
// Helper to convert cents-per-1M to dollars-per-1M (assumption per API examples)
60-
const centsToDollars = (v?: number) => (typeof v === "number" ? v / 100 : undefined)
59+
// Helper to convert fractional-cents-per-1M (basis points) to dollars-per-1M
60+
// The API returns values in 1/100th of a cent, so divide by 10,000 to get dollars
61+
const centsToDollars = (v?: number) => (typeof v === "number" ? v / 10_000 : undefined)
6162

6263
for (const m of items) {
6364
const id = m.id

src/shared/__tests__/cost.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from "vitest"
2+
import { parseApiPrice, calculateApiCostAnthropic, calculateApiCostOpenAI } from "../cost"
3+
import type { ModelInfo } from "@roo-code/types"
4+
5+
describe("parseApiPrice", () => {
6+
it("should handle zero as a number", () => {
7+
expect(parseApiPrice(0)).toBe(0)
8+
})
9+
10+
it("should handle zero as a string", () => {
11+
expect(parseApiPrice("0")).toBe(0)
12+
})
13+
14+
it("should handle positive numbers", () => {
15+
expect(parseApiPrice(0.0002)).toBe(200)
16+
expect(parseApiPrice(0.00002)).toBe(20)
17+
})
18+
19+
it("should handle positive number strings", () => {
20+
expect(parseApiPrice("0.0002")).toBe(200)
21+
expect(parseApiPrice("0.00002")).toBe(20)
22+
})
23+
24+
it("should return undefined for null", () => {
25+
expect(parseApiPrice(null)).toBeUndefined()
26+
})
27+
28+
it("should return undefined for undefined", () => {
29+
expect(parseApiPrice(undefined)).toBeUndefined()
30+
})
31+
32+
it("should return undefined for empty string", () => {
33+
expect(parseApiPrice("")).toBeUndefined()
34+
})
35+
})
36+
37+
describe("calculateApiCostAnthropic", () => {
38+
const modelInfo: ModelInfo = {
39+
maxTokens: 4096,
40+
contextWindow: 200000,
41+
supportsImages: true,
42+
supportsPromptCache: true,
43+
inputPrice: 300,
44+
outputPrice: 1500,
45+
cacheWritesPrice: 375,
46+
cacheReadsPrice: 30,
47+
}
48+
49+
it("should calculate cost without caching", () => {
50+
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500)
51+
expect(cost).toBeCloseTo(0.3 + 0.75, 10)
52+
})
53+
54+
it("should calculate cost with cache creation", () => {
55+
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500, 2000)
56+
expect(cost).toBeCloseTo(0.3 + 0.75 + 0.75, 10)
57+
})
58+
59+
it("should calculate cost with cache reads", () => {
60+
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500, 0, 3000)
61+
expect(cost).toBeCloseTo(0.3 + 0.75 + 0.09, 10)
62+
})
63+
64+
it("should handle zero cost for free models", () => {
65+
const freeModel: ModelInfo = {
66+
maxTokens: 4096,
67+
contextWindow: 200000,
68+
supportsImages: false,
69+
supportsPromptCache: false,
70+
inputPrice: 0,
71+
outputPrice: 0,
72+
}
73+
const cost = calculateApiCostAnthropic(freeModel, 1000, 500)
74+
expect(cost).toBe(0)
75+
})
76+
})
77+
78+
describe("calculateApiCostOpenAI", () => {
79+
const modelInfo: ModelInfo = {
80+
maxTokens: 4096,
81+
contextWindow: 128000,
82+
supportsImages: true,
83+
supportsPromptCache: true,
84+
inputPrice: 150,
85+
outputPrice: 600,
86+
cacheWritesPrice: 187.5,
87+
cacheReadsPrice: 15,
88+
}
89+
90+
it("should calculate cost without caching", () => {
91+
const cost = calculateApiCostOpenAI(modelInfo, 1000, 500)
92+
expect(cost).toBeCloseTo(0.15 + 0.3, 10)
93+
})
94+
95+
it("should subtract cached tokens from input tokens", () => {
96+
const cost = calculateApiCostOpenAI(modelInfo, 5000, 500, 2000, 1000)
97+
// 5000 total - 2000 cache creation - 1000 cache read = 2000 non-cached
98+
// Cost: (2000 * 0.00015) + (2000 * 0.0001875) + (1000 * 0.000015) + (500 * 0.0006)
99+
expect(cost).toBeCloseTo(0.3 + 0.375 + 0.015 + 0.3, 10)
100+
})
101+
102+
it("should handle zero cost for free models", () => {
103+
const freeModel: ModelInfo = {
104+
maxTokens: 4096,
105+
contextWindow: 128000,
106+
supportsImages: false,
107+
supportsPromptCache: false,
108+
inputPrice: 0,
109+
outputPrice: 0,
110+
}
111+
const cost = calculateApiCostOpenAI(freeModel, 1000, 500)
112+
expect(cost).toBe(0)
113+
})
114+
})

src/shared/cost.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,8 @@ export function calculateApiCostOpenAI(
5454
)
5555
}
5656

57-
export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)
57+
export const parseApiPrice = (price: any) => {
58+
if (price == null) return undefined
59+
const parsed = parseFloat(price)
60+
return isNaN(parsed) ? undefined : parsed * 1_000_000
61+
}

0 commit comments

Comments
 (0)