Skip to content

Commit ca00374

Browse files
committed
refactor(providers): drop unreachable billing fallbacks
Every provider pricing helper took a policy parameter no caller passed. Worse than dead: passing one would have double-applied the margin the central layer already applies. Removed, so providers can only price at list. Also removed guards that cannot fire. The central fallback normalized cache buckets no provider can reach it with (all three that report cache usage price themselves) and did so at a 1x write multiplier no vendor charges. priceModelUsage re-validated token counts the adapter had already clamped, and applyModelCostPolicy defaulted a required total field. Validation now happens once, in the adapter that parses the vendor payload and is the only layer that knows cache buckets are a subset of the prompt total.
1 parent fbca58a commit ca00374

7 files changed

Lines changed: 91 additions & 105 deletions

File tree

apps/sim/providers/anthropic/usage.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import type { BlockTokens } from '@/executor/types'
2-
import {
3-
LIST_PRICE_POLICY,
4-
type ModelCostPolicy,
5-
type ModelUsage,
6-
priceModelUsage,
7-
} from '@/providers/cost-policy'
2+
import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy'
83
import type { ModelPricing } from '@/providers/types'
94

105
export interface AnthropicUsageLike {
@@ -128,14 +123,17 @@ export function buildAnthropicModelUsage(accumulator: AnthropicUsageAccumulator)
128123
/**
129124
* Prices one Anthropic request, cache tiers included, through the shared
130125
* pricing function.
126+
*
127+
* Always at list price. Billability and the margin are applied once, centrally,
128+
* by `executeProviderRequest` — a provider applying them here would double-count
129+
* the multiplier.
131130
*/
132131
export function buildAnthropicUsageCost(
133132
model: string,
134133
accumulator: AnthropicUsageAccumulator,
135-
toolCost = 0,
136-
policy: ModelCostPolicy = LIST_PRICE_POLICY
134+
toolCost = 0
137135
): AnthropicUsageCost {
138-
const cost = priceModelUsage(model, buildAnthropicModelUsage(accumulator), policy)
136+
const cost = priceModelUsage(model, buildAnthropicModelUsage(accumulator), LIST_PRICE_POLICY)
139137

140138
return {
141139
input: cost.input,

apps/sim/providers/cost-policy.test.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -133,21 +133,6 @@ describe('priceModelUsage', () => {
133133
expect(cost).toMatchObject({ input: 0, output: 0, total: 0 })
134134
})
135135

136-
it('ignores negative and non-finite token counts rather than crediting the run', () => {
137-
const cost = priceModelUsage(
138-
PRICED_MODEL,
139-
{
140-
input: 1000,
141-
output: 0,
142-
cacheRead: -5000,
143-
cacheWrites: [{ tokens: 1000, inputRateMultiplier: Number.NaN }],
144-
},
145-
LIST_PRICE_POLICY
146-
)
147-
148-
expect(cost.input).toBeCloseTo(1000 * PER_TOKEN_INPUT, 8)
149-
})
150-
151136
/**
152137
* The regression this guards: Anthropic reports `input_tokens` already
153138
* excluding cache tokens while OpenAI and Gemini report a cached subset of

apps/sim/providers/cost-policy.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function applyModelCostPolicy(
9393
cost: ModelCost | undefined,
9494
policy: ModelCostPolicy
9595
): ModelCost {
96-
const toolCost = typeof cost?.toolCost === 'number' ? cost.toolCost : 0
96+
const toolCost = cost?.toolCost ?? 0
9797

9898
if (!cost || !policy.billable) {
9999
return notBilledCost(toolCost)
@@ -103,7 +103,7 @@ export function applyModelCostPolicy(
103103
return cost
104104
}
105105

106-
const modelTotal = (cost.total ?? cost.input + cost.output) - toolCost
106+
const modelTotal = cost.total - toolCost
107107

108108
return {
109109
...cost,
@@ -139,17 +139,18 @@ export interface ModelUsage {
139139
cacheWrites?: CacheWriteUsage[]
140140
}
141141

142-
function nonNegative(value: number | undefined): number {
143-
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
144-
}
145-
146142
/**
147143
* The single cache-aware pricing function.
148144
*
149145
* Every surface that turns tokens into a charge routes through here, so a cache
150146
* hit is priced identically whether it came from Anthropic, OpenAI, Gemini, or
151147
* an OpenAI-compatible vendor. Callers must not pre-apply the policy multiplier
152148
* or the cached rate themselves.
149+
*
150+
* Token counts are validated by the provider adapter that builds the
151+
* {@link ModelUsage}, which is the only layer that knows the vendor's shape and
152+
* can enforce that cache buckets are a subset of the prompt total. This function
153+
* re-validates nothing.
153154
*/
154155
export function priceModelUsage(
155156
model: string,
@@ -163,16 +164,15 @@ export function priceModelUsage(
163164
const multiplier = policy.multiplier
164165
const base = calculateCost(model, usage.input, usage.output, false, multiplier, multiplier)
165166

166-
const cacheRead = nonNegative(usage.cacheRead)
167+
const cacheRead = usage.cacheRead ?? 0
167168
const read = cacheRead > 0 ? calculateCost(model, cacheRead, 0, true, multiplier, 0) : undefined
168169

169170
let writeInputCost = 0
170171
for (const write of usage.cacheWrites ?? []) {
171-
const tokens = nonNegative(write.tokens)
172-
if (tokens === 0 || !Number.isFinite(write.inputRateMultiplier)) continue
172+
if (write.tokens <= 0) continue
173173
writeInputCost += calculateCost(
174174
model,
175-
tokens,
175+
write.tokens,
176176
0,
177177
false,
178178
multiplier * write.inputRateMultiplier,
@@ -194,21 +194,19 @@ export function priceModelUsage(
194194
/**
195195
* Prices tokens for a model and applies the billing policy in one step. Use
196196
* wherever a caller holds raw token counts and needs the charge Sim records.
197+
*
198+
* Cache-free by design: a caller that has cache usage also knows its tiers, so
199+
* it builds a {@link ModelUsage} and calls {@link priceModelUsage} directly.
197200
*/
198201
export function calculateBillableModelCost(
199202
model: string,
200203
promptTokens = 0,
201204
completionTokens = 0,
202-
options: { cacheRead?: number; cacheWrites?: CacheWriteUsage[]; isBYOK?: boolean } = {}
205+
options: { isBYOK?: boolean } = {}
203206
): PricedModelCost {
204207
return priceModelUsage(
205208
model,
206-
{
207-
input: promptTokens,
208-
output: completionTokens,
209-
cacheRead: options.cacheRead,
210-
cacheWrites: options.cacheWrites,
211-
},
209+
{ input: promptTokens, output: completionTokens },
212210
resolveModelCostPolicy(model, options.isBYOK)
213211
)
214212
}

apps/sim/providers/gemini/usage.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
LIST_PRICE_POLICY,
3-
type ModelCostPolicy,
4-
type PricedModelCost,
5-
priceModelUsage,
6-
} from '@/providers/cost-policy'
1+
import { LIST_PRICE_POLICY, type PricedModelCost, priceModelUsage } from '@/providers/cost-policy'
72
import type { GeminiUsage } from '@/providers/gemini/types'
83

94
/**
@@ -33,11 +28,13 @@ export function splitGeminiTokens(
3328
candidatesTokens: number,
3429
cachedTokens: number
3530
): GeminiTokenSplit {
31+
const prompt = Math.max(0, promptTokens)
3632
// Clamped to the prompt total: a payload reporting more cached tokens than it
3733
// processed would otherwise bill more input than the request contained.
38-
const cacheRead = Math.min(Math.max(0, cachedTokens), Math.max(0, promptTokens))
34+
const cacheRead = Math.min(Math.max(0, cachedTokens), prompt)
35+
3936
return {
40-
input: Math.max(0, promptTokens) - cacheRead,
37+
input: prompt - cacheRead,
4138
output: candidatesTokens,
4239
cacheRead,
4340
}
@@ -55,11 +52,11 @@ export function splitGeminiUsage(usage: GeminiUsage): GeminiTokenSplit {
5552
/**
5653
* Prices a split through the shared cache-aware pricing function. With no cache
5754
* hit this matches pricing the whole prompt total at the base input rate.
55+
*
56+
* Always at list price. Billability and the margin are applied once, centrally,
57+
* by `executeProviderRequest` — a provider applying them here would double-count
58+
* the multiplier.
5859
*/
59-
export function priceGeminiTokens(
60-
model: string,
61-
split: GeminiTokenSplit,
62-
policy: ModelCostPolicy = LIST_PRICE_POLICY
63-
): PricedModelCost {
64-
return priceModelUsage(model, split, policy)
60+
export function priceGeminiTokens(model: string, split: GeminiTokenSplit): PricedModelCost {
61+
return priceModelUsage(model, split, LIST_PRICE_POLICY)
6562
}

apps/sim/providers/index.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -179,27 +179,21 @@ export async function executeProviderRequest(
179179
const costPolicy = resolveModelCostPolicy(response.model, isBYOK)
180180

181181
if (response.tokens) {
182-
const {
183-
input: promptTokens = 0,
184-
output: completionTokens = 0,
185-
cacheRead,
186-
cacheWrite,
187-
} = response.tokens
182+
const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens
188183

189184
/**
190-
* A provider that priced its own usage knows cache tiers this layer cannot
191-
* reconstruct from token counts alone (Anthropic's 5m vs 1h writes), so its
192-
* cost is authoritative and only the policy is applied on top. Everything
193-
* else is priced from reported tokens. Tool cost is stripped either way —
194-
* it is re-derived from `toolResults` below and must not be counted twice.
185+
* Any provider that reports cache buckets also prices itself, because only
186+
* it knows the tiers involved — Anthropic's 5m vs 1h writes cannot be
187+
* reconstructed from a single `cacheWrite` count. Its cost is therefore
188+
* authoritative and only the policy is applied on top. The fallback prices
189+
* providers that report no cache usage at all.
190+
*
191+
* Tool cost is stripped either way: it is re-derived from `toolResults`
192+
* below and must not be counted twice.
195193
*/
196194
response.cost = response.cost
197195
? (applyModelCostPolicy(withoutToolCost(response.cost), costPolicy) as typeof response.cost)
198-
: calculateBillableModelCost(response.model, promptTokens, completionTokens, {
199-
cacheRead,
200-
...(cacheWrite ? { cacheWrites: [{ tokens: cacheWrite, inputRateMultiplier: 1 }] } : {}),
201-
isBYOK,
202-
})
196+
: calculateBillableModelCost(response.model, promptTokens, completionTokens, { isBYOK })
203197

204198
if (!costPolicy.billable) {
205199
logger.info(

apps/sim/providers/openai/usage.ts

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import type { BlockTokens } from '@/executor/types'
2-
import {
3-
LIST_PRICE_POLICY,
4-
type ModelCostPolicy,
5-
type ModelUsage,
6-
priceModelUsage,
7-
} from '@/providers/cost-policy'
2+
import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy'
83
import {
94
OPENAI_CACHE_WRITE_MULTIPLIER,
105
type ResponsesUsageTokens,
11-
toOpenAIModelUsage,
6+
splitOpenAIUsage,
127
} from '@/providers/openai/utils'
138
import type { ModelPricing } from '@/providers/types'
149

@@ -34,10 +29,6 @@ interface OpenAIUsageCost {
3429
pricing: ModelPricing
3530
}
3631

37-
function tokenCount(value: number | undefined): number {
38-
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
39-
}
40-
4132
function roundedCost(value: number): number {
4233
return Number.parseFloat(value.toFixed(8))
4334
}
@@ -59,7 +50,7 @@ export function createOpenAIUsageAccumulator(): OpenAIUsageAccumulator {
5950
* Adds one Responses API turn's usage without counting cache tokens as
6051
* uncached input.
6152
*
62-
* Normalization goes through {@link toOpenAIModelUsage} so that subtracting the
53+
* Normalization goes through {@link splitOpenAIUsage} so that subtracting the
6354
* cache buckets out of the prompt total — and clamping a vendor payload that
6455
* reports more cache tokens than it processed — stays in one place.
6556
*/
@@ -69,15 +60,13 @@ export function addOpenAIUsage(
6960
): void {
7061
if (!usage) return
7162

72-
const normalized = toOpenAIModelUsage(usage)
63+
const split = splitOpenAIUsage(usage)
7364

74-
accumulator.input += tokenCount(normalized.input)
75-
accumulator.output += tokenCount(normalized.output)
76-
accumulator.cacheRead += tokenCount(normalized.cacheRead)
77-
for (const write of normalized.cacheWrites ?? []) {
78-
accumulator.cacheWrite += tokenCount(write.tokens)
79-
}
80-
accumulator.total += tokenCount(usage.totalTokens)
65+
accumulator.input += split.input
66+
accumulator.output += split.output
67+
accumulator.cacheRead += split.cacheRead
68+
accumulator.cacheWrite += split.cacheWrite
69+
accumulator.total += usage.totalTokens
8170
}
8271

8372
/**
@@ -117,14 +106,17 @@ export function buildOpenAIModelUsage(accumulator: OpenAIUsageAccumulator): Mode
117106
/**
118107
* Prices one OpenAI request, cache reads and writes included, through the
119108
* shared pricing function.
109+
*
110+
* Always at list price. Billability and the margin are applied once, centrally,
111+
* by `executeProviderRequest` — a provider applying them here would double-count
112+
* the multiplier.
120113
*/
121114
export function buildOpenAIUsageCost(
122115
model: string,
123116
accumulator: OpenAIUsageAccumulator,
124-
toolCost = 0,
125-
policy: ModelCostPolicy = LIST_PRICE_POLICY
117+
toolCost = 0
126118
): OpenAIUsageCost {
127-
const cost = priceModelUsage(model, buildOpenAIModelUsage(accumulator), policy)
119+
const cost = priceModelUsage(model, buildOpenAIModelUsage(accumulator), LIST_PRICE_POLICY)
128120

129121
return {
130122
input: cost.input,

apps/sim/providers/openai/utils.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,49 @@ export interface ResponsesUsageTokens {
2121
export const OPENAI_CACHE_WRITE_MULTIPLIER = 1.25
2222

2323
/**
24-
* Normalizes OpenAI usage into the shared {@link ModelUsage} shape.
24+
* One OpenAI response's tokens split into the buckets that price differently.
25+
* `input` is the uncached remainder, so `input + cacheRead + cacheWrite` is the
26+
* prompt total OpenAI reported.
27+
*/
28+
export interface OpenAITokenSplit {
29+
input: number
30+
output: number
31+
cacheRead: number
32+
cacheWrite: number
33+
}
34+
35+
/**
36+
* Splits a cache-inclusive OpenAI prompt total into its billing buckets.
2537
*
26-
* `cached_tokens` is a subset of `input_tokens`, so the uncached remainder is
27-
* the subtraction — the opposite of Anthropic, whose `input_tokens` already
28-
* excludes cache tokens.
38+
* `cached_tokens` and `cache_write_tokens` are subsets of `input_tokens`, so the
39+
* uncached remainder is the subtraction — the opposite of Anthropic, whose
40+
* `input_tokens` already excludes cache tokens.
41+
*
42+
* Both buckets are clamped to what the request actually contained. OpenAI has
43+
* shipped payloads where reads plus writes summed past the prompt total, so
44+
* without this a vendor reporting bug becomes an overcharge.
2945
*/
30-
export function toOpenAIModelUsage(usage: ResponsesUsageTokens): ModelUsage {
46+
export function splitOpenAIUsage(usage: ResponsesUsageTokens): OpenAITokenSplit {
3147
const promptTokens = Math.max(0, usage.promptTokens)
3248
const cacheRead = Math.min(Math.max(0, usage.cachedTokens), promptTokens)
33-
/**
34-
* A write can only cover tokens this request processed uncached, so it can
35-
* never exceed the remainder. OpenAI has shipped usage payloads where reads
36-
* plus writes summed past the prompt total; clamping keeps a vendor
37-
* reporting bug from over-charging the run.
38-
*/
3949
const cacheWrite = Math.min(Math.max(0, usage.cacheWriteTokens), promptTokens - cacheRead)
4050

4151
return {
4252
input: promptTokens - cacheRead - cacheWrite,
4353
output: usage.completionTokens,
4454
cacheRead,
55+
cacheWrite,
56+
}
57+
}
58+
59+
/** Adapts a {@link splitOpenAIUsage} result to the shared pricing shape. */
60+
export function toOpenAIModelUsage(usage: ResponsesUsageTokens): ModelUsage {
61+
const { input, output, cacheRead, cacheWrite } = splitOpenAIUsage(usage)
62+
63+
return {
64+
input,
65+
output,
66+
cacheRead,
4567
cacheWrites: [{ tokens: cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }],
4668
}
4769
}

0 commit comments

Comments
 (0)