Skip to content

Commit 7245f4d

Browse files
committed
fix(providers): route OpenAI and Gemini block cost through cache-aware pricing
Cache-aware pricing only reached trace segments. The billable block cost still called calculateCost on the cache-inclusive prompt total, so OpenAI cache hits and Gemini implicit-cache hits were charged at the full input rate and GPT-5.6+ cache writes went unbilled. Both providers now accumulate cache buckets and price through priceModelUsage, matching the Anthropic token convention where input excludes cache reads and writes. Cached counts are clamped to the prompt total so an over-reporting payload cannot bill more input than the request contained.
1 parent c6c2f63 commit 7245f4d

16 files changed

Lines changed: 838 additions & 161 deletions
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { executeGeminiRequest } from '@/providers/gemini/core'
6+
import type { ProviderResponse } from '@/providers/types'
7+
import { calculateCost } from '@/providers/utils'
8+
9+
const { mockExecuteTool } = vi.hoisted(() => ({
10+
mockExecuteTool: vi.fn(),
11+
}))
12+
13+
vi.mock('@/tools', () => ({
14+
executeTool: mockExecuteTool,
15+
}))
16+
17+
vi.mock('@/providers', () => ({
18+
MAX_TOOL_ITERATIONS: 5,
19+
}))
20+
21+
/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */
22+
const MODEL = 'gemini-2.5-flash'
23+
24+
function textTurn(usageMetadata: Record<string, number>) {
25+
return {
26+
candidates: [
27+
{
28+
content: { role: 'model', parts: [{ text: 'answer' }] },
29+
finishReason: 'STOP',
30+
},
31+
],
32+
usageMetadata,
33+
}
34+
}
35+
36+
function toolTurn(usageMetadata: Record<string, number>) {
37+
return {
38+
candidates: [
39+
{
40+
content: { role: 'model', parts: [{ functionCall: { name: 'lookup', args: {} } }] },
41+
finishReason: 'STOP',
42+
},
43+
],
44+
functionCalls: [{ name: 'lookup', args: {} }],
45+
usageMetadata,
46+
}
47+
}
48+
49+
async function run(generateContent: ReturnType<typeof vi.fn>, withTools = false) {
50+
return (await executeGeminiRequest({
51+
ai: { models: { generateContent, generateContentStream: vi.fn() } } as never,
52+
model: MODEL,
53+
providerType: 'google',
54+
request: {
55+
model: MODEL,
56+
apiKey: 'test-key',
57+
messages: [{ role: 'user', content: 'Look this up' }],
58+
...(withTools
59+
? {
60+
tools: [
61+
{
62+
id: 'lookup',
63+
name: 'lookup',
64+
description: 'Lookup',
65+
parameters: { type: 'object', properties: {}, required: [] },
66+
},
67+
],
68+
}
69+
: {}),
70+
},
71+
})) as ProviderResponse
72+
}
73+
74+
describe('Gemini block cost with implicit prompt caching', () => {
75+
beforeEach(() => {
76+
vi.clearAllMocks()
77+
mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } })
78+
})
79+
80+
it('bills cached prompt tokens at the discounted rate, not the full input rate', async () => {
81+
const generateContent = vi.fn().mockResolvedValue(
82+
textTurn({
83+
promptTokenCount: 100_000,
84+
cachedContentTokenCount: 80_000,
85+
candidatesTokenCount: 1_000,
86+
totalTokenCount: 101_000,
87+
})
88+
)
89+
90+
const response = await run(generateContent)
91+
92+
expect(response.tokens).toEqual({
93+
input: 20_000,
94+
output: 1_000,
95+
cacheRead: 80_000,
96+
total: 101_000,
97+
})
98+
expect(response.cost?.input).toBeCloseTo(0.0084, 10)
99+
expect(response.cost?.output).toBeCloseTo(0.0025, 10)
100+
expect(response.cost?.total).toBeCloseTo(0.0109, 10)
101+
102+
const cacheBlind = calculateCost(MODEL, 100_000, 1_000)
103+
expect(cacheBlind.total).toBeCloseTo(0.0325, 10)
104+
expect(response.cost?.total).toBeLessThan(cacheBlind.total)
105+
})
106+
107+
it('accumulates the cached and uncached buckets separately across tool-loop turns', async () => {
108+
const generateContent = vi
109+
.fn()
110+
.mockResolvedValueOnce(
111+
toolTurn({
112+
promptTokenCount: 10_000,
113+
cachedContentTokenCount: 6_000,
114+
candidatesTokenCount: 100,
115+
totalTokenCount: 10_100,
116+
})
117+
)
118+
.mockResolvedValueOnce(
119+
textTurn({
120+
promptTokenCount: 12_000,
121+
cachedContentTokenCount: 9_000,
122+
candidatesTokenCount: 200,
123+
totalTokenCount: 12_200,
124+
})
125+
)
126+
127+
const response = await run(generateContent, true)
128+
129+
expect(generateContent).toHaveBeenCalledTimes(2)
130+
expect(response.tokens).toEqual({
131+
input: 7_000,
132+
output: 300,
133+
cacheRead: 15_000,
134+
total: 22_300,
135+
})
136+
expect(response.cost?.input).toBeCloseTo(0.00255, 10)
137+
expect(response.cost?.output).toBeCloseTo(0.00075, 10)
138+
expect(response.cost?.total).toBeCloseTo(0.0033, 10)
139+
})
140+
141+
it('matches plain calculateCost when the response reports no cache hit', async () => {
142+
const generateContent = vi.fn().mockResolvedValue(
143+
textTurn({
144+
promptTokenCount: 100_000,
145+
candidatesTokenCount: 1_000,
146+
totalTokenCount: 101_000,
147+
})
148+
)
149+
150+
const response = await run(generateContent)
151+
152+
expect(response.tokens).toEqual({
153+
input: 100_000,
154+
output: 1_000,
155+
cacheRead: 0,
156+
total: 101_000,
157+
})
158+
expect(response.cost).toEqual(calculateCost(MODEL, 100_000, 1_000))
159+
})
160+
})

apps/sim/providers/gemini/core.streaming.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,12 @@ describe('executeGeminiRequest settled stream projection', () => {
223223
})
224224
await expect(reader.read()).resolves.toEqual({ done: true, value: undefined })
225225
expect(result.execution.output.content).toBe('{"value":"capped"}')
226-
expect(result.execution.output.tokens).toEqual({ input: 6, output: 6, total: 12 })
226+
expect(result.execution.output.tokens).toEqual({
227+
input: 6,
228+
output: 6,
229+
cacheRead: 0,
230+
total: 12,
231+
})
227232
expect(result.execution.output.providerTiming?.iterations).toBe(3)
228233
expect(
229234
result.execution.output.providerTiming?.timeSegments?.filter(

0 commit comments

Comments
 (0)