Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/openrouter-cost-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@tanstack/ai-openrouter': minor
'@tanstack/ai': minor
---

Surface OpenRouter's per-request cost on `RUN_FINISHED.usage`.

OpenRouter reports the actual cost of each request inline on the chat response.
The `openRouterText` and `openRouterResponsesText` adapters now forward that
value on the terminal `RUN_FINISHED` event as `usage.cost`, with OpenRouter's
per-request breakdown under `usage.costDetails`. This is the cost OpenRouter
itself reports — it is not computed locally from token counts, so it accounts
for routing, fallback providers, BYOK, and cached-token pricing.

`@tanstack/ai` adds a shared `UsageTotals` type with optional `cost` and
`costDetails` fields, plus a provider-neutral `UsageCostBreakdown` interface
with three canonical fields (`upstreamCost`, `upstreamInputCost`,
`upstreamOutputCost`). Each adapter's extractor normalizes its provider's
wire-shape onto this canonical form, so consumer code reads the same fields
regardless of which gateway populated them — swapping adapters is a one-line
change with no consumer rewrites. The OpenRouter adapter collapses its two
endpoint naming styles (Chat Completions' `prompt`/`completions` and
Responses' `input`/`output`) onto the same canonical input/output split, since
they bill against the same tokens. `RunFinishedEvent.usage`, the middleware
`UsageInfo` (`onUsage`), and `FinishInfo.usage` (`onFinish`) all use
`UsageTotals`. The fields are optional and additive — adapters that do not
report cost are unaffected.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Official adapters include:

| Package | Use it for |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API |
| [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking |
| [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools |
| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, and structured outputs |
| [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation |
Expand Down
32 changes: 32 additions & 0 deletions docs/adapters/openrouter.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,38 @@ Caveats while the Responses adapter is in beta:
- If in doubt, prefer `openRouterText`. The Chat Completions endpoint has
broader provider coverage and feature parity today.

## Cost Tracking

OpenRouter reports the actual cost of each request inline on the streamed
response. When present, the adapter forwards it on the terminal `RUN_FINISHED`
event under `usage.cost`, with OpenRouter's per-request breakdown under
`usage.costDetails`. This is the cost OpenRouter itself reports for the
request — it is **not** computed locally from token counts, so it already
accounts for routing, fallback providers, BYOK, and cached-token pricing. See
OpenRouter's [Usage Accounting](https://openrouter.ai/docs/use-cases/usage-accounting)
docs for the meaning and units of these fields.

```typescript
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";

for await (const chunk of chat({
adapter: openRouterText("openai/gpt-5"),
messages: [{ role: "user", content: "Hello!" }],
})) {
if (chunk.type === "RUN_FINISHED") {
console.log("cost:", chunk.usage?.cost);
console.log("breakdown:", chunk.usage?.costDetails);
}
}
```

The same `usage` (including `cost` / `costDetails`) is passed to middleware via
the `onUsage` and `onFinish` hooks. When OpenRouter does not report a cost, the
fields are simply absent and the stream completes normally. Both
`openRouterText` and `openRouterResponsesText` populate cost when OpenRouter
returns it.

## Next Steps

- [Getting Started](../getting-started/quick-start) - Learn the basics
Expand Down
101 changes: 101 additions & 0 deletions packages/ai-openrouter/src/adapters/cost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Helpers for extracting OpenRouter's provider-reported per-request cost from the
* SDK usage object and shaping it for `RUN_FINISHED.usage`.
*
* OpenRouter returns an authoritative per-request `cost` plus an optional
* `cost_details` breakdown. We forward `cost` verbatim and normalize the
* breakdown onto `@tanstack/ai`'s canonical `UsageCostBreakdown` shape — so
* consumer code reads the same three fields regardless of which adapter (or
* which OpenRouter endpoint) produced them. OpenRouter exposes the breakdown
* under two naming families (Chat Completions: `prompt`/`completions`,
* Responses: `input`/`output`); both map onto the same canonical input/output
* split, because they bill against the same tokens.
*
* Input is intentionally typed `unknown`: callers pass usage objects whose static
* types are narrowed to token-only fields (notably the Responses adapter), and the
* Responses usage normalizer can leave `cost_details` in snake_case. Reading both
* `costDetails` and `cost_details` and narrowing here keeps every call site simple.
*/

import type { UsageCostBreakdown } from '@tanstack/ai'

export interface ExtractedCost {
cost?: number
costDetails?: UsageCostBreakdown
}

/**
* Wire-key → canonical-key mapping. Snake_case keys come from the raw/UNKNOWN
* `response.completed` fallback in the Responses adapter; camelCase keys come
* from the SDK-parsed path. Both Chat Completions' prompt/completions naming
* and Responses' input/output naming collapse onto `upstreamInputCost` /
* `upstreamOutputCost`.
*/
const KNOWN_DETAIL_KEYS: Record<string, keyof UsageCostBreakdown> = {
upstream_inference_cost: 'upstreamCost',
upstreamInferenceCost: 'upstreamCost',
upstream_inference_prompt_cost: 'upstreamInputCost',
upstreamInferencePromptCost: 'upstreamInputCost',
upstream_inference_input_cost: 'upstreamInputCost',
upstreamInferenceInputCost: 'upstreamInputCost',
upstream_inference_completions_cost: 'upstreamOutputCost',
upstreamInferenceCompletionsCost: 'upstreamOutputCost',
upstream_inference_output_cost: 'upstreamOutputCost',
upstreamInferenceOutputCost: 'upstreamOutputCost',
}

function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
: undefined
}

/**
* Narrow a raw `cost_details`/`costDetails` map to the canonical fields of
* `UsageCostBreakdown`. Negative values (e.g. discounts) are preserved; `null`,
* non-finite numbers, non-numeric values, and unknown keys are dropped.
*/
function extractCostDetails(details: unknown): UsageCostBreakdown | undefined {
const record = asRecord(details)
if (!record) return undefined

const out: UsageCostBreakdown = {}
for (const [rawKey, value] of Object.entries(record)) {
const key = KNOWN_DETAIL_KEYS[rawKey]
if (!key) continue
if (typeof value === 'number' && Number.isFinite(value)) {
out[key] = value
}
}

return Object.keys(out).length > 0 ? out : undefined
}

/**
* Extract `cost`/`costDetails` from a provider usage object.
*
* - `cost` is attached only when it is a finite number — this preserves `cost === 0`
* and rejects `NaN`/`Infinity`, and does not clamp negative values.
* - `costDetails` is attached only alongside a valid `cost` (an orphan breakdown
* without a total cannot be reconciled and is dropped). Both camelCase
* `costDetails` and snake_case `cost_details` are read.
*
* Returns an empty object when no usable cost is present, so call sites can spread
* the result unconditionally.
*/
export function extractUsageCost(usage: unknown): ExtractedCost {
const record = asRecord(usage)
if (!record) return {}

const cost = record.cost
if (typeof cost !== 'number' || !Number.isFinite(cost)) return {}

const costDetails = extractCostDetails(
record.costDetails ?? record.cost_details,
)

return {
cost,
...(costDetails && { costDetails }),
}
}
3 changes: 3 additions & 0 deletions packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool
import { isWebSearchTool } from '../tools/web-search-tool'
import { isWebFetchTool } from '../tools/web-fetch-tool'
import { getOpenRouterApiKeyFromEnv } from '../utils'
import { extractUsageCost } from './cost'
import type { SDKOptions } from '@openrouter/sdk'
import type { ResponsesFunctionTool } from '../internal/responses-tool-converter'
import type {
Expand Down Expand Up @@ -623,6 +624,7 @@ export class OpenRouterResponsesTextAdapter<
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: usage.totalTokens ?? 0,
...extractUsageCost(usage),
},
}),
}
Expand Down Expand Up @@ -1433,6 +1435,7 @@ export class OpenRouterResponsesTextAdapter<
promptTokens: responseObj.usage?.inputTokens || 0,
completionTokens: responseObj.usage?.outputTokens || 0,
totalTokens: responseObj.usage?.totalTokens || 0,
...extractUsageCost(responseObj.usage),
},
finishReason,
}
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { extractRequestOptions } from '../internal/request-options'
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
import { convertToolsToProviderFormat } from '../tools'
import { getOpenRouterApiKeyFromEnv } from '../utils'
import { extractUsageCost } from './cost'
import type { SDKOptions } from '@openrouter/sdk'
import type {
ChatContentItems,
Expand Down Expand Up @@ -549,6 +550,7 @@ export class OpenRouterTextAdapter<
promptTokens: lastUsage.promptTokens,
completionTokens: lastUsage.completionTokens,
totalTokens: lastUsage.totalTokens,
...extractUsageCost(lastUsage),
},
}),
}
Expand Down Expand Up @@ -1076,6 +1078,7 @@ export class OpenRouterTextAdapter<
promptTokens: lastUsage.promptTokens || 0,
completionTokens: lastUsage.completionTokens || 0,
totalTokens: lastUsage.totalTokens || 0,
...extractUsageCost(lastUsage),
},
}),
finishReason,
Expand Down
145 changes: 145 additions & 0 deletions packages/ai-openrouter/tests/cost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { extractUsageCost } from '../src/adapters/cost'

describe('extractUsageCost', () => {
it('extracts a finite cost', () => {
expect(extractUsageCost({ cost: 0.0123 })).toEqual({ cost: 0.0123 })
})

it('preserves cost === 0 (not treated as absent)', () => {
expect(extractUsageCost({ cost: 0 })).toEqual({ cost: 0 })
})

it('returns empty object when cost is absent', () => {
expect(extractUsageCost({ promptTokens: 5 })).toEqual({})
})

it('returns empty object for non-number / non-finite cost', () => {
expect(extractUsageCost({ cost: '0.5' })).toEqual({})
expect(extractUsageCost({ cost: NaN })).toEqual({})
expect(extractUsageCost({ cost: Infinity })).toEqual({})
expect(extractUsageCost({ cost: null })).toEqual({})
})

it('returns empty object for non-object input', () => {
expect(extractUsageCost(undefined)).toEqual({})
expect(extractUsageCost(null)).toEqual({})
expect(extractUsageCost(42)).toEqual({})
})

it('reads costDetails (camelCase) and normalizes to canonical keys', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: { upstreamInferenceCost: 0.008 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamCost: 0.008 } })
})

it('reads cost_details (snake_case) and normalizes to canonical keys', () => {
expect(
extractUsageCost({
cost: 0.01,
cost_details: { upstream_inference_cost: 0.008 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamCost: 0.008 } })
})

it('collapses Chat Completions prompt/completions onto canonical input/output', () => {
expect(
extractUsageCost({
cost: 0.0042,
cost_details: {
upstream_inference_completions_cost: 0.0026,
upstream_inference_cost: 0.0038,
upstream_inference_prompt_cost: 0.0012,
},
}),
).toEqual({
cost: 0.0042,
costDetails: {
upstreamOutputCost: 0.0026,
upstreamCost: 0.0038,
upstreamInputCost: 0.0012,
},
})
})

it('collapses Responses input/output onto the same canonical input/output', () => {
expect(
extractUsageCost({
cost: 0.0042,
cost_details: {
upstream_inference_cost: 0.0038,
upstream_inference_input_cost: 0.0012,
upstream_inference_output_cost: 0.0026,
},
}),
).toEqual({
cost: 0.0042,
costDetails: {
upstreamCost: 0.0038,
upstreamInputCost: 0.0012,
upstreamOutputCost: 0.0026,
},
})
})

it('prefers camelCase costDetails when both are present', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: { upstreamInferenceCost: 1 },
cost_details: { upstream_inference_cost: 2 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamCost: 1 } })
})

it('preserves negative detail values (e.g. cache discount)', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: { upstreamInferenceCost: -0.002 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamCost: -0.002 } })
})

it('drops null, non-finite, and non-numeric detail entries', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: {
upstreamInferenceCost: 0.5,
upstreamInferenceInputCost: null,
upstreamInferenceOutputCost: Infinity,
upstreamInferencePromptCost: NaN,
upstreamInferenceCompletionsCost: 'x',
},
}),
).toEqual({ cost: 0.01, costDetails: { upstreamCost: 0.5 } })
})

it('drops unknown breakdown keys', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: {
upstreamInferenceCost: 0.008,
futureUnknownField: 0.001,
},
}),
).toEqual({ cost: 0.01, costDetails: { upstreamCost: 0.008 } })
})

it('omits costDetails entirely when no known entries remain', () => {
expect(
extractUsageCost({ cost: 0.01, costDetails: { unknownKey: 1 } }),
).toEqual({ cost: 0.01 })
})

it('drops an orphan costDetails when cost is absent', () => {
expect(
extractUsageCost({ costDetails: { upstreamInferenceCost: 0.008 } }),
).toEqual({})
})
})
Loading
Loading