feat: add searxng web-search provider, openrouter cost balance UI, offline scripts; update source-launch and docs
Some checks failed
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 / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 58s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 2m46s
Deploy documentation / deploy (push) Has been skipped
E2E (real DeepSeek API) / e2e (push) Failing after 1m18s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m18s
Landlock Run / Matrix (push) Successful in 13s
Release (vendor) / Pack npm tarballs (push) Failing after 3m43s
Release (dsh) / Pack npm tarballs (push) Failing after 1m53s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m51s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
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 / 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
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, linux, dsh-ubuntu-24-04-8core, typecheck) (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
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled

This commit is contained in:
2026-08-20 13:01:40 +07:00
parent 99f6f02fec
commit ed152416d5
111 changed files with 5038 additions and 43 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-openrouter-usage/README.md
README.md: 88a0d1eb886ffc3f4ff3392c908d8592da31ab2f
README.zh.md: 7bd5fdf2783b7ab59d52f6ea4250a0385ad06e14

View File

@@ -0,0 +1,46 @@
# @deepseek-ai/dsh-client-ui-openrouter-usage
English | [中文](README.zh.md)
OpenRouter spend surface plugin, browser half: the per-session 会话费用
readout (`CostDock`) in the `conversation.composer.dock` strip (order 5, right
after the shipped stats line) plus the account 余额 badge (`BalanceBadge`) at
the sidebar foot (`sidebar.footer.action`).
`CostDock` reads the durable `openRouterCost` projection (seeded by the history
tail page, updated by `session/projection` frames), showing `会话费用 $X.XX`
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.
`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.
The `/client` exports are the plugin body (`apply`/`inject`), the
`CostDock`/`BalanceBadge` components, and the injected face types.
## Model Experience
Indirectly, none. Neither the cost readout nor the balance badge injects any
model context; they are pure client-side read models over the `openRouterCost`
projection and the `openRouterUsage` Remote snapshot. The agent loop and all
prompts are unchanged.
#### KV Cache effect
None. Cost derives from existing logged usage events and balance is a fetched
account figure; neither is a new model-visible input.
## Known Limitations and Deferred Work
- **Priced as of the fold** — `CostDock` renders whatever the durable
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.

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-ui-openrouter-usage
[English](README.md) | 中文
OpenRouter 花费界面插件(浏览器端部分):`conversation.composer.dock` 条带中的每会话 会话费用 读数(`CostDock`,order 5,紧跟内置 stats 行之后),加上侧边栏底部的账户 余额 徽章(`BalanceBadge`,位于 `sidebar.footer.action`)。
`CostDock` 读取持久的 `openRouterCost` 投影(由历史尾页播种、由 `session/projection` 帧更新),仅当一个 step 真正计价后才显示 `会话费用 $X.XX`——不会出现零成本分组。未知定价的 step 数会通过 tooltip 呈现,从而让数值的覆盖范围依然可见。因此 `CostDock` 不持有 store、不设刷新链、不挂事件监听。
`BalanceBadge` 是账户级别的值,不属于会话投影:slot 注入面携带一个 `snapshot()` 回调,按 60 秒间隔轮询 host 的 `ctx.remote.openRouterUsage.snapshot()` Remote,并驱动本地状态。在折叠的 rail 中只渲染余额标记;在宽列中呈现带标签的胶囊。未知或缺失的值渲染为空占位。两个条目在真实数据出现之前都不渲染任何内容,因此未组装 OpenRouter 网关或 key 的组装体不会产生任何布局成本。
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`CostDock`/`BalanceBadge` 组件与注入面类型。
## 模型体验
间接影响:无。无论是花费读数还是余额徽章都不会注入任何模型上下文;它们只是 `openRouterCost` 投影与 `openRouterUsage` Remote 快照的纯客户端只读模型。agent 循环与所有提示词均保持不变。
#### KV Cache 影响
无。花费来自既有的日志用量事件,余额是一个获取到的账户数值;两者都不是新的模型可见输入。
## 已知限制与暂缓事项
- **以折叠时刻计价**——`CostDock` 渲染持久投影当前持有的数值;已折叠历史的数值反映折叠时的定价表(见 `dsh-openrouter-usage`)。
- **轮询而非推送**——`BalanceBadge` 轮询 host 快照;没有 `openRouterUsage` 转发事件通道,因此数值按轮询间隔更新,而非即时。

View File

@@ -0,0 +1,85 @@
{
"name": "@deepseek-ai/dsh-client-ui-openrouter-usage",
"description": "OpenRouter cost + balance surface: per-session 会话费用 in the composer stats dock and the account 余额 badge at the sidebar foot",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-openrouter-usage"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,36 @@
/* Balance badge at the sidebar foot: an icon marker in the rail, a labeled
pill in the wide column. Mirrors the Settings trigger's foot-row posture. */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
max-width: 100%;
padding: 0 8px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 28px;
cursor: default;
}
.badge:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.marker {
flex: none;
font-size: 12px;
line-height: 28px;
}
.text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,68 @@
// The OpenRouter account-balance badge at the sidebar foot. Renders `余额`
// 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.
import { useEffect, 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'
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. */
export interface BalanceBadgeActions {
/** Fetch the latest account snapshot from the host gateway. */
snapshot: () => Promise<OpenRouterBalance>
}
/** Composed props: the sidebar footer-action owner share plus the injected read. */
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.
* @returns the balance badge.
*/
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
useEffect(() => {
let disposed = false
const poll = async (): Promise<void> => {
let next: OpenRouterBalance
try {
next = await snapshot()
} catch (_failedFetch) {
return
}
if (!disposed) setBalance(next)
}
void poll()
const timer = setInterval(() => void poll(), BALANCE_POLL_MS)
return () => {
disposed = true
clearInterval(timer)
}
}, [snapshot])
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 })
return (
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
<span className={css.marker} aria-hidden>◈</span>
{wide && <span className={css.text}>{label}</span>}
</button>
)
}

View File

@@ -0,0 +1,10 @@
/* Session cost readout on the composer stats dock: an inline monospace USD
figure sharing the stats-line row's typography (13/20 secondary). */
.cost {
font-size: 13px;
line-height: 20px;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
}

View File

@@ -0,0 +1,39 @@
// The per-session OpenRouter spend readout on the composer stats dock.
// 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).
import { memo } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// 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'
import { formatUsd } from './money.ts'
import css from './CostDock.module.css'
/** Composed props: composer.dock owner share + the projection/read seats. */
export type CostDockProps =
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
/**
* 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.
* @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) })
return (
<span
className={css.cost}
title={cost.unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
: undefined}
>
{label}
</span>
)
})

View File

@@ -0,0 +1,77 @@
/**
* OpenRouter usage surface plugin, browser half: the per-session 会话费用
* readout on the composer stats dock (from the openRouterCost projection) and
* the account 余额 badge at the sidebar foot (from a polled Remote snapshot).
* Both render nothing until real data exists, so assemblies without the host
* gateway or key cost no layout.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the composer.dock entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the ui-sidebar SlotMap merge (the sidebar.footer.action entry).
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { BalanceBadgeActions } from './BalanceBadge.tsx'
import { BalanceBadge } from './BalanceBadge.tsx'
import { CostDock } from './CostDock.tsx'
import { en, zh, type OpenRouterUsageKey } from './locales.ts'
export { BalanceBadge } from './BalanceBadge.tsx'
export { CostDock } from './CostDock.tsx'
export type { BalanceBadgeActions, BalanceBadgeProps } from './BalanceBadge.tsx'
export type { CostDockProps } from './CostDock.tsx'
export type { OpenRouterUsageKey } from './locales.ts'
export { formatUsd } from './money.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The OpenRouter usage strip/badge copy. */
openRouterUsage: OpenRouterUsageKey
}
}
/** Dictionary namespace owned by this plugin. */
export const NS = 'openRouterUsage'
/** Required services for the two surface entries, the Remote gateway, and copy. */
export const inject = ['slots', 'locale', 'remote', 'remote.openRouterUsage']
/**
* Client plugin body: the cost dock and balance badge registrations.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-openrouter-usage: dictionaries')
// Session cost rides the composer stats dock, ordered after the shipped
// stats line (its own order 0) so it reads as a trailing billing group.
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
name: 'conversation.composer.dock',
id: 'openrouter-cost',
order: 5,
locale: NS,
}, CostDock))
const snapshot: BalanceBadgeActions['snapshot'] = async () => {
const result = await ctx.remote.openRouterUsage.snapshot()
if (!result.ok) {
throw new Error(`openRouterUsage.snapshot 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
// simply resolves an empty contribution.
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
name: 'sidebar.footer.action',
id: 'openrouter-balance',
order: 10,
locale: NS,
inject: (): BalanceBadgeActions => ({ snapshot }),
}, BalanceBadge))
}

View File

@@ -0,0 +1,22 @@
/** `openrouterUsage` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'cost.label': '会话费用 {amount}',
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
'balance.label': '余额 {amount}',
'balance.tooltip': 'OpenRouter 账户余额({label})',
'balance.empty': '——',
} satisfies Record<string, string>
/** The openrouterUsage namespace key union. */
export type OpenRouterUsageKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'cost.label': 'Session cost {amount}',
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
'balance.label': 'Balance {amount}',
'balance.tooltip': 'OpenRouter account balance ({label})',
'balance.empty': '——',
} satisfies Record<OpenRouterUsageKey, string>

View File

@@ -0,0 +1,18 @@
// USD display helpers for the OpenRouter cost and balance surfaces.
/**
* Format a USD figure with a $-prefix and up to two decimals, delimited with
* commas above one thousand (e.g. `$1,234.56`). Fractional cents round to the
* nearest cent.
* @param usd - non-negative USD amount.
* @returns display string.
*/
export function formatUsd(usd: number): string {
const amount = usd.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
return amount
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* OpenRouter usage surface plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-openrouter-usage`.
* @module @deepseek-ai/dsh-client-ui-openrouter-usage/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-openrouter-usage'
/** Cordis companion plugin name. */
export const name = 'client-ui-openrouter-usage-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: two slot registrations whose disposal is proven by
* HMR-safety specs — the plugin owns no store (cost arrives on the
* openRouterCost projection, balance on a Remote snapshot), emits no cordis
* events, and holds no cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,70 @@
// @vitest-environment jsdom
// BalanceBadge presentation: polls an injected snapshot callback and renders
// 余额 in both widths, hiding unknown figures behind the empty placeholder.
import { act, cleanup, 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'
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
import { BALANCE_POLL_MS, BalanceBadge, type BalanceBadgeProps } from '../src/client/BalanceBadge.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
vi.useRealTimers()
cleanup()
})
const t = makeTranslate(zh, commonZh)
function makeBalance(over: Partial<OpenRouterBalance> = {}): OpenRouterBalance {
return {
balanceUsd: 10.5,
label: 'prod-key',
usageTokens: 1000,
limitTokens: 100000,
isFreeTier: false,
updatedAt: Date.now(),
currency: 'USD',
...over,
}
}
function makeProps(over: Partial<BalanceBadgeProps> = {}) {
return {
wide: true,
snapshot: vi.fn(async () => makeBalance()),
t,
...over,
} as unknown as Parameters<typeof BalanceBadge>[0]
}
describe('BalanceBadge', () => {
it('renders the balance label with the USD figure in wide mode', async () => {
render(<BalanceBadge {...makeProps()} />)
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
expect(screen.getByText('余额 $10.50')).toBeTruthy()
})
it('polls the snapshot callback on the balance interval', async () => {
vi.useFakeTimers()
const snapshot = vi.fn(async () => makeBalance())
render(<BalanceBadge {...makeProps({ snapshot })} />)
await act(async () => { await Promise.resolve() })
expect(snapshot).toHaveBeenCalledTimes(1)
await act(async () => { vi.advanceTimersByTime(BALANCE_POLL_MS) })
await act(async () => { await Promise.resolve() })
expect(snapshot).toHaveBeenCalledTimes(2)
})
it('renders only the rail marker in the collapsed sidebar', async () => {
render(<BalanceBadge {...makeProps({ wide: false })} />)
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
expect(screen.queryByText('余额 $10.50')).toBeNull()
})
it('shows the empty placeholder when no balance is known yet', async () => {
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
})
})

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
/**
* ui-openrouter-usage browser half on a real cordis Context with fake slots/
* locale/remote faces: the plugin registers the CostDock entry at
* conversation.composer.dock and the BalanceBadge entry at
* sidebar.footer.action, the balance snapshot reads roll through the injected
* face at call time, and registration disposal rides the plugin fiber (HMR
* safety). The node half and the invariant companion are exercised over the
* same Context.
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
import type { BalanceBadgeActions } from '../src/client/BalanceBadge.tsx'
import { BalanceBadge } from '../src/client/BalanceBadge.tsx'
import { CostDock } from '../src/client/CostDock.tsx'
import { apply, inject, NS } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
afterEach(cleanup)
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
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 }
}
function declare(slots: SlotRegistry): () => void {
return slots.register({
name: 'root',
children: {
'conversation.composer.dock': { kind: 'list', scope: 'session' },
'sidebar.footer.action': { kind: 'list', scope: 'root' },
},
} as never, () => null)
}
function balance(over: Partial<OpenRouterBalance> = {}): OpenRouterBalance {
return {
balanceUsd: 5.5,
label: 'k',
usageTokens: 0,
limitTokens: 0,
isFreeTier: false,
updatedAt: 1_700_000_000_000,
currency: 'USD',
...over,
}
}
describe('ui-openrouter-usage browser plugin', () => {
it('declares only the services its two entries read', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.openRouterUsage'])
})
it('registers localized cost and balance entries without reading the Remote eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const cost = b.slots.entries('conversation.composer.dock')[0]!
expect(cost.component).toBe(CostDock)
expect(cost.options).toMatchObject({ id: 'openrouter-cost', order: 5 })
expect(cost.locale).toBe(NS)
const badge = b.slots.entries('sidebar.footer.action')[0]!
expect(badge.component).toBe(BalanceBadge)
expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
expect(badge.locale).toBe(NS)
// Registration reads nothing live; the snapshot rolls at call time.
expect(b.snapshot).not.toHaveBeenCalled()
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.snapshot()).resolves.toEqual(balance())
expect(b.snapshot).toHaveBeenCalledOnce()
await b.ctx.fiber.dispose()
})
it('forwards a Remote failure out of the injected snapshot verbatim', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.snapshot.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'boom', details: {} } })
const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.snapshot()).rejects.toThrow('openRouterUsage.snapshot failed: REMOTE_ERROR: boom')
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 })
await fiber.await()
expect(b.slots.entries('conversation.composer.dock')).toHaveLength(0)
expect(b.slots.entries('sidebar.footer.action')).toHaveLength(0)
const stop = declare(b.slots)
await vi.waitFor(() => { expect(b.slots.entries('conversation.composer.dock')).toHaveLength(1) })
expect(b.slots.entries('sidebar.footer.action')).toHaveLength(1)
stop()
expect(b.slots.entries('conversation.composer.dock')).toHaveLength(0)
declare(b.slots)
await vi.waitFor(() => {
expect(b.slots.entries('sidebar.footer.action')[0]?.component).toBe(BalanceBadge)
})
await fiber.dispose()
expect(b.slots.entries('conversation.composer.dock')).toHaveLength(0)
expect(b.slots.entries('sidebar.footer.action')).toHaveLength(0)
await b.ctx.fiber.dispose()
})
})
describe('ui-openrouter-usage node half', () => {
it('the node apply is an inert loader seat', () => {
expect(() => { nodeApply() }).not.toThrow()
})
})

View File

@@ -0,0 +1,60 @@
// @vitest-environment jsdom
// CostDock presentation: renders the running session cost from the
// openRouterCost projection, 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'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CostDock } from '../src/client/CostDock.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}) {
return {
useProjection: vi.fn(() => undefined),
t,
...over,
} as unknown as Parameters<typeof CostDock>[0]
}
describe('CostDock', () => {
it('renders nothing while the projection is absent or priced zero steps', () => {
for (const value of [undefined, { totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, currency: 'USD' }]) {
const { container } = render(<CostDock {...makeProps({ useProjection: () => value })} />)
expect(container.firstChild).toBeNull()
cleanup()
}
})
it('renders the priced session cost as a USD figure', () => {
render(<CostDock {...makeProps({
useProjection: () => ({ totalUsd: 1.2345, pricedSteps: 2, unknownModelSteps: 0, currency: 'USD' }),
})} />)
expect(screen.getByText('会话费用 $1.23')).toBeTruthy()
})
it('surfaces the unknown-pricing step count in a tooltip when present', () => {
render(<CostDock {...makeProps({
useProjection: () => ({ totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 3, currency: 'USD' }),
})} />)
expect(screen.getByText('会话费用 $0.50').getAttribute('title')).toBe('包含 3 个未知定价步骤')
})
it('omits the tooltip when every step priced', () => {
render(<CostDock {...makeProps({
useProjection: () => ({ totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }),
})} />)
expect(screen.getByText('会话费用 $0.50').getAttribute('title')).toBeNull()
})
it('reads the projection through the openRouterCost key', () => {
const useProjection = vi.fn(() => undefined)
render(<CostDock {...makeProps({ useProjection })} />)
act(() => {})
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
})
})

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-sidebar"
},
{
"path": "../ui-slots"
},
{
"path": "../../llm/openrouter-usage"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-openrouter-usage', ['lib/types/index.js', 'lib/types/invariant.js'])