Skip to content

Commit 1d5579b

Browse files
committed
refine OpenRouter cost tracking
1 parent 29215bf commit 1d5579b

8 files changed

Lines changed: 168 additions & 270 deletions

File tree

.changeset/openrouter-cost-tracking.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
'@tanstack/ai': minor
44
---
55

6-
- Add OpenRouter cost tracking. The `OpenRouterTextAdapter` now attaches the authoritative per-request USD cost to the `RUN_FINISHED` event under `usage.cost`, along with `usage.costDetails.upstreamInferenceCost` and `usage.costDetails.cacheDiscount` when present. Cost is sourced from OpenRouter's chat completion response itself (the field arrives in the trailing SSE chunk), so there is no extra HTTP request and no added end-of-stream latency.
7-
- Cost is always sourced from OpenRouter — never computed locally from token counts and a price table — because OpenRouter routes the same model id to different upstream providers (primary, fallback, BYOK) with different pricing, and applies cache discounts the SDK cannot reconstruct.
8-
- Cost is captured via the SDK's public `HTTPClient` response hook. The hook calls `Response.clone()` and parses the cloned body out-of-band to recover `usage.cost` and `usage.cost_details`, which the @openrouter/sdk Zod parser would otherwise strip (the schema doesn't declare those fields). The SDK's stream consumer is unaffected — both clones are read independently.
6+
- Add OpenRouter cost tracking. The `OpenRouterTextAdapter` now attaches OpenRouter's authoritative per-request cost amount to the `RUN_FINISHED` event under `usage.cost`, along with numeric/null fields from `usage.cost_details` under `usage.costDetails`. Cost is sourced from OpenRouter's chat completion response itself (the field arrives in the trailing SSE chunk), so there is no extra HTTP request and no added end-of-stream latency.
7+
- Cost is always sourced from OpenRouter — never computed locally from token counts and a price table — because OpenRouter routes the same model id to different upstream providers (primary, fallback, BYOK) with different pricing, and may expose provider-specific cost breakdowns the SDK cannot reconstruct.
8+
- Cost is captured via the SDK's public `HTTPClient` response hook. The hook calls `Response.clone()` and parses the cloned body out-of-band to recover `usage.cost` and `usage.cost_details`, which the @openrouter/sdk chat-completion parser would otherwise strip. The SDK's stream consumer is unaffected — both clones are read independently.
99
- Custom `httpClient` values passed into the adapter are preserved: the adapter clones the caller's client (inheriting their fetcher, retries, tracing, and any pre-registered hooks) and appends the cost-capture hook to the clone, so the caller's original instance is never mutated and cost tracking still works when callers supply their own transport.
1010
- Defer the OpenRouter `RUN_FINISHED` emission until after the upstream stream fully drains, so token usage that arrives in a trailing usage-only chunk (the common case for OpenAI-compatible providers, where the final chunk has empty `choices`) is included in `usage` instead of being dropped.
1111
- Extend `RunFinishedEvent.usage` in `@tanstack/ai` with optional `cost` and `costDetails` fields. The middleware `UsageInfo` (consumed by `onUsage`) and `FinishInfo.usage` (consumed by `onFinish`) carry the same fields, so middleware authors can read cost without casts. The change is additive and backwards-compatible for adapters that don't populate cost.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ A powerful, type-safe AI SDK for building AI-powered applications.
4646
- Isomorphic type-safe tools with server/client execution
4747
- **Enhanced integration with TanStack Start** - Share implementations between AI tools and server functions
4848
- **Observability events** - Structured, typed events for text, tools, image, speech, transcription, and video ([docs](./docs/guides/observability.md))
49-
- **Cost tracking** - Per-request USD cost on `RUN_FINISHED` for providers that report it (currently [OpenRouter](./docs/adapters/openrouter.md#cost-tracking))
49+
- **Cost tracking** - Per-request cost on `RUN_FINISHED` for providers that report it (currently [OpenRouter](./docs/adapters/openrouter.md#cost-tracking))
5050

5151
### <a href="https://tanstack.com/ai">Read the docs →</a>
5252

docs/adapters/openrouter.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,9 @@ OPENROUTER_API_KEY=sk-or-...
117117

118118
## Cost Tracking
119119

120-
The OpenRouter adapter attaches the authoritative per-request cost to the `RUN_FINISHED` event under `usage.cost` (USD). OpenRouter [reports cost inline in every chat response](https://openrouter.ai/docs/use-cases/usage-accounting), so cost arrives in the same SSE stream as the model output — there is **no extra HTTP request** and **no added latency**.
120+
The OpenRouter adapter attaches the authoritative per-request cost to the `RUN_FINISHED` event under `usage.cost`. OpenRouter [reports cost inline in every chat response](https://openrouter.ai/docs/use-cases/usage-accounting), in credits, so cost arrives in the same SSE stream as the model output — there is **no extra HTTP request** and **no added latency**.
121121

122-
Why we don't compute cost locally from tokens × price: OpenRouter routes the same model id to different upstream providers (primary, fallback, BYOK), each with different pricing, plus applies cache discounts and BYOK upstream costs. A static price table would silently drift and produce wrong numbers.
122+
Why we don't compute cost locally from tokens × price: OpenRouter routes the same model id to different upstream providers (primary, fallback, BYOK), each with different pricing, and may include cached-token pricing or BYOK upstream costs. A static price table would silently drift and produce wrong numbers.
123123

124124
```typescript
125125
import { chat } from "@tanstack/ai";
@@ -132,9 +132,8 @@ const stream = chat({
132132

133133
for await (const chunk of stream) {
134134
if (chunk.type === "RUN_FINISHED") {
135-
console.log("USD cost:", chunk.usage?.cost);
135+
console.log("OpenRouter cost:", chunk.usage?.cost);
136136
console.log("Upstream inference cost:", chunk.usage?.costDetails?.upstreamInferenceCost);
137-
console.log("Cache discount:", chunk.usage?.costDetails?.cacheDiscount);
138137
}
139138
}
140139
```
@@ -203,4 +202,3 @@ const stream = chat({
203202
```
204203

205204
**Supported models:** all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
206-

packages/typescript/ai-openrouter/src/adapters/cost-capture.ts

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { HTTPClient } from '@openrouter/sdk'
22

33
export interface CostInfo {
4-
cost?: number
5-
costDetails?: {
6-
upstreamInferenceCost?: number | null
7-
cacheDiscount?: number | null
8-
}
4+
cost: number
5+
costDetails?: Record<string, number | null | undefined>
96
}
107

118
interface CostEntry {
@@ -310,42 +307,43 @@ function extractDataPayload(event: string): string | undefined {
310307
function safeParseJson(text: string): Record<string, unknown> | undefined {
311308
try {
312309
const v = JSON.parse(text)
313-
return v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined
310+
return v && typeof v === 'object'
311+
? (v as Record<string, unknown>)
312+
: undefined
314313
} catch {
315314
return undefined
316315
}
317316
}
318317

319-
function pickNumberOrNull(
320-
obj: Record<string, unknown> | undefined,
321-
key: string,
322-
): number | null | undefined {
323-
if (!obj) return undefined
324-
const v = obj[key]
325-
if (typeof v === 'number') return v
326-
if (v === null) return null
327-
return undefined
318+
function toCamelCase(key: string): string {
319+
return key.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase())
320+
}
321+
322+
function extractCostDetails(
323+
details: Record<string, unknown> | undefined,
324+
): CostInfo['costDetails'] | undefined {
325+
if (!details) return undefined
326+
const result: NonNullable<CostInfo['costDetails']> = {}
327+
for (const [key, value] of Object.entries(details)) {
328+
if (typeof value === 'number' || value === null) {
329+
result[toCamelCase(key)] = value
330+
}
331+
}
332+
return Object.keys(result).length > 0 ? result : undefined
328333
}
329334

330335
function extractCostFromUsage(
331336
usage: Record<string, unknown>,
332337
): CostInfo | undefined {
333-
// `cost` is the authoritative per-request USD total. Emitting `costDetails`
334-
// without it would surface a breakdown that can't be reconciled against a
335-
// total — callers could misread it as the bill.
338+
// `cost` is the authoritative per-request total reported by OpenRouter.
339+
// Emitting `costDetails` without it would surface a breakdown that can't be
340+
// reconciled against a total — callers could misread it as the bill.
336341
const cost = typeof usage.cost === 'number' ? usage.cost : undefined
337342
if (cost === undefined) return undefined
338343
const details = usage.cost_details as Record<string, unknown> | undefined
339-
const upstream = pickNumberOrNull(details, 'upstream_inference_cost')
340-
const cacheDiscount = pickNumberOrNull(details, 'cache_discount')
341-
const hasDetails = upstream !== undefined || cacheDiscount !== undefined
344+
const costDetails = extractCostDetails(details)
342345
return {
343346
cost,
344-
...(hasDetails && {
345-
costDetails: {
346-
...(upstream !== undefined && { upstreamInferenceCost: upstream }),
347-
...(cacheDiscount !== undefined && { cacheDiscount }),
348-
},
349-
}),
347+
...(costDetails && { costDetails }),
350348
}
351349
}

packages/typescript/ai-openrouter/src/adapters/text.ts

Lines changed: 56 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -164,66 +164,66 @@ export class OpenRouterTextAdapter<
164164
if (chunk.id) responseId = chunk.id
165165
if (chunk.model) currentModel = chunk.model
166166

167-
// Emit RUN_STARTED on first chunk
168-
if (!aguiState.hasEmittedRunStarted) {
169-
aguiState.hasEmittedRunStarted = true
170-
yield asChunk({
171-
type: 'RUN_STARTED',
172-
runId: aguiState.runId,
173-
threadId: aguiState.threadId,
174-
model: currentModel || options.model,
175-
timestamp,
176-
})
177-
}
167+
// Emit RUN_STARTED on first chunk
168+
if (!aguiState.hasEmittedRunStarted) {
169+
aguiState.hasEmittedRunStarted = true
170+
yield asChunk({
171+
type: 'RUN_STARTED',
172+
runId: aguiState.runId,
173+
threadId: aguiState.threadId,
174+
model: currentModel || options.model,
175+
timestamp,
176+
})
177+
}
178178

179-
if (chunk.error) {
180-
// Emit AG-UI RUN_ERROR
181-
yield asChunk({
182-
type: 'RUN_ERROR',
183-
runId: aguiState.runId,
184-
model: currentModel || options.model,
185-
timestamp,
179+
if (chunk.error) {
180+
// Emit AG-UI RUN_ERROR
181+
yield asChunk({
182+
type: 'RUN_ERROR',
183+
runId: aguiState.runId,
184+
model: currentModel || options.model,
185+
timestamp,
186+
message: chunk.error.message || 'Unknown error',
187+
code: String(chunk.error.code),
188+
error: {
186189
message: chunk.error.message || 'Unknown error',
187190
code: String(chunk.error.code),
188-
error: {
189-
message: chunk.error.message || 'Unknown error',
190-
code: String(chunk.error.code),
191-
},
192-
})
193-
continue
194-
}
191+
},
192+
})
193+
continue
194+
}
195195

196-
for (const choice of chunk.choices) {
197-
yield* this.processChoice(
198-
choice,
199-
toolCallBuffers,
200-
{
201-
id: responseId || this.generateId(),
202-
model: currentModel,
203-
timestamp,
204-
},
205-
{ reasoning: accumulatedReasoning, content: accumulatedContent },
206-
(r, c) => {
207-
accumulatedReasoning = r
208-
accumulatedContent = c
209-
},
210-
chunk.usage,
211-
aguiState,
212-
)
213-
}
196+
for (const choice of chunk.choices) {
197+
yield* this.processChoice(
198+
choice,
199+
toolCallBuffers,
200+
{
201+
id: responseId || this.generateId(),
202+
model: currentModel,
203+
timestamp,
204+
},
205+
{ reasoning: accumulatedReasoning, content: accumulatedContent },
206+
(r, c) => {
207+
accumulatedReasoning = r
208+
accumulatedContent = c
209+
},
210+
chunk.usage,
211+
aguiState,
212+
)
213+
}
214214

215-
// Capture usage from a trailing `choices: []` chunk that the
216-
// choice loop above would have skipped. OpenRouter (and other
217-
// OpenAI-compatible streams) often report final token counts in
218-
// a terminal chunk with no choices, after `finishReason` was
219-
// delivered on an earlier chunk.
220-
if (chunk.usage && !aguiState.deferredUsage) {
221-
aguiState.deferredUsage = {
222-
promptTokens: chunk.usage.promptTokens || 0,
223-
completionTokens: chunk.usage.completionTokens || 0,
224-
totalTokens: chunk.usage.totalTokens || 0,
225-
}
215+
// Capture usage from a trailing `choices: []` chunk that the
216+
// choice loop above would have skipped. OpenRouter (and other
217+
// OpenAI-compatible streams) often report final token counts in
218+
// a terminal chunk with no choices, after `finishReason` was
219+
// delivered on an earlier chunk.
220+
if (chunk.usage && !aguiState.deferredUsage) {
221+
aguiState.deferredUsage = {
222+
promptTokens: chunk.usage.promptTokens || 0,
223+
completionTokens: chunk.usage.completionTokens || 0,
224+
totalTokens: chunk.usage.totalTokens || 0,
226225
}
226+
}
227227
}
228228

229229
// Emit RUN_FINISHED after the stream ends so we capture usage from
@@ -376,11 +376,11 @@ export class OpenRouterTextAdapter<
376376
// trailing usage chunk), emit no usage at all — even if `costInfo` was
377377
// captured. Synthesizing zero-token counts alongside a non-zero cost
378378
// would feed billing/telemetry a "successful run with zero tokens but
379-
// $X cost" signal, which is worse than an absent usage payload.
379+
// nonzero cost" signal, which is worse than an absent usage payload.
380380
if (!usage) return undefined
381381
return {
382382
...usage,
383-
...(costInfo?.cost !== undefined && { cost: costInfo.cost }),
383+
...(costInfo && { cost: costInfo.cost }),
384384
...(costInfo?.costDetails && { costDetails: costInfo.costDetails }),
385385
}
386386
}

0 commit comments

Comments
 (0)