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
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:
6
packages/client/ui-openrouter-usage/README.i18n.yaml
Normal file
6
packages/client/ui-openrouter-usage/README.i18n.yaml
Normal 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
|
||||
46
packages/client/ui-openrouter-usage/README.md
Normal file
46
packages/client/ui-openrouter-usage/README.md
Normal 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.
|
||||
24
packages/client/ui-openrouter-usage/README.zh.md
Normal file
24
packages/client/ui-openrouter-usage/README.zh.md
Normal 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` 转发事件通道,因此数值按轮询间隔更新,而非即时。
|
||||
85
packages/client/ui-openrouter-usage/package.json
Normal file
85
packages/client/ui-openrouter-usage/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
39
packages/client/ui-openrouter-usage/src/client/CostDock.tsx
Normal file
39
packages/client/ui-openrouter-usage/src/client/CostDock.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
77
packages/client/ui-openrouter-usage/src/client/index.ts
Normal file
77
packages/client/ui-openrouter-usage/src/client/index.ts
Normal 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))
|
||||
}
|
||||
22
packages/client/ui-openrouter-usage/src/client/locales.ts
Normal file
22
packages/client/ui-openrouter-usage/src/client/locales.ts
Normal 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>
|
||||
18
packages/client/ui-openrouter-usage/src/client/money.ts
Normal file
18
packages/client/ui-openrouter-usage/src/client/money.ts
Normal 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
|
||||
}
|
||||
6
packages/client/ui-openrouter-usage/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-openrouter-usage/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-openrouter-usage/src/index.ts
Normal file
9
packages/client/ui-openrouter-usage/src/index.ts
Normal 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 {}
|
||||
32
packages/client/ui-openrouter-usage/src/invariant.ts
Normal file
32
packages/client/ui-openrouter-usage/src/invariant.ts
Normal 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 */
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-openrouter-usage/tsconfig.json
Normal file
42
packages/client/ui-openrouter-usage/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-openrouter-usage/tsdown.config.ts
Normal file
3
packages/client/ui-openrouter-usage/tsdown.config.ts
Normal 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'])
|
||||
Reference in New Issue
Block a user