feat: add searxng web-search provider, openrouter cost balance UI, offline scripts; update source-launch and docs
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 58s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 2m46s
Deploy documentation / deploy (push) Has been skipped
E2E (real DeepSeek API) / e2e (push) Failing after 1m18s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m18s
Landlock Run / Matrix (push) Successful in 13s
Release (vendor) / Pack npm tarballs (push) Failing after 3m43s
Release (dsh) / Pack npm tarballs (push) Failing after 1m53s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m51s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 58s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 2m46s
Deploy documentation / deploy (push) Has been skipped
E2E (real DeepSeek API) / e2e (push) Failing after 1m18s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m18s
Landlock Run / Matrix (push) Successful in 13s
Release (vendor) / Pack npm tarballs (push) Failing after 3m43s
Release (dsh) / Pack npm tarballs (push) Failing after 1m53s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m51s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
This commit is contained in:
6
packages/llm/openrouter-usage/README.i18n.yaml
Normal file
6
packages/llm/openrouter-usage/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/openrouter-usage/README.md
|
||||
README.md: e6606d4cc02b8b34dc643a2c810edf7c41288007
|
||||
README.zh.md: 07e7d32245688f33cef43858c7089a630306b8ae
|
||||
101
packages/llm/openrouter-usage/README.md
Normal file
101
packages/llm/openrouter-usage/README.md
Normal 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.
|
||||
63
packages/llm/openrouter-usage/README.zh.md
Normal file
63
packages/llm/openrouter-usage/README.zh.md
Normal 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 暴露配置;设置项界面推迟实现。
|
||||
84
packages/llm/openrouter-usage/package.json
Normal file
84
packages/llm/openrouter-usage/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
11
packages/llm/openrouter-usage/src/client.ts
Normal file
11
packages/llm/openrouter-usage/src/client.ts
Normal 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'
|
||||
253
packages/llm/openrouter-usage/src/index.ts
Normal file
253
packages/llm/openrouter-usage/src/index.ts
Normal 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
|
||||
33
packages/llm/openrouter-usage/src/invariant.ts
Normal file
33
packages/llm/openrouter-usage/src/invariant.ts
Normal 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 */
|
||||
226
packages/llm/openrouter-usage/src/openrouter.ts
Normal file
226
packages/llm/openrouter-usage/src/openrouter.ts
Normal 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)
|
||||
}
|
||||
132
packages/llm/openrouter-usage/src/projection.ts
Normal file
132
packages/llm/openrouter-usage/src/projection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
81
packages/llm/openrouter-usage/src/types.ts
Normal file
81
packages/llm/openrouter-usage/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
173
packages/llm/openrouter-usage/tests/loader-composition.spec.ts
Normal file
173
packages/llm/openrouter-usage/tests/loader-composition.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
174
packages/llm/openrouter-usage/tests/openrouter.spec.ts
Normal file
174
packages/llm/openrouter-usage/tests/openrouter.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
205
packages/llm/openrouter-usage/tests/projection.spec.ts
Normal file
205
packages/llm/openrouter-usage/tests/projection.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
45
packages/llm/openrouter-usage/tsconfig.json
Normal file
45
packages/llm/openrouter-usage/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user