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/client/ui-trajectory/README.md
README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
README.md: e409485e23dc41fccbe38e39d5984783e4ae21ff
README.zh.md: f4163d8849241b6775e03ea343709a6a3e304407

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. When the `openRouterCost` session projection is composed, each priced assistant step shows its host-priced USD cost on the request boundary chip and in the inspector summary. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明直到鼠标悬停该区域或其中包含键盘焦点时才显示同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。当组合了 `openRouterCost` 会话投影时,每个已计价的助手 step 会在请求边界标记与检查器摘要中显示其宿主计价的美元成本。可滚动的概述区域默认保持滚动条滑块透明直到鼠标悬停该区域或其中包含键盘焦点时才显示同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定api-contracts v3 §8。
## 模型体验

View File

@@ -34,7 +34,8 @@
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-openrouter-usage"
],
"platform": "web"
}
@@ -54,6 +55,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -68,6 +70,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -408,6 +408,8 @@ interface TrajectoryRequestNumberBase {
requestConfig?: AssistantRequestConfig
usage?: TrajectoryUsage
cumulativeUsage?: TrajectoryUsage
/** Host-priced OpenRouter spend for this step in USD, when priced. */
stepCostUsd?: number
}
/** One purpose-discriminated request identity paired with its session-global number. */
@@ -491,6 +493,25 @@ function requestStep(group: string): number | undefined {
return Number.isInteger(value) && value > 0 ? value : undefined
}
/**
* Format a per-step USD price: cents-style above a cent, three significant
* digits below it so sub-cent figures like $0.0028 stay readable instead of
* collapsing to $0.00.
* @param usd - non-negative USD amount.
* @returns display string.
*/
function formatStepUsd(usd: number): string {
if (usd >= 0.01 || usd === 0) {
return usd.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
return `$${usd.toPrecision(3).replace(/0+$/, '').replace(/\.$/, '')}`
}
function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}`
}
@@ -2296,7 +2317,7 @@ export function TrajectoryTable({
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}${requestInfo?.stepCostUsd === undefined ? '' : ` · ${formatStepUsd(requestInfo.stepCostUsd)}`}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.group === record.group
@@ -2733,6 +2754,12 @@ export function TrajectoryTable({
</dd>
</div>
)}
{selectedRequestInfo?.stepCostUsd !== undefined && (
<div>
<dt>Cost</dt>
<dd>{formatStepUsd(selectedRequestInfo.stepCostUsd)}</dd>
</div>
)}
<div>
<dt>Tool calls</dt>
<dd>{selectedRequestToolCalls}</dd>

View File

@@ -3,6 +3,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: merges openRouterCost into SessionProjectionMap so the
// framework useProjection seat types the step-cost lookup below. The value
// itself comes from the runtime's per-session projection store, absent when
// the OpenRouter usage plugin is not composed.
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
import type {
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
SnapshotStore,
@@ -118,7 +123,7 @@ function addUsage(
}
export function TrajectoryView({
useSession, useDuration, loadOlder, setActualDuration,
useProjection, useSession, useDuration, loadOlder, setActualDuration,
inspect, onInspectDone, t,
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
@@ -151,6 +156,9 @@ export function TrajectoryView({
const runningCalls = inspection.runningCalls
const requests = inspection.requests
const callSchemas = inspection.callSchemas
// Per-step OpenRouter spend (host-priced): keyed `${turn}:${step}`, only
// priced steps present; undefined when the projection is not composed.
const stepCosts = useProjection('openRouterCost')?.steps
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const node of nodes) {
@@ -197,6 +205,7 @@ export function TrajectoryView({
const provider = request?.provenance?.provider ?? node?.provenance?.provider
const model = request?.provenance?.model ?? node?.provenance?.model
const requestConfig = request?.requestConfig ?? node?.requestConfig
const stepCostUsd = stepCosts?.[`${turn}:${step}`]
numbered.push({
seq: entry.seq,
turn,
@@ -218,6 +227,7 @@ export function TrajectoryView({
...(requestConfig === undefined ? {} : { requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
...(stepCostUsd === undefined ? {} : { stepCostUsd }),
})
continue
}
@@ -248,7 +258,7 @@ export function TrajectoryView({
return numbered
}, [
nodes, requests,
nodes, requests, stepCosts,
])
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null

View File

@@ -871,4 +871,46 @@ describe('TrajectoryTable', () => {
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
expect(onInspectApplied).not.toHaveBeenCalled()
})
it('shows a host-priced step cost on the request boundary and in the summary', () => {
render(
<TrajectoryTable
turns={TURNS}
requestNumbers={[{
turn: 1, step: 1, group: 'Step 1', number: 1, seq: 1,
stepCostUsd: 0.0028,
}]}
{...FOLD_PROPS}
/>,
)
const chip = screen.getByRole('button', { name: 'Request #1 · $0.0028' })
expect(chip).toBeTruthy()
fireEvent.click(chip)
expect(screen.getByText('Cost')).toBeTruthy()
expect(screen.getByText('$0.0028')).toBeTruthy()
})
it('formats step costs above a cent with the currency format', () => {
render(
<TrajectoryTable
turns={TURNS}
requestNumbers={[{
turn: 1, step: 1, group: 'Step 1', number: 1, seq: 1,
stepCostUsd: 1.234,
}]}
{...FOLD_PROPS}
/>,
)
const chip = screen.getByRole('button', { name: 'Request #1 · $1.23' })
expect(chip).toBeTruthy()
fireEvent.click(chip)
expect(screen.getByText('Cost')).toBeTruthy()
expect(screen.getByText('$1.23')).toBeTruthy()
})
it('omits the cost row when the step was not priced', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
expect(screen.queryByText('Cost')).toBeNull()
})
})

View File

@@ -255,7 +255,8 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES
return (
<View
{...injectedProps}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
{...({ sessionId: SID, useSession, useProjection: () => undefined,
useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
)
@@ -1199,6 +1200,32 @@ describe('TrajectoryView state', () => {
)).toBeTruthy()
})
it('attaches the per-step OpenRouter cost from the openRouterCost projection', () => {
render(
<TrajectoryView
{...standaloneProps(NODES)}
{...standaloneHistory(historySnapshot(NODES))}
{...standaloneDuration()}
useProjection={((key: string) => (key === 'openRouterCost'
? {
totalUsd: 0.00336,
pricedSteps: 2,
unknownModelSteps: 0,
steps: { '1:1': 0.0028 },
currency: 'USD',
}
: undefined)) as never}
/>,
)
// The turn-1/step-1 request is priced; the turn-2/step-1 one is not.
expect(screen.getByRole('button', { name: 'Request #1 · $0.0028' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Request #2 · $0.0028' })).toBeNull()
// The summary of the priced step shows its cost.
fireEvent.click(screen.getByRole('button', { name: 'Request #1 · $0.0028' }))
expect(screen.getByText('Cost')).toBeTruthy()
expect(screen.getByText('$0.0028')).toBeTruthy()
})
})
describe('node half', () => {

View File

@@ -32,6 +32,9 @@
{
"path": "../../compaction/compaction"
},
{
"path": "../../llm/openrouter-usage"
},
{
"path": "../../runtime-diagnostics/invariants"
}

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',
})
})