feat: add searxng web-search provider, openrouter cost balance UI, offline scripts; update source-launch and docs
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 58s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 2m46s
Deploy documentation / deploy (push) Has been skipped
E2E (real DeepSeek API) / e2e (push) Failing after 1m18s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m18s
Landlock Run / Matrix (push) Successful in 13s
Release (vendor) / Pack npm tarballs (push) Failing after 3m43s
Release (dsh) / Pack npm tarballs (push) Failing after 1m53s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m51s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled

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

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/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"
}
]
}