feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s

- new economy and maximum agent presets with three-role pipeline skill
- new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools)
- new packages/subagent/subagent-cursor provider
- openrouter balance UI with on-demand refresh
- session projection context-seed boundary fold
- regenerate docs catalogs; keep local searxng benchmark scripts
This commit is contained in:
2026-08-23 22:12:30 +07:00
parent e818e37f0a
commit 8719fa84f0
112 changed files with 5686 additions and 169 deletions

View File

@@ -8,6 +8,7 @@ export type AgentPresetSettingsKey =
| 'presetCodeName' | 'presetCodeDescription'
| 'presetMinimalName' | 'presetMinimalDescription'
| 'presetCordisName' | 'presetCordisDescription'
| 'presetMaximumName' | 'presetMaximumDescription'
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
@@ -46,6 +47,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
presetCordisName: 'Creator mode',
presetCordisDescription:
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
presetMaximumName: 'Maximum mode',
presetMaximumDescription:
'Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer.',
duplicate: 'Duplicate',
duplicateUnavailable: 'This deployment has no writable preset directory',
delete: 'Delete',
@@ -106,6 +110,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
presetCordisName: '创造模式',
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
presetMaximumName: '全能模式',
presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。',
duplicate: '复制',
duplicateUnavailable: '此部署未配置可写的预设目录',
delete: '删除',
@@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>>
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' },
}
/**

View File

@@ -13,13 +13,21 @@ only once a step actually priced — no zero-cost group. An unknown-pricing step
count surfaces in a tooltip so the figure's coverage stays visible. `CostDock`
therefore owns no store, refresh chain, or event listener.
The figure covers the session plus its subagent subtree, walked over the
session list's `parentId`/`origin` rows; a subtree share names itself in the
label. Each projection value describes only its own session's work — the host
fold excludes a forked child's inherited prefix — so the walk sums rather than
double counts. Ordinary forks are not delegated work and stay out of it.
`BalanceBadge` is account-global, so it is not a session projection: the slot
inject face carries a `snapshot()` callback that polls the host
`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives
local state. In the collapsed rail it renders only the balance marker; in the
wide column a labeled pill. Unknown or absent figures render the empty
placeholder. Both entries render nothing before real data exists, so an
assembly without the OpenRouter gateway or key costs no layout.
local state. Clicking the badge calls the `refresh()` callback instead, which
makes the host fetch the account immediately; the badge marks itself
`aria-busy` for the duration and ignores further clicks until it settles, and
a failed refresh keeps the last-known figure. In the collapsed rail it renders
only the balance marker; in the wide column a labeled pill. Unknown or absent
figures render the empty placeholder.
The `/client` exports are the plugin body (`apply`/`inject`), the
`CostDock`/`BalanceBadge` components, and the injected face types.
@@ -42,5 +50,10 @@ account figure; neither is a new model-visible input.
projection holds; figures for already-folded history reflect the pricing
table at fold time (see `dsh-openrouter-usage`).
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
no `openRouterUsage` forwarded-event channel, so the figure updates on the
poll interval rather than instantly.
no `openRouterUsage` forwarded-event channel, so an account change the user
did not click for surfaces on the poll interval rather than instantly.
- **A cold subagent contributes nothing** — the subtree walk reads the
descendants' projection values the client holds. A subagent session never
opened, and with no version-matching row in the host's cold projection
cache, carries no value and is indistinguishable from a zero-cost child, so
its spend is missing from the total rather than marked absent.

View File

@@ -14,7 +14,12 @@
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 28px;
cursor: default;
cursor: pointer;
}
.badge[aria-busy='true'] {
cursor: progress;
opacity: 0.6;
}
.badge:hover {

View File

@@ -2,10 +2,11 @@
// plus the latest snapshot's USD figure in both widths (rail = marker-only,
// wide = label + figure). The value is account-global, so it is not a session
// projection: an injected `snapshot` callback polls the host Remote gateway on
// an interval and drives local state. `wide` arrives from the sidebar's owner
// share; `snapshot` from the register's inject face.
// an interval, a click runs `refresh` for an on-demand read, and both drive
// local state. `wide` arrives from the sidebar's owner share; the two
// callbacks from the register's inject face.
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { formatUsd } from './money.ts'
@@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css'
/** Poll interval for the balance figure, ms. */
export const BALANCE_POLL_MS = 60_000
/** Injected face: the balance read callback the badge polls. */
/** Injected face: the balance reads the badge polls and re-runs on demand. */
export interface BalanceBadgeActions {
/** Fetch the latest account snapshot from the host gateway. */
/** Read the host gateway's cached account snapshot. */
snapshot: () => Promise<OpenRouterBalance>
/** Make the host fetch the account NOW and serve the result. */
refresh: () => Promise<OpenRouterBalance>
}
/** Composed props: the sidebar footer-action owner share plus the injected read. */
/** Composed props: the sidebar footer-action owner share plus the injected reads. */
export type BalanceBadgeProps =
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
/**
* Poll `snapshot` on an interval and render the account balance. Unknown or
* absent figures render the empty placeholder so the seat costs no confusion.
* A transient failure keeps the last-known figure and retries next tick.
* @param props - owner state plus the injected snapshot callback.
* Poll `snapshot` on an interval, re-read through `refresh` when the user
* clicks, and render the account balance. Unknown or absent figures render the
* empty placeholder so the seat costs no confusion. A transient failure keeps
* the last-known figure and retries next tick; a click while one refresh is
* still outstanding is ignored rather than queued.
* @param props - owner state plus the injected snapshot/refresh callbacks.
* @returns the balance badge.
*/
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) {
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
const [refreshing, setRefreshing] = useState(false)
// Survives the unmount a disposed registration causes mid-fetch: the
// resolving callback must not set state on a gone component.
const mounted = useRef(true)
useEffect(() => () => { mounted.current = false }, [])
useEffect(() => {
let disposed = false
@@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
}
}, [snapshot])
const onClick = useCallback(() => {
if (refreshing) return
setRefreshing(true)
void (async () => {
try {
const next = await refresh()
if (mounted.current) setBalance(next)
} catch (_failedRefresh) {
// The click keeps the last-known figure; the interval poll retries.
} finally {
if (mounted.current) setRefreshing(false)
}
})()
}, [refresh, refreshing])
const usd = balance?.balanceUsd ?? null
const amount = usd === null
? t('balance.empty')
: formatUsd(usd)
const label = t('balance.label', { amount })
const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
const tooltip = refreshing
? t('balance.refreshing')
: balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
return (
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
<button
type="button"
className={css.badge}
title={tooltip}
aria-label={label}
aria-busy={refreshing}
onClick={onClick}
>
<span className={css.marker} aria-hidden>◈</span>
{wide && <span className={css.text}>{label}</span>}
</button>

View File

@@ -2,9 +2,15 @@
// Reads the durable openRouterCost projection, so paging and compaction
// cannot change the figure; the entry renders nothing until at least one
// step priced (no zero-cost group, mirroring the stats line's billing gate).
//
// The figure covers the session plus its subagent subtree. Each projection
// value describes only its own session's work — the host fold excludes a
// forked child's inherited prefix — so summing the lineage is a sum, not a
// double count.
import { memo } from 'react'
import { memo, useMemo } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: merges openRouterCost into SessionProjectionMap for useProjection.
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client'
@@ -15,22 +21,75 @@ import css from './CostDock.module.css'
export type CostDockProps =
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
/** Summed spend over one session's subagent subtree. */
interface SubtreeCost {
totalUsd: number
pricedSteps: number
unknownModelSteps: number
}
/**
* Render the running session cost as `会话费用 $X.XX`, shown only when the
* session actually priced steps. An unknown-pricing step count feeds a
* tooltip so the figure's coverage stays visible without polluting the row.
* session or any of its subagents actually priced steps. An unknown-pricing
* step count feeds a tooltip so the figure's coverage stays visible without
* polluting the row.
* @param props - framework/runtime props.
* @returns the cost group, or null on a zero/absent projection.
*/
export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) {
const cost = useProjection('openRouterCost') as OpenRouterCost | undefined
if (cost === undefined || cost.pricedSteps === 0) return null
const label = t('cost.label', { amount: formatUsd(cost.totalUsd) })
export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) {
const cost: OpenRouterCost | undefined = useProjection('openRouterCost')
const sessionById = useSessions(s => s.byId)
const subagentCost = useMemo((): SubtreeCost => {
let totalUsd = 0
let pricedSteps = 0
let unknownModelSteps = 0
const rows = Object.values(sessionById)
// Breadth-first over subagent-origin descendants, one frontier at a time.
// `visited` also closes a parentId cycle a corrupt lineage could present.
const visited = new Set<SessionId>([sessionId])
let frontier: SessionId[] = [sessionId]
while (frontier.length > 0) {
const next: SessionId[] = []
for (const parent of frontier) {
for (const row of rows) {
if (row.parentId !== parent || row.origin !== 'subagent' || visited.has(row.id)) continue
visited.add(row.id)
next.push(row.id)
// A descendant the client holds no projection value for (never
// opened, and no version-matching cold cache row) contributes
// nothing; it cannot be told apart from a zero-cost child here.
const childCost = row.projectionValues?.openRouterCost
if (childCost === undefined) continue
totalUsd += childCost.totalUsd
pricedSteps += childCost.pricedSteps
unknownModelSteps += childCost.unknownModelSteps
}
}
frontier = next
}
return { totalUsd, pricedSteps, unknownModelSteps }
}, [sessionById, sessionId])
const selfPricedSteps = cost?.pricedSteps ?? 0
if (selfPricedSteps === 0 && subagentCost.pricedSteps === 0) return null
const selfUsd = cost?.totalUsd ?? 0
const totalUsd = selfUsd + subagentCost.totalUsd
const unknownModelSteps = (cost?.unknownModelSteps ?? 0) + subagentCost.unknownModelSteps
const label = subagentCost.pricedSteps > 0
? t('cost.labelWithSubagents', {
amount: formatUsd(totalUsd),
subagentAmount: formatUsd(subagentCost.totalUsd),
})
: t('cost.label', { amount: formatUsd(totalUsd) })
return (
<span
className={css.cost}
title={cost.unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
title={unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(unknownModelSteps) })
: undefined}
>
{label}

View File

@@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void {
return result.value
}
const refresh: BalanceBadgeActions['refresh'] = async () => {
const result = await ctx.remote.openRouterUsage.refresh()
if (!result.ok) {
throw new Error(`openRouterUsage.refresh failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
// Account balance is account-global (root scope): the injected face carries
// the snapshot read the badge polls. The seat is declared but unhoused by
// default; this registration names it, so an assembly without ui-sidebar
@@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void {
id: 'openrouter-balance',
order: 10,
locale: NS,
inject: (): BalanceBadgeActions => ({ snapshot }),
inject: (): BalanceBadgeActions => ({ snapshot, refresh }),
}, BalanceBadge))
}

View File

@@ -3,10 +3,12 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'cost.label': '会话费用 {amount}',
'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount})',
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
'balance.label': '余额 {amount}',
'balance.tooltip': 'OpenRouter 账户余额({label})',
'balance.tooltip': 'OpenRouter 账户余额({label})— 点击刷新',
'balance.empty': '——',
'balance.refreshing': '正在刷新 OpenRouter 账户余额',
} satisfies Record<string, string>
/** The openrouterUsage namespace key union. */
@@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'cost.label': 'Session cost {amount}',
'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})',
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
'balance.label': 'Balance {amount}',
'balance.tooltip': 'OpenRouter account balance ({label})',
'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh',
'balance.empty': '——',
'balance.refreshing': 'Refreshing the OpenRouter account balance',
} satisfies Record<OpenRouterUsageKey, string>

View File

@@ -1,8 +1,9 @@
// @vitest-environment jsdom
// BalanceBadge presentation: polls an injected snapshot callback and renders
// 余额 in both widths, hiding unknown figures behind the empty placeholder.
// 余额 in both widths, hiding unknown figures behind the empty placeholder,
// and re-reads through the injected refresh callback when the user clicks it.
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -34,6 +35,7 @@ function makeProps(over: Partial<BalanceBadgeProps> = {}) {
return {
wide: true,
snapshot: vi.fn(async () => makeBalance()),
refresh: vi.fn(async () => makeBalance()),
t,
...over,
} as unknown as Parameters<typeof BalanceBadge>[0]
@@ -67,4 +69,37 @@ describe('BalanceBadge', () => {
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
})
it('re-reads through refresh on click and renders the fetched figure', async () => {
const refresh = vi.fn(async () => makeBalance({ balanceUsd: 42.25 }))
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledOnce()
expect(await screen.findByLabelText('余额 $42.25')).toBeTruthy()
})
it('ignores a click while a refresh is still outstanding', async () => {
let release: (value: OpenRouterBalance) => void = () => {}
const refresh = vi.fn(() => new Promise<OpenRouterBalance>((resolve) => { release = resolve }))
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(badge.getAttribute('aria-busy')).toBe('true')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledOnce()
await act(async () => { release(makeBalance({ balanceUsd: 7 })) })
expect(badge.getAttribute('aria-busy')).toBe('false')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledTimes(2)
})
it('keeps the last-known figure when a refresh fails', async () => {
const refresh = vi.fn(async () => { throw new Error('boom') })
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
expect(badge.getAttribute('aria-busy')).toBe('false')
})
})

View File

@@ -37,8 +37,10 @@ async function bench() {
new RemoteService(ctx)
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
.mockResolvedValue({ ok: true, value: balance() })
ctx.provide('remote.openRouterUsage', { snapshot })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot }
const refresh = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
.mockResolvedValue({ ok: true, value: balance({ balanceUsd: 9 }) })
ctx.provide('remote.openRouterUsage', { snapshot, refresh })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot, refresh }
}
function declare(slots: SlotRegistry): () => void {
@@ -84,11 +86,14 @@ describe('ui-openrouter-usage browser plugin', () => {
expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
expect(badge.locale).toBe(NS)
// Registration reads nothing live; the snapshot rolls at call time.
// Registration reads nothing live; both reads roll at call time.
expect(b.snapshot).not.toHaveBeenCalled()
expect(b.refresh).not.toHaveBeenCalled()
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.snapshot()).resolves.toEqual(balance())
expect(b.snapshot).toHaveBeenCalledOnce()
await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 }))
expect(b.refresh).toHaveBeenCalledOnce()
await b.ctx.fiber.dispose()
})
@@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => {
await b.ctx.fiber.dispose()
})
it('forwards a Remote failure out of the injected refresh verbatim', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.refresh.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'nope', details: {} } })
const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.refresh()).rejects.toThrow('openRouterUsage.refresh failed: REMOTE_ERROR: nope')
await b.ctx.fiber.dispose()
})
it('follows locale and recovers across late declaration and declarer reload', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
// CostDock presentation: renders the running session cost from the
// openRouterCost projection, and nothing at all until a step was priced.
// openRouterCost projection plus the subagent subtree read off the session
// list, and nothing at all until a step was priced.
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -13,9 +14,37 @@ afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}) {
const PARENT = 'session-parent'
/** One session-list row as much of it as the dock reads. */
interface Row {
id: string
parentId?: string
origin?: 'subagent'
cost?: { totalUsd: number; pricedSteps: number; unknownModelSteps: number }
}
/** A `useSessions` stub serving `byId` built from the given rows. */
function sessionsHook(rows: readonly Row[]) {
const byId: Record<string, unknown> = {}
for (const row of rows) {
byId[row.id] = {
id: row.id,
...row.parentId === undefined ? {} : { parentId: row.parentId },
...row.origin === undefined ? {} : { origin: row.origin },
...row.cost === undefined
? {}
: { projectionValues: { openRouterCost: { ...row.cost, currency: 'USD' } } },
}
}
return (selector: (state: { byId: Record<string, unknown> }) => unknown) => selector({ byId })
}
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}, rows: readonly Row[] = []) {
return {
sessionId: PARENT,
useProjection: vi.fn(() => undefined),
useSessions: sessionsHook(rows),
t,
...over,
} as unknown as Parameters<typeof CostDock>[0]
@@ -57,4 +86,53 @@ describe('CostDock', () => {
act(() => {})
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
})
it('adds the subagent subtree to the figure and names its share', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 1 } }],
)} />)
const row = screen.getByText('会话费用 $1.50(含子代理 $0.50)')
expect(row.getAttribute('title')).toBe('包含 1 个未知定价步骤')
})
it('follows a nested subagent chain and ignores non-subagent children', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[
{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.25, pricedSteps: 1, unknownModelSteps: 0 } },
{ id: 'grandchild', parentId: 'child', origin: 'subagent', cost: { totalUsd: 0.125, pricedSteps: 1, unknownModelSteps: 0 } },
// An ordinary fork of the same parent is not delegated work.
{ id: 'sibling', parentId: PARENT, cost: { totalUsd: 9, pricedSteps: 1, unknownModelSteps: 0 } },
],
)} />)
expect(screen.getByText('会话费用 $1.38(含子代理 $0.38)')).toBeTruthy()
})
it('shows the subagent subtree alone when the session itself priced nothing', () => {
render(<CostDock {...makeProps(
{ useProjection: () => undefined },
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.75, pricedSteps: 2, unknownModelSteps: 0 } }],
)} />)
expect(screen.getByText('会话费用 $0.75(含子代理 $0.75)')).toBeTruthy()
})
it('skips a descendant the client holds no projection value for', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 2, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[{ id: 'cold-child', parentId: PARENT, origin: 'subagent' }],
)} />)
expect(screen.getByText('会话费用 $2.00')).toBeTruthy()
})
it('terminates on a parentId cycle', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[
{ id: 'a', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0 } },
{ id: PARENT, parentId: 'a', origin: 'subagent', cost: { totalUsd: 99, pricedSteps: 1, unknownModelSteps: 0 } },
],
)} />)
expect(screen.getByText('会话费用 $1.50(含子代理 $0.50)')).toBeTruthy()
})
})

View File

@@ -282,6 +282,10 @@ function CatalogRows({
entry.activity,
now,
)
const costUsd = summary?.projectionValues?.openRouterCost?.totalUsd
const costMetric = (costUsd !== undefined && costUsd > 0)
? `$${costUsd < 0.01 ? '<0.01' : costUsd.toFixed(2)}`
: undefined
const tokenMetric = totalTokens === undefined
? undefined
: `${formatTokens(totalTokens)} tok`
@@ -291,7 +295,7 @@ function CatalogRows({
compact: formatDuration(durationMs, t),
exact: formatExactDuration(durationMs, t),
}
const metrics = [tokenMetric, durationMetric?.exact]
const metrics = [costMetric, tokenMetric, durationMetric?.exact]
.filter(value => value !== undefined)
.join(' · ')

View File

@@ -84,6 +84,7 @@ interface UsageLike {
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
costUsd?: number
}
function requestUsage(value: unknown): TrajectoryUsage | undefined {
@@ -205,7 +206,9 @@ 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}`]
const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd
?? (entry.node?.usage as UsageLike | undefined)?.costUsd
const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`]
numbered.push({
seq: entry.seq,
turn,
@@ -232,6 +235,7 @@ export function TrajectoryView({
continue
}
const request = entry.request
const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd
numbered.push({
seq: request.startSeq,
turn: request.turn,
@@ -253,6 +257,7 @@ export function TrajectoryView({
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }),
})
}

View File

@@ -48,6 +48,7 @@ interface UsageLike {
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
costUsd?: number
}
/** Cell plus absolute ms for group wall-span descriptions. */

View File

@@ -18,6 +18,7 @@ interface UsageValue {
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
readonly reasoningTokens?: number
readonly costUsd?: number
}
interface RetryValue {
@@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
...(current?.costUsd === undefined && next.costUsd === undefined
? {}
: { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }),
}
}

View File

@@ -1226,6 +1226,27 @@ describe('TrajectoryView state', () => {
expect(screen.getByText('$0.0028')).toBeTruthy()
})
it('attaches the per-step cost directly from node usage even when projection is undefined', () => {
const nodesWithCost = [
NODES[0]!,
{
...NODES[1]!,
usage: { inputTokens: 100, outputTokens: 50, costUsd: 0.0045 },
},
NODES[2]!,
NODES[3]!,
]
render(
<TrajectoryView
{...standaloneProps(nodesWithCost)}
{...standaloneHistory(historySnapshot(nodesWithCost))}
{...standaloneDuration()}
useProjection={(() => undefined) as never}
/>,
)
expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy()
})
})
describe('node half', () => {