feat: show per-step OpenRouter cost on the Trajectory tab

Extend the openRouterCost projection with a per-step priced-cost map
keyed by turn:step so surfaces can render spend without re-pricing, and
have the Trajectory view read it through the framework useProjection seat:
each priced assistant step shows its USD cost on the request boundary chip
and in the inspector summary, while unpriced steps stay blank. Also fix a
pre-existing exactOptionalPropertyTypes error in extractCacheRates that the
host typecheck surfaced.
This commit is contained in:
2026-08-20 22:14:09 +07:00
parent d2ad46b8aa
commit 99b63abde6
20 changed files with 234 additions and 29 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/openrouter-usage/README.md
README.md: e6606d4cc02b8b34dc643a2c810edf7c41288007
README.zh.md: 07e7d32245688f33cef43858c7089a630306b8ae
README.md: eca28c70b532c3292777617cac8f19190e85e811
README.zh.md: 0612727b5d7e94a31c91e90762c082709e3dc3b8

View File

@@ -54,7 +54,10 @@ step) against the pricing table. Attribution prefers the assembled message's
own `provider`/`model`; a chunk-only (failed) step prices from the newest
`request/context` route. A step on a non-`openrouter` provider is outside the
domain and changes nothing; an OpenRouter step whose model has no pricing
entry counts as an unknown (unpriced) step.
entry counts as an unknown (unpriced) step. The projection's `steps` view
field maps each priced step to its cost in USD under `${turn}:${step}` keys,
so per-step surfaces can render spend without re-pricing; unpriced steps are
absent from the map.
## Extension points

View File

@@ -33,7 +33,7 @@ key 经凭证边界(`ctx.credentials`)解析,并以后端环境作为回
`ctx.openRouterUsage` 是一个 Typert Remote 网关。`snapshot()` 方法返回最近一次成功的余额快照的副本:`balanceUsd` 是来自 `GET /credits` 的可用余额(`total_credits` 减去已花费的 `total_usage`,即 OpenRouter 仪表盘展示的数字),外加来自 `GET /auth/key``label` 与月度 `usageTokens`/`limitTokens` 预算、`isFreeTier``updatedAt` 时间戳。在任何成功获取之前,它返回一个全 `null` 的记录;一次失败的刷新会保留上一次已知快照并记录日志。同一个 key 还会从 `GET /models` 刷新模型定价表USD 每 token 的 `pricing.prompt`/`completion`,以及 flat 的 `request` 费用,若 API 披露时还有可选的 `input_cache_read`/`input_cache_write`)。
`openRouterCost` 投影会按定价表折叠每个会话日志中的 token 用量(`assistant/chunk` 的 usage 与 `assistant/message` 的 usage按 step 去重)。归属优先采用已组装消息自身的 `provider`/`model`;仅有 chunk 的失败step 则按最新的 `request/context` 路由计价。非 `openrouter` provider 上的 step 不属于本域,不会改变任何值;模型没有对应定价条目的 OpenRouter step 会计作未知未计价step。
`openRouterCost` 投影会按定价表折叠每个会话日志中的 token 用量(`assistant/chunk` 的 usage 与 `assistant/message` 的 usage按 step 去重)。归属优先采用已组装消息自身的 `provider`/`model`;仅有 chunk 的失败step 则按最新的 `request/context` 路由计价。非 `openrouter` provider 上的 step 不属于本域,不会改变任何值;模型没有对应定价条目的 OpenRouter step 会计作未知未计价step。投影的 `steps` 视图字段以 `${turn}:${step}` 为键记录每个已计价 step 的美元成本,便于按 step 展示花费而无需重复计价;未计价的 step 不在该映射中。
## 扩展点

View File

@@ -76,9 +76,14 @@ function parsePricing(pricing: unknown): {
*/
function extractCacheRates(pricing: unknown): { cacheReadUsd?: number; cacheWriteUsd?: number } {
const record = pricing as Record<string, unknown>
// Read each rate once so the conditionals below narrow the local to a plain
// number; spreading the raw `rateOf(...)` call would keep `undefined` in the
// property type, which exactOptionalPropertyTypes rejects.
const cacheReadUsd = rateOf(record['input_cache_read'])
const cacheWriteUsd = rateOf(record['input_cache_write'])
return {
...rateOf(record['input_cache_read']) === undefined ? {} : { cacheReadUsd: rateOf(record['input_cache_read']) },
...rateOf(record['input_cache_write']) === undefined ? {} : { cacheWriteUsd: rateOf(record['input_cache_write']) },
...(cacheReadUsd === undefined ? {} : { cacheReadUsd }),
...(cacheWriteUsd === undefined ? {} : { cacheWriteUsd }),
}
}
@@ -151,7 +156,13 @@ export async function fetchAccountBalance(
baseURL: string,
apiKey: string,
signal: AbortSignal,
): Promise<{ balanceUsd: number | null; label: string | null; usageTokens: number | null; limitTokens: number | null; isFreeTier: boolean | null } | undefined> {
): Promise<{
balanceUsd: number | null
label: string | null
usageTokens: number | null
limitTokens: number | null
isFreeTier: boolean | null
} | undefined> {
const nonEmptyString = (value: unknown): string | null => typeof value === 'string' && value.length > 0 ? value : null
const nonNegativeNumber = (value: unknown): number | null => {
const parsed = rateOf(value)

View File

@@ -35,6 +35,8 @@ interface OpenRouterCostState {
totalUsd: number
pricedSteps: number
unknownModelSteps: number
/** Per-step priced cost in USD keyed by `${turn}:${step}`; only priced steps have an entry. */
steps: Record<string, number>
/** The newest sample's attribution, for same-step replacement. */
last: { turn: number; step: number; costUsd: number; priced: boolean } | null
/** Newest `request/context` route, for chunk-only step attribution. */
@@ -45,6 +47,7 @@ const costSchema = z.object({
totalUsd: z.number().nonnegative(),
pricedSteps: z.number().int().nonnegative(),
unknownModelSteps: z.number().int().nonnegative(),
steps: z.record(z.string(), z.number().nonnegative()),
currency: z.literal('USD'),
}).strict()
@@ -80,7 +83,7 @@ export function createOpenRouterCostProjection(
return {
key: 'openRouterCost',
schema: costSchema as unknown as z.ZodType<OpenRouterCost>,
init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, last: null, lastModel: null }),
init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, last: null, lastModel: null }),
apply: (state, event: SessionEvent) => {
if (event.type === 'request/context') {
const nextModel = { provider: event.data.provider, model: event.data.model }
@@ -116,17 +119,31 @@ export function createOpenRouterCostProjection(
: null
if (previous !== null && previous.costUsd === costUsd && previous.priced === priced) return state
// Per-step map follows the same replacement discipline: the step's key
// holds the newest sample's priced cost, and only priced steps have an
// entry (a same-step sample that turns unpriced removes it).
const steps = { ...state.steps }
if (priced) steps[`${turn}:${step}`] = costUsd
else delete steps[`${turn}:${step}`]
return {
totalUsd: state.totalUsd - (previous?.costUsd ?? 0) + costUsd,
pricedSteps: state.pricedSteps - (previous?.priced ?? false ? 1 : 0) + (priced ? 1 : 0),
unknownModelSteps: state.unknownModelSteps
- (previous !== null && !previous.priced ? 1 : 0)
+ (priced ? 0 : 1),
steps,
last: { turn, step, costUsd, priced },
lastModel: state.lastModel,
}
},
view: state => ({ totalUsd: state.totalUsd, pricedSteps: state.pricedSteps, unknownModelSteps: state.unknownModelSteps, currency: 'USD' }),
stateVersion: 1,
view: state => ({
totalUsd: state.totalUsd,
pricedSteps: state.pricedSteps,
unknownModelSteps: state.unknownModelSteps,
steps: state.steps,
currency: 'USD',
}),
stateVersion: 2,
}
}

View File

@@ -46,6 +46,8 @@ export interface OpenRouterCost {
pricedSteps: number
/** OpenRouter steps whose model had no pricing entry; excluded from the total. */
unknownModelSteps: number
/** Per-step priced cost in USD keyed by `${turn}:${step}`; only priced steps have an entry. */
steps: Record<string, number>
/** Fixed display currency of every monetary field. */
currency: 'USD'
}

View File

@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; session: Session }> {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
ctx.sessionProjections.register(createOpenRouterCostProjection((model) => PRICING.get(model)))
ctx.sessionProjections.register(createOpenRouterCostProjection(model => PRICING.get(model)))
return { ctx, session }
}
@@ -75,7 +75,7 @@ const projected = (ctx: Context, session: Session) => {
describe('openRouterCost session projection', () => {
it('serves an all-zero view on an empty log', async () => {
const { ctx, session } = await harness()
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, currency: 'USD' })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, currency: 'USD' })
})
it('prices input/output/cache buckets at the model rates', async () => {
@@ -98,7 +98,13 @@ describe('openRouterCost session projection', () => {
startStep(session, 1, 1)
const source = usageChunk(session, { inputTokens: 10, outputTokens: 4 }, 1, 1)
finalUsage(session, { inputTokens: 10, outputTokens: 4 }, 1, 1, [source])
expect(projected(ctx, session)).toEqual({ totalUsd: 10 * 1.4e-6 + 4 * 2.8e-6, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' })
expect(projected(ctx, session)).toEqual({
totalUsd: 10 * 1.4e-6 + 4 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
steps: { '1:1': 10 * 1.4e-6 + 4 * 2.8e-6 },
currency: 'USD',
})
})
it('replaces an earlier same-step chunk sample with the final usage', async () => {
@@ -111,6 +117,7 @@ describe('openRouterCost session projection', () => {
totalUsd: 14 * 1.4e-6 + 5 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
steps: { '1:1': 14 * 1.4e-6 + 5 * 2.8e-6 },
currency: 'USD',
})
})
@@ -123,6 +130,7 @@ describe('openRouterCost session projection', () => {
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session).totalUsd).toBeCloseTo(9 * 1.4e-6 + 1 * 2.8e-6, 12)
expect(projected(ctx, session).pricedSteps).toBe(1)
expect(projected(ctx, session).steps).toEqual({ '1:1': 9 * 1.4e-6 + 1 * 2.8e-6 })
})
it('counts an unknown-priced OpenRouter model as an unpriced step', async () => {
@@ -131,7 +139,7 @@ describe('openRouterCost session projection', () => {
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, currency: 'USD' })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, steps: {}, currency: 'USD' })
})
it('ignores a step on a non-openrouter provider entirely', async () => {
@@ -140,7 +148,7 @@ describe('openRouterCost session projection', () => {
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, currency: 'USD' })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, currency: 'USD' })
})
it('prefers the assistant-message source over the last request/context record', async () => {
@@ -172,7 +180,48 @@ describe('openRouterCost session projection', () => {
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 1, unknownModelSteps: 0, steps: { '1:1': 0 }, currency: 'USD' })
})
it('maps each step to its priced cost under turn:step keys', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 1_000, outputTokens: 500 }, 1, 1)
startStep(session, 1, 2)
usageChunk(session, { inputTokens: 200, outputTokens: 100 }, 1, 2)
session.append('step/end', { turn: 1, step: 2 })
expect(projected(ctx, session)).toEqual({
totalUsd: (1_000 * 1.4e-6 + 500 * 2.8e-6) + (200 * 1.4e-6 + 100 * 2.8e-6),
pricedSteps: 2,
unknownModelSteps: 0,
steps: {
'1:1': 1_000 * 1.4e-6 + 500 * 2.8e-6,
'1:2': 200 * 1.4e-6 + 100 * 2.8e-6,
},
currency: 'USD',
})
})
it('drops a step entry when the final sample re-attributes it to an unknown-priced model', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
const source = usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'openrouter', model: 'brand-new/model' },
}),
usage: { inputTokens: 100, outputTokens: 10 },
}, { surfaceOp: 'append', sourceEventSeqs: [source] })
session.append('step/end', { turn: 1, step: 1 })
// The earlier priced chunk's entry is removed with the re-attribution: the
// step is no longer priced, so it has no entry.
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, steps: {}, currency: 'USD' })
})
it('pushes no change for unrelated events', async () => {
@@ -199,6 +248,7 @@ describe('openRouterCost session projection', () => {
totalUsd: 8 * 1.4e-6 + 2 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
steps: { '1:1': 8 * 1.4e-6 + 2 * 2.8e-6 },
currency: 'USD',
})
})