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

@@ -70,6 +70,7 @@
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
@@ -87,6 +88,7 @@
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",

View File

@@ -6,6 +6,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
import openRouterUsageRemote from '@deepseek-ai/dsh-openrouter-usage/remote'
import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol'
@@ -14,6 +15,7 @@ export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
export type {} from '@deepseek-ai/dsh-openrouter-usage/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypertRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -106,7 +108,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [
commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote,
commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, openRouterUsageRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution))
}

View File

@@ -42,6 +42,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/openrouter-usage"
},
{
"path": "../../preset/agent-presets"
},

View File

@@ -94,6 +94,19 @@
- id: plugin-inventory
name: '@deepseek-ai/dsh-host-plugin-inventory'
# OpenRouter spend: the account-balance Remote gateway plus the
# openRouterCost session projection, priced from the model table. Both
# surfaces stay hidden until an OPENROUTER_API_KEY resolves, so assemblies
# not routed through OpenRouter cost no layout and no fetch.
- id: openrouter-usage
name: '@deepseek-ai/dsh-openrouter-usage'
config:
apiKeyEnv: OPENROUTER_API_KEY
baseURL: https://openrouter.ai/api/v1
syncEnabled: true
pricingRefreshMs: 21600000
balanceRefreshMs: 60000
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. The base layer's agent-default-model service owns the default model.
- id: api-gateway
@@ -241,6 +254,13 @@
- id: ui-goal
name: '@deepseek-ai/dsh-client-ui-goal'
# OpenRouter spend surface: the per-session 会话费用 on the composer stats
# dock (over the openRouterCost projection) and the account 余额 badge at
# the sidebar foot (over the openRouterUsage Remote). Both render nothing
# before real data exists.
- id: ui-openrouter-usage
name: '@deepseek-ai/dsh-client-ui-openrouter-usage'
# Per-message feedback: Like/Dislike plus an optional note in the
# assistant-message action strip, over the messageFeedback Remote.
- id: ui-message-feedback

View File

@@ -61,6 +61,7 @@
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^",
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
"@deepseek-ai/dsh-client-ui-openrouter-usage": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model-selection": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
@@ -94,6 +95,7 @@
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-log-export": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",

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'])

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/llm/openrouter-usage/README.md
README.md: e6606d4cc02b8b34dc643a2c810edf7c41288007
README.zh.md: 07e7d32245688f33cef43858c7089a630306b8ae

View File

@@ -0,0 +1,101 @@
# @deepseek-ai/dsh-openrouter-usage
English | [中文](README.zh.md)
OpenRouter spend tracking: a Typert Remote gateway exposing the account
balance plus the `openRouterCost` session projection, which prices logged
token usage against OpenRouter's model pricing table.
The package is inert without a resolvable `OPENROUTER_API_KEY` (the same
credential OpenRouter LLM routing already uses): no key means no fetch, an
empty pricing table (so every step stays unpriced), and an empty balance.
## Config
```yaml
- id: openrouter-usage
name: '@deepseek-ai/dsh-openrouter-usage'
config:
apiKeyEnv: OPENROUTER_API_KEY
baseURL: https://openrouter.ai/api/v1
syncEnabled: true
pricingRefreshMs: 21600000
balanceRefreshMs: 60000
```
| Key | Default | Meaning |
| --- | --- | --- |
| `apiKeyEnv` | `OPENROUTER_API_KEY` | Credential reference resolved per refresh. |
| `baseURL` | `https://openrouter.ai/api/v1` | OpenRouter API root; `/credits`, `/models`, and `/auth/key` are appended. |
| `syncEnabled` | `true` | Whether periodic pricing/balance refresh runs. |
| `pricingRefreshMs` | `21600000` (6h) | Pricing-table refresh interval in ms. |
| `balanceRefreshMs` | `60000` (60s) | Balance refresh interval in ms. |
The key resolves through the credentials seam (`ctx.credentials`), with the
launch environment as fallback, exactly like `dsh-web-search-deepseek`.
## Service contract
`ctx.openRouterUsage` is a Typert Remote gateway. The `snapshot()` method
returns a detached copy of the last successful balance snapshot: `balanceUsd`
is the available balance from `GET /credits` (`total_credits` minus the spent
`total_usage`, the figure OpenRouter's dashboard surfaces), plus `label` and
the monthly `usageTokens`/`limitTokens` budget from `GET /auth/key`,
`isFreeTier`, and an `updatedAt` epoch. Before any successful fetch it serves
an all-`null`
record; a failed refresh keeps the last-known snapshot and logs. The same key
also refreshes the model pricing table from `GET /models`
(`pricing.prompt`/`completion` USD per token, plus a flat `request` fee and
optional `input_cache_read`/`input_cache_write` when disclosed).
The `openRouterCost` projection folds each session's logged token usage
(`assistant/chunk` usage and `assistant/message` usage, deduplicated per
step) against the pricing table. Attribution prefers the assembled message's
own `provider`/`model`; a chunk-only (failed) step prices from the newest
`request/context` route. A step on a non-`openrouter` provider is outside the
domain and changes nothing; an OpenRouter step whose model has no pricing
entry counts as an unknown (unpriced) step.
## Extension points
Web surfaces read the `openRouterCost` projection and call the balance Remote
through the client assembly (`ctx.remote.openRouterUsage.snapshot()`); the
component stack ships in `dsh-client-ui-openrouter-usage`. The gateway
requires no session or agent wiring — everything rides the durable whole-log
projection and one cached fetch.
## Model Experience
### OpenRouter cost and balance readouts
#### What the model sees
Nothing. Cost and balance are client-facing read models only: neither enters
a model request, a tool schema, or any prompt. The agent loop is unchanged.
#### Token effect
No model tokens. Priced figures derive from usage already logged by the
existing `assistant/message` and `assistant/chunk` events.
#### KV Cache effect
None; the KV cache is unaffected because cost is a projection of existing
usage events, never a new model-visible input.
## Known Limitations and Deferred Work
- **Estimate, not itemized billing** — cost is `tokens × model pricing`,
not OpenRouter's itemized per-generation billing. Generation ids are not
durably logged, and the pi-ai adapter discards `usage.cost`, so the
projection reconstructs spend from token counts. Free-tier and
promotional pricing may differ from the model table.
- **Pricing as of the fold** — the projection prices a cell with the model
table current when that cell folds. Refreshing pricing only affects cells
folded afterward; already-folded history keeps its prior figures.
- **Per-token approximation** — cache-read/cache-write fall back to the
prompt rate when the API does not disclose separate cache rates, and the
flat request fee is charged once per step. Bills may differ by fractions
of a cent.
- **No settings card** — the plugin exposes config only through cordis.yml;
a settings-section UI is deferred.

View File

@@ -0,0 +1,63 @@
# @deepseek-ai/dsh-openrouter-usage
[English](README.md) | 中文
OpenRouter 花费追踪:一个提供账户余额的 Typert Remote 网关,加上 `openRouterCost` 会话投影——后者用 OpenRouter 的模型定价表为日志中的 token 用量计价。
在无法解析到 `OPENROUTER_API_KEY`(即 OpenRouter LLM 路由所用的同一个凭证)时,本包处于惰性状态:无 key 意味着不发起请求、定价表为空(因此每个 step 都保持未计价)、余额为空。
## 配置
```yaml
- id: openrouter-usage
name: '@deepseek-ai/dsh-openrouter-usage'
config:
apiKeyEnv: OPENROUTER_API_KEY
baseURL: https://openrouter.ai/api/v1
syncEnabled: true
pricingRefreshMs: 21600000
balanceRefreshMs: 60000
```
| Key | 默认值 | 含义 |
| --- | --- | --- |
| `apiKeyEnv` | `OPENROUTER_API_KEY` | 每次刷新解析的凭证引用。 |
| `baseURL` | `https://openrouter.ai/api/v1` | OpenRouter API 根;会拼接 `/credits`、`/models` 与 `/auth/key`。 |
| `syncEnabled` | `true` | 是否运行周期性的定价/余额刷新。 |
| `pricingRefreshMs` | `21600000`(6 小时) | 定价表刷新间隔(毫秒)。 |
| `balanceRefreshMs` | `60000`(60 秒) | 余额刷新间隔(毫秒)。 |
key 经凭证边界(`ctx.credentials`)解析,并以后端环境作为回退,方式与 `dsh-web-search-deepseek` 一致。
## 服务契约
`ctx.openRouterUsage` 是一个 Typert Remote 网关。`snapshot()` 方法返回最近一次成功的余额快照的副本:`balanceUsd` 是来自 `GET /credits` 的可用余额(`total_credits` 减去已花费的 `total_usage`,即 OpenRouter 仪表盘展示的数字),外加来自 `GET /auth/key` 的 `label` 与月度 `usageTokens`/`limitTokens` 预算、`isFreeTier` 与 `updatedAt` 时间戳。在任何成功获取之前,它返回一个全 `null` 的记录;一次失败的刷新会保留上一次已知快照并记录日志。同一个 key 还会从 `GET /models` 刷新模型定价表(USD 每 token 的 `pricing.prompt`/`completion`,以及 flat 的 `request` 费用,若 API 披露时还有可选的 `input_cache_read`/`input_cache_write`)。
`openRouterCost` 投影会按定价表折叠每个会话日志中的 token 用量(`assistant/chunk` 的 usage 与 `assistant/message` 的 usage,按 step 去重)。归属优先采用已组装消息自身的 `provider`/`model`;仅有 chunk 的(失败)step 则按最新的 `request/context` 路由计价。非 `openrouter` provider 上的 step 不属于本域,不会改变任何值;模型没有对应定价条目的 OpenRouter step 会计作未知(未计价)step。
## 扩展点
Web 界面读取 `openRouterCost` 投影并通过客户端组装调用余额 Remote(`ctx.remote.openRouterUsage.snapshot()`);组件栈由 `dsh-client-ui-openrouter-usage` 提供。网关不需要任何 session 或 agent 接线——一切都依托持久全量日志投影与一次缓存的请求。
## 模型体验
### OpenRouter 花费与余额读数
#### 模型看到什么
什么也看不到。花费与余额只是面向客户端的只读模型:两者都不会进入模型请求、工具 schema 或任何提示词。agent 循环保持不变。
#### Token 影响
不会产生模型 token。计价的数字来自 `assistant/message` 与 `assistant/chunk` 事件中早已记录的用量。
#### KV Cache 影响
无;因为花费只是对既有用量事件的投影,而非新的模型可见输入,KV cache 不受影响。
## 已知限制与暂缓事项
- **估算,而非逐条计费**——花费是 `tokens × 模型定价`,不是 OpenRouter 逐条 generation 的计费。generation id 并未持久记录,且 pi-ai 适配器丢弃了 `usage.cost`,因此投影是根据 token 数重建花费的。free tier 与促销定价可能与模型表有出入。
- **计价以折叠时刻为准**——投影在单元格折叠时使用当时的模型表计价。刷新定价只会影响其后折叠的单元格;已折叠的历史保持此前的数值。
- **逐 token 近似**——当 API 未披露单独的 cache 费率时,cache 读/写回退到 prompt 费率;flat 的 request 费用每个 step 记一次。账单可能相差不到一分钱。
- **无设置卡片**——本插件只通过 cordis.yml 暴露配置;设置项界面推迟实现。

View File

@@ -0,0 +1,84 @@
{
"name": "@deepseek-ai/dsh-openrouter-usage",
"description": "OpenRouter spend tracking: session cost projection from logged token usage × fetched model pricing, and the OpenRouter account balance Remote gateway",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/llm/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"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts"
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,11 @@
/**
* Client-namespace projection of the openrouter-usage domain: a pure
* re-export of the package's types outlet. Client code imports ONLY the
* client namespace (repo discipline), so `./client` projects the same
* single-source content `./types` serves to host consumers — zero
* duplication, and both carry the `openRouterCost` SessionProjectionMap merge.
*
* @module @deepseek-ai/dsh-openrouter-usage/client
*/
export type * from './types.ts'

View File

@@ -0,0 +1,253 @@
/**
* OpenRouter spend + balance gateway: a Typert Remote exposing the account
* snapshot, plus the `openRouterCost` session-projection registration.
* @module @deepseek-ai/dsh-openrouter-usage
*/
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import type {} from '@deepseek-ai/dsh-session-projection'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import type {} from 'zod'
import {
DEFAULT_API_KEY_ENV,
DEFAULT_BASE_URL,
fetchAccountBalance,
fetchModelPricing,
} from './openrouter.ts'
import { createOpenRouterCostProjection } from './projection.ts'
import type { ModelPricing, OpenRouterBalance } from './types.ts'
export type * from './types.ts'
export {
DEFAULT_API_KEY_ENV,
DEFAULT_BASE_URL,
fetchAccountBalance,
fetchModelPricing,
} from './openrouter.ts'
export { createOpenRouterCostProjection, stepCostUsd } from './projection.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** OpenRouter usage + balance gateway. */
openRouterUsage: OpenRouterUsageGateway
}
}
/** Default pricing refresh interval, 6h. */
const DEFAULT_PRICING_REFRESH_MS = 6 * 60 * 60 * 1000
/** Default balance refresh interval, 60s. */
const DEFAULT_BALANCE_REFRESH_MS = 60 * 1000
/** Network timeout for OpenRouter fetches. */
const FETCH_TIMEOUT_MS = 15 * 1000
/** Plugin config (all optional — the service fills env-var and constant defaults). */
export interface Config {
/** Credential reference resolved per refresh; defaults to `OPENROUTER_API_KEY`. */
apiKeyEnv?: string
/** OpenRouter API root; `/models` and `/auth/key` are appended. */
baseURL?: string
/** Whether periodic pricing/balance sync runs. Defaults to true. */
syncEnabled?: boolean
/** Pricing-table refresh interval, ms. Defaults to 6h. */
pricingRefreshMs?: number
/** Balance refresh interval, ms. Defaults to 60s. */
balanceRefreshMs?: number
}
export const Config: z<Config> = z.object({
apiKeyEnv: z.string().role('credential-ref'),
baseURL: z.string(),
syncEnabled: z.boolean(),
pricingRefreshMs: z.number().step(1).min(1000),
balanceRefreshMs: z.number().step(1).min(1000),
})
/** Consumer-owned settings namespace for this plugin's section. */
export const OPENROUTER_USAGE_SETTINGS_NAMESPACE = settingsNamespace('openrouter-usage')
/** Empty balance the gateway serves before any successful fetch. */
function emptyBalance(): OpenRouterBalance {
return {
balanceUsd: null,
label: null,
usageTokens: null,
limitTokens: null,
isFreeTier: null,
updatedAt: null,
currency: 'USD',
}
}
/** Strip a trailing slash so `${baseURL}/models` never doubles one. */
function joinBaseURL(baseURL: string): string {
return baseURL.length > 1 && baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
}
/** OpenRouter usage + balance gateway (`ctx.openRouterUsage`). */
export class OpenRouterUsageGateway extends TypertRemoteService {
static Config: z<Config> = Config
/** Live pricing table the projection fold reads (swapped in place on refresh). */
private readonly pricing = new Map<string, ModelPricing>()
/** Latest successful account snapshot; empty before the first one. */
private balance: OpenRouterBalance = emptyBalance()
/** Abort handle for the armed pricing fetch, if any. */
private pricingTimer: ReturnType<typeof setInterval> | undefined
/** Abort handle for the armed balance fetch, if any. */
private balanceTimer: ReturnType<typeof setInterval> | undefined
/** Abort controller chaining the current fetches together. */
private readonly abortController = new AbortController()
/** Authoritative settings thunk; re-pointed when the section (re)mounts. */
private currentSource: () => Config
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'openRouterUsage')
this.currentSource = () => config
installSettingsSection(ctx, OPENROUTER_USAGE_SETTINGS_NAMESPACE, Config, config, {
setSource: (source) => {
this.currentSource = source
},
onChange: () => {
// Re-arm the sync loops against the authoritative section, then run one
// immediate refresh so a committed change lands promptly.
this.arm(this.resolve(this.currentSource()))
void this.refreshAll(this.resolve(this.currentSource()))
},
})
// The `openRouterCost` projection unit: folds logged usage against the
// live pricing thunk. The unit child activates only when a projection
// registry is composed (headless assemblies stay unaffected).
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(createOpenRouterCostProjection((model) => this.pricing.get(model)))
})
ctx.effect(() => () => {
this.abortController.abort()
if (this.pricingTimer !== undefined) clearInterval(this.pricingTimer)
if (this.balanceTimer !== undefined) clearInterval(this.balanceTimer)
}, 'openrouter-usage.dispose')
}
/** Run after peer services (credentials/settings) are available. */
protected async [Service.init](): Promise<void> {
this.arm(this.resolve(this.currentSource()))
// The credentials-local provider publishes `credentials/updated` once it has
// finished loading its document, which can land after this service's own
// init. Re-sync reactively so an immediate fetch runs the moment the key
// becomes resolvable rather than only on the next scheduled tick.
this.ctx.on('credentials/updated', () => {
void this.refreshAll(this.resolve(this.currentSource()))
})
// Best-effort immediate sync for compositions where the key was already
// present (env fallback / credentials resolved at boot).
await this.refreshAll(this.resolve(this.currentSource()))
}
/**
* The latest known account snapshot.
* @returns a fresh copy of the cached balance.
*/
@Remote('snapshot')
snapshot(): OpenRouterBalance {
return { ...this.balance }
}
/** Materialize plugin defaults against the validated section. */
private resolve(config: Config): Required<Config> {
return {
apiKeyEnv: config.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
baseURL: joinBaseURL(config.baseURL ?? DEFAULT_BASE_URL),
syncEnabled: config.syncEnabled ?? true,
pricingRefreshMs: config.pricingRefreshMs ?? DEFAULT_PRICING_REFRESH_MS,
balanceRefreshMs: config.balanceRefreshMs ?? DEFAULT_BALANCE_REFRESH_MS,
}
}
/** (Re)arm the refresh loops per the authoritative section. */
private arm(config: Required<Config>): void {
if (this.pricingTimer !== undefined) {
clearInterval(this.pricingTimer)
this.pricingTimer = undefined
}
if (this.balanceTimer !== undefined) {
clearInterval(this.balanceTimer)
this.balanceTimer = undefined
}
// Both sync loops are zero-cost when sync is disabled or the wiring never
// arms them, so they are always safe to schedule.
if (!config.syncEnabled) return
this.pricingTimer = setInterval(() => void this.refreshPricing(config), config.pricingRefreshMs)
this.balanceTimer = setInterval(() => void this.refreshBalance(config), config.balanceRefreshMs)
// Unref the sync loops so a long-running composition cannot be held open,
// and a test composition tears down without waiting on the next tick.
this.pricingTimer.unref()
this.balanceTimer.unref()
}
/** Refresh pricing and balance from the authoritative section. */
private async refreshAll(config: Required<Config>): Promise<void> {
await this.refreshPricing(config)
await this.refreshBalance(config)
}
/** Refresh the pricing table; a failure keeps the last-known table. */
private async refreshPricing(config: Required<Config>): Promise<void> {
const apiKey = await this.resolveApiKey(config)
if (apiKey === undefined) {
// No key: the projection still folds (pricing mirrors provider routing's
// own absence), but with an empty table every step stays unpriced.
this.pricing.clear()
return
}
try {
const fetched = await fetchModelPricing(config.baseURL, apiKey, this.fetchSignal())
if (fetched === undefined) return
this.pricing.clear()
for (const [model, rate] of fetched) this.pricing.set(model, rate)
} catch (error) {
this.ctx.logger.warn(`openrouter-usage: pricing refresh failed: ${String(error)}`)
}
}
/** Refresh the account snapshot; a failure keeps the last-known balance. */
private async refreshBalance(config: Required<Config>): Promise<void> {
const apiKey = await this.resolveApiKey(config)
if (apiKey === undefined) return
try {
const fetched = await fetchAccountBalance(config.baseURL, apiKey, this.fetchSignal())
if (fetched === undefined) return
this.balance = { ...fetched, updatedAt: Date.now(), currency: 'USD' }
} catch (error) {
this.ctx.logger.warn(`openrouter-usage: balance refresh failed: ${String(error)}`)
}
}
/** Resolve the API key through the credentials seam, with the environment as fallback. */
private async resolveApiKey(config: Required<Config>): Promise<string | undefined> {
const ref = credentialRef(config.apiKeyEnv)
// Non-strict get: during the loader mount the peer's fiber may not be
// ACTIVE yet, but the registered impl can already serve a committed value
// (and strict get would return undefined until the composition settles).
const credentials = this.ctx.get('credentials', false)
if (credentials !== undefined) {
const resolved = await credentials.resolve(ref)
return resolved?.value && resolved.value.length > 0 ? resolved.value : undefined
}
const ambient = launchEnvironmentOf(this.ctx).get(ref)
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
}
/** A per-call AbortSignal that also trips on service disposal. */
private fetchSignal(): AbortSignal {
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS)
return AbortSignal.any([timeout, this.abortController.signal])
}
}
export default OpenRouterUsageGateway

View File

@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-openrouter-usage`.
* @module @deepseek-ai/dsh-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-openrouter-usage'
/** Cordis companion plugin name. */
export const name = 'openrouter-usage-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the `openRouterCost` projection is projection-grade —
* its state is plain JSON, its schema pins the view, and its pricing input is
* an external thunk with no authoritative local stream to relate to — and the
* account balance is an opaque fetched cache. There is no owned event/data
* relationship a companion could observe beyond what the fold itself asserts.
*/
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,226 @@
/**
* OpenRouter HTTP client: raw model-pricing and account-balance fetches.
*
* Plain `globalThis.fetch` is the host standard (mirrors dsh-web-search-deepseek),
* with `redirect: 'error'` so a credential-bearing request never forwards the
* API key to another origin.
*
* @module @deepseek-ai/dsh-openrouter-usage/openrouter
*/
import { userAgent } from '@deepseek-ai/dsh-llm'
import type { ModelPricing } from './types.ts'
/** Default OpenRouter API root. */
export const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1'
/** OpenRouter bearer-token credential reference; the one that also routes LLM calls. */
export const DEFAULT_API_KEY_ENV = 'OPENROUTER_API_KEY'
/** Response of `GET {baseURL}/models`. */
interface OpenRouterModelsEnvelope {
data?: Array<{
id?: unknown
pricing?: unknown
}>
}
/** Response of `GET {baseURL}/auth/key`. */
interface OpenRouterAuthKeyEnvelope {
data?: {
label?: unknown
usage?: unknown
limit?: unknown
is_free_tier?: unknown
}
}
/** Response of `GET {baseURL}/credits`. */
interface OpenRouterCreditsEnvelope {
data?: {
total_credits?: unknown
total_usage?: unknown
is_free_tier?: unknown
}
}
/** Read a finite non-negative USD rate from an undisclosed/raw JSON field. */
function rateOf(value: unknown): number | undefined {
if (typeof value !== 'number' && typeof value !== 'string') return undefined
const parsed = typeof value === 'number' ? value : Number.parseFloat(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
}
/** Parse one model's `pricing` object into per-token USD, missing fields staying undefined. */
function parsePricing(pricing: unknown): {
promptUsd: number | undefined
completionUsd: number | undefined
requestUsd: number | undefined
} {
if (typeof pricing !== 'object' || pricing === null) return { promptUsd: undefined, completionUsd: undefined, requestUsd: undefined }
const record = pricing as Record<string, unknown>
return {
promptUsd: rateOf(record['prompt']),
completionUsd: rateOf(record['completion']),
requestUsd: rateOf(record['request']),
}
}
/**
* Extract the disclosed cache-read/write rates from a model's `pricing` object.
* Either rate is omitted when the API does not disclose it, leaving the fold to
* fall back to the prompt rate. Only call when `pricing` is a non-null object —
* the caller's `parsePricing` has already proven that via a defined prompt rate.
* @param pricing - a model's `pricing` object.
* @returns the disclosed cache rates, as partial fields.
*/
function extractCacheRates(pricing: unknown): { cacheReadUsd?: number; cacheWriteUsd?: number } {
const record = pricing as Record<string, unknown>
return {
...rateOf(record['input_cache_read']) === undefined ? {} : { cacheReadUsd: rateOf(record['input_cache_read']) },
...rateOf(record['input_cache_write']) === undefined ? {} : { cacheWriteUsd: rateOf(record['input_cache_write']) },
}
}
/**
* Fetch OpenRouter's current model pricing table.
*
* Both the prompt and completion rates and the optional flat per-request fee
* are read from `data[].pricing`; cache read/write rates fall back to the
* prompt rate when the API does not disclose them. A model with no `id` or no
* parseable pricing is skipped.
*
* @param baseURL - API root, `/models` appended.
* @param apiKey - bearer token.
* @param signal - caller cancellation.
* @returns pricing keyed by model id, or `undefined` when the request failed.
*/
export async function fetchModelPricing(
baseURL: string,
apiKey: string,
signal: AbortSignal,
): Promise<Map<string, ModelPricing> | undefined> {
const response = await fetch(`${baseURL}/models`, {
headers: {
authorization: `Bearer ${apiKey}`,
'user-agent': userAgent(),
},
redirect: 'error',
signal,
})
if (!response.ok) return undefined
let envelope: OpenRouterModelsEnvelope
try {
envelope = await response.json() as OpenRouterModelsEnvelope
} catch (_invalidJson) {
return undefined
}
const pricing = new Map<string, ModelPricing>()
for (const item of envelope.data ?? []) {
if (typeof item.id !== 'string' || item.id.length === 0) continue
const { promptUsd, completionUsd, requestUsd } = parsePricing(item.pricing)
if (promptUsd === undefined || completionUsd === undefined) continue
pricing.set(item.id, {
promptUsd,
completionUsd,
requestUsd: requestUsd ?? 0,
...extractCacheRates(item.pricing),
})
}
return pricing
}
/**
* Fetch the OpenRouter account snapshot behind one API key.
*
* The available balance comes from `GET {baseURL}/credits` as `total_credits`
* minus `total_usage` (spent), the figure OpenRouter's own dashboard surfaces;
* `GET {baseURL}/auth/key` supplies the key label and the monthly token budget
* (`usage`/`limit`). Standard keys return no `credits` field on `/auth/key`,
* so that endpoint alone would always read a null balance. A non-OK or
* malformed response yields an undefined-valued record — e.g. all-null on a
* body failure — rather than a throw, so the caller keeps the last-known
* snapshot.
*
* @param baseURL - API root, `/credits` and `/auth/key` appended.
* @param apiKey - bearer token.
* @param signal - caller cancellation.
* @returns the merged snapshot (stringly-typed survival), or undefined when the request failed.
*/
export async function fetchAccountBalance(
baseURL: string,
apiKey: string,
signal: AbortSignal,
): Promise<{ balanceUsd: number | null; label: string | null; usageTokens: number | null; limitTokens: number | null; isFreeTier: boolean | null } | undefined> {
const nonEmptyString = (value: unknown): string | null => typeof value === 'string' && value.length > 0 ? value : null
const nonNegativeNumber = (value: unknown): number | null => {
const parsed = rateOf(value)
return parsed === undefined ? null : parsed
}
const [creditsResponse, keyResponse] = await Promise.all([
fetch(`${baseURL}/credits`, {
headers: {
authorization: `Bearer ${apiKey}`,
'user-agent': userAgent(),
},
redirect: 'error',
signal,
}),
fetch(`${baseURL}/auth/key`, {
headers: {
authorization: `Bearer ${apiKey}`,
'user-agent': userAgent(),
},
redirect: 'error',
signal,
}),
])
const readKind = async <Envelope>(response: Response): Promise<Envelope | undefined> => {
if (!response.ok) return undefined
try {
return await response.json() as Envelope
} catch (_invalidJson) {
return undefined
}
}
const [creditsEnvelope, keyEnvelope] = await Promise.all([
readKind<OpenRouterCreditsEnvelope>(creditsResponse),
readKind<OpenRouterAuthKeyEnvelope>(keyResponse),
])
if (creditsEnvelope?.data === undefined && keyEnvelope?.data === undefined) return undefined
const credits = creditsEnvelope?.data
const authKey = keyEnvelope?.data
return {
// The `/credits` endpoint is authoritative. A standard-key `/auth/key`
// response carries no credits field at all, so balance cannot come from it.
balanceUsd: credits !== undefined && credits !== null ? availableUsd(credits) : null,
label: authKey !== undefined && authKey !== null ? nonEmptyString(authKey.label) : null,
usageTokens: authKey !== undefined && authKey !== null ? nonNegativeNumber(authKey.usage) : null,
limitTokens: authKey !== undefined && authKey !== null ? nonNegativeNumber(authKey.limit) : null,
isFreeTier: credits !== undefined && credits !== null && typeof credits.is_free_tier === 'boolean'
? credits.is_free_tier
: authKey !== undefined && authKey !== null && typeof authKey.is_free_tier === 'boolean'
? authKey.is_free_tier
: null,
}
}
/**
* Compute the available balance from a `/credits` response's `data` record:
* `total_credits` minus `total_usage` (spent), clamped to 0 so a momentarily
* under-counted usage never yields a negative figure. Returns null when either
* field is absent or malformed.
* @param credits - the `/credits` response data record.
* @returns available credits in USD, or null when not derivable.
*/
function availableUsd(
credits: NonNullable<OpenRouterCreditsEnvelope['data']>,
): number | null {
const total = rateOf(credits.total_credits)
const used = rateOf(credits.total_usage)
if (total === undefined || used === undefined) return null
return Math.max(0, total - used)
}

View File

@@ -0,0 +1,132 @@
/**
* The `openRouterCost` projection unit: a pure fold of provider-reported token
* usage priced against the OpenRouter model table current at fold time.
*
* The fold follows token-meter's dedup discipline — usage chunks provide an
* early sample, an assistant/message the final sample for the same turn/step,
* and a repeated sample replaces that step's earlier value instead of double
* counting. Pricing is NOT part of the fold state: the unit reads a captured
* `pricingOf` thunk (the plugin swaps the underlying table on refresh), so the
* fold stays synchronous and each fold is priced as of the moment it runs.
* Refreshing pricing only affects cells folded afterward — the documented
* "as of fold" limitation.
*
* Model attribution: an `assistant/message` carries its own provider/model in
* `message.source`; a chunk-only (failed) step has none, so the fold prices it
* from the newest `request/context` last-wins record. A step on a provider that
* is not `openrouter` is outside this plugin's domain and changes nothing; an
* OpenRouter step whose model has no pricing entry counts as an unknown
* (unpriced) step.
*
* @module @deepseek-ai/dsh-openrouter-usage/projection
*/
import { z } from 'zod'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { ModelPricing, OpenRouterCost } from './types.ts'
/** The provider route this projection prices; LLM routing must land here. */
const OPENROUTER_PROVIDER = 'openrouter'
/** Pure/state carried across events; plain JSON per the persisted-cache precondition. */
interface OpenRouterCostState {
totalUsd: number
pricedSteps: number
unknownModelSteps: number
/** The newest sample's attribution, for same-step replacement. */
last: { turn: number; step: number; costUsd: number; priced: boolean } | null
/** Newest `request/context` route, for chunk-only step attribution. */
lastModel: { provider: string; model: string } | null
}
const costSchema = z.object({
totalUsd: z.number().nonnegative(),
pricedSteps: z.number().int().nonnegative(),
unknownModelSteps: z.number().int().nonnegative(),
currency: z.literal('USD'),
}).strict()
/**
* Token cost of one usage sample under one model's pricing, USD. Cache
* traffic falls back to the prompt rate when the model discloses no cache
* rate; the flat per-request fee is charged once per sample.
* @param usage - disjoint provider usage buckets.
* @param pricing - the attributing model's pricing.
* @returns summed USD.
*/
export function stepCostUsd(usage: TokenUsage, pricing: ModelPricing): number {
return usage.inputTokens * pricing.promptUsd
+ usage.outputTokens * pricing.completionUsd
+ (usage.cacheReadTokens ?? 0) * (pricing.cacheReadUsd ?? pricing.promptUsd)
+ (usage.cacheWriteTokens ?? 0) * (pricing.cacheWriteUsd ?? pricing.promptUsd)
+ pricing.requestUsd
}
/**
* Build the `openRouterCost` unit closed over an external pricing thunk.
*
* `pricingOf` must be a synchronous pure read of the plugin-owned pricing
* table (the fold never fetches). Call sites pass a thunk reading the live
* map, so a pricing refresh is visible to any cell folded afterward.
*
* @param pricingOf - model-id → pricing lookup.
* @returns the ready-to-register projection definition.
*/
export function createOpenRouterCostProjection(
pricingOf: (model: string) => ModelPricing | undefined,
): ProjectionDefinition<'openRouterCost', OpenRouterCostState> {
return {
key: 'openRouterCost',
schema: costSchema as unknown as z.ZodType<OpenRouterCost>,
init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, last: null, lastModel: null }),
apply: (state, event: SessionEvent) => {
if (event.type === 'request/context') {
const nextModel = { provider: event.data.provider, model: event.data.model }
if (state.lastModel?.provider === nextModel.provider && state.lastModel?.model === nextModel.model) return state
return { ...state, lastModel: nextModel }
}
let turn: number
let step: number
let usage: TokenUsage
let attribution: { provider: string; model: string } | undefined
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
;({ turn, step } = event.data)
usage = event.data.chunk.usage
// A chunk carries no route; the newest request/context supplies it.
attribution = state.lastModel ?? undefined
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
;({ turn, step, usage } = event.data)
attribution = { provider: event.data.message.source.provider, model: event.data.message.source.model }
} else {
return state
}
// A non-openrouter step is outside this plugin's domain: never counted,
// never recorded (a later, correctly-attributed message for the same
// step must still land fresh).
if (attribution === undefined || attribution.provider !== OPENROUTER_PROVIDER) return state
const pricing = pricingOf(attribution.model)
const priced = pricing !== undefined
const costUsd = priced ? stepCostUsd(usage, pricing) : 0
const previous = state.last !== null && state.last.turn === turn && state.last.step === step
? state.last
: null
if (previous !== null && previous.costUsd === costUsd && previous.priced === priced) return state
return {
totalUsd: state.totalUsd - (previous?.costUsd ?? 0) + costUsd,
pricedSteps: state.pricedSteps - (previous?.priced ?? false ? 1 : 0) + (priced ? 1 : 0),
unknownModelSteps: state.unknownModelSteps
- (previous !== null && !previous.priced ? 1 : 0)
+ (priced ? 0 : 1),
last: { turn, step, costUsd, priced },
lastModel: state.lastModel,
}
},
view: state => ({ totalUsd: state.totalUsd, pricedSteps: state.pricedSteps, unknownModelSteps: state.unknownModelSteps, currency: 'USD' }),
stateVersion: 1,
}
}

View File

@@ -0,0 +1,81 @@
/**
* Pure types of the openrouter-usage domain: the ONE home of the
* `openRouterCost` projection-key declaration plus the balance snapshot
* vocabulary, free of this package's host-side value imports (cordis
* context, zod, fetch). Two namespace projections serve it — `./types` for
* host consumers, `./client` for client aggregates — with zero content
* duplication.
*
* @module @deepseek-ai/dsh-openrouter-usage/types
*/
// Marks this file a module so the declaration below AUGMENTS the projection
// table instead of declaring an ambient module.
export {}
/**
* Per-model OpenRouter pricing as served by `GET /api/v1/models`. All rates
* are USD; `prompt`/`completion` are per-token and `request` is a flat
* per-request fee. Cache rates fall back to the prompt rate when the API
* does not disclose them.
*/
export interface ModelPricing {
/** USD per input token (uncached and cache-write traffic). */
promptUsd: number
/** USD per output token. */
completionUsd: number
/** Flat USD charged once per request; 0 for models without one. */
requestUsd: number
/** USD per cache-read token; defaults to {@link promptUsd} when undisclosed. */
cacheReadUsd?: number
/** USD per cache-write token; defaults to {@link promptUsd} when undisclosed. */
cacheWriteUsd?: number
}
/**
* Whole-log OpenRouter spend for one session, priced from the logged token
* usage of its steps against the pricing table current at fold time. Every
* field is 0 until its first contributing priced step lands; a session whose
* provider route is not `openrouter`, or whose models have no pricing entry,
* stays all-zero.
*/
export interface OpenRouterCost {
/** Summed USD over steps priced against a known model entry. */
totalUsd: number
/** Steps whose usage contributed to {@link totalUsd}. */
pricedSteps: number
/** OpenRouter steps whose model had no pricing entry; excluded from the total. */
unknownModelSteps: number
/** Fixed display currency of every monetary field. */
currency: 'USD'
}
/**
* One OpenRouter account snapshot from `GET /api/v1/credits` (balance) plus
* `GET /api/v1/auth/key` (label, monthly token budget). The values stay null
* until the first successful fetch; a fetch failure keeps the last snapshot
* and leaves {@link updatedAt} stale.
*/
export interface OpenRouterBalance {
/** Available account credits in USD (`total_credits` minus spent), when derivable. */
balanceUsd: number | null
/** The key's label as shown on openrouter.ai, when disclosed. */
label: string | null
/** Monthly token usage budget consumed, when disclosed. */
usageTokens: number | null
/** Monthly token usage budget limit, when disclosed. */
limitTokens: number | null
/** Whether the account is on OpenRouter's free tier. */
isFreeTier: boolean | null
/** Epoch milliseconds of the last successful fetch; null before any. */
updatedAt: number | null
/** Fixed display currency of {@link balanceUsd}. */
currency: 'USD'
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Whole-log OpenRouter spend; see {@link OpenRouterCost}. */
openRouterCost: OpenRouterCost
}
}

View File

@@ -0,0 +1,173 @@
/**
* REAL-composition proof: the shipped gateway YAML shape (session +
* projection registry + credentials + openrouter-usage) boots through the
* vendored Loader, the service default-export survives, a key resolved from
* the credentials document lets a mocked OpenRouter fetch populate the
* pricing table and the balance, and a logged step serves a priced
* `openRouterCost` view through the composed registry.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import { createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
import OpenRouterUsageGateway from '@deepseek-ai/dsh-openrouter-usage'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
vi.unstubAllGlobals()
})
/** Mock OpenRouter's read endpoints and record the calls. */
function stubOpenRouter() {
const pricing = [
{ id: 'deepseek/deepseek-chat', pricing: { prompt: '0.0000014', completion: '0.0000028', request: '0' } },
]
const calls: string[] = []
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
calls.push(url)
if (url.endsWith('/models')) {
return new Response(JSON.stringify({ data: pricing }), { status: 200 })
}
if (url.endsWith('/credits')) {
return new Response(JSON.stringify({
data: { total_credits: 42, total_usage: 1, is_free_tier: false },
}), { status: 200 })
}
if (url.endsWith('/auth/key')) {
return new Response(JSON.stringify({
data: { label: 'test', usage: 1, limit: 1000, is_free_tier: false },
}), { status: 200 })
}
return new Response('not found', { status: 404 })
}))
return { calls }
}
async function loadComposition(): Promise<Context> {
let rootDir = root
if (rootDir === undefined) {
rootDir = await mkdtemp(join(tmpdir(), 'dsh-openrouter-composition-'))
root = rootDir
}
await writeFile(join(rootDir, '.credentials.yaml'), 'OPENROUTER_API_KEY: sk-openrouter-test\n', { mode: 0o600 })
const settingsPath = join(rootDir, 'settings.yaml')
await writeFile(settingsPath, '# test settings\n')
await writeFile(join(rootDir, 'cordis.yml'), [
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-session-projection'",
'- id: settings',
" name: '@deepseek-ai/dsh-settings-file'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(join(rootDir, '.credentials.yaml'))}`,
' debounceMs: 10',
"- name: '@deepseek-ai/dsh-openrouter-usage'",
' config:',
' syncEnabled: false',
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(rootDir).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-settings-file', FileSettingsProvider],
['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider],
['@deepseek-ai/dsh-openrouter-usage', OpenRouterUsageGateway],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(join(rootDir, 'cordis.yml')).href },
})
await ctx.loader.await()
return ctx
}
describe('openrouter-usage real composition', () => {
it('resolves the key, fetches pricing, and prices a logged step through the composed registry', async () => {
const openRouter = stubOpenRouter()
const loaded = await loadComposition()
// Give the gateway's refresh a moment to run against the mock.
await vi.waitFor(() => {
expect(openRouter.calls.some(url => url.endsWith('/models'))).toBe(true)
}, { timeout: 5000 })
const session = loaded.sessions.create()
session.append('request/context', { provider: 'openrouter', model: 'deepseek/deepseek-chat' })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 1_000, outputTokens: 200 } },
})
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'model', provider: 'openrouter', model: 'deepseek/deepseek-chat' },
}),
usage: { inputTokens: 1_000, outputTokens: 200 },
}, { surfaceOp: 'append', sourceEventSeqs: [session.events.length - 1] })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const cost = loaded.sessionProjections.snapshot(session).values.openRouterCost
expect(cost).toMatchObject({
totalUsd: 1000 * 1.4e-6 + 200 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
currency: 'USD',
})
})
it('serves the account balance snapshot through the Remote gateway', async () => {
const openRouter = stubOpenRouter()
const loaded = await loadComposition()
await vi.waitFor(() => {
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
}, { timeout: 5000 })
const balance = loaded.openRouterUsage.snapshot()
expect(balance.balanceUsd).toBe(41)
expect(balance.label).toBe('test')
expect(balance.currency).toBe('USD')
expect(balance.updatedAt).not.toBeNull()
})
})

View File

@@ -0,0 +1,174 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_BASE_URL,
fetchAccountBalance,
fetchModelPricing,
} from '../src/openrouter.ts'
afterEach(() => {
vi.restoreAllMocks()
})
function mockFetch(status: number, body: unknown): void {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status })))
}
const KEY = 'sk-test'
describe('fetchModelPricing', () => {
it('parses per-token USD pricing keyed by model id', async () => {
mockFetch(200, {
data: [
{
id: 'deepseek/deepseek-chat',
pricing: { prompt: '0.0000014', completion: '0.0000028', request: '0' },
},
// A model with disclosed cache rates and a flat per-request fee.
{
id: 'anthropic/claude-3.5-sonnet',
pricing: {
prompt: '0.000003', completion: '0.000015', request: '0.0005',
input_cache_read: '0.0000003', input_cache_write: '0.000003',
},
},
],
})
const pricing = await fetchModelPricing(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(pricing).not.toBeUndefined()
expect(pricing!.get('deepseek/deepseek-chat')).toEqual({ promptUsd: 1.4e-6, completionUsd: 2.8e-6, requestUsd: 0 })
expect(pricing!.get('anthropic/claude-3.5-sonnet')).toEqual({
promptUsd: 3e-6,
completionUsd: 15e-6,
requestUsd: 0.0005,
cacheReadUsd: 3e-7,
cacheWriteUsd: 3e-6,
})
})
it('skips models with a missing id or no parseable pricing', async () => {
mockFetch(200, {
data: [
{ id: '', pricing: { prompt: '0.1', completion: '0.2' } },
{ id: 'no-rates', pricing: {} },
{ id: 'bad-number', pricing: { prompt: 'nope', completion: '0.2' } },
{ id: 'good/model', pricing: { prompt: '0.1', completion: '0.2' } },
],
})
const pricing = await fetchModelPricing(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect([...pricing!.keys()]).toEqual(['good/model'])
})
it('returns undefined on a non-OK response', async () => {
mockFetch(401, {})
const pricing = await fetchModelPricing(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(pricing).toBeUndefined()
})
it('returns undefined on invalid JSON', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
const pricing = await fetchModelPricing(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(pricing).toBeUndefined()
})
it('sends the bearer token and a user-agent, and rejects redirects', async () => {
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await fetchModelPricing(DEFAULT_BASE_URL, KEY, new AbortController().signal)
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(call[0]).toBe(`${DEFAULT_BASE_URL}/models`)
expect(call[1].redirect).toBe('error')
expect((call[1].headers as Record<string, string>).authorization).toBe(`Bearer ${KEY}`)
expect((call[1].headers as Record<string, string>)['user-agent']).toBeTruthy()
})
})
describe('fetchAccountBalance', () => {
function mockAccountFetch(paths: Record<string, unknown>, status = 200): void {
const calls: string[] = []
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
calls.push(url)
if (!(url in paths)) {
throw new Error(`unexpected fetch: ${url}; seen ${calls.join(', ')}`)
}
const body = typeof paths[url] === 'string' ? paths[url] as string : JSON.stringify(paths[url])
return new Response(body, { status })
}))
}
it('computes the available balance (total minus spent) from /credits', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: {
data: { total_credits: 12.34, total_usage: 2.5, is_free_tier: false },
},
[`${DEFAULT_BASE_URL}/auth/key`]: {
data: { label: 'my key', usage: 5000, limit: 100000 },
},
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toEqual({ balanceUsd: 9.84, label: 'my key', usageTokens: 5000, limitTokens: 100000, isFreeTier: false })
})
it('clamps a momentarily under-counted usage to a zero balance', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: { data: { total_credits: 5, total_usage: 7 } },
[`${DEFAULT_BASE_URL}/auth/key`]: { data: {} },
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toEqual({ balanceUsd: 0, label: null, usageTokens: null, limitTokens: null, isFreeTier: null })
})
it('falls back to the /auth/key free-tier flag when /credits hides it', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: { data: { total_credits: 8, total_usage: 5 } },
[`${DEFAULT_BASE_URL}/auth/key`]: { data: { is_free_tier: true } },
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toEqual({ balanceUsd: 3, label: null, usageTokens: null, limitTokens: null, isFreeTier: true })
})
it('nulls the available balance when total_usage is absent', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: { data: { total_credits: 4 } },
[`${DEFAULT_BASE_URL}/auth/key`]: { data: {} },
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toEqual({ balanceUsd: null, label: null, usageTokens: null, limitTokens: null, isFreeTier: null })
})
it('treats a non-JSON response body as an absent envelope', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: 'not json {',
[`${DEFAULT_BASE_URL}/auth/key`]: { data: { label: 'my key' } },
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toEqual({ balanceUsd: null, label: 'my key', usageTokens: null, limitTokens: null, isFreeTier: null })
})
it('nulls absent or invalid fields', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: { data: { total_credits: 'not-a-number', total_usage: 1 } },
[`${DEFAULT_BASE_URL}/auth/key`]: { data: {} },
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toEqual({ balanceUsd: null, label: null, usageTokens: null, limitTokens: null, isFreeTier: null })
})
it('returns undefined when both envelopes lack a data object', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: {},
[`${DEFAULT_BASE_URL}/auth/key`]: {},
})
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toBeUndefined()
})
it('returns undefined when both responses are non-OK', async () => {
mockAccountFetch({
[`${DEFAULT_BASE_URL}/credits`]: {},
[`${DEFAULT_BASE_URL}/auth/key`]: {},
}, 500)
const balance = await fetchAccountBalance(DEFAULT_BASE_URL, KEY, new AbortController().signal)
expect(balance).toBeUndefined()
})
})

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createMessage } from '@deepseek-ai/dsh-llm'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ModelPricing } from '@deepseek-ai/dsh-openrouter-usage/client'
import { createOpenRouterCostProjection } from '../src/projection.ts'
const PRICING = new Map<string, ModelPricing>([
['deepseek/deepseek-chat', {
promptUsd: 1.4e-6,
completionUsd: 2.8e-6,
requestUsd: 0,
cacheReadUsd: 1.4e-7,
cacheWriteUsd: 1.4e-6,
}],
// A free model: priced, but at zero USD per bucket (still a priced step).
['deepseek/deepseek-chat:free', { promptUsd: 0, completionUsd: 0, requestUsd: 0 }],
// A model with a flat per-request fee and no disclosed cache rate.
['expensive/request-fee', { promptUsd: 1e-5, completionUsd: 1e-5, requestUsd: 0.5 }],
])
async function harness(): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
ctx.sessionProjections.register(createOpenRouterCostProjection((model) => PRICING.get(model)))
return { ctx, session }
}
function startStep(session: Session, turn: number, step: number): void {
session.append('step/start', { turn, step })
}
/** Append a usage chunk and return its seq. */
function usageChunk(session: Session, usage: TokenUsage, turn: number, step: number): number {
return session.append('assistant/chunk', { turn, step, chunk: { type: 'usage', usage } }).seq
}
/** Append the assistant message for a step and close it. */
function finalUsage(
session: Session,
usage: TokenUsage,
turn: number,
step: number,
sourceSeqs: number[],
model = 'deepseek/deepseek-chat',
): void {
session.append('assistant/message', {
turn,
step,
message: createMessage({
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'openrouter', model },
}),
usage,
}, { surfaceOp: 'append', sourceEventSeqs: sourceSeqs })
session.append('step/end', { turn, step })
}
function recordContext(session: Session): void {
session.append('request/context', { provider: 'openrouter', model: 'deepseek/deepseek-chat' })
}
const projected = (ctx: Context, session: Session) => {
const value = ctx.sessionProjections.snapshot(session).values.openRouterCost
if (value === undefined) throw new Error('openRouterCost projection is not registered')
return value
}
describe('openRouterCost session projection', () => {
it('serves an all-zero view on an empty log', async () => {
const { ctx, session } = await harness()
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, currency: 'USD' })
})
it('prices input/output/cache buckets at the model rates', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
const source = usageChunk(session, { inputTokens: 1_000, outputTokens: 500, cacheReadTokens: 400, cacheWriteTokens: 100 }, 1, 1)
finalUsage(session, { inputTokens: 1_000, outputTokens: 500, cacheReadTokens: 400, cacheWriteTokens: 100 }, 1, 1, [source])
const input = 1_000 * 1.4e-6
const cacheRead = 400 * 1.4e-7
const cacheWrite = 100 * 1.4e-6
const output = 500 * 2.8e-6
expect(projected(ctx, session).totalUsd).toBeCloseTo(input + cacheRead + cacheWrite + output, 12)
expect(projected(ctx, session).pricedSteps).toBe(1)
})
it('does not double-count a usage chunk and the identical final usage', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
const source = usageChunk(session, { inputTokens: 10, outputTokens: 4 }, 1, 1)
finalUsage(session, { inputTokens: 10, outputTokens: 4 }, 1, 1, [source])
expect(projected(ctx, session)).toEqual({ totalUsd: 10 * 1.4e-6 + 4 * 2.8e-6, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' })
})
it('replaces an earlier same-step chunk sample with the final usage', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
const source = usageChunk(session, { inputTokens: 10, outputTokens: 2 }, 1, 1)
finalUsage(session, { inputTokens: 14, outputTokens: 5 }, 1, 1, [source])
expect(projected(ctx, session)).toEqual({
totalUsd: 14 * 1.4e-6 + 5 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
currency: 'USD',
})
})
it('retains a usage chunk when no final assistant message lands (failed step)', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 9, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session).totalUsd).toBeCloseTo(9 * 1.4e-6 + 1 * 2.8e-6, 12)
expect(projected(ctx, session).pricedSteps).toBe(1)
})
it('counts an unknown-priced OpenRouter model as an unpriced step', async () => {
const { ctx, session } = await harness()
session.append('request/context', { provider: 'openrouter', model: 'brand-new/model' })
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, currency: 'USD' })
})
it('ignores a step on a non-openrouter provider entirely', async () => {
const { ctx, session } = await harness()
session.append('request/context', { provider: 'deepseek', model: 'deepseek-chat' })
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, currency: 'USD' })
})
it('prefers the assistant-message source over the last request/context record', async () => {
const { ctx, session } = await harness()
session.append('request/context', { provider: 'openrouter', model: 'brand-new/model' })
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
// The message attributes the step to a priced model, overriding the last
// request/context attribution used for the early chunk.
const source = session.events.length - 1
finalUsage(session, { inputTokens: 100, outputTokens: 10 }, 1, 1, [source])
expect(projected(ctx, session).pricedSteps).toBe(1)
expect(projected(ctx, session).unknownModelSteps).toBe(0)
})
it('charges the flat per-request fee once per step', async () => {
const { ctx, session } = await harness()
session.append('request/context', { provider: 'openrouter', model: 'expensive/request-fee' })
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session).totalUsd).toBeCloseTo(100 * 1e-5 + 1 * 1e-5 + 0.5, 12)
expect(projected(ctx, session).pricedSteps).toBe(1)
})
it('prices a zero-rate free model as a priced (not unknown) step', async () => {
const { ctx, session } = await harness()
session.append('request/context', { provider: 'openrouter', model: 'deepseek/deepseek-chat:free' })
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' })
})
it('pushes no change for unrelated events', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 10, outputTokens: 1 }, 1, 1)
const changed: string[] = []
ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
session.append('todo/write', { todos: [] })
expect(changed).not.toContain('openRouterCost')
})
it('restores from a JSON checkpoint', async () => {
const { ctx, session } = await harness()
recordContext(session)
startStep(session, 1, 1)
usageChunk(session, { inputTokens: 8, outputTokens: 2 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).openRouterCost).toEqual({
totalUsd: 8 * 1.4e-6 + 2 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
currency: 'USD',
})
})
})

View File

@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/launch-environment"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../settings/settings"
},
{
"path": "../../typert/protocol"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/README.md
README.md: fc37d7cdead59138db149b5a86f0a0c031d40037
README.zh.md: 53fe673ddaed235bbb938db4757e9cd240928d61
README.md: 449c96c71b580964486968431c28f71dc466e8eb
README.zh.md: a7d7d6dc1a382abe481f3520aa42480ff0317976

View File

@@ -8,6 +8,7 @@ This family provides provider-neutral web search and fetch operations plus the m
|---|---|---|
| [`web/`](web/README.md) | Defines web provider registration, selection, and shared errors | `ctx.web` |
| [`web-search-exa/`](web-search-exa/README.md) | Provides web search through Exa | registers on `ctx.web` |
| [`web-search-searxng/`](web-search-searxng/README.md) | Provides web search through a SearXNG instance | registers on `ctx.web` |
| [`web-search-perplexity/`](web-search-perplexity/README.md) | Provides web search through Perplexity | registers on `ctx.web` |
| [`web-search-deepseek/`](web-search-deepseek/README.md) | Provides native DeepSeek web search | registers on `ctx.web` |
| [`web-fetch-http/`](web-fetch-http/README.md) | Fetches public HTTP and HTTPS resources | registers on `ctx.web` |

View File

@@ -8,6 +8,7 @@
|---|---|---|
| [`web/`](web/README.md) | 定义 web 提供方注册、选择和共享错误 | `ctx.web` |
| [`web-search-exa/`](web-search-exa/README.md) | 通过 Exa 提供 web 搜索 | 注册到 `ctx.web` |
| [`web-search-searxng/`](web-search-searxng/README.md) | 通过 SearXNG 实例提供 web 搜索 | 注册到 `ctx.web` |
| [`web-search-perplexity/`](web-search-perplexity/README.md) | 通过 Perplexity 提供 web 搜索 | 注册到 `ctx.web` |
| [`web-search-deepseek/`](web-search-deepseek/README.md) | 提供 DeepSeek 原生 web 搜索 | 注册到 `ctx.web` |
| [`web-fetch-http/`](web-fetch-http/README.md) | 抓取公共 HTTP 和 HTTPS 资源 | 注册到 `ctx.web` |

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/web/web-search-searxng/README.md
README.md: 0e0c76fc884eb5968618673cc58f6ae5f88112cc
README.zh.md: aa550a67c49458c9488c4b3bf24532935db99e01

View File

@@ -0,0 +1,52 @@
# @deepseek-ai/dsh-web-search-searxng
English | [中文](README.zh.md)
A [SearXNG](https://docs.searxng.org)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls a SearXNG instance's JSON API (`GET /search?format=json`) and maps the flat `results[]` into the seam's normalized `WebSearchResult`. It targets a self-hosted or private SearXNG: there is no single canonical public instance, so `baseURL` has no default, and the provider deliberately carries no API key or `Authorization` header.
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the `ctx.web` key and it does not register a model-facing tool (that is `@deepseek-ai/dsh-tool-web`). Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`) that registers its backend, not a default-export service.
## Config
| Key | Default | Meaning |
|---|---|---|
| `baseURL` | (none) | SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. |
| `language` | `auto` | SearXNG `language` request value; `auto` lets the instance decide per user preferences. |
| `timeRange` | (unset) | SearXNG `time_range` recency filter: `day`, `week`, `month`, or `year`. Omitted sends no filter. |
```yaml
- id: web-search-searxng
name: '@deepseek-ai/dsh-web-search-searxng'
config:
baseURL: https://searx.example
```
The entry above is the base layer of the `web-search-searxng` Settings section: a user layer over it (a `settings.yaml` section or an in-session settings edit) reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when the instance or a filter changes. A section without `baseURL` still passes the schema but leaves the provider unavailable — no endpoint is guessed.
```yaml
# $DSH_HOME/settings.yaml
web-search-searxng:
baseURL: https://searx.example
language: auto
timeRange: day
```
SearXNG exposes no per-request result-count control — page size is instance configuration — so no `numResults` option exists; the seam enforces `maxResults` on the result.
## Mapping
SearXNG returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `content`, `publishedAt` ← `publishedDate`. Unlike the Exa provider it does **not** drop snippet-less entries: URL and title are still useful, so every result is kept. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. The request carries no `Authorization` header, so no credential can leak to a redirect target.
## Model Experience
Indirectly, through [`dsh-tool-web`](../tool-web/README.md), which retains this provider's `maxResults`-bounded URLs, titles, snippets, and publication dates or its exact `SearXNG search aborted`, `SearXNG search request failed: <error>`, and `SearXNG returned an unprocessable response body: <error>` failures under the consumer's error wrapper while generated answers and provider-private fields remain outside context.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Result count is not bounded at the request** — SearXNG page size is instance configuration, so the provider sends no count and the seam truncates on return; a single page (typically ~20 results) is fetched.
- **No aggregate-answer or infobox content is surfaced** — SearXNG `answers` and `infoboxes` are not mapped into `content`. No generated answer is trusted.
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.

View File

@@ -0,0 +1,52 @@
# @deepseek-ai/dsh-web-search-searxng
[English](README.md) | 中文
由 [SearXNG](https://docs.searxng.org) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 SearXNG 实例的 JSON API(`GET /search?format=json`),把扁平 `results[]` 映射为 seam 规范化的 `WebSearchResult`。此提供方面向自托管或私有 SearXNG:不存在统一的公共实例,因此 `baseURL` 没有默认值,提供方刻意不携带 API 密钥或 `Authorization` 头。
这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有 `ctx.web` 键,也不注册面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`),负责注册后端,而非默认导出服务。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `baseURL` | (无) | SearXNG 实例基址;追加 `/search`。为空或无法解析时提供方不可用。 |
| `language` | `auto` | SearXNG `language` 请求值;`auto` 让实例按用户偏好决定。 |
| `timeRange` | (未设置) | SearXNG `time_range` 时效过滤器:`day`、`week`、`month` 或 `year`。省略时不发送过滤器。 |
```yaml
- id: web-search-searxng
name: '@deepseek-ai/dsh-web-search-searxng'
config:
baseURL: https://searx.example
```
上面的条目是 `web-search-searxng` Settings 分节的基础层:覆盖在它之上的用户层(`settings.yaml` 分节或会话内设置的编辑)会作用于**下一次**搜索,因为提供方按调用投影分节,而非在注册时捕获。因此 seam 的提供方选择在实例或过滤器变化时不会闪烁。不含 `baseURL` 的分节仍能通过 schema,但提供方保持不可用——不会猜测任何端点。
```yaml
# $DSH_HOME/settings.yaml
web-search-searxng:
baseURL: https://searx.example
language: auto
timeRange: day
```
SearXNG 不提供按请求控制结果数量的方式——页面大小属于实例配置——因此没有 `numResults` 选项;seam 会在结果返回时强制执行 `maxResults`。
## 映射
SearXNG 返回扁平 `results[]`,不返回生成答案,因此省略 `content`。每项结果映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `content`、`publishedAt` ← `publishedDate`。与 Exa 提供方不同,此提供方**不会**丢弃无 snippet 的结果:URL 与标题仍然有用,因此保留每项结果。提供方失败(HTTP 错误、网络失败、响应体无法解析或结构不符)以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在访问 `Location` 指向的目标之前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。请求不携带 `Authorization` 头,因此不会有凭据泄露给重定向目标。
## 模型体验
通过 [`dsh-tool-web`](../tool-web/README.md) 间接影响;该工具保留此提供方经 `maxResults` 限制的 URL、标题、snippet 与发布日期,或将确切的错误消息 `SearXNG search aborted`、`SearXNG search request failed: <error>` 和 `SearXNG returned an unprocessable response body: <error>` 置于消费方的错误包装层内;生成答案与提供方私有字段不进入上下文。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
## 已知限制与暂缓事项
- **请求端不限制结果数量**:SearXNG 页面大小属于实例配置,因此提供方不发送数量,由 seam 在返回时截断;只抓取单页(通常约 20 条结果)。
- **不呈现聚合答案或 infobox 内容**:SearXNG 的 `answers` 与 `infoboxes` 不映射进 `content`。不信任任何生成答案。
- **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。

View File

@@ -0,0 +1,49 @@
{
"name": "@deepseek-ai/dsh-web-search-searxng",
"description": "SearXNG-backed search provider for the DeepSeek Harness web capability seam (ctx.web)",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/web/web-search-searxng"
},
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,79 @@
/**
* `@deepseek-ai/dsh-web-search-searxng`: registers a SearXNG-backed
* `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a
* default-export service): a search provider does not own the `ctx.web` key —
* it registers INTO the seam's provider registry, exactly as
* `@deepseek-ai/dsh-web-search-exa` does. The key is owned by
* `@deepseek-ai/dsh-web`.
*
* @module @deepseek-ai/dsh-web-search-searxng
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/dsh-web'
import { SearXngSearchProvider } from './provider.ts'
export {
SEARXNG_DEFAULT_LANGUAGE,
SEARXNG_NO_TIME_RANGE,
SEARXNG_PROVIDER_ID,
SEARXNG_TIME_RANGES,
SearXngSearchProvider,
} from './provider.ts'
export type {
SearXngSearchProviderOptions,
SearXngSearchProviderSource,
SearXngTimeRange,
} from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-searxng'
/** The web seam this provider registers into. */
export const inject = ['web']
/** Plugin config. `baseURL` is optional in the schema so an omitted value
* surfaces as an unavailable provider (the seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)
* rather than failing boot; `apply` fills constant defaults. */
export interface Config {
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
baseURL?: string
/** SearXNG `language` to request. Defaults to `auto`. */
language?: string
/** SearXNG `time_range` recency filter. Omitted = no filter. */
timeRange?: 'day' | 'week' | 'month' | 'year'
}
export const Config: z<Config> = z.object({
baseURL: z.string(),
language: z.string(),
timeRange: z.union(['day', 'week', 'month', 'year'] as const),
})
/** Settings namespace carrying the SearXNG instance and any per-search filters. */
export const SEARXNG_SETTINGS_NAMESPACE = settingsNamespace('web-search-searxng')
/** Register the SearXNG search provider with `ctx.web`, reading the live section
* per search so an in-session settings edit applies without re-registration. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
installSettingsSection(ctx, SEARXNG_SETTINGS_NAMESPACE, Config, config, {
setSource: (source) => {
current = source
},
// The registration carries no resolved value: the provider projects the
// section per search, so a committed change needs no re-registration.
onChange: () => {},
})
ctx.web.registerSearchProvider(new SearXngSearchProvider(() => ({
// SearXNG exposes no uniform public endpoint and the product requires
// evidence over an unsupported default, so `baseURL` has no constant
// fallback: an omitted value surfaces as an unavailable provider (the
// seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`), not a silent endpoint.
baseURL: current().baseURL ?? '',
...current().language !== undefined ? { language: current().language } : {},
...current().timeRange !== undefined ? { timeRange: current().timeRange } : {},
})))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-web-search-searxng`.
* @module @deepseek-ai/dsh-web-search-searxng/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-web-search-searxng'
/** Cordis companion plugin name. */
export const name = 'web-search-searxng-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
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,166 @@
/**
* `SearXngSearchProvider`: a `WebSearchProvider` backed by a SearXNG instance's
* JSON API (`GET /search?format=json`). It maps against the flat `results[]`,
* keeps entries even without a snippet (URL and title remain useful), and
* omits `content` because SearXNG returns no generated answer. The provider
* carries no credentials, so a request carries no `Authorization` header.
* @module @deepseek-ai/dsh-web-search-searxng/provider
*/
import { WebError } from '@deepseek-ai/dsh-web'
import type {
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type { SearXngError, SearXngResult, SearXngSearchResponse } from './types.ts'
/** Stable id this provider registers under. */
export const SEARXNG_PROVIDER_ID = 'searxng'
/** Default language: let SearXNG decide per user preferences. */
export const SEARXNG_DEFAULT_LANGUAGE = 'auto'
/** `time_range` sentinel meaning "no recency filter". */
export const SEARXNG_NO_TIME_RANGE = 'none'
/** Valid `time_range` values SearXNG accepts. */
export const SEARXNG_TIME_RANGES = ['day', 'week', 'month', 'year'] as const
/** One of SearXNG's `time_range` values, passed through to the endpoint. */
export type SearXngTimeRange = (typeof SEARXNG_TIME_RANGES)[number]
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface SearXngSearchProviderOptions {
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
baseURL: string
/** SearXNG `language` to request. `auto` lets the instance decide. */
language?: string
/** SearXNG `time_range` recency filter. */
timeRange?: SearXngTimeRange
}
/** A snapshot or a per-search resolver (settings live-reload hands the latter). */
export type SearXngSearchProviderSource = SearXngSearchProviderOptions | (() => SearXngSearchProviderOptions)
/**
* Map one SearXNG result to a normalized source. The URL is always carried; a
* blank title or snippet is omitted rather than emitted as empty.
*
* @param result - one entry of SearXNG's `results[]`.
* @returns the normalized source.
*/
export function mapSearXngResult(result: SearXngResult): WebSearchSource {
return {
url: result.url,
...result.title != null && result.title.trim().length > 0 ? { title: result.title } : {},
...result.content != null && result.content.trim().length > 0 ? { snippet: result.content } : {},
...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
}
}
/**
* Map a SearXNG response envelope to a normalized search result.
*
* @param response - the parsed `format=json` response body.
* @returns the normalized result; `content` is omitted (no generated answer).
*/
export function mapSearXngResponse(response: SearXngSearchResponse): WebSearchResult {
// SearXNG cannot bound per-request result count (page size is instance
// configuration), so the request carries no count and the web service owns
// the final `maxResults` truncation; this provider reports `truncated: false`.
return { sources: (response.results ?? []).map(mapSearXngResult), truncated: false }
}
/** The SearXNG-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
export class SearXngSearchProvider implements WebSearchProvider {
readonly id = SEARXNG_PROVIDER_ID
constructor(source: SearXngSearchProviderSource) {
this.source = source
}
private readonly source: SearXngSearchProviderSource
/** Read the current options: a snapshot stays fixed, a resolver reads live settings. */
private resolveOptions(): SearXngSearchProviderOptions {
return typeof this.source === 'function' ? this.source() : this.source
}
available(): boolean {
const options = this.resolveOptions()
return isValidBaseUrl(options.baseURL)
&& (options.language === undefined || options.language.length > 0)
&& (options.timeRange === undefined || SEARXNG_TIME_RANGES.includes(options.timeRange))
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const options = this.resolveOptions()
const base = trimTrailingSlashes(options.baseURL)
const params = new URLSearchParams({ q: request.query, format: 'json' })
if (options.language !== undefined) params.set('language', options.language)
if (options.timeRange !== undefined) params.set('time_range', options.timeRange)
let response: Response
try {
response = await fetch(`${base}/search?${params}`, {
method: 'GET',
redirect: 'error',
headers: {
'accept': 'application/json',
'user-agent': USER_AGENT,
},
...signal !== undefined ? { signal } : {},
})
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`SearXNG search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
if (!response.ok) {
const status = response.status
let message = `SearXNG API error (HTTP ${status})`
try {
const parsed = await response.json() as SearXngError
const detail = parsed.error ?? parsed.message ?? parsed.content
if (detail !== undefined && detail.length > 0) message = detail
} catch (error: unknown) {
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
// into a generic HTTP-error message — cancellation is not a provider
// error (the seam's cancellation contract).
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
// Otherwise: the HTTP status is already captured in `message` above; a
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
// cost a richer provider message, never the real error.
}
throw new WebError(message, 'WEB_PROVIDER_ERROR')
}
try {
const payload = await response.json() as SearXngSearchResponse
return mapSearXngResponse(payload)
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`SearXNG returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
}
}
/** True when `baseURL` parses as an absolute URL (a cheap local config check). */
function isValidBaseUrl(baseURL: string): boolean {
return URL.canParse(baseURL)
}
/** Strip a trailing slash so `${base}/search` never doubles a separator. */
function trimTrailingSlashes(baseURL: string): string {
return baseURL.replace(/\/+$/, '')
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}

View File

@@ -0,0 +1,30 @@
/**
* Wire types for the SearXNG JSON API (`GET {base}/search?q=...&format=json`).
* Types only — no runtime code. SearXNG returns a flat `results[]`; each entry
* carries a URL, an optional title, an optional `content` snippet, and an
* optional `publishedDate`.
*
* @module @deepseek-ai/dsh-web-search-searxng/types
*/
/** One entry of SearXNG's flat `results[]`. */
export interface SearXngResult {
url: string
title?: string | null
/** The page snippet SearXNG derives for the result. */
content?: string | null
publishedDate?: string | null
}
/** SearXNG's JSON search response envelope. */
export interface SearXngSearchResponse {
results?: SearXngResult[]
}
/** SearXNG's error response envelope (best-effort; fields vary by failure). */
export interface SearXngError {
error?: string
message?: string
/** Some SearXNG error responses carry the message under `content`. */
content?: string
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { SearXngSearchProvider } from '@deepseek-ai/dsh-web-search-searxng'
/**
* Real-API smoke for the SearXNG search provider against a live instance.
* Self-skips without `$SEARXNG_BASE_URL` (CI has no endpoint), per the
* with-key e2e policy in docs/testing.md.
*/
const baseURL = process.env.SEARXNG_BASE_URL
const maybe = baseURL !== undefined && baseURL.length > 0 ? describe : describe.skip
maybe('SearXngSearchProvider real API', () => {
it('returns sources for a live query', async () => {
const provider = new SearXngSearchProvider({
baseURL: baseURL!,
...process.env.SEARXNG_LANGUAGE !== undefined && process.env.SEARXNG_LANGUAGE.length > 0
? { language: process.env.SEARXNG_LANGUAGE }
: {},
})
const result = await provider.search({ query: 'DeepSeek Harness', maxResults: 5 })
expect(result.sources.length).toBeGreaterThan(0)
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
}, 30_000)
})

View File

@@ -0,0 +1,226 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import WebRuntime from '@deepseek-ai/dsh-web'
import { SearXngSearchProvider, SEARXNG_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-searxng'
import * as searxngPlugin from '@deepseek-ai/dsh-web-search-searxng'
import { mapSearXngResponse, mapSearXngResult } from '../src/provider.ts'
const options = { baseURL: 'https://searx.test' }
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init })
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('SearXng result mapping', () => {
it('maps a full result entry', () => {
expect(mapSearXngResult({
url: 'https://a.test',
title: 'A',
content: 'an excerpt',
publishedDate: '2026-01-01',
})).toEqual({ url: 'https://a.test', title: 'A', snippet: 'an excerpt', publishedAt: '2026-01-01' })
})
it('keeps a URL-only result rather than dropping it', () => {
expect(mapSearXngResult({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' })
expect(mapSearXngResult({ url: 'https://a.test', content: ' ' })).toEqual({ url: 'https://a.test' })
})
it('omits null/empty optional fields rather than emitting them', () => {
expect(mapSearXngResult({ url: 'https://a.test', title: null, content: null, publishedDate: null }))
.toEqual({ url: 'https://a.test' })
expect(mapSearXngResult({ url: 'https://a.test', title: '', content: '', publishedDate: '' }))
.toEqual({ url: 'https://a.test' })
})
it('maps a response to a result with no content and all sources kept', () => {
const result = mapSearXngResponse({
results: [
{ url: 'https://a.test', title: 'A', content: 'one' },
{ url: 'https://b.test' },
{ url: 'https://c.test', content: 'three' },
],
})
expect(result).toEqual({
sources: [
{ url: 'https://a.test', title: 'A', snippet: 'one' },
{ url: 'https://b.test' },
{ url: 'https://c.test', snippet: 'three' },
],
truncated: false,
})
expect(result.content).toBeUndefined()
})
it('tolerates a missing results array', () => {
expect(mapSearXngResponse({}).sources).toEqual([])
})
})
describe('SearXngSearchProvider availability', () => {
it('is unavailable without a base URL', () => {
expect(new SearXngSearchProvider({ baseURL: '' }).available()).toBe(false)
})
it('is available with a base URL', () => {
expect(new SearXngSearchProvider(options).available()).toBe(true)
})
it('is misconfigured when the base URL is unparseable', () => {
expect(new SearXngSearchProvider({ baseURL: 'not a url' }).available()).toBe(false)
})
it('is misconfigured when language is empty or timeRange is invalid', () => {
expect(new SearXngSearchProvider({ ...options, language: '' }).available()).toBe(false)
expect(new SearXngSearchProvider({ ...options, timeRange: 'decade' as never }).available()).toBe(false)
expect(new SearXngSearchProvider({ ...options, timeRange: 'week' }).available()).toBe(true)
})
})
describe('SearXngSearchProvider request mapping', () => {
it('issues a GET with query and json format and no authorization header', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test' }] }))
vi.stubGlobal('fetch', fetchMock)
await new SearXngSearchProvider(options).search({ query: 'hello world' })
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://searx.test/search?q=hello+world&format=json')
expect(init.method).toBe('GET')
expect(init.redirect).toBe('error')
expect((init.headers as Record<string, string>)['authorization']).toBeUndefined()
})
it('sends language and time_range when configured', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
vi.stubGlobal('fetch', fetchMock)
await new SearXngSearchProvider({ ...options, language: 'en', timeRange: 'week' }).search({ query: 'q' })
const [url] = fetchMock.mock.calls[0] as unknown as [string]
expect(url).toBe('https://searx.test/search?q=q&format=json&language=en&time_range=week')
})
it('omits language and time_range when unset', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
vi.stubGlobal('fetch', fetchMock)
await new SearXngSearchProvider(options).search({ query: 'q' })
const [url] = fetchMock.mock.calls[0] as unknown as [string]
expect(url).toBe('https://searx.test/search?q=q&format=json')
})
it('does not double the separator when baseURL carries a trailing slash', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
vi.stubGlobal('fetch', fetchMock)
await new SearXngSearchProvider({ baseURL: 'https://searx.test/' }).search({ query: 'q' })
const [url] = fetchMock.mock.calls[0] as unknown as [string]
expect(url).toBe('https://searx.test/search?q=q&format=json')
})
it('forwards the abort signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
await new SearXngSearchProvider(options).search({ query: 'q' }, controller.signal)
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(init.signal).toBe(controller.signal)
})
})
describe('SearXngSearchProvider error handling', () => {
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'missing instance' }, { status: 401 })))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'missing instance' }))
})
it('keeps a status-line message when the error body is not JSON', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('gateway down', { status: 502 })))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'SearXNG API error (HTTP 502)' }))
})
it('reads the message from content when present', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: 'json disabled' }, { status: 403 })))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'json disabled' }))
})
it('maps a network failure to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused'))))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('maps an abort to WEB_ABORTED', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError'))))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 })))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => {
const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 }
vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('surfaces an abort during error-body parse as WEB_ABORTED', async () => {
const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 }
vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
await expect(new SearXngSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
})
describe('web-search-searxng plugin registration', () => {
it('registers the provider into ctx.web (HMR-safe)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })))
const ctx = new Context()
await ctx.plugin(WebRuntime, { searchProvider: SEARXNG_PROVIDER_ID })
const fiber = await ctx.plugin(searxngPlugin, { baseURL: options.baseURL })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false })
await fiber.dispose()
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
})
it('has no default export (namespace plugin export shape)', () => {
expect('default' in searxngPlugin).toBe(false)
})
it('threads language and timeRange config into the request', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
vi.stubGlobal('fetch', fetchMock)
const ctx = new Context()
await ctx.plugin(WebRuntime, { searchProvider: SEARXNG_PROVIDER_ID })
const fiber = await ctx.plugin(searxngPlugin, { baseURL: options.baseURL, language: 'en', timeRange: 'week' })
await ctx.web.search({ query: 'q' })
const [url] = fetchMock.mock.calls[0] as unknown as [string]
expect(url).toBe('https://searx.test/search?q=q&format=json&language=en&time_range=week')
await fiber.dispose()
})
it('is unavailable when baseURL is omitted', async () => {
const ctx = new Context()
await ctx.plugin(WebRuntime, { searchProvider: SEARXNG_PROVIDER_ID })
await ctx.plugin(searxngPlugin, { baseURL: '' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
})
})

View File

@@ -0,0 +1,120 @@
/** The `web-search-searxng` settings section layered over the composition entry. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { SettingsProvider } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import WebRuntime from '@deepseek-ai/dsh-web'
import * as searxngPlugin from '@deepseek-ai/dsh-web-search-searxng'
import { SEARXNG_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-web-search-searxng'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends SettingsProvider {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
/** The smallest SearXNG-shaped answer the provider accepts — enough to observe the request. */
const ONE_RESULT = {
results: [{ url: 'https://a.test', title: 'A', content: 'snip' }],
}
async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; pluginFiber: Fiber }> {
const ctx = new Context()
await ctx.plugin(WebRuntime, {})
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const pluginFiber = ctx.plugin(searxngPlugin, { baseURL: 'https://search.entry.test' })
await pluginFiber.await()
return { ctx, settingsFiber, pluginFiber }
}
afterEach(() => {
vi.restoreAllMocks()
})
/**
* Run one search and answer the endpoint it reached. A fresh `Response` per
* call because a body can only be read once, and the call history is cleared
* because repeated `spyOn` returns the same spy.
* @param ctx - context whose `ctx.web` serves the search.
* @returns the URL the provider fetched.
*/
async function searchOnce(ctx: Context): Promise<string> {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
.mockImplementation(() => Promise.resolve(jsonResponse(ONE_RESULT)))
fetchSpy.mockClear()
await ctx.web.search({ query: 'anything' })
return String((fetchSpy.mock.calls.at(-1)?.[0] as URL | string | undefined) ?? '')
}
describe('web-search-searxng settings section', () => {
it('serves a stored endpoint to the next search without re-registering the provider', async () => {
const bench = await boot()
expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test')
await bench.ctx.settings.update(SEARXNG_SETTINGS_NAMESPACE, {
baseURL: 'https://search.stored.test',
})
expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test')
await bench.ctx.fiber.dispose()
})
it('applies a stored language and time range to the next request', async () => {
const bench = await boot()
await bench.ctx.settings.update(SEARXNG_SETTINGS_NAMESPACE, {
baseURL: 'https://search.entry.test',
language: 'de',
timeRange: 'week',
})
const url = new URL(await searchOnce(bench.ctx))
expect(url.searchParams.get('language')).toBe('de')
expect(url.searchParams.get('time_range')).toBe('week')
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot()
await bench.ctx.settings.update(SEARXNG_SETTINGS_NAMESPACE, {
baseURL: 'https://search.stored.test',
})
expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test')
await bench.settingsFiber.dispose()
expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test')
await bench.ctx.fiber.dispose()
})
it('releases the namespace when the plugin unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('web-search-searxng')
await bench.pluginFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('web-search-searxng')
await bench.ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../web"
},
{
"path": "../../settings/settings"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/web/README.md
README.md: 8dfc7f032e25e40207bb3880777b814e67175a20
README.zh.md: 3354037bfcd77ca2b1a07da710b565065baf543b
README.md: 0071d12d36c4fb696c8b35bef35bf1c1d62b22f1
README.zh.md: 9146b78778dd9e8e208c2ae3d997fee72829c18a

View File

@@ -10,6 +10,7 @@ This package owns the Service Definition role of the web capability. Unlike shel
|---|---|
| `@deepseek-ai/dsh-web` (this) | Service Definition: the service, provider registries, selection policy, request/result vocabulary, the `WebError` taxonomy |
| `@deepseek-ai/dsh-web-search-exa` | Search provider: Exa |
| `@deepseek-ai/dsh-web-search-searxng` | Search provider: SearXNG |
| `@deepseek-ai/dsh-web-search-perplexity` | Search provider: Perplexity |
| `@deepseek-ai/dsh-web-fetch-http` | Fetch provider: anonymous public HTTP(S) |
| `@deepseek-ai/dsh-tool-web` | Consumer: the model-facing `web_search` / `web_fetch` tool schemas over `ctx.web` |

View File

@@ -10,6 +10,7 @@
|---|---|
| `@deepseek-ai/dsh-web`(本包) | Service Definition:服务、提供方注册表、选择策略、请求/结果词汇、`WebError` 分类体系 |
| `@deepseek-ai/dsh-web-search-exa` | 搜索提供方:Exa |
| `@deepseek-ai/dsh-web-search-searxng` | 搜索提供方:SearXNG |
| `@deepseek-ai/dsh-web-search-perplexity` | 搜索提供方:Perplexity |
| `@deepseek-ai/dsh-web-fetch-http` | 抓取提供方:匿名公共 HTTP(S) |
| `@deepseek-ai/dsh-tool-web` | Consumer:面向模型的 `web_search`/`web_fetch` 工具 schema,构建于 `ctx.web` 之上 |