Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
22 changes: 22 additions & 0 deletions .changeset/openrouter-cost-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@tanstack/ai-openrouter': minor
'@tanstack/ai': patch
---

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 `UsageCostDetails` type enumerating the known
breakdown fields (`upstreamInferenceCost`, `upstreamInferencePromptCost`,
`upstreamInferenceCompletionsCost`, `upstreamInferenceInputCost`,
`upstreamInferenceOutputCost`). `RunFinishedEvent.usage`, the middleware
`UsageInfo` (`onUsage`), and `FinishInfo.usage` (`onFinish`) all use it, so cost
can be read without casts and with autocomplete on the breakdown. 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
92 changes: 92 additions & 0 deletions packages/ai-openrouter/src/adapters/cost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* 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 the known breakdown fields verbatim — no
* local token-times-price math, which would drift across routing/fallback/BYOK
* /caching. Unknown breakdown keys are dropped so the public `UsageCostDetails`
* shape stays closed.
*
* 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 { UsageCostDetails } from '@tanstack/ai'

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

/** Keys recognized in `cost_details` / `costDetails`, snake_case ↔ camelCase. */
const KNOWN_DETAIL_KEYS: Record<string, keyof UsageCostDetails> = {
upstream_inference_cost: 'upstreamInferenceCost',
upstreamInferenceCost: 'upstreamInferenceCost',
upstream_inference_prompt_cost: 'upstreamInferencePromptCost',
upstreamInferencePromptCost: 'upstreamInferencePromptCost',
upstream_inference_completions_cost: 'upstreamInferenceCompletionsCost',
upstreamInferenceCompletionsCost: 'upstreamInferenceCompletionsCost',
upstream_inference_input_cost: 'upstreamInferenceInputCost',
upstreamInferenceInputCost: 'upstreamInferenceInputCost',
upstream_inference_output_cost: 'upstreamInferenceOutputCost',
upstreamInferenceOutputCost: 'upstreamInferenceOutputCost',
}

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 known fields of
* `UsageCostDetails`. Negative values (e.g. discounts) are preserved; `null`,
* non-finite numbers, non-numeric values, and unknown keys are dropped.
*/
function extractCostDetails(details: unknown): UsageCostDetails | undefined {
const record = asRecord(details)
if (!record) return undefined

const out: UsageCostDetails = {}
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
125 changes: 125 additions & 0 deletions packages/ai-openrouter/tests/cost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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) alongside a valid cost', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: { upstreamInferenceCost: 0.008 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamInferenceCost: 0.008 } })
})

it('reads cost_details (snake_case) alongside a valid cost, camelCasing keys', () => {
expect(
extractUsageCost({
cost: 0.01,
cost_details: { upstream_inference_cost: 0.008 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamInferenceCost: 0.008 } })
})

it('normalizes the full snake_case breakdown to camelCase (raw/UNKNOWN fallback path)', () => {
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: {
upstreamInferenceCompletionsCost: 0.0026,
upstreamInferenceCost: 0.0038,
upstreamInferencePromptCost: 0.0012,
},
})
})

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: { upstreamInferenceCost: 1 } })
})

it('preserves negative detail values (e.g. cache discount)', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: { upstreamInferenceCost: -0.002 },
}),
).toEqual({ cost: 0.01, costDetails: { upstreamInferenceCost: -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: { upstreamInferenceCost: 0.5 } })
})

it('drops unknown breakdown keys', () => {
expect(
extractUsageCost({
cost: 0.01,
costDetails: {
upstreamInferenceCost: 0.008,
futureUnknownField: 0.001,
},
}),
).toEqual({ cost: 0.01, costDetails: { upstreamInferenceCost: 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