From ed152416d51417aea2d590e0a9494cc846d6b6ee Mon Sep 17 00:00:00 2001 From: Coder Date: Thu, 20 Aug 2026 13:01:40 +0700 Subject: [PATCH] feat: add searxng web-search provider, openrouter cost balance UI, offline scripts; update source-launch and docs --- ...8-19-searxng-web-search-provider.i18n.yaml | 6 + .../2026-08-19-searxng-web-search-provider.md | 38 +++ ...26-08-19-searxng-web-search-provider.zh.md | 38 +++ ...openrouter-cost-balance-readouts.i18n.yaml | 6 + ...-08-20-openrouter-cost-balance-readouts.md | 90 ++++++ ...-20-openrouter-cost-balance-readouts.zh.md | 32 ++ ...19-offline-pnpm-prefetch-scripts.i18n.yaml | 6 + ...026-08-19-offline-pnpm-prefetch-scripts.md | 37 +++ ...-08-19-offline-pnpm-prefetch-scripts.zh.md | 37 +++ ...19-source-dsh-http-proxy-overlay.i18n.yaml | 6 + ...026-08-19-source-dsh-http-proxy-overlay.md | 35 +++ ...-08-19-source-dsh-http-proxy-overlay.zh.md | 35 +++ ...ce-run-without-managed-installer.i18n.yaml | 4 +- ...10-source-run-without-managed-installer.md | 2 +- ...source-run-without-managed-installer.zh.md | 2 +- ...eparate-source-launch-from-build.i18n.yaml | 4 +- ...08-12-separate-source-launch-from-build.md | 4 +- ...12-separate-source-launch-from-build.zh.md | 4 +- .gitignore | 3 + _probe2.py | 55 ++++ apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/tests/source-launch.compat.spec.ts | 8 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 10 +- docs/capability-seams.zh.md | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 45 +++ docs/config-catalog.zh.md | 45 +++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 29 +- docs/module-graph.zh.md | 29 +- docs/subsystems/web.i18n.yaml | 4 +- docs/subsystems/web.md | 2 +- docs/subsystems/web.zh.md | 2 +- knip.json | 10 + package.json | 2 +- packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/tsconfig.client.json | 3 + packages/bundle/web-app/cordis.patch.yml | 20 ++ packages/bundle/web-app/package.json | 2 + .../ui-openrouter-usage/README.i18n.yaml | 6 + packages/client/ui-openrouter-usage/README.md | 46 +++ .../client/ui-openrouter-usage/README.zh.md | 24 ++ .../client/ui-openrouter-usage/package.json | 85 +++++ .../src/client/BalanceBadge.module.css | 36 +++ .../src/client/BalanceBadge.tsx | 68 ++++ .../src/client/CostDock.module.css | 10 + .../src/client/CostDock.tsx | 39 +++ .../ui-openrouter-usage/src/client/index.ts | 77 +++++ .../ui-openrouter-usage/src/client/locales.ts | 22 ++ .../ui-openrouter-usage/src/client/money.ts | 18 ++ .../ui-openrouter-usage/src/css-modules.d.ts | 6 + .../client/ui-openrouter-usage/src/index.ts | 9 + .../ui-openrouter-usage/src/invariant.ts | 32 ++ .../tests/balance-badge.client.spec.tsx | 70 +++++ .../tests/browser-plugin.client.spec.tsx | 134 ++++++++ .../tests/cost-dock.client.spec.tsx | 60 ++++ .../client/ui-openrouter-usage/tsconfig.json | 42 +++ .../ui-openrouter-usage/tsdown.config.ts | 3 + .../llm/openrouter-usage/README.i18n.yaml | 6 + packages/llm/openrouter-usage/README.md | 101 ++++++ packages/llm/openrouter-usage/README.zh.md | 63 ++++ packages/llm/openrouter-usage/package.json | 84 +++++ packages/llm/openrouter-usage/src/client.ts | 11 + packages/llm/openrouter-usage/src/index.ts | 253 +++++++++++++++ .../llm/openrouter-usage/src/invariant.ts | 33 ++ .../llm/openrouter-usage/src/openrouter.ts | 226 ++++++++++++++ .../llm/openrouter-usage/src/projection.ts | 132 ++++++++ packages/llm/openrouter-usage/src/types.ts | 81 +++++ .../tests/loader-composition.spec.ts | 173 +++++++++++ .../openrouter-usage/tests/openrouter.spec.ts | 174 +++++++++++ .../openrouter-usage/tests/projection.spec.ts | 205 ++++++++++++ packages/llm/openrouter-usage/tsconfig.json | 45 +++ packages/web/README.i18n.yaml | 4 +- packages/web/README.md | 1 + packages/web/README.zh.md | 1 + .../web/web-search-searxng/README.i18n.yaml | 6 + packages/web/web-search-searxng/README.md | 52 ++++ packages/web/web-search-searxng/README.zh.md | 52 ++++ packages/web/web-search-searxng/package.json | 49 +++ packages/web/web-search-searxng/src/index.ts | 79 +++++ .../web/web-search-searxng/src/invariant.ts | 30 ++ .../web/web-search-searxng/src/provider.ts | 166 ++++++++++ packages/web/web-search-searxng/src/types.ts | 30 ++ .../web-search-searxng/tests/searxng.e2e.ts | 24 ++ .../web-search-searxng/tests/searxng.spec.ts | 226 ++++++++++++++ .../web-search-searxng/tests/settings.spec.ts | 120 +++++++ packages/web/web-search-searxng/tsconfig.json | 30 ++ packages/web/web/README.i18n.yaml | 4 +- packages/web/web/README.md | 1 + packages/web/web/README.zh.md | 1 + pnpm-lock.yaml | 122 ++++++++ scripts/AGENTS.md | 2 + scripts/apply-source-dsh-http-proxy.spec.ts | 98 ++++++ scripts/apply-source-dsh-http-proxy.ts | 175 +++++++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 10 +- scripts/offline/download-deps.ps1 | 206 ++++++++++++ scripts/offline/download-deps.sh | 293 ++++++++++++++++++ scripts/offline/install-deps.ps1 | 69 +++++ scripts/offline/install-deps.sh | 87 ++++++ scripts/run-source-dsh.ts | 65 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.client.json | 1 + tsconfig.host.json | 2 + 111 files changed, 5038 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.md create mode 100644 .agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.zh.md create mode 100644 .agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.md create mode 100644 .agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.zh.md create mode 100644 .agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.md create mode 100644 .agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.zh.md create mode 100644 _probe2.py create mode 100644 packages/client/ui-openrouter-usage/README.i18n.yaml create mode 100644 packages/client/ui-openrouter-usage/README.md create mode 100644 packages/client/ui-openrouter-usage/README.zh.md create mode 100644 packages/client/ui-openrouter-usage/package.json create mode 100644 packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css create mode 100644 packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx create mode 100644 packages/client/ui-openrouter-usage/src/client/CostDock.module.css create mode 100644 packages/client/ui-openrouter-usage/src/client/CostDock.tsx create mode 100644 packages/client/ui-openrouter-usage/src/client/index.ts create mode 100644 packages/client/ui-openrouter-usage/src/client/locales.ts create mode 100644 packages/client/ui-openrouter-usage/src/client/money.ts create mode 100644 packages/client/ui-openrouter-usage/src/css-modules.d.ts create mode 100644 packages/client/ui-openrouter-usage/src/index.ts create mode 100644 packages/client/ui-openrouter-usage/src/invariant.ts create mode 100644 packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx create mode 100644 packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx create mode 100644 packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx create mode 100644 packages/client/ui-openrouter-usage/tsconfig.json create mode 100644 packages/client/ui-openrouter-usage/tsdown.config.ts create mode 100644 packages/llm/openrouter-usage/README.i18n.yaml create mode 100644 packages/llm/openrouter-usage/README.md create mode 100644 packages/llm/openrouter-usage/README.zh.md create mode 100644 packages/llm/openrouter-usage/package.json create mode 100644 packages/llm/openrouter-usage/src/client.ts create mode 100644 packages/llm/openrouter-usage/src/index.ts create mode 100644 packages/llm/openrouter-usage/src/invariant.ts create mode 100644 packages/llm/openrouter-usage/src/openrouter.ts create mode 100644 packages/llm/openrouter-usage/src/projection.ts create mode 100644 packages/llm/openrouter-usage/src/types.ts create mode 100644 packages/llm/openrouter-usage/tests/loader-composition.spec.ts create mode 100644 packages/llm/openrouter-usage/tests/openrouter.spec.ts create mode 100644 packages/llm/openrouter-usage/tests/projection.spec.ts create mode 100644 packages/llm/openrouter-usage/tsconfig.json create mode 100644 packages/web/web-search-searxng/README.i18n.yaml create mode 100644 packages/web/web-search-searxng/README.md create mode 100644 packages/web/web-search-searxng/README.zh.md create mode 100644 packages/web/web-search-searxng/package.json create mode 100644 packages/web/web-search-searxng/src/index.ts create mode 100644 packages/web/web-search-searxng/src/invariant.ts create mode 100644 packages/web/web-search-searxng/src/provider.ts create mode 100644 packages/web/web-search-searxng/src/types.ts create mode 100644 packages/web/web-search-searxng/tests/searxng.e2e.ts create mode 100644 packages/web/web-search-searxng/tests/searxng.spec.ts create mode 100644 packages/web/web-search-searxng/tests/settings.spec.ts create mode 100644 packages/web/web-search-searxng/tsconfig.json create mode 100644 scripts/apply-source-dsh-http-proxy.spec.ts create mode 100644 scripts/apply-source-dsh-http-proxy.ts create mode 100644 scripts/offline/download-deps.ps1 create mode 100644 scripts/offline/download-deps.sh create mode 100644 scripts/offline/install-deps.ps1 create mode 100644 scripts/offline/install-deps.sh create mode 100644 scripts/run-source-dsh.ts diff --git a/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.i18n.yaml new file mode 100644 index 0000000000..a6988fcdc5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.md +2026-08-19-searxng-web-search-provider.md: 377dba2af133677b4fd1fd591cb3929ac2acda33 +2026-08-19-searxng-web-search-provider.zh.md: 29d406831ea947c383263dd5cdba840fa9e448a8 diff --git a/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.md b/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.md new file mode 100644 index 0000000000..377dba2af1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.md @@ -0,0 +1,38 @@ +# Agent Note: SearXNG web search provider + +Status: implemented + +English | [中文](2026-08-19-searxng-web-search-provider.zh.md) + +## Problem + +The harness [web capability seam](2026-06-24-web-capability-seam.md) ships three search providers — Exa, Perplexity, and native DeepSeek web search — all of which are hosted API services. None fits a deployment that wants to self-host its own metasearch. SearXNG is a self-hostable metasearch engine with a JSON API, so it is the natural backend for such deployments. It differs from the hosted providers in two ways that shape the adapter: there is no single canonical SearXNG endpoint (each instance is its own server, so `baseURL` cannot have a sensible public default), and an engine does not expose a per-request result-count control (page size is instance configuration). + +## Decision + +Add `@deepseek-ai/dsh-web-search-searxng` (`packages/web/web-search-searxng`), a `WebSearchProvider` that registers under the stable id `searxng` and calls a SearXNG instance's JSON API (`GET {base}/search?q=...&format=json`). + +- `baseURL` has **no default**; the schema keeps it optional so an omitted or unparseable value surfaces as an unavailable provider (`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`) rather than a boot error. The product requires evidence before an unsupported default endpoint, and no single public SearXNG instance is authoritative. +- Config flows through the `web-search-searxng` Settings namespace (layered over the cordis entry), and the provider projects the resolved section **per search**, so a settings edit applies to the next call without re-registration — the same projection topology as `dsh-web-search-deepseek`. +- The provider carries **no API key and no `Authorization` header**: it targets open instances, so no credential can leak to a redirect target. HTTP redirects are still rejected with `redirect: 'error'`, mapping to `WEB_PROVIDER_ERROR`, matching the seam's redirect hygiene. +- `language` (SearXNG `language`, default `auto`) and `timeRange` (`day`|`week`|`month`|`year`) are exposed; a request carries no result count because the API cannot bound one, so the seam's `maxResults` truncation is the sole bound. `content` is omitted (no generated answer). +- Results map to `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `content`. Unlike Exa, a result **without a snippet is kept** — URL and title are still useful — so sources are not dropped for missing snippets. + +The package follows the provider topology of `dsh-web-search-exa`: a function/namespace plugin (`inject: ['web']`) that registers into `ctx.web`, `inject`-less in the sense that it registers a backend rather than owning the `ctx.web` key, with package-owned mapping and error translation and an empty package invariant companion. + +## Alternatives considered + +### Give `baseURL` a public-instance default + +Rejected. A default like `https://searx.be` would pick an arbitrary, possibly unreachable or rate-limited public instance and silently send traffic to a third party without the operator choosing it. Requiring an explicit `baseURL` makes the provider unavailable until configured, which is the honest misconfiguration signal the seam already names (`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`). + +### Route SearXNG through an OpenAI-compatible gateway instead of a dedicated provider + +Rejected. The harness search seam consumes `WebSearchProvider` backends, not arbitrary OpenAI-compatible endpoints; the LLM provider configuration surface does not feed `ctx.web`. A dedicated SearXNG `WebSearchProvider` is the only path that reaches the web search tools without a bespoke glue service. + +## Consequences + +- A deployment gains a self-hosted search backend whose query goes to the operator's own instance, with optional language and recency filters but no per-query result-count control — single-page results are truncated by the seam. +- The provider set grows to four search backends; `searchProvider` (or `$DSH_WEB_SEARCH_PROVIDER`) names `searxng` to select it, or auto-selection picks it when it is the only usable provider. +- Redirect rejection means a misconfigured `baseURL` that answers 3xx surfaces as `WEB_PROVIDER_ERROR` rather than silently following; with no credentials on the request this is hygiene, not secret protection. +- Snippet-less results are retained, so `tool-web` renders fallback URL/hostname labels for them rather than dropping the source. diff --git a/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.zh.md b/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.zh.md new file mode 100644 index 0000000000..29d406831e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-searxng-web-search-provider.zh.md @@ -0,0 +1,38 @@ +# Agent Note: SearXNG web search provider + +Status: implemented + +[English](2026-08-19-searxng-web-search-provider.md) | 中文 + +## 问题 + +harness [web 能力 seam](2026-06-24-web-capability-seam.md) 自带了三个搜索提供方——Exa、Perplexity 与 DeepSeek 原生 web 搜索——它们都是托管 API 服务。它们都不适合希望自托管元搜索的部署场景。SearXNG 是具备 JSON API 的可自托管元搜索引擎,因此是这类部署的自然后端。它在这两个方面与托管提供方不同,进而塑造了适配器:不存在统一的 SearXNG 端点(每个实例都是独立服务器,因此 `baseURL` 无法有合理的公共默认值),且引擎不提供按请求控制结果数量的方式(页面大小属于实例配置)。 + +## 决策 + +新增 `@deepseek-ai/dsh-web-search-searxng`(`packages/web/web-search-searxng`),一个以稳定 id `searxng` 注册、调用 SearXNG 实例 JSON API(`GET {base}/search?q=...&format=json`)的 `WebSearchProvider`。 + +- `baseURL` **无默认值**;schema 保持其为可选,因此省略或无法解析的值呈现为不可用提供方(`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`),而非启动错误。产品要求先有证据再采用未经验证的默认端点,且不存在权威的公共 SearXNG 实例。 +- 配置经由 `web-search-searxng` Settings 命名空间流入(叠加在 cordis 条目之上),提供方**按搜索**投影解析后的分节,因此设置变更在下次调用即生效,无需重新注册——与 `dsh-web-search-deepseek` 的投影拓扑一致。 +- 提供方**不携带 API 密钥或 `Authorization` 头**:它面向开放实例,因此不会有凭据泄露给重定向目标。HTTP 重定向仍以 `redirect: 'error'` 拒绝,映射为 `WEB_PROVIDER_ERROR`,与 seam 的重定向卫生一致。 +- 公开 `language`(SearXNG `language`,默认 `auto`)与 `timeRange`(`day`|`week`|`month`|`year`);请求不携带结果数量,因为 API 无法限制数量,因此 seam 的 `maxResults` 截断是唯一上限。省略 `content`(无生成答案)。 +- 结果映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `content`。与 Exa 不同,**没有 snippet 的结果会被保留**——URL 与标题仍然有用——因此不会因缺少 snippet 而丢弃来源。 + +该包遵循 `dsh-web-search-exa` 的提供方拓扑:函数/命名空间插件(`inject: ['web']`)向 `ctx.web` 注册后端而非拥有 `ctx.web` 键,拥有包级映射与错误转换,并带一个空的包级 invariant 伴生插件。 + +## 备选方案 + +### 为 `baseURL` 提供公共实例默认值 + +拒绝。类似 `https://searx.be` 的默认值会挑选任意、可能不可达或被限速的公共实例,并在未由操作员选择的情况下把流量悄悄发给第三方。要求显式 `baseURL` 会使提供方在配置前不可用,这正是 seam 已命名的诚实误配置信号(`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)。 + +### 通过 OpenAI 兼容网关路由 SearXNG,而非专用提供方 + +拒绝。harness 搜索 seam 消费的是 `WebSearchProvider` 后端,而非任意 OpenAI 兼容端点;LLM 提供方配置面并不供给 `ctx.web`。专用 SearXNG `WebSearchProvider` 是不借助自制胶水服务而触达 web 搜索工具的唯一路径。 + +## 后果 + +- 部署获得自托管搜索后端,其查询发往操作员自己的实例,支持可选的语言与时效过滤器,但没有按查询的结果数量控制——单页结果由 seam 截断。 +- 提供方集合增至四个搜索后端;`searchProvider`(或 `$DSH_WEB_SEARCH_PROVIDER`)指定 `searxng` 以选择它,或当其是唯一可用提供方时自动选择。 +- 拒绝重定向意味着返回 3xx 的误配置 `baseURL` 呈现为 `WEB_PROVIDER_ERROR` 而非静默跟随;由于请求无凭据,这是卫生而非密钥保护。 +- 无 snippet 的结果被保留,因此 `tool-web` 为它们渲染回退的 URL/主机名标签,而非丢弃来源。 diff --git a/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.i18n.yaml new file mode 100644 index 0000000000..6d0bbbee46 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md +2026-08-20-openrouter-cost-balance-readouts.md: 339acc4140eb6c34d1c3910a93babbeda9a28cac +2026-08-20-openrouter-cost-balance-readouts.zh.md: 16606ae58b346a1eeb10f703eae748665f64ea83 diff --git a/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md b/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md new file mode 100644 index 0000000000..339acc4140 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md @@ -0,0 +1,90 @@ +# Agent Note: OpenRouter cost and balance readouts in the web GUI + +Status: implemented + +English | [中文](2026-08-20-openrouter-cost-balance-readouts.zh.md) + +> Scope: two new packages — `@deepseek-ai/dsh-openrouter-usage` (host gateway + +> projection) and `@deepseek-ai/dsh-client-ui-openrouter-usage` (web readouts) — +> composed into the web profile. The gateway resolves the same +> `OPENROUTER_API_KEY` credential that OpenRouter LLM routing uses. + +## Problem + +A user running the web GUI with LLM calls routed through OpenRouter has no +visibility into what those calls cost or how much account credit remains. +Token counts exist in the session log (`assistant/message.usage`, `assistant/chunk` +usage chunks) and the token-meter projection, but there is no monetary figure +anywhere in the repository, and no durable record of OpenRouter's per-model +pricing or account balance. The harness deliberately discards the provider's +own cost metadata (the pi-ai adapter zeroes `usage.cost`), so cost had to be +reconstructed from token counts against a pricing table fetched from +OpenRouter's public API. + +## Decision + +**Cost is a client-facing read-model projection, never a logged or model-visible +fact.** The gateway fetches OpenRouter's model pricing table (`GET /models`, +`pricing.prompt`/`completion` USD per token, flat `request` fee, disclosed +cache rates) and folds each session's logged usage against it in the +`openRouterCost` `SessionProjectionMap` entry. Balance comes from OpenRouter's +canonical balance endpoint `GET /credits` (available credits = `total_credits` +minus the spent `total_usage`, the figure the dashboard surfaces), merged with +`GET /auth/key` for the key label and the monthly token budget +(`usage`/`limit`); a standard `sk-or-v1` key returns **no** `credits` field on +`/auth/key`, so that endpoint alone would always read a null balance. Both +fetches go through plain `globalThis.fetch` with `redirect: 'error'`, the host +standard for credential-bearing outbound calls. + +**The web surface is two slot entries.** A per-session cost readout registers +into `conversation.composer.dock` (the shipped stats line's dock), reading the +`openRouterCost` projection through the `useProjection` framework seat; a live +balance badge registers into `sidebar.footer.action`, polling the balance +Remote on a 60s client-side interval. Both hide themselves when their data is +absent: cost hides before any priced step, balance renders an empty dashes +marker when the snapshot has no value yet. + +**The API key is a credential reference, seeded identically to routing.** +`apiKeyEnv` (default `OPENROUTER_API_KEY`) resolves through the credentials +seam with an environment fallback exactly like `dsh-web-search-deepseek`, +captured per refresh in a thunk so a credentials or settings change applies on +the next tick without a rebuild. + +**No key → no fetch, and the plugin stays inert.** No key produces an empty +pricing table (every step stays unpriced), an all-`null` balance, and the +client reads null-ish values it renders as absent — the same dormant posture +as the LLM adapters, not a load-time failure. + +## Alternatives considered + +- **Persisting per-request cost as a session event** — cost is derived from + events already logged (`assistant/message` usage, `assistant/chunk` usage); + an event would duplicate accounting, be priced at the wrong time (pricing + changes with `pricingRefreshMs`), and leak a client-facing figure into the + durable log. The projection stays the single read-model home. +- **Balance via `/auth/key` alone** — the plan's original shape; the real + endpoint response for a standard `sk-or-v1` key carries no `credits` field, + verified against OpenRouter's live API, so the snapshot would always read + null. `/credits` is the authoritative balance endpoint and `/auth/key` only + supplements label + monthly budget. +- **Reading `usage.cost` from pi-ai** — the harness deliberately discards + provider cost metadata (the adapter pins `NO_COST`); reconstructing from + tokens × table is the only available path and is documented as an estimate. +- **A settings card and a dedicated credentials UI** — deferred: the readouts + work with the existing credential the routing already uses; a card adds + surface without changing behavior. + +## Consequences + +`test:gui` stays green (3800 tests) and both packages carry unit tests plus a +real-composition loader test that boots the shipped YAML shape through the +vendored Loader, resolves the key from a credentials document, and asserts +the mocked `/models` and `/credits` fetches populate the projection and the +snapshot. The numbers are an estimate, not itemized billing: the projection +prices `tokens × pricing` per step, pricing is applied as of fold time +(refreshing pricing affects only cells folded afterward), cache rates fall +back to the prompt rate when undisclosed, and free-tier / promotional pricing +may differ from the model table — all documented under Known Limitations. +Nothing is model-visible: the agent loop, session log format, and SDKs are +untouched. Deferred: a settings card, and any per-generation billing fidelity +that would require logging OpenRouter generation ids. \ No newline at end of file diff --git a/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.zh.md b/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.zh.md new file mode 100644 index 0000000000..16606ae58b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.zh.md @@ -0,0 +1,32 @@ +# Agent Note: OpenRouter cost and balance readouts in the web GUI + +Status: implemented + +[English](2026-08-20-openrouter-cost-balance-readouts.md) | 中文 + +> 范围:两个新包 —— `@deepseek-ai/dsh-openrouter-usage`(主机端网关 + 投影)与 `@deepseek-ai/dsh-client-ui-openrouter-usage`(Web 读数)—— 组合进 web profile。网关解析与 OpenRouter LLM 路由相同的 `OPENROUTER_API_KEY` 凭证。 + +## Problem + +使用 Web GUI 并通过 OpenRouter 路由 LLM 调用的用户,无法看到这些调用花费了多少、账户还剩多少额度。token 计数存在于会话日志(`assistant/message.usage`、`assistant/chunk` 用量块)和 token-meter 投影中,但仓库里没有任何货币数字,也没有 OpenRouter 按模型定价或账户余额的持久记录。harness 有意丢弃 provider 自身的成本元数据(pi-ai 适配器将 `usage.cost` 清零),因此成本必须根据 token 计数与从 OpenRouter 公共 API 拉取的定价表重建。 + +## Decision + +**费用是面向客户端的只读模型投影,绝不是被记录或模型可见的事实。** 网关拉取 OpenRouter 的模型定价表(`GET /models`,`pricing.prompt`/`completion` USD 每 token、flat 的 `request` 费用、披露的缓存费率),并在 `openRouterCost` 的 `SessionProjectionMap` 条目中按各会话已记录的用量折叠它。余额来自 OpenRouter 权威余额端点 `GET /credits`(可用余额 = `total_credits` 减去已花费的 `total_usage`,即仪表盘展示的数字),并与 `GET /auth/key` 合并以获取 key 标签与月度 token 预算(`usage`/`limit`);标准 `sk-or-v1` key 在 `/auth/key` 上**不**返回 `credits` 字段,因此仅靠该端点总会读到空余额。两个请求都使用 `redirect: 'error'` 的 `global.fetch` —— 携带凭证的出站调用的主机标准。 + +**Web 表面是两个 slot 条目。**每会话费用读数注册进 `conversation.composer.dock`(随附统计行的 dock),通过 `useProjection` 框架座位读取 `openRouterCost` 投影;实时余额徽章注册进 `sidebar.footer.action`,以客户端 60s 间隔轮询余额 Remote。两者在数据缺失时自我隐藏:费用在任何已计价步骤前隐藏,余额在快照尚无值时渲染空的破折号标记。 + +**API key 是凭证引用,与路由的初始化方式一致。**`apiKeyEnv`(默认 `OPENROUTER_API_KEY`)与 `dsh-web-search-deepseek` 完全一样,通过凭证接缝加环境回退解析,按刷新在 thunk 中捕获,使凭证或设置变更无需重建即可在下一周期生效。 + +**无 key → 不发请求,插件保持惰性。**无 key 产生空定价表(每个 step 都保持未计价)、全 `null` 余额,客户端把 null 渲染为缺失 —— 与 LLM 适配器相同的休眠姿态,而非加载期失败。 + +## Alternatives considered + +- **将会话事件持久化每次请求费用** —— 费用派生自已记录的事件(`assistant/message` 用量、`assistant/chunk` 用量);事件会重复记账、在错误时间计价(`pricingRefreshMs` 变更定价),并使客户端数字泄漏进持久日志。投影保持唯一的读数模型归属。 +- **仅用 `/auth/key` 读取余额** —— 计划的初始形态;真实端点响应中标准 `sk-or-v1` key 不携带 `credits` 字段(已对照 OpenRouter 线上 API 验证),因此快照总会是 null。`/credits` 是权威余额端点,`/auth/key` 只是补充标签与月度预算。 +- **读 pi-ai 的 `usage.cost`** —— harness 刻意丢弃 provider 成本元数据(适配器钉定 `NO_COST`);从 token × 定价表重建是唯一可行路径,并记录为估算。 +- **提供设置卡片与专用凭证 UI** —— 推迟:读数可以用路由已使用的既有凭证工作;卡片只是界面,不改变行为。 + +## Consequences + +`test:gui` 保持全绿(3800 个测试),两个包都带单元测试加真实组合 loader 测试:把带变形的 YAML 形状(session + 投影注册表 + 凭证 + openrouter-usage)跑进 vendored Loader,从凭证文档解析 key,并断言被 mock 的 `/models` 与 `/credits` 请求填充投影与快照。数字是估算而非逐项账单:投影按 step 以 `tokens × pricing` 计价,计价格次折叠时生效(刷新定价只影响其后折叠的单元格),未披露时缓存费率回退到 prompt 费率,免费档位/促销价格可能与模型表不同 —— 全部记录在 Known Limitations。没有任何内容对模型可见:agent 循环、会话日志格式与 SDK 均未改动。推迟:设置卡片、以及需要记录 OpenRouter generation id 的逐代账单保真度。 \ No newline at end of file diff --git a/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.i18n.yaml b/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.i18n.yaml new file mode 100644 index 0000000000..34dc0cb898 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.md +2026-08-19-offline-pnpm-prefetch-scripts.md: c00eda84455b582f1ee006617d3031473b14f9e4 +2026-08-19-offline-pnpm-prefetch-scripts.zh.md: 6314726a7a30cfa79b188d2a98eefa3f2525437a diff --git a/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.md b/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.md new file mode 100644 index 0000000000..c00eda8445 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.md @@ -0,0 +1,37 @@ +# Agent Note: Offline pnpm prefetch scripts + +Status: implemented + +English | [中文](2026-08-19-offline-pnpm-prefetch-scripts.zh.md) + +## Problem + +A source checkout cannot boot `dsh web` until a supported Node runtime, `pnpm install`, and `pnpm run build` have materialized `node_modules`, package `lib/` output, and `apps/web/dist`. Install reaches the npm registry (and Corepack reaches it for `pnpm@11.7.0`); the Node distro comes from `nodejs.org/dist`. Node's fetch client cannot use `socks5h://` proxies, so a machine whose only ambient proxy is SOCKS fails `pnpm install` even while curl through the same proxy succeeds. An air-gapped follow-up install therefore needs a same-OS prefetch of the official Node archive, the lockfile store, and a Corepack home that does not call the registry. + +## Decision + +[`scripts/offline/download-deps`](../../../../scripts/offline/download-deps.ps1) (POSIX twin [`.sh`](../../../../scripts/offline/download-deps.sh)) runs while `nodejs.org` and the registry are reachable. It downloads the official Node zip/tar for the current OS and CPU from `DSH_NODE_DIST_BASE` (default `https://nodejs.org/dist`), verifies `SHASUMS256.txt`, unpacks into ignored [`.offline-cache/node/runtime`](../../../../.gitignore), and writes `.offline-cache/node/runtime.json`. The Node version is `DSH_NODE_VERSION` when set, otherwise the running Node when it satisfies `engines.node` (`^22.19.0 || >=24.0.0`), otherwise the newest `v24.x` from `index.json` that publishes this platform's archive. It then sets `COREPACK_HOME` to `.offline-cache/corepack`, `corepack prepare`s the root `package.json` `packageManager` pin using that Node, and `pnpm fetch --frozen-lockfile --store-dir .offline-store`. [`scripts/offline/install-deps`](../../../../scripts/offline/install-deps.ps1) (POSIX twin [`.sh`](../../../../scripts/offline/install-deps.sh)) unpacks the cached Node if needed, prepends it to `PATH`, sets `COREPACK_ENABLE_NETWORK=0`, and runs `pnpm install --offline --frozen-lockfile` against that store. Optional `-Build` / `--build` runs `npm run build:lib` then `pnpm --dir apps/web run build`, avoiding the nested `pnpm --filter` invocation inside `npm run build:web` that can pick a different Corepack pnpm than `packageManager`. + +`DSH_NPM_HTTP_PROXY`, when set, is an HTTP overlay applied only around `corepack prepare` and `pnpm fetch`, then the process proxy variables are restored so a caller SOCKS session is unchanged. curl uses the inherited `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` (SOCKS included) and falls back to `DSH_NPM_HTTP_PROXY` only when none of those are set. A `socks5` or `socks5h` value for Node/pnpm (from `DSH_NPM_HTTP_PROXY`, or from the inherited proxies when no HTTP overlay is set) fails before Corepack runs, because that scheme is not a supported pnpm transport. + +The scripts prefetch Node and the lockfile for the host OS and CPU they run on. They do not download LLM weights, Playwright browsers, or a local inference server. Copy the checkout including `.offline-store` and `.offline-cache` to the offline machine. + +## Alternatives considered + +**Tell contributors to copy a finished `node_modules` tree and skip a second install.** A copied isolated pnpm layout is host-specific and breaks when the store path or package content-address links do not match; `pnpm install --offline` against a fetched store is the documented pnpm air-gap path. + +**Check `supportedArchitectures` into the download script so one store serves win32 and linux.** Rejected for the default: optional native packages (esbuild, koffi, node-pty) must match the install host, and a mixed store still needs a same-OS install to unpack the right binaries. Cross-OS prefetch remains an explicit local `pnpm fetch` config, not the script default. + +**Add root `package.json` scripts that call these files.** Nested `pnpm` under `npm run` is what already mismatches Corepack versions on `build:web`; the wrappers stay invoked as files so they control `COREPACK_HOME` and `--store-dir`. + +**Overwrite the caller's `HTTP_PROXY` for the whole download script.** Rejected: `.\download-deps.ps1` runs in the current PowerShell session, so a session-wide overwrite would replace a working SOCKS proxy for later commands. curl can use SOCKS; only Node/pnpm need the HTTP overlay, and only for those child processes. + +**Ship the Node MSI or pkg installer.** Rejected: those installers need administrator rights and a global install; the official zip/tar is relocatable, and install only unpacks it and prepends `PATH`. + +**Hardcode one Node version in the script.** Rejected: a pinned patch goes stale against `engines.node` and CI (`22.19`, `24`, `26`). Matching the running Node keeps optional native packages aligned with the prefetch host; `index.json` is the fallback when Node is not on `PATH`. + +**Vendor the pnpm store in git.** The store is hundreds of megabytes of registry tarballs; `.gitignore` keeps `.offline-store/` and `.offline-cache/` untracked. + +## Consequences + +Air-gap preparation is a two-command, same-OS pair and does not change the online `pnpm install` path. The offline machine does not need a preinstalled Node: install unpacks the cached official distro. `dsh plugin … add` of a registry or git spec still needs a network on the machine that runs it. Local chat still needs a separately installed OpenAI-compatible server; these scripts do not substitute for that. diff --git a/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.zh.md b/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.zh.md new file mode 100644 index 0000000000..6314726a7a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-offline-pnpm-prefetch-scripts.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Offline pnpm prefetch scripts + +Status: implemented + +[English](2026-08-19-offline-pnpm-prefetch-scripts.md) | 中文 + +## Problem + +源码检出在受支持的 Node 运行时、`pnpm install` 与 `pnpm run build` 物化 `node_modules`、各包 `lib/` 以及 `apps/web/dist` 之前无法启动 `dsh web`。安装会访问 npm registry(Corepack 为 `pnpm@11.7.0` 也会访问);Node 发行包来自 `nodejs.org/dist`。Node 的 fetch 客户端不能使用 `socks5h://` 代理,因此环境里只有 SOCKS 代理的机器即使 curl 经同一代理成功,`pnpm install` 仍会失败。后续在隔离网络中安装,需要按相同 OS 预取官方 Node 归档、lockfile store,以及一个不再访问 registry 的 Corepack home。 + +## Decision + +[`scripts/offline/download-deps`](../../../../scripts/offline/download-deps.ps1)(POSIX 对侧 [`.sh`](../../../../scripts/offline/download-deps.sh))在能访问 `nodejs.org` 与 registry 时运行。它从 `DSH_NODE_DIST_BASE`(默认 `https://nodejs.org/dist`)下载当前 OS 与 CPU 的官方 Node zip/tar,校验 `SHASUMS256.txt`,解压到已忽略的 [`.offline-cache/node/runtime`](../../../../.gitignore),并写入 `.offline-cache/node/runtime.json`。Node 版本在设置了 `DSH_NODE_VERSION` 时用它,否则在运行中的 Node 满足 `engines.node`(`^22.19.0 || >=24.0.0`)时用该版本,否则用 `index.json` 中发布了当前平台归档的最新 `v24.x`。随后把 `COREPACK_HOME` 设为 `.offline-cache/corepack`,用该 Node 按根目录 `package.json` 的 `packageManager` 引脚执行 `corepack prepare`,并运行 `pnpm fetch --frozen-lockfile --store-dir .offline-store`。[`scripts/offline/install-deps`](../../../../scripts/offline/install-deps.ps1)(POSIX 对侧 [`.sh`](../../../../scripts/offline/install-deps.sh))在需要时解压缓存的 Node,把它前置到 `PATH`,设置 `COREPACK_ENABLE_NETWORK=0`,并对该 store 执行 `pnpm install --offline --frozen-lockfile`。可选的 `-Build` / `--build` 先运行 `npm run build:lib`,再运行 `pnpm --dir apps/web run build`,从而避开 `npm run build:web` 内部嵌套的 `pnpm --filter`——它可能选出与 `packageManager` 不同的 Corepack pnpm。 + +若设置了 `DSH_NPM_HTTP_PROXY`,它只作为 HTTP 覆盖层套在 `corepack prepare` 与 `pnpm fetch` 周围,随后恢复进程内的代理变量,因此调用方会话里的 SOCKS 代理保持不变。curl 使用继承的 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`(包括 SOCKS),仅在这些变量都未设置时才回退到 `DSH_NPM_HTTP_PROXY`。供 Node/pnpm 使用的值(来自 `DSH_NPM_HTTP_PROXY`,或在未设置 HTTP 覆盖层时来自继承的代理变量)若为 `socks5` 或 `socks5h`,会在 Corepack 运行前失败,因为该 scheme 不是 pnpm 支持的传输。 + +脚本只为运行时所在主机的 OS 与 CPU 预取 Node 与 lockfile。它们不下载 LLM 权重、Playwright 浏览器或本地推理服务器。把包含 `.offline-store` 与 `.offline-cache` 的检出复制到离线机器。 + +## Alternatives considered + +**让贡献者复制已完成的 `node_modules` 树并跳过第二次安装。** 复制出的 isolated pnpm 布局与主机相关,在 store 路径或包 content-address 链接不匹配时会损坏;针对已 fetch 的 store 运行 `pnpm install --offline` 才是 pnpm 文档中的隔离网络路径。 + +**把 `supportedArchitectures` 写进 download 脚本,使一份 store 同时服务 win32 与 linux。** 拒绝作为默认:可选原生包(esbuild、koffi、node-pty)必须匹配安装主机,混合 store 仍需在相同 OS 上安装才能解出正确二进制。跨 OS 预取仍是显式的本地 `pnpm fetch` 配置,不是脚本默认。 + +**在根 `package.json` 增加调用这些文件的 scripts。** `npm run` 下嵌套 `pnpm` 正是 `build:web` 上 Corepack 版本错配的原因;包装器保持按文件调用,以便自行控制 `COREPACK_HOME` 与 `--store-dir`。 + +**在整个 download 脚本期间覆盖调用方的 `HTTP_PROXY`。** 拒绝:`.\download-deps.ps1` 在当前 PowerShell 会话中运行,会话级覆盖会把后续命令仍可用的 SOCKS 代理换掉。curl 可以使用 SOCKS;只有 Node/pnpm 需要 HTTP 覆盖层,且仅针对这些子进程。 + +**随脚本分发 Node 的 MSI 或 pkg 安装包。** 拒绝:那些安装器需要管理员权限和全局安装;官方 zip/tar 可重定位,install 只需解压并前置 `PATH`。 + +**在脚本中写死某一个 Node 版本。** 拒绝:钉死的 patch 会相对 `engines.node` 与 CI(`22.19`、`24`、`26`)过时。与正在运行的 Node 对齐,可使可选原生包与预取主机一致;`PATH` 上没有 Node 时回退到 `index.json`。 + +**把 pnpm store 纳入 git。** store 是数百 MB 的 registry tarball;`.gitignore` 使 `.offline-store/` 与 `.offline-cache/` 保持未跟踪。 + +## Consequences + +隔离网络准备是一对相同 OS 上的两条命令,不改变在线 `pnpm install` 路径。离线机器不需要预先安装 Node:install 会解压缓存的官方发行包。在运行 `dsh plugin … add` 安装 registry 或 git spec 的机器上仍然需要网络。本地对话仍需要另行安装的 OpenAI-compatible 服务器;这些脚本不能替代它。 diff --git a/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.i18n.yaml b/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.i18n.yaml new file mode 100644 index 0000000000..22c31267fb --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.md +2026-08-19-source-dsh-http-proxy-overlay.md: 814633aeda22d1afb306e9f42c26a0701e62b746 +2026-08-19-source-dsh-http-proxy-overlay.zh.md: 878a6024027bbc6081cc08b5891d7da6dc243895 diff --git a/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.md b/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.md new file mode 100644 index 0000000000..814633aeda --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.md @@ -0,0 +1,35 @@ +# Agent Note: Source dsh HTTP proxy overlay + +Status: implemented + +English | [中文](2026-08-19-source-dsh-http-proxy-overlay.zh.md) + +## Problem + +Machines whose ambient proxy is SOCKS can reach OpenRouter with curl and still fail `pnpm dsh` HTTPS fetches: Node, undici, and pi-ai accept `http://` / `https://` proxy URLs and reject `socks5` / `socks5h`. Setting `HTTPS_PROXY` in the calling PowerShell or bash session also routes later shell commands through that HTTP proxy. Product `.env` files cannot supply `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, or `NO_PROXY` because those names are bootstrap-only. Assigning those names on `process.env` after Node has started does not rebind `fetch`: undici reads `NODE_USE_ENV_PROXY` and `HTTPS_PROXY` at process start. Node `--env-file-if-exists` also leaves inherited names in place, so a user-level `HTTPS_PROXY=socks5h://…` still wins. + +## Decision + +The root `dsh` script runs [`scripts/run-source-dsh.ts`](../../../../scripts/run-source-dsh.ts) as `node --import tsx/esm scripts/run-source-dsh.ts`. [`applySourceDshHttpProxy`](../../../../scripts/apply-source-dsh-http-proxy.ts) writes HTTP proxy variables onto a copy of `process.env`. When gitignored [`.dsh-http-proxy.env`](../../../../.gitignore) or `DSH_HTTP_PROXY` supplies an overlay and `DSH_SOURCE_HTTP_PROXY_APPLIED` is unset, the wrapper respawns the same Node argv with that environment so `NODE_USE_ENV_PROXY=1` and the HTTP proxy URLs exist before `fetch` initializes. The marker prevents a second respawn. The calling shell is unchanged. + +`DSH_HTTP_PROXY`, when set to a non-empty HTTP(S) URL, overwrites `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` (both cases) and sets `NODE_USE_ENV_PROXY=1`. When `NO_PROXY` and `no_proxy` are both unset, it sets them to `localhost,127.0.0.1,::1`. Otherwise the overlay file's parsed assignments are applied, then any remaining SOCKS value on those six names is replaced by the file's HTTP URL so an omitted `ALL_PROXY` cannot keep `socks5h://`. A `socks5` or `socks5h` winning overlay fails before the CLI boots. Absent both sources, the inherited environment is unchanged and there is no respawn. The wrapper prints the applied HTTP URL once to stderr before respawn. + +The installed `apps/cli/lib/bin.js` path is unchanged: it has no checkout overlay file. + +## Alternatives considered + +**Tell the user to export `HTTPS_PROXY` in the shell before `pnpm dsh`.** That reaches OpenRouter, but the same variables remain on later commands in that session, including tools that should keep a SOCKS proxy. + +**Put proxy names in the invoking `.env` or `$DSH_HOME/.env`.** Rejected by the [configuration-source-ownership decision](../architecture/2026-08-04-configuration-source-ownership.md): bootstrap network variables may come only from the inherited process environment. + +**Assign `NODE_USE_ENV_PROXY` only inside the wrapper after Node has started.** OpenRouter traffic uses `fetch`; Node binds the env-proxy dispatcher at process start, so a later `process.env` write does not change it. + +**Use Node `--env-file-if-exists` without respawning.** That file does not replace names the parent already set, so an inherited `socks5h://` `HTTPS_PROXY` still reaches `fetch`. + +## Consequences + +Source `pnpm dsh` can use an HTTP overlay without mutating the calling shell. An inherited SOCKS `HTTPS_PROXY` is replaced in the respawned Node process, not in the shell. Contributors without `DSH_HTTP_PROXY` or `.dsh-http-proxy.env` see the previous inheritance behavior and no extra process. A SOCKS-only overlay fails loud instead of timing out against OpenRouter. The published bin still requires the caller to pass HTTP proxy variables when the host needs a proxy. `--use-env-proxy` is not on the command: Node 22.19 in CI rejects the flag. + +## Testing + +`scripts/apply-source-dsh-http-proxy.spec.ts` covers no-overlay, `DSH_HTTP_PROXY` overwrite of SOCKS, file parse, file-versus-`DSH_HTTP_PROXY` precedence, SOCKS leftover on omitted names, SOCKS rejection, and the one-shot respawn marker. `apps/cli/tests/source-launch.compat.spec.ts` pins the root `dsh` command to `scripts/run-source-dsh.ts` and smokes that vector. diff --git a/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.zh.md b/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.zh.md new file mode 100644 index 0000000000..878a602402 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-source-dsh-http-proxy-overlay.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Source dsh HTTP proxy overlay + +Status: implemented + +[English](2026-08-19-source-dsh-http-proxy-overlay.md) | 中文 + +## 问题 + +环境中只有 SOCKS 代理的机器可以用 curl 访问 OpenRouter,但 `pnpm dsh` 的 HTTPS fetch 仍会失败:Node、undici 和 pi-ai 接受 `http://` / `https://` 代理 URL,并拒绝 `socks5` / `socks5h`。在调用方的 PowerShell 或 bash 会话里设置 `HTTPS_PROXY` 也会让该会话中后续命令走这个 HTTP 代理。产品 `.env` 文件不能提供 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 或 `NO_PROXY`,因为这些名称只能来自进程启动环境。在 Node 启动之后再往 `process.env` 写入这些名称不会重新绑定 `fetch`:undici 在进程启动时读取 `NODE_USE_ENV_PROXY` 和 `HTTPS_PROXY`。Node `--env-file-if-exists` 同样不会替换已继承的名称,因此用户级的 `HTTPS_PROXY=socks5h://…` 仍然生效。 + +## 决策 + +根目录的 `dsh` 脚本以 `node --import tsx/esm scripts/run-source-dsh.ts` 运行 [`scripts/run-source-dsh.ts`](../../../../scripts/run-source-dsh.ts)。[`applySourceDshHttpProxy`](../../../../scripts/apply-source-dsh-http-proxy.ts) 把 HTTP 代理变量写入 `process.env` 的副本。当被 gitignore 的 [`.dsh-http-proxy.env`](../../../../.gitignore) 或 `DSH_HTTP_PROXY` 提供覆盖层且未设置 `DSH_SOURCE_HTTP_PROXY_APPLIED` 时,包装层用该环境按相同的 Node argv 再拉起一次进程,以便在 `fetch` 初始化之前就存在 `NODE_USE_ENV_PROXY=1` 和 HTTP 代理 URL。该标记防止第二次再拉起。调用方 shell 不变。 + +当 `DSH_HTTP_PROXY` 设为非空的 HTTP(S) URL 时,它会覆盖 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`(两种大小写)并设置 `NODE_USE_ENV_PROXY=1`。当 `NO_PROXY` 与 `no_proxy` 都未设置时,它将它们设为 `localhost,127.0.0.1,::1`。否则先应用覆盖文件中解析出的赋值,再把这六个名称上残留的 SOCKS 值换成文件中的 HTTP URL,以免漏写的 `ALL_PROXY` 仍指向 `socks5h://`。最终生效的覆盖层若为 `socks5` 或 `socks5h`,会在 CLI 启动前失败。两种来源都不存在时,继承的环境保持不变,也不会再拉起进程。包装层在再拉起之前会把生效的 HTTP URL 打印到 stderr 一次。 + +已安装的 `apps/cli/lib/bin.js` 路径不变:它没有检出级覆盖文件。 + +## 考虑过的备选方案 + +**让用户在运行 `pnpm dsh` 之前于 shell 中导出 `HTTPS_PROXY`。**这样可以访问 OpenRouter,但这些变量会留在该会话的后续命令上,包括本应继续使用 SOCKS 代理的工具。 + +**把代理名称放进调用目录的 `.env` 或 `$DSH_HOME/.env`。**被[配置来源归属决策](../architecture/2026-08-04-configuration-source-ownership.md)拒绝:网络启动变量只能来自继承的进程环境。 + +**仅在包装层内、Node 启动之后赋值 `NODE_USE_ENV_PROXY`。** OpenRouter 流量走 `fetch`;Node 在进程启动时绑定 env-proxy dispatcher,随后再写 `process.env` 不会改变它。 + +**使用 Node `--env-file-if-exists` 且不再拉起进程。**该文件不会替换父进程已经设置的名称,因此继承来的 `socks5h://` `HTTPS_PROXY` 仍会到达 `fetch`。 + +## 影响 + +源码路径的 `pnpm dsh` 可以使用 HTTP 覆盖层,而不修改调用方 shell。继承来的 SOCKS `HTTPS_PROXY` 会在再拉起的 Node 进程中被替换,而不是在 shell 中。没有 `DSH_HTTP_PROXY` 或 `.dsh-http-proxy.env` 的贡献者仍看到原来的继承行为,也不会多一个进程。仅有 SOCKS 的覆盖层会立即失败,而不是在访问 OpenRouter 时超时。已发布的 bin 在宿主需要代理时,仍要求调用方传入 HTTP 代理变量。命令行不加 `--use-env-proxy`:CI 中的 Node 22.19 会拒绝该 flag。 + +## 测试 + +`scripts/apply-source-dsh-http-proxy.spec.ts` 覆盖无覆盖层、`DSH_HTTP_PROXY` 覆盖 SOCKS、文件解析、文件与 `DSH_HTTP_PROXY` 的优先级、文件未写出的名称上残留的 SOCKS、SOCKS 拒绝,以及一次性再拉起标记。`apps/cli/tests/source-launch.compat.spec.ts` 将根目录 `dsh` 命令钉在 `scripts/run-source-dsh.ts` 上,并对该启动向量做冒烟测试。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index a1263c1db4..767edb269d 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md -2026-08-10-source-run-without-managed-installer.md: ce618c88327fc11ad0cad9042e171800a5492663 -2026-08-10-source-run-without-managed-installer.zh.md: e54ec93b8bd13a36b7e8b6813899d5a1e27b788c +2026-08-10-source-run-without-managed-installer.md: 3deb66367cc869bdef04527ca13300f0558d40d6 +2026-08-10-source-run-without-managed-installer.zh.md: 70b9d3fb604dc472841e44977317dc46d8e352d4 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md index ce618c8832..3deb66367c 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -12,7 +12,7 @@ That lifecycle is not required to run or develop DeepSeek Harness from a source ## Decision -The repository supports source execution through its root `pnpm` scripts. The `dsh` entry in `package.json` launches `apps/cli/src/bin.ts` directly through `node --import tsx/esm`; artifact generation is the separate `pnpm run build` operation defined by the [source-launch/build separation decision](2026-08-12-separate-source-launch-from-build.md). The package script forwards arguments and inherits the caller's environment, including `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. +The repository supports source execution through its root `pnpm` scripts. The `dsh` entry in `package.json` launches [`scripts/run-source-dsh.ts`](../../../../scripts/run-source-dsh.ts) through `node --import tsx/esm`; that wrapper overlays HTTP proxy variables for this Node process ([source HTTP proxy overlay](../process/2026-08-19-source-dsh-http-proxy-overlay.md)) and then dispatches `apps/cli/src/bin.ts`. Artifact generation is the separate `pnpm run build` operation defined by the [source-launch/build separation decision](2026-08-12-separate-source-launch-from-build.md). The package script forwards arguments and inherits the caller's environment; the overlay can replace inherited SOCKS proxy variables so Node fetch can reach providers such as OpenRouter. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md index e54ec93b8b..70b9d3fb60 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -仓库通过根目录的 `pnpm` 脚本支持从源码运行。`package.json` 中的 `dsh` 项通过 `node --import tsx/esm` 直接启动 `apps/cli/src/bin.ts`;产物生成是独立的 `pnpm run build` 操作,由[源码启动与构建分离决策](2026-08-12-separate-source-launch-from-build.md)规定。该包脚本会转发参数并继承调用方环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,调用方可设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`package.json` 中的 `dsh` 项通过 `node --import tsx/esm` 启动 [`scripts/run-source-dsh.ts`](../../../../scripts/run-source-dsh.ts);该包装层仅为本 Node 进程套用 HTTP 代理变量([源码 HTTP 代理覆盖层](../process/2026-08-19-source-dsh-http-proxy-overlay.md)),然后分派 `apps/cli/src/bin.ts`。产物生成是独立的 `pnpm run build` 操作,由[源码启动与构建分离决策](2026-08-12-separate-source-launch-from-build.md)规定。该包脚本会转发参数并继承调用方环境;该覆盖层可以替换继承来的 SOCKS 代理变量,以便 Node fetch 能够访问 OpenRouter 等提供方。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.i18n.yaml index 69e2ebfef5..b5aa4c8f65 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md -2026-08-12-separate-source-launch-from-build.md: 639e9de0ce13a571e7f83929d9c76eac2664daab -2026-08-12-separate-source-launch-from-build.zh.md: bbb55123860f2bba0209e95e803fa7f49328bdef +2026-08-12-separate-source-launch-from-build.md: 0fba024bee8b3099be625a5727c6d1a53248162e +2026-08-12-separate-source-launch-from-build.zh.md: 6ce95330bb2a4c9f63bb0e3d969b07eb43941036 diff --git a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md index 639e9de0ce..0fba024bee 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md +++ b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md @@ -12,7 +12,7 @@ Source modules reached through tsx and browser modules reached through built bun ## Decision -The root `dsh` script only runs `node --import tsx/esm apps/cli/src/bin.ts`. `pnpm run build` remains the separate operation that generates package and frontend artifacts. Source users run the build before the first production-like launch and whenever frontend or client-plugin artifacts need refreshing. +The root `dsh` script runs `node --import tsx/esm scripts/run-source-dsh.ts`, which overlays HTTP proxy variables for that Node process and then dispatches `apps/cli/src/bin.ts`. `pnpm run build` remains the separate operation that generates package and frontend artifacts. Source users run the build before the first production-like launch and whenever frontend or client-plugin artifacts need refreshing. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend and client-plugin artifacts fail at startup with diagnostics that direct the user to `pnpm run build`. The launcher does not validate artifact freshness: existing stale frontend or client-plugin bundles are accepted and can run older browser code until the next build. After package Node halves have been built once, `pnpm run dev:web` rebuilds only packages that declare `dsh.client`; it keeps client-plugin bundles current and activates their hot-reload path, but does not rebuild the frontend shell. @@ -30,7 +30,7 @@ This decision owns build scheduling only. The [tsx ESM source-launch decision](. - Repeated source launches do not wait for a complete repository build, and build output is not mixed with CLI output. - Source users own artifact freshness. Missing artifacts stop startup, but only frontend and client-plugin failures direct users to `pnpm run build`; existing stale frontend and client-plugin bundles can silently serve older browser code. -- TUI, Web, and headless selection, argument forwarding, environment inheritance, and the tsx ESM launch vector remain unchanged. +- TUI, Web, and headless selection, argument forwarding, and the tsx ESM launch vector remain unchanged. HTTP proxy overlay for the source Node process is owned by the [source HTTP proxy overlay decision](../process/2026-08-19-source-dsh-http-proxy-overlay.md). - The root onboarding and CLI reference show build and launch as separate commands and document the stale-artifact behavior. ## Verification diff --git a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md index bbb5512386..6ce95330bb 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md @@ -12,7 +12,7 @@ TypeScript 源码启动器无需在每次调用前完成整个仓库的构建。 ## 决策 -根目录的 `dsh` 脚本只运行 `node --import tsx/esm apps/cli/src/bin.ts`。`pnpm run build` 仍是生成包与前端产物的独立操作。源码用户在首次进行类生产启动前运行构建,并在前端或 Client plugin 产物需要刷新时再次运行。 +根目录的 `dsh` 脚本运行 `node --import tsx/esm scripts/run-source-dsh.ts`,该包装层仅为本 Node 进程套用 HTTP 代理变量,然后分派 `apps/cli/src/bin.ts`。`pnpm run build` 仍是生成包与前端产物的独立操作。源码用户在首次进行类生产启动前运行构建,并在前端或 Client plugin 产物需要刷新时再次运行。 Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 产物缺失,启动会失败,诊断信息会指示用户运行 `pnpm run build`。启动器不会验证产物是否为最新:已有的陈旧前端或 Client plugin 组合包仍会被接受,并可能继续运行旧版浏览器代码,直至下次构建。各包的 Node 半侧至少构建过一次后,`pnpm run dev:web` 只重建声明了 `dsh.client` 的包;它会保持 Client plugin 组合包为最新状态并启用其热重载路径,但不会重建前端 shell。 @@ -30,7 +30,7 @@ Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解 - 重复的源码启动无需等待完整的仓库构建,构建输出也不会与 CLI 输出混在一起。 - 源码用户负责产物新鲜度。产物缺失会阻止启动,但只有前端与 Client plugin 产物缺失的错误会指示用户运行 `pnpm run build`;已有的过期前端与 Client plugin 组合包可能静默提供旧版浏览器代码。 -- TUI、Web 与无头模式选择、参数转发、环境继承,以及 tsx ESM 启动方式保持不变。 +- TUI、Web 与无头模式选择、参数转发,以及 tsx ESM 启动方式保持不变。源码 Node 进程的 HTTP 代理覆盖层由[源码 HTTP 代理覆盖层决策](../process/2026-08-19-source-dsh-http-proxy-overlay.md)规定。 - 根目录上手指南与 CLI 参考将构建和启动列为独立命令,并说明过期产物行为。 ## 验证 diff --git a/.gitignore b/.gitignore index 3d0fd8e322..ce6dd0ac5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ CLAUDE.local.md .env +.dsh-http-proxy.env node_modules/ lib/ *.tsbuildinfo pnpm-debug.log .pnpm-store/ +.offline-store/ +.offline-cache/ .cache/ examples/*/*.jsonl .storages/ diff --git a/_probe2.py b/_probe2.py new file mode 100644 index 0000000000..8072ff7105 --- /dev/null +++ b/_probe2.py @@ -0,0 +1,55 @@ +import urllib.request, ssl, json, urllib.parse, socket + +ctx = ssl.create_default_context() + +def get(url, headers=None, timeout=10): + h = headers or {'user-agent': 'Mozilla/5.0 (compatible; deepseek-harness/0.0.1)'} + req = urllib.request.Request(url, headers=h) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r: + return r.status, r.read().decode('utf-8', 'replace') + +# Independent recent instance lists +lists = [ + 'https://searx.space/data/instances.json', + 'https://raw.githubusercontent.com/searxng/searx-instances/master/instances.json', +] +seen = {} +for src in lists: + try: + st, body = get(src) + data = json.loads(body) + inst = data.get('instances', data) if isinstance(data, dict) else {} + if isinstance(inst, dict): + for host, meta in inst.items(): + if isinstance(meta, dict): + urlv = meta.get('url') or (f'https://{host}' if not host.startswith('http') else host) + jf = meta.get('json', False) + seen.setdefault(urlv.rstrip('/'), {'json': jf, 'meta': meta}) + print(f'[{src}] status={st} counted={len(seen)}') + except Exception as e: + print(f'[{src}] FAIL {type(e).__name__}: {e}') + +print('total candidates:', len(seen)) +q = urllib.parse.quote('погода Норильск') +found = [] + +# Probe json-capable first, then all https ones briefly +pref = [u for u, m in seen.items() if m.get('json')] +rest = [u for u, m in seen.items() if not m.get('json')] +for u in pref + rest: + if not u.startswith('http'): + continue + base = u.rstrip('/') + url = f'{base}/search?q={q}&format=json' + try: + st, body = get(url, {'accept': 'application/json', 'user-agent': 'deepseek-harness/0.0.1'}, timeout=8) + if '"results"' in body and ('"url"' in body or 'results' in body): + found.append((base, st, body[:600])) + print(f'!!! CANDIDATE {base} HTTP {st}') + print(body[:600]) + break + except Exception as e: + pass + +if not found: + print('NO working json instance among', len(seen), 'candidates') \ No newline at end of file diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b62de584e2..dc10e5be83 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 0be64fdfc0ad4e81d23f25a26881fa89f37565b0 -README.zh.md: 649bf15df814abf4875fed794c2e76c494bfcc25 +README.md: 4d0fca9e7749da722aa3210898bdc8566b706191 +README.zh.md: b254e891b304173d145be0969d6661a7a1ac3f66 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0be64fdfc0..4d0fca9e77 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -81,4 +81,4 @@ Install external plugin bundles through `dsh plugin --profile add `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. +From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches [`scripts/run-source-dsh.ts`](../../../scripts/run-source-dsh.ts) with `node --import tsx/esm` without building. When `.dsh-http-proxy.env` or `DSH_HTTP_PROXY` is set, that wrapper respawns Node with HTTP proxy variables and `NODE_USE_ENV_PROXY=1` so `fetch` binds them at start, then dispatches `apps/cli/src/bin.ts` and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment; the respawned process can replace an inherited SOCKS proxy because Node cannot fetch through SOCKS, and `.env` files cannot set `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 649bf15df8..b254e891b3 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -81,4 +81,4 @@ dsh web --help ## 源码执行 -请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 [`scripts/run-source-dsh.ts`](../../../scripts/run-source-dsh.ts)。当设置了 `.dsh-http-proxy.env` 或 `DSH_HTTP_PROXY` 时,该包装层会用 HTTP 代理变量和 `NODE_USE_ENV_PROXY=1` 再拉起 Node,以便 `fetch` 在启动时绑定它们,随后分派 `apps/cli/src/bin.ts` 并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境;再拉起的进程可以替换继承来的 SOCKS 代理,因为 Node 无法通过 SOCKS 发起 fetch,且 `.env` 文件不能设置 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 975e4c3591..4bd5d19861 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -4,9 +4,9 @@ import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** - * Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts` + * Keyless smoke for SOURCE `dsh` execution: run `scripts/run-source-dsh.ts` * with the exact production runtime vector (`node --import tsx/esm`, the - * vector the root `dsh` script invokes directly) and assert the + * vector the root `dsh` script invokes) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin @@ -14,14 +14,14 @@ import { describe, expect, it } from 'vitest' */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) -const dshSourceBin = 'apps/cli/src/bin.ts' +const dshSourceBin = 'scripts/run-source-dsh.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { it('launches the source CLI without building', async () => { const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { readonly scripts?: Record } - expect(rootPackage.scripts?.dsh).toBe('node --import tsx/esm apps/cli/src/bin.ts') + expect(rootPackage.scripts?.dsh).toBe('node --import tsx/esm scripts/run-source-dsh.ts') }) it('boots the source entry and requires a profile', async () => { diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4584a0f345..bae90fad9a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: a990a9dd4d92d10e37b82e6a63caa4a5a469c441 -capability-seams.zh.md: 441d9222835e67c4a9f657d2335860005934094b +capability-seams.md: d6022d8889075b8bf839a24bb916c71ec73f5d4a +capability-seams.zh.md: 99cc8ee117994af574a27b52a1e2c9ccf4270be9 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a990a9dd4d..d6022d8889 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -164,6 +164,7 @@ flowchart LR pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] + pkg_web_search_searxng["web-search-searxng"] pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_fetch_http["web-fetch-http"] @@ -190,6 +191,9 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_tool_lsp["tool-lsp"] svc_apiProxy["ctx.apiProxy
Host API dispatch"] + pkg_openrouter_usage["openrouter-usage"] + svc_openRouterUsage["ctx.openRouterUsage
OpenRouter usage and balance gateway"] + pkg_ui_openrouter_usage["ui-openrouter-usage"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] @@ -235,6 +239,7 @@ flowchart LR pkg_lsp_local --> svc_lsp pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModules + pkg_openrouter_usage --> svc_openRouterUsage pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode pkg_pwsh_local --> svc_shell @@ -290,6 +295,7 @@ flowchart LR pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web pkg_web_search_perplexity --> svc_web + pkg_web_search_searxng --> svc_web pkg_webserver --> svc_webServer pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine @@ -328,6 +334,7 @@ flowchart LR svc_llm --> pkg_agent_loop svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp + svc_openRouterUsage --> pkg_ui_openrouter_usage svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -457,7 +464,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | -| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-searxng`](../packages/web/web-search-searxng), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | @@ -465,6 +472,7 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | `lsp-local` | [`tool-lsp`](../packages/lsp/tool-lsp) | - | Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result. | | `ctx.apiProxy` | `core` | `apiproxy` | - | `connection` | - | The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb. | +| `ctx.openRouterUsage` | `core` | [`openrouter-usage`](../packages/llm/openrouter-usage) | - | `ui-openrouter-usage` | - | The host gateway folds token-usage projections and a live balance snapshot; the web UI client reaches the same service over the wire through its remote namespace. | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace. | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 441d922283..99cc8ee117 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -166,6 +166,7 @@ flowchart LR pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] + pkg_web_search_searxng["web-search-searxng"] pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_fetch_http["web-fetch-http"] @@ -192,6 +193,9 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_tool_lsp["tool-lsp"] svc_apiProxy["ctx.apiProxy
Host API dispatch"] + pkg_openrouter_usage["openrouter-usage"] + svc_openRouterUsage["ctx.openRouterUsage
OpenRouter usage and balance gateway"] + pkg_ui_openrouter_usage["ui-openrouter-usage"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] @@ -237,6 +241,7 @@ flowchart LR pkg_lsp_local --> svc_lsp pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModules + pkg_openrouter_usage --> svc_openRouterUsage pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode pkg_pwsh_local --> svc_shell @@ -292,6 +297,7 @@ flowchart LR pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web pkg_web_search_perplexity --> svc_web + pkg_web_search_searxng --> svc_web pkg_webserver --> svc_webServer pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine @@ -330,6 +336,7 @@ flowchart LR svc_llm --> pkg_agent_loop svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp + svc_openRouterUsage --> pkg_ui_openrouter_usage svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -459,7 +466,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | -| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-searxng`](../packages/web/web-search-searxng), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | @@ -467,6 +474,7 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | `lsp-local` | [`tool-lsp`](../packages/lsp/tool-lsp) | - | 提供方注册与选择,加上恰好四种操作的标准化查询执行;该 seam 不提供协议逃生口,后端必须转换为标准化请求和结果。 | | `ctx.apiProxy` | `core` | `apiproxy` | - | `connection` | - | 与传输无关的 Host 网关接口:它分派浏览器 API 调用,每条打开的 Host 流自行订阅转发事件,而不是由广播方法向其推送。 | +| `ctx.openRouterUsage` | `core` | [`openrouter-usage`](../packages/llm/openrouter-usage) | - | `ui-openrouter-usage` | - | Host 网关折叠 token 用量投影与实时余额快照;Web UI 客户端通过其 remote 命名空间经线路访问同一服务。 | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 拥有内存定义注册表、Host 半的 vm 沙箱和 request-run 往返流程;浏览器页面通过其 Remote 命名空间在线访问同一服务。 | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 注册 Host inspect 提供方、镜像 Client 提供方 manifest,并通过动态 Cordis 传输路由 Client 查询。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 894abf27d4..695afa5151 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 45e617809a7f1d2b54ed657cbc4c53d5b453e6a9 -config-catalog.zh.md: 5217fd51827f1a61b1db3c2a66aceeb514179c70 +config-catalog.md: 040deb80fe232dc61bba1b2041a541d33516973f +config-catalog.zh.md: e0166072b66d4a940c5242f23974a602a5658f1a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 45e617809a..040deb80fe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1293,6 +1293,28 @@ export interface Config { Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + + +## `@deepseek-ai/dsh-openrouter-usage` + +```ts config-catalog +/** 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 +} +``` + +Source: [`packages/llm/openrouter-usage/src/index.ts:48`](../packages/llm/openrouter-usage/src/index.ts) + ## `@deepseek-ai/dsh-permission-presets` @@ -2991,6 +3013,28 @@ export interface Config { Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts) + + +## `@deepseek-ai/dsh-web-search-searxng` + +Requires: `web` + +```ts config-catalog +/** Plugin config. `baseURL` is optional in the schema so an omitted value + * surfaces as an unavailable provider (the seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`) + * rather than failing boot; `apply` fills constant defaults. */ +export interface Config { + /** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */ + baseURL?: string + /** SearXNG `language` to request. Defaults to `auto`. */ + language?: string + /** SearXNG `time_range` recency filter. Omitted = no filter. */ + timeRange?: 'day' | 'week' | 'month' | 'year' +} +``` + +Source: [`packages/web/web-search-searxng/src/index.ts:40`](../packages/web/web-search-searxng/src/index.ts) + ## `@deepseek-ai/dsh-workflow-worker-thread` @@ -3044,6 +3088,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-openrouter-usage` ([`packages/client/ui-openrouter-usage/src/index.ts`](../packages/client/ui-openrouter-usage/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5217fd5182..e0166072b6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1295,6 +1295,28 @@ export interface Config { 来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + + +## `@deepseek-ai/dsh-openrouter-usage` + +```ts config-catalog +/** 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 +} +``` + +来源:[`packages/llm/openrouter-usage/src/index.ts:48`](../packages/llm/openrouter-usage/src/index.ts) + ## `@deepseek-ai/dsh-permission-presets` @@ -2993,6 +3015,28 @@ export interface Config { 来源:[`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts) + + +## `@deepseek-ai/dsh-web-search-searxng` + +需要:`web` + +```ts config-catalog +/** Plugin config. `baseURL` is optional in the schema so an omitted value + * surfaces as an unavailable provider (the seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`) + * rather than failing boot; `apply` fills constant defaults. */ +export interface Config { + /** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */ + baseURL?: string + /** SearXNG `language` to request. Defaults to `auto`. */ + language?: string + /** SearXNG `time_range` recency filter. Omitted = no filter. */ + timeRange?: 'day' | 'week' | 'month' | 'year' +} +``` + +来源:[`packages/web/web-search-searxng/src/index.ts:40`](../packages/web/web-search-searxng/src/index.ts) + ## `@deepseek-ai/dsh-workflow-worker-thread` @@ -3046,6 +3090,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-openrouter-usage`([`packages/client/ui-openrouter-usage/src/index.ts`](../packages/client/ui-openrouter-usage/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets`([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7e67006a40..d18fe27dfd 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 6a79e6f7ce5addc64b10efa8da7a886dcfb36dc2 -event-producer-consumer.zh.md: f7576a8e28e4f1db2c65c324595c05c98b8fe488 +event-producer-consumer.md: 6adc181c811b34433801c337b36b4ebf086a4fae +event-producer-consumer.zh.md: a0e1f993eaf342b555450086f42ca151d1e0d6d5 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6a79e6f7ce..6adc181c81 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -29,7 +29,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials), [`openrouter-usage`](../packages/llm/openrouter-usage) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f7576a8e28..a0e1f993ea 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -31,7 +31,7 @@ | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials), [`openrouter-usage`](../packages/llm/openrouter-usage) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 409d52132f..e6a315823c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 8fdf13d9943e89b4527cba05cbaced35778a56ac -module-graph.zh.md: 10b46262e7df9ed076c204f77de9728082577f0f +module-graph.md: 1a09c6c39dd0710b490ec23ae76b928cd6edf8d6 +module-graph.zh.md: 811ee0899068ce475df8b6b168513f60a492d56f diff --git a/docs/module-graph.md b/docs/module-graph.md index 8fdf13d994..1a09c6c39d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -21,6 +21,7 @@ flowchart TD pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] + pkg_openrouter_usage["openrouter-usage"] pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] @@ -74,6 +75,7 @@ flowchart TD pkg_web_search_deepseek["web-search-deepseek"] pkg_web_search_exa["web-search-exa"] pkg_web_search_perplexity["web-search-perplexity"] + pkg_web_search_searxng["web-search-searxng"] end subgraph group_spill["packages/spill"] pkg_spill["spill"] @@ -137,6 +139,7 @@ flowchart TD pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] + pkg_client_ui_openrouter_usage["client-ui-openrouter-usage"] pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] @@ -455,6 +458,9 @@ flowchart TD pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web + pkg_web_search_searxng --> pkg_invariants + pkg_web_search_searxng --> pkg_settings + pkg_web_search_searxng --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_invariants pkg_spill --> pkg_llm @@ -486,6 +492,14 @@ flowchart TD pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout + pkg_openrouter_usage --> pkg_credentials + pkg_openrouter_usage --> pkg_invariants + pkg_openrouter_usage --> pkg_launch_environment + pkg_openrouter_usage --> pkg_llm + pkg_openrouter_usage --> pkg_session + pkg_openrouter_usage --> pkg_session_projection + pkg_openrouter_usage --> pkg_settings + pkg_openrouter_usage --> pkg_typert_protocol pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -1007,6 +1021,7 @@ flowchart TD pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_message_feedback + pkg_api_remotes --> pkg_openrouter_usage pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_settings @@ -1327,6 +1342,15 @@ flowchart TD pkg_client_ui_message_feedback --> pkg_invariants pkg_client_ui_message_feedback --> pkg_message_feedback pkg_client_ui_message_feedback --> pkg_typert_protocol + pkg_client_ui_openrouter_usage --> pkg_api_remotes + pkg_client_ui_openrouter_usage --> pkg_client_locale + pkg_client_ui_openrouter_usage --> pkg_client_runtime + pkg_client_ui_openrouter_usage --> pkg_client_ui_conversation + pkg_client_ui_openrouter_usage --> pkg_client_ui_primitives + pkg_client_ui_openrouter_usage --> pkg_client_ui_sidebar + pkg_client_ui_openrouter_usage --> pkg_client_ui_slots + pkg_client_ui_openrouter_usage --> pkg_invariants + pkg_client_ui_openrouter_usage --> pkg_openrouter_usage pkg_client_ui_plan --> pkg_api_remotes pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_runtime @@ -1483,6 +1507,7 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`web-search-searxng`](../packages/web/web-search-searxng) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1492,6 +1517,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`openrouter-usage`](../packages/llm/openrouter-usage) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1585,7 +1611,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`openrouter-usage`](../packages/llm/openrouter-usage), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1629,6 +1655,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-openrouter-usage`](../packages/client/ui-openrouter-usage) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`openrouter-usage`](../packages/llm/openrouter-usage) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 10b46262e7..811ee08990 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -23,6 +23,7 @@ flowchart TD pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] + pkg_openrouter_usage["openrouter-usage"] pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] @@ -76,6 +77,7 @@ flowchart TD pkg_web_search_deepseek["web-search-deepseek"] pkg_web_search_exa["web-search-exa"] pkg_web_search_perplexity["web-search-perplexity"] + pkg_web_search_searxng["web-search-searxng"] end subgraph group_spill["packages/spill"] pkg_spill["spill"] @@ -139,6 +141,7 @@ flowchart TD pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] + pkg_client_ui_openrouter_usage["client-ui-openrouter-usage"] pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] @@ -457,6 +460,9 @@ flowchart TD pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web + pkg_web_search_searxng --> pkg_invariants + pkg_web_search_searxng --> pkg_settings + pkg_web_search_searxng --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_invariants pkg_spill --> pkg_llm @@ -488,6 +494,14 @@ flowchart TD pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout + pkg_openrouter_usage --> pkg_credentials + pkg_openrouter_usage --> pkg_invariants + pkg_openrouter_usage --> pkg_launch_environment + pkg_openrouter_usage --> pkg_llm + pkg_openrouter_usage --> pkg_session + pkg_openrouter_usage --> pkg_session_projection + pkg_openrouter_usage --> pkg_settings + pkg_openrouter_usage --> pkg_typert_protocol pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -1009,6 +1023,7 @@ flowchart TD pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_message_feedback + pkg_api_remotes --> pkg_openrouter_usage pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_settings @@ -1329,6 +1344,15 @@ flowchart TD pkg_client_ui_message_feedback --> pkg_invariants pkg_client_ui_message_feedback --> pkg_message_feedback pkg_client_ui_message_feedback --> pkg_typert_protocol + pkg_client_ui_openrouter_usage --> pkg_api_remotes + pkg_client_ui_openrouter_usage --> pkg_client_locale + pkg_client_ui_openrouter_usage --> pkg_client_runtime + pkg_client_ui_openrouter_usage --> pkg_client_ui_conversation + pkg_client_ui_openrouter_usage --> pkg_client_ui_primitives + pkg_client_ui_openrouter_usage --> pkg_client_ui_sidebar + pkg_client_ui_openrouter_usage --> pkg_client_ui_slots + pkg_client_ui_openrouter_usage --> pkg_invariants + pkg_client_ui_openrouter_usage --> pkg_openrouter_usage pkg_client_ui_plan --> pkg_api_remotes pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_runtime @@ -1485,6 +1509,7 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`web-search-searxng`](../packages/web/web-search-searxng) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1494,6 +1519,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`openrouter-usage`](../packages/llm/openrouter-usage) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1587,7 +1613,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`openrouter-usage`](../packages/llm/openrouter-usage), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1631,6 +1657,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-openrouter-usage`](../packages/client/ui-openrouter-usage) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`openrouter-usage`](../packages/llm/openrouter-usage) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index 840865ef35..7a474a123e 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web.md -web.md: 3bcd3ac24927c8c51baeabd770e2bd91c5ad1b77 -web.zh.md: 3348be2b808dc286364f5a795b236cb299acd6a6 +web.md: 1d5f9bb5fa4dd12673a379f1d495b0f578183ef8 +web.zh.md: 38b9d5da9a4685adeb5db83e7d0ee895ae2ce013 diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 3bcd3ac249..1d5f9bb5fa 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -2,7 +2,7 @@ English | [中文](web.zh.md) -The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL. +The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-searxng](../../packages/web/web-search-searxng), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL. Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 3348be2b80..38b9d5da9a 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -2,7 +2,7 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-searxng](../../packages/web/web-search-searxng)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/knip.json b/knip.json index 3017292382..ed90c16ff2 100644 --- a/knip.json +++ b/knip.json @@ -530,6 +530,16 @@ "tests/**/*.ts" ] }, + "packages/web/web-search-searxng": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/web/web-search-perplexity": { "entry": [ "tests/**/*.spec.ts", diff --git a/package.json b/package.json index 4229920f59..fcc758400a 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "release:pack": "tsx scripts/release/pack.ts", "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", - "dsh": "node --import tsx/esm apps/cli/src/bin.ts", + "dsh": "node --import tsx/esm scripts/run-source-dsh.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0bc596bf71..fb116ef615 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -70,6 +70,7 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-openrouter-usage": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", @@ -87,6 +88,7 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-openrouter-usage": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 210ec0bf80..4cf4d5f7f0 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -6,6 +6,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import openRouterUsageRemote from '@deepseek-ai/dsh-openrouter-usage/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' @@ -14,6 +15,7 @@ export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-openrouter-usage/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -106,7 +108,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, openRouterUsageRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index a0161dd047..bb45cc7020 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -42,6 +42,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/openrouter-usage" + }, { "path": "../../preset/agent-presets" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index b06e63b54f..36afe55c7c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -94,6 +94,19 @@ - id: plugin-inventory name: '@deepseek-ai/dsh-host-plugin-inventory' + # OpenRouter spend: the account-balance Remote gateway plus the + # openRouterCost session projection, priced from the model table. Both + # surfaces stay hidden until an OPENROUTER_API_KEY resolves, so assemblies + # not routed through OpenRouter cost no layout and no fetch. + - id: openrouter-usage + name: '@deepseek-ai/dsh-openrouter-usage' + config: + apiKeyEnv: OPENROUTER_API_KEY + baseURL: https://openrouter.ai/api/v1 + syncEnabled: true + pricingRefreshMs: 21600000 + balanceRefreshMs: 60000 + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -241,6 +254,13 @@ - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' + # OpenRouter spend surface: the per-session 会话费用 on the composer stats + # dock (over the openRouterCost projection) and the account 余额 badge at + # the sidebar foot (over the openRouterUsage Remote). Both render nothing + # before real data exists. + - id: ui-openrouter-usage + name: '@deepseek-ai/dsh-client-ui-openrouter-usage' + # Per-message feedback: Like/Dislike plus an optional note in the # assistant-message action strip, over the messageFeedback Remote. - id: ui-message-feedback diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index ad9ec48fae..b2576fb102 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-openrouter-usage": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", @@ -94,6 +95,7 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-openrouter-usage": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", diff --git a/packages/client/ui-openrouter-usage/README.i18n.yaml b/packages/client/ui-openrouter-usage/README.i18n.yaml new file mode 100644 index 0000000000..6da64568a8 --- /dev/null +++ b/packages/client/ui-openrouter-usage/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-openrouter-usage/README.md +README.md: 88a0d1eb886ffc3f4ff3392c908d8592da31ab2f +README.zh.md: 7bd5fdf2783b7ab59d52f6ea4250a0385ad06e14 diff --git a/packages/client/ui-openrouter-usage/README.md b/packages/client/ui-openrouter-usage/README.md new file mode 100644 index 0000000000..88a0d1eb88 --- /dev/null +++ b/packages/client/ui-openrouter-usage/README.md @@ -0,0 +1,46 @@ +# @deepseek-ai/dsh-client-ui-openrouter-usage + +English | [中文](README.zh.md) + +OpenRouter spend surface plugin, browser half: the per-session 会话费用 +readout (`CostDock`) in the `conversation.composer.dock` strip (order 5, right +after the shipped stats line) plus the account 余额 badge (`BalanceBadge`) at +the sidebar foot (`sidebar.footer.action`). + +`CostDock` reads the durable `openRouterCost` projection (seeded by the history +tail page, updated by `session/projection` frames), showing `会话费用 $X.XX` +only once a step actually priced — no zero-cost group. An unknown-pricing step +count surfaces in a tooltip so the figure's coverage stays visible. `CostDock` +therefore owns no store, refresh chain, or event listener. + +`BalanceBadge` is account-global, so it is not a session projection: the slot +inject face carries a `snapshot()` callback that polls the host +`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives +local state. In the collapsed rail it renders only the balance marker; in the +wide column a labeled pill. Unknown or absent figures render the empty +placeholder. Both entries render nothing before real data exists, so an +assembly without the OpenRouter gateway or key costs no layout. + +The `/client` exports are the plugin body (`apply`/`inject`), the +`CostDock`/`BalanceBadge` components, and the injected face types. + +## Model Experience + +Indirectly, none. Neither the cost readout nor the balance badge injects any +model context; they are pure client-side read models over the `openRouterCost` +projection and the `openRouterUsage` Remote snapshot. The agent loop and all +prompts are unchanged. + +#### KV Cache effect + +None. Cost derives from existing logged usage events and balance is a fetched +account figure; neither is a new model-visible input. + +## Known Limitations and Deferred Work + +- **Priced as of the fold** — `CostDock` renders whatever the durable + projection holds; figures for already-folded history reflect the pricing + table at fold time (see `dsh-openrouter-usage`). +- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is + no `openRouterUsage` forwarded-event channel, so the figure updates on the + poll interval rather than instantly. diff --git a/packages/client/ui-openrouter-usage/README.zh.md b/packages/client/ui-openrouter-usage/README.zh.md new file mode 100644 index 0000000000..7bd5fdf278 --- /dev/null +++ b/packages/client/ui-openrouter-usage/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-openrouter-usage + +[English](README.md) | 中文 + +OpenRouter 花费界面插件(浏览器端部分):`conversation.composer.dock` 条带中的每会话 会话费用 读数(`CostDock`,order 5,紧跟内置 stats 行之后),加上侧边栏底部的账户 余额 徽章(`BalanceBadge`,位于 `sidebar.footer.action`)。 + +`CostDock` 读取持久的 `openRouterCost` 投影(由历史尾页播种、由 `session/projection` 帧更新),仅当一个 step 真正计价后才显示 `会话费用 $X.XX`——不会出现零成本分组。未知定价的 step 数会通过 tooltip 呈现,从而让数值的覆盖范围依然可见。因此 `CostDock` 不持有 store、不设刷新链、不挂事件监听。 + +`BalanceBadge` 是账户级别的值,不属于会话投影:slot 注入面携带一个 `snapshot()` 回调,按 60 秒间隔轮询 host 的 `ctx.remote.openRouterUsage.snapshot()` Remote,并驱动本地状态。在折叠的 rail 中只渲染余额标记;在宽列中呈现带标签的胶囊。未知或缺失的值渲染为空占位。两个条目在真实数据出现之前都不渲染任何内容,因此未组装 OpenRouter 网关或 key 的组装体不会产生任何布局成本。 + +`/client` 的导出接口包括插件本体(`apply`/`inject`)、`CostDock`/`BalanceBadge` 组件与注入面类型。 + +## 模型体验 + +间接影响:无。无论是花费读数还是余额徽章都不会注入任何模型上下文;它们只是 `openRouterCost` 投影与 `openRouterUsage` Remote 快照的纯客户端只读模型。agent 循环与所有提示词均保持不变。 + +#### KV Cache 影响 + +无。花费来自既有的日志用量事件,余额是一个获取到的账户数值;两者都不是新的模型可见输入。 + +## 已知限制与暂缓事项 + +- **以折叠时刻计价**——`CostDock` 渲染持久投影当前持有的数值;已折叠历史的数值反映折叠时的定价表(见 `dsh-openrouter-usage`)。 +- **轮询而非推送**——`BalanceBadge` 轮询 host 快照;没有 `openRouterUsage` 转发事件通道,因此数值按轮询间隔更新,而非即时。 diff --git a/packages/client/ui-openrouter-usage/package.json b/packages/client/ui-openrouter-usage/package.json new file mode 100644 index 0000000000..dbee6a198f --- /dev/null +++ b/packages/client/ui-openrouter-usage/package.json @@ -0,0 +1,85 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-openrouter-usage", + "description": "OpenRouter cost + balance surface: per-session 会话费用 in the composer stats dock and the account 余额 badge at the sidebar foot", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-openrouter-usage" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-openrouter-usage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-openrouter-usage": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css new file mode 100644 index 0000000000..d774a75362 --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css @@ -0,0 +1,36 @@ +/* Balance badge at the sidebar foot: an icon marker in the rail, a labeled + pill in the wide column. Mirrors the Settings trigger's foot-row posture. */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + max-width: 100%; + padding: 0 8px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 28px; + cursor: default; +} + +.badge:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.marker { + flex: none; + font-size: 12px; + line-height: 28px; +} + +.text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} diff --git a/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx new file mode 100644 index 0000000000..fe07256a2b --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx @@ -0,0 +1,68 @@ +// The OpenRouter account-balance badge at the sidebar foot. Renders `余额` +// plus the latest snapshot's USD figure in both widths (rail = marker-only, +// wide = label + figure). The value is account-global, so it is not a session +// projection: an injected `snapshot` callback polls the host Remote gateway on +// an interval and drives local state. `wide` arrives from the sidebar's owner +// share; `snapshot` from the register's inject face. + +import { useEffect, useState } from 'react' +import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { formatUsd } from './money.ts' +import css from './BalanceBadge.module.css' + +/** Poll interval for the balance figure, ms. */ +export const BALANCE_POLL_MS = 60_000 + +/** Injected face: the balance read callback the badge polls. */ +export interface BalanceBadgeActions { + /** Fetch the latest account snapshot from the host gateway. */ + snapshot: () => Promise +} + +/** Composed props: the sidebar footer-action owner share plus the injected read. */ +export type BalanceBadgeProps = + PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'> + +/** + * Poll `snapshot` on an interval and render the account balance. Unknown or + * absent figures render the empty placeholder so the seat costs no confusion. + * A transient failure keeps the last-known figure and retries next tick. + * @param props - owner state plus the injected snapshot callback. + * @returns the balance badge. + */ +export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) { + const [balance, setBalance] = useState(undefined) + + useEffect(() => { + let disposed = false + const poll = async (): Promise => { + let next: OpenRouterBalance + try { + next = await snapshot() + } catch (_failedFetch) { + return + } + if (!disposed) setBalance(next) + } + void poll() + const timer = setInterval(() => void poll(), BALANCE_POLL_MS) + return () => { + disposed = true + clearInterval(timer) + } + }, [snapshot]) + + const usd = balance?.balanceUsd ?? null + const amount = usd === null + ? t('balance.empty') + : formatUsd(usd) + const label = t('balance.label', { amount }) + const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label }) + return ( + + ) +} diff --git a/packages/client/ui-openrouter-usage/src/client/CostDock.module.css b/packages/client/ui-openrouter-usage/src/client/CostDock.module.css new file mode 100644 index 0000000000..4db436640d --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/CostDock.module.css @@ -0,0 +1,10 @@ +/* Session cost readout on the composer stats dock: an inline monospace USD + figure sharing the stats-line row's typography (13/20 secondary). */ + +.cost { + font-size: 13px; + line-height: 20px; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; +} diff --git a/packages/client/ui-openrouter-usage/src/client/CostDock.tsx b/packages/client/ui-openrouter-usage/src/client/CostDock.tsx new file mode 100644 index 0000000000..c33ff554c5 --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/CostDock.tsx @@ -0,0 +1,39 @@ +// The per-session OpenRouter spend readout on the composer stats dock. +// Reads the durable openRouterCost projection, so paging and compaction +// cannot change the figure; the entry renders nothing until at least one +// step priced (no zero-cost group, mirroring the stats line's billing gate). + +import { memo } from 'react' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: merges openRouterCost into SessionProjectionMap for useProjection. +import type {} from '@deepseek-ai/dsh-openrouter-usage/client' +import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client' +import { formatUsd } from './money.ts' +import css from './CostDock.module.css' + +/** Composed props: composer.dock owner share + the projection/read seats. */ +export type CostDockProps = + PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'> + +/** + * Render the running session cost as `会话费用 $X.XX`, shown only when the + * session actually priced steps. An unknown-pricing step count feeds a + * tooltip so the figure's coverage stays visible without polluting the row. + * @param props - framework/runtime props. + * @returns the cost group, or null on a zero/absent projection. + */ +export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) { + const cost = useProjection('openRouterCost') as OpenRouterCost | undefined + if (cost === undefined || cost.pricedSteps === 0) return null + const label = t('cost.label', { amount: formatUsd(cost.totalUsd) }) + return ( + 0 + ? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) }) + : undefined} + > + {label} + + ) +}) diff --git a/packages/client/ui-openrouter-usage/src/client/index.ts b/packages/client/ui-openrouter-usage/src/client/index.ts new file mode 100644 index 0000000000..d8fa7f66f2 --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/index.ts @@ -0,0 +1,77 @@ +/** + * OpenRouter usage surface plugin, browser half: the per-session 会话费用 + * readout on the composer stats dock (from the openRouterCost projection) and + * the account 余额 badge at the sidebar foot (from a polled Remote snapshot). + * Both render nothing until real data exists, so assemblies without the host + * gateway or key cost no layout. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the ui-conversation SlotMap merge (the composer.dock entry). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the ui-sidebar SlotMap merge (the sidebar.footer.action entry). +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { BalanceBadgeActions } from './BalanceBadge.tsx' +import { BalanceBadge } from './BalanceBadge.tsx' +import { CostDock } from './CostDock.tsx' +import { en, zh, type OpenRouterUsageKey } from './locales.ts' + +export { BalanceBadge } from './BalanceBadge.tsx' +export { CostDock } from './CostDock.tsx' +export type { BalanceBadgeActions, BalanceBadgeProps } from './BalanceBadge.tsx' +export type { CostDockProps } from './CostDock.tsx' +export type { OpenRouterUsageKey } from './locales.ts' +export { formatUsd } from './money.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The OpenRouter usage strip/badge copy. */ + openRouterUsage: OpenRouterUsageKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'openRouterUsage' + +/** Required services for the two surface entries, the Remote gateway, and copy. */ +export const inject = ['slots', 'locale', 'remote', 'remote.openRouterUsage'] + +/** + * Client plugin body: the cost dock and balance badge registrations. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-openrouter-usage: dictionaries') + + // Session cost rides the composer stats dock, ordered after the shipped + // stats line (its own order 0) so it reads as a trailing billing group. + ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({ + name: 'conversation.composer.dock', + id: 'openrouter-cost', + order: 5, + locale: NS, + }, CostDock)) + + const snapshot: BalanceBadgeActions['snapshot'] = async () => { + const result = await ctx.remote.openRouterUsage.snapshot() + if (!result.ok) { + throw new Error(`openRouterUsage.snapshot failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + + // Account balance is account-global (root scope): the injected face carries + // the snapshot read the badge polls. The seat is declared but unhoused by + // default; this registration names it, so an assembly without ui-sidebar + // simply resolves an empty contribution. + ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ + name: 'sidebar.footer.action', + id: 'openrouter-balance', + order: 10, + locale: NS, + inject: (): BalanceBadgeActions => ({ snapshot }), + }, BalanceBadge)) +} diff --git a/packages/client/ui-openrouter-usage/src/client/locales.ts b/packages/client/ui-openrouter-usage/src/client/locales.ts new file mode 100644 index 0000000000..79284d9b0f --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/locales.ts @@ -0,0 +1,22 @@ +/** `openrouterUsage` namespace dictionaries. */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'cost.label': '会话费用 {amount}', + 'cost.tooltipUnknown': '包含 {count} 个未知定价步骤', + 'balance.label': '余额 {amount}', + 'balance.tooltip': 'OpenRouter 账户余额({label})', + 'balance.empty': '——', +} satisfies Record + +/** The openrouterUsage namespace key union. */ +export type OpenRouterUsageKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'cost.label': 'Session cost {amount}', + 'cost.tooltipUnknown': 'Includes {count} unpriced steps', + 'balance.label': 'Balance {amount}', + 'balance.tooltip': 'OpenRouter account balance ({label})', + 'balance.empty': '——', +} satisfies Record diff --git a/packages/client/ui-openrouter-usage/src/client/money.ts b/packages/client/ui-openrouter-usage/src/client/money.ts new file mode 100644 index 0000000000..da5149150d --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/client/money.ts @@ -0,0 +1,18 @@ +// USD display helpers for the OpenRouter cost and balance surfaces. + +/** + * Format a USD figure with a $-prefix and up to two decimals, delimited with + * commas above one thousand (e.g. `$1,234.56`). Fractional cents round to the + * nearest cent. + * @param usd - non-negative USD amount. + * @returns display string. + */ +export function formatUsd(usd: number): string { + const amount = usd.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + return amount +} diff --git a/packages/client/ui-openrouter-usage/src/css-modules.d.ts b/packages/client/ui-openrouter-usage/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-openrouter-usage/src/index.ts b/packages/client/ui-openrouter-usage/src/index.ts new file mode 100644 index 0000000000..2c1cbf0b4d --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/index.ts @@ -0,0 +1,9 @@ +/** + * OpenRouter usage surface plugin, node half. Pure UI plugin: the empty apply + * exists so the plugin appears in the host cordis.yml / Loader; the browser + * half ships via exports["./client"], discovered through the package.json + * dsh.client declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-openrouter-usage/src/invariant.ts b/packages/client/ui-openrouter-usage/src/invariant.ts new file mode 100644 index 0000000000..95f0b9e6ce --- /dev/null +++ b/packages/client/ui-openrouter-usage/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-openrouter-usage`. + * @module @deepseek-ai/dsh-client-ui-openrouter-usage/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-openrouter-usage' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-openrouter-usage-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: two slot registrations whose disposal is proven by + * HMR-safety specs — the plugin owns no store (cost arrives on the + * openRouterCost projection, balance on a Remote snapshot), emits no cordis + * events, and holds no cross-plugin mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx b/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx new file mode 100644 index 0000000000..ac5a609fc6 --- /dev/null +++ b/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +// BalanceBadge presentation: polls an injected snapshot callback and renders +// 余额 in both widths, hiding unknown figures behind the empty placeholder. + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client' +import { BALANCE_POLL_MS, BalanceBadge, type BalanceBadgeProps } from '../src/client/BalanceBadge.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(() => { + vi.useRealTimers() + cleanup() +}) + +const t = makeTranslate(zh, commonZh) + +function makeBalance(over: Partial = {}): OpenRouterBalance { + return { + balanceUsd: 10.5, + label: 'prod-key', + usageTokens: 1000, + limitTokens: 100000, + isFreeTier: false, + updatedAt: Date.now(), + currency: 'USD', + ...over, + } +} + +function makeProps(over: Partial = {}) { + return { + wide: true, + snapshot: vi.fn(async () => makeBalance()), + t, + ...over, + } as unknown as Parameters[0] +} + +describe('BalanceBadge', () => { + it('renders the balance label with the USD figure in wide mode', async () => { + render() + expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy() + expect(screen.getByText('余额 $10.50')).toBeTruthy() + }) + + it('polls the snapshot callback on the balance interval', async () => { + vi.useFakeTimers() + const snapshot = vi.fn(async () => makeBalance()) + render() + await act(async () => { await Promise.resolve() }) + expect(snapshot).toHaveBeenCalledTimes(1) + await act(async () => { vi.advanceTimersByTime(BALANCE_POLL_MS) }) + await act(async () => { await Promise.resolve() }) + expect(snapshot).toHaveBeenCalledTimes(2) + }) + + it('renders only the rail marker in the collapsed sidebar', async () => { + render() + expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy() + expect(screen.queryByText('余额 $10.50')).toBeNull() + }) + + it('shows the empty placeholder when no balance is known yet', async () => { + render( makeBalance({ balanceUsd: null })) })} />) + expect(await screen.findByLabelText('余额 ——')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx b/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..dedd3ab010 --- /dev/null +++ b/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +/** + * ui-openrouter-usage browser half on a real cordis Context with fake slots/ + * locale/remote faces: the plugin registers the CostDock entry at + * conversation.composer.dock and the BalanceBadge entry at + * sidebar.footer.action, the balance snapshot reads roll through the injected + * face at call time, and registration disposal rides the plugin fiber (HMR + * safety). The node half and the invariant companion are exercised over the + * same Context. + */ +import { Context, Service } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client' +import type { BalanceBadgeActions } from '../src/client/BalanceBadge.tsx' +import { BalanceBadge } from '../src/client/BalanceBadge.tsx' +import { CostDock } from '../src/client/CostDock.tsx' +import { apply, inject, NS } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +afterEach(cleanup) + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + const snapshot = vi.fn<() => Promise>>() + .mockResolvedValue({ ok: true, value: balance() }) + ctx.provide('remote.openRouterUsage', { snapshot }) + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot } +} + +function declare(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { + 'conversation.composer.dock': { kind: 'list', scope: 'session' }, + 'sidebar.footer.action': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) +} + +function balance(over: Partial = {}): OpenRouterBalance { + return { + balanceUsd: 5.5, + label: 'k', + usageTokens: 0, + limitTokens: 0, + isFreeTier: false, + updatedAt: 1_700_000_000_000, + currency: 'USD', + ...over, + } +} + +describe('ui-openrouter-usage browser plugin', () => { + it('declares only the services its two entries read', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.openRouterUsage']) + }) + + it('registers localized cost and balance entries without reading the Remote eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + + const cost = b.slots.entries('conversation.composer.dock')[0]! + expect(cost.component).toBe(CostDock) + expect(cost.options).toMatchObject({ id: 'openrouter-cost', order: 5 }) + expect(cost.locale).toBe(NS) + + const badge = b.slots.entries('sidebar.footer.action')[0]! + expect(badge.component).toBe(BalanceBadge) + expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 }) + expect(badge.locale).toBe(NS) + + // Registration reads nothing live; the snapshot rolls at call time. + expect(b.snapshot).not.toHaveBeenCalled() + const injected = (badge.inject as unknown as () => BalanceBadgeActions)() + await expect(injected.snapshot()).resolves.toEqual(balance()) + expect(b.snapshot).toHaveBeenCalledOnce() + await b.ctx.fiber.dispose() + }) + + it('forwards a Remote failure out of the injected snapshot verbatim', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.snapshot.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'boom', details: {} } }) + const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)() + await expect(injected.snapshot()).rejects.toThrow('openRouterUsage.snapshot failed: REMOTE_ERROR: boom') + await b.ctx.fiber.dispose() + }) + + it('follows locale and recovers across late declaration and declarer reload', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('conversation.composer.dock')).toHaveLength(0) + expect(b.slots.entries('sidebar.footer.action')).toHaveLength(0) + + const stop = declare(b.slots) + await vi.waitFor(() => { expect(b.slots.entries('conversation.composer.dock')).toHaveLength(1) }) + expect(b.slots.entries('sidebar.footer.action')).toHaveLength(1) + + stop() + expect(b.slots.entries('conversation.composer.dock')).toHaveLength(0) + declare(b.slots) + await vi.waitFor(() => { + expect(b.slots.entries('sidebar.footer.action')[0]?.component).toBe(BalanceBadge) + }) + + await fiber.dispose() + expect(b.slots.entries('conversation.composer.dock')).toHaveLength(0) + expect(b.slots.entries('sidebar.footer.action')).toHaveLength(0) + await b.ctx.fiber.dispose() + }) +}) + +describe('ui-openrouter-usage node half', () => { + it('the node apply is an inert loader seat', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx b/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx new file mode 100644 index 0000000000..d890ae8967 --- /dev/null +++ b/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +// CostDock presentation: renders the running session cost from the +// openRouterCost projection, and nothing at all until a step was priced. + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { CostDock } from '../src/client/CostDock.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = makeTranslate(zh, commonZh) + +function makeProps(over: Partial[0]> = {}) { + return { + useProjection: vi.fn(() => undefined), + t, + ...over, + } as unknown as Parameters[0] +} + +describe('CostDock', () => { + it('renders nothing while the projection is absent or priced zero steps', () => { + for (const value of [undefined, { totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, currency: 'USD' }]) { + const { container } = render( value })} />) + expect(container.firstChild).toBeNull() + cleanup() + } + }) + + it('renders the priced session cost as a USD figure', () => { + render( ({ totalUsd: 1.2345, pricedSteps: 2, unknownModelSteps: 0, currency: 'USD' }), + })} />) + expect(screen.getByText('会话费用 $1.23')).toBeTruthy() + }) + + it('surfaces the unknown-pricing step count in a tooltip when present', () => { + render( ({ totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 3, currency: 'USD' }), + })} />) + expect(screen.getByText('会话费用 $0.50').getAttribute('title')).toBe('包含 3 个未知定价步骤') + }) + + it('omits the tooltip when every step priced', () => { + render( ({ totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }), + })} />) + expect(screen.getByText('会话费用 $0.50').getAttribute('title')).toBeNull() + }) + + it('reads the projection through the openRouterCost key', () => { + const useProjection = vi.fn(() => undefined) + render() + act(() => {}) + expect(useProjection).toHaveBeenCalledWith('openRouterCost') + }) +}) diff --git a/packages/client/ui-openrouter-usage/tsconfig.json b/packages/client/ui-openrouter-usage/tsconfig.json new file mode 100644 index 0000000000..c1f9cee65b --- /dev/null +++ b/packages/client/ui-openrouter-usage/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../llm/openrouter-usage" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} \ No newline at end of file diff --git a/packages/client/ui-openrouter-usage/tsdown.config.ts b/packages/client/ui-openrouter-usage/tsdown.config.ts new file mode 100644 index 0000000000..ae7e740fe9 --- /dev/null +++ b/packages/client/ui-openrouter-usage/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-openrouter-usage', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/llm/openrouter-usage/README.i18n.yaml b/packages/llm/openrouter-usage/README.i18n.yaml new file mode 100644 index 0000000000..c872bb5f96 --- /dev/null +++ b/packages/llm/openrouter-usage/README.i18n.yaml @@ -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 diff --git a/packages/llm/openrouter-usage/README.md b/packages/llm/openrouter-usage/README.md new file mode 100644 index 0000000000..e6606d4cc0 --- /dev/null +++ b/packages/llm/openrouter-usage/README.md @@ -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. diff --git a/packages/llm/openrouter-usage/README.zh.md b/packages/llm/openrouter-usage/README.zh.md new file mode 100644 index 0000000000..07e7d32245 --- /dev/null +++ b/packages/llm/openrouter-usage/README.zh.md @@ -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 暴露配置;设置项界面推迟实现。 diff --git a/packages/llm/openrouter-usage/package.json b/packages/llm/openrouter-usage/package.json new file mode 100644 index 0000000000..79950e0d69 --- /dev/null +++ b/packages/llm/openrouter-usage/package.json @@ -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:^" + } +} \ No newline at end of file diff --git a/packages/llm/openrouter-usage/src/client.ts b/packages/llm/openrouter-usage/src/client.ts new file mode 100644 index 0000000000..f31bd83cbd --- /dev/null +++ b/packages/llm/openrouter-usage/src/client.ts @@ -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' diff --git a/packages/llm/openrouter-usage/src/index.ts b/packages/llm/openrouter-usage/src/index.ts new file mode 100644 index 0000000000..e7f7bedc08 --- /dev/null +++ b/packages/llm/openrouter-usage/src/index.ts @@ -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 = 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 + + /** Live pricing table the projection fold reads (swapped in place on refresh). */ + private readonly pricing = new Map() + /** 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 | undefined + /** Abort handle for the armed balance fetch, if any. */ + private balanceTimer: ReturnType | 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 { + 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 { + 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): 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): Promise { + await this.refreshPricing(config) + await this.refreshBalance(config) + } + + /** Refresh the pricing table; a failure keeps the last-known table. */ + private async refreshPricing(config: Required): Promise { + 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): Promise { + 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): Promise { + 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 diff --git a/packages/llm/openrouter-usage/src/invariant.ts b/packages/llm/openrouter-usage/src/invariant.ts new file mode 100644 index 0000000000..15f4f0c750 --- /dev/null +++ b/packages/llm/openrouter-usage/src/invariant.ts @@ -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 */ diff --git a/packages/llm/openrouter-usage/src/openrouter.ts b/packages/llm/openrouter-usage/src/openrouter.ts new file mode 100644 index 0000000000..6c5164a7e5 --- /dev/null +++ b/packages/llm/openrouter-usage/src/openrouter.ts @@ -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 + 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 + 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 | 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() + 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 (response: Response): Promise => { + if (!response.ok) return undefined + try { + return await response.json() as Envelope + } catch (_invalidJson) { + return undefined + } + } + + const [creditsEnvelope, keyEnvelope] = await Promise.all([ + readKind(creditsResponse), + readKind(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, +): 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) +} diff --git a/packages/llm/openrouter-usage/src/projection.ts b/packages/llm/openrouter-usage/src/projection.ts new file mode 100644 index 0000000000..792ef03069 --- /dev/null +++ b/packages/llm/openrouter-usage/src/projection.ts @@ -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, + 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, + } +} diff --git a/packages/llm/openrouter-usage/src/types.ts b/packages/llm/openrouter-usage/src/types.ts new file mode 100644 index 0000000000..4b018e1703 --- /dev/null +++ b/packages/llm/openrouter-usage/src/types.ts @@ -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 + } +} diff --git a/packages/llm/openrouter-usage/tests/loader-composition.spec.ts b/packages/llm/openrouter-usage/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..6f1aaa0cd8 --- /dev/null +++ b/packages/llm/openrouter-usage/tests/loader-composition.spec.ts @@ -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 { + 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([ + ['@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 + 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() + }) +}) diff --git a/packages/llm/openrouter-usage/tests/openrouter.spec.ts b/packages/llm/openrouter-usage/tests/openrouter.spec.ts new file mode 100644 index 0000000000..d678d5ac9d --- /dev/null +++ b/packages/llm/openrouter-usage/tests/openrouter.spec.ts @@ -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).authorization).toBe(`Bearer ${KEY}`) + expect((call[1].headers as Record)['user-agent']).toBeTruthy() + }) +}) + +describe('fetchAccountBalance', () => { + function mockAccountFetch(paths: Record, 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() + }) +}) diff --git a/packages/llm/openrouter-usage/tests/projection.spec.ts b/packages/llm/openrouter-usage/tests/projection.spec.ts new file mode 100644 index 0000000000..3c26ef67fb --- /dev/null +++ b/packages/llm/openrouter-usage/tests/projection.spec.ts @@ -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([ + ['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 + expect(ctx.sessionProjections.viewCheckpoint(checkpoint).openRouterCost).toEqual({ + totalUsd: 8 * 1.4e-6 + 2 * 2.8e-6, + pricedSteps: 1, + unknownModelSteps: 0, + currency: 'USD', + }) + }) +}) diff --git a/packages/llm/openrouter-usage/tsconfig.json b/packages/llm/openrouter-usage/tsconfig.json new file mode 100644 index 0000000000..b9a437e916 --- /dev/null +++ b/packages/llm/openrouter-usage/tsconfig.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/packages/web/README.i18n.yaml b/packages/web/README.i18n.yaml index 19da6ba386..abf2036ac1 100644 --- a/packages/web/README.i18n.yaml +++ b/packages/web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/README.md -README.md: fc37d7cdead59138db149b5a86f0a0c031d40037 -README.zh.md: 53fe673ddaed235bbb938db4757e9cd240928d61 +README.md: 449c96c71b580964486968431c28f71dc466e8eb +README.zh.md: a7d7d6dc1a382abe481f3520aa42480ff0317976 diff --git a/packages/web/README.md b/packages/web/README.md index fc37d7cdea..449c96c71b 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -8,6 +8,7 @@ This family provides provider-neutral web search and fetch operations plus the m |---|---|---| | [`web/`](web/README.md) | Defines web provider registration, selection, and shared errors | `ctx.web` | | [`web-search-exa/`](web-search-exa/README.md) | Provides web search through Exa | registers on `ctx.web` | +| [`web-search-searxng/`](web-search-searxng/README.md) | Provides web search through a SearXNG instance | registers on `ctx.web` | | [`web-search-perplexity/`](web-search-perplexity/README.md) | Provides web search through Perplexity | registers on `ctx.web` | | [`web-search-deepseek/`](web-search-deepseek/README.md) | Provides native DeepSeek web search | registers on `ctx.web` | | [`web-fetch-http/`](web-fetch-http/README.md) | Fetches public HTTP and HTTPS resources | registers on `ctx.web` | diff --git a/packages/web/README.zh.md b/packages/web/README.zh.md index 53fe673dda..a7d7d6dc1a 100644 --- a/packages/web/README.zh.md +++ b/packages/web/README.zh.md @@ -8,6 +8,7 @@ |---|---|---| | [`web/`](web/README.md) | 定义 web 提供方注册、选择和共享错误 | `ctx.web` | | [`web-search-exa/`](web-search-exa/README.md) | 通过 Exa 提供 web 搜索 | 注册到 `ctx.web` | +| [`web-search-searxng/`](web-search-searxng/README.md) | 通过 SearXNG 实例提供 web 搜索 | 注册到 `ctx.web` | | [`web-search-perplexity/`](web-search-perplexity/README.md) | 通过 Perplexity 提供 web 搜索 | 注册到 `ctx.web` | | [`web-search-deepseek/`](web-search-deepseek/README.md) | 提供 DeepSeek 原生 web 搜索 | 注册到 `ctx.web` | | [`web-fetch-http/`](web-fetch-http/README.md) | 抓取公共 HTTP 和 HTTPS 资源 | 注册到 `ctx.web` | diff --git a/packages/web/web-search-searxng/README.i18n.yaml b/packages/web/web-search-searxng/README.i18n.yaml new file mode 100644 index 0000000000..c679a7832f --- /dev/null +++ b/packages/web/web-search-searxng/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/web/web-search-searxng/README.md +README.md: 0e0c76fc884eb5968618673cc58f6ae5f88112cc +README.zh.md: aa550a67c49458c9488c4b3bf24532935db99e01 diff --git a/packages/web/web-search-searxng/README.md b/packages/web/web-search-searxng/README.md new file mode 100644 index 0000000000..0e0c76fc88 --- /dev/null +++ b/packages/web/web-search-searxng/README.md @@ -0,0 +1,52 @@ +# @deepseek-ai/dsh-web-search-searxng + +English | [中文](README.zh.md) + +A [SearXNG](https://docs.searxng.org)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls a SearXNG instance's JSON API (`GET /search?format=json`) and maps the flat `results[]` into the seam's normalized `WebSearchResult`. It targets a self-hosted or private SearXNG: there is no single canonical public instance, so `baseURL` has no default, and the provider deliberately carries no API key or `Authorization` header. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the `ctx.web` key and it does not register a model-facing tool (that is `@deepseek-ai/dsh-tool-web`). Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`) that registers its backend, not a default-export service. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `baseURL` | (none) | SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. | +| `language` | `auto` | SearXNG `language` request value; `auto` lets the instance decide per user preferences. | +| `timeRange` | (unset) | SearXNG `time_range` recency filter: `day`, `week`, `month`, or `year`. Omitted sends no filter. | + +```yaml +- id: web-search-searxng + name: '@deepseek-ai/dsh-web-search-searxng' + config: + baseURL: https://searx.example +``` + +The entry above is the base layer of the `web-search-searxng` Settings section: a user layer over it (a `settings.yaml` section or an in-session settings edit) reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when the instance or a filter changes. A section without `baseURL` still passes the schema but leaves the provider unavailable — no endpoint is guessed. + +```yaml +# $DSH_HOME/settings.yaml +web-search-searxng: + baseURL: https://searx.example + language: auto + timeRange: day +``` + +SearXNG exposes no per-request result-count control — page size is instance configuration — so no `numResults` option exists; the seam enforces `maxResults` on the result. + +## Mapping + +SearXNG returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `content`, `publishedAt` ← `publishedDate`. Unlike the Exa provider it does **not** drop snippet-less entries: URL and title are still useful, so every result is kept. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. The request carries no `Authorization` header, so no credential can leak to a redirect target. + +## Model Experience + +Indirectly, through [`dsh-tool-web`](../tool-web/README.md), which retains this provider's `maxResults`-bounded URLs, titles, snippets, and publication dates or its exact `SearXNG search aborted`, `SearXNG search request failed: `, and `SearXNG returned an unprocessable response body: ` failures under the consumer's error wrapper while generated answers and provider-private fields remain outside context. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Result count is not bounded at the request** — SearXNG page size is instance configuration, so the provider sends no count and the seam truncates on return; a single page (typically ~20 results) is fetched. +- **No aggregate-answer or infobox content is surfaced** — SearXNG `answers` and `infoboxes` are not mapped into `content`. No generated answer is trusted. +- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`. diff --git a/packages/web/web-search-searxng/README.zh.md b/packages/web/web-search-searxng/README.zh.md new file mode 100644 index 0000000000..aa550a67c4 --- /dev/null +++ b/packages/web/web-search-searxng/README.zh.md @@ -0,0 +1,52 @@ +# @deepseek-ai/dsh-web-search-searxng + +[English](README.md) | 中文 + +由 [SearXNG](https://docs.searxng.org) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 SearXNG 实例的 JSON API(`GET /search?format=json`),把扁平 `results[]` 映射为 seam 规范化的 `WebSearchResult`。此提供方面向自托管或私有 SearXNG:不存在统一的公共实例,因此 `baseURL` 没有默认值,提供方刻意不携带 API 密钥或 `Authorization` 头。 + +这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有 `ctx.web` 键,也不注册面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`),负责注册后端,而非默认导出服务。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `baseURL` | (无) | SearXNG 实例基址;追加 `/search`。为空或无法解析时提供方不可用。 | +| `language` | `auto` | SearXNG `language` 请求值;`auto` 让实例按用户偏好决定。 | +| `timeRange` | (未设置) | SearXNG `time_range` 时效过滤器:`day`、`week`、`month` 或 `year`。省略时不发送过滤器。 | + +```yaml +- id: web-search-searxng + name: '@deepseek-ai/dsh-web-search-searxng' + config: + baseURL: https://searx.example +``` + +上面的条目是 `web-search-searxng` Settings 分节的基础层:覆盖在它之上的用户层(`settings.yaml` 分节或会话内设置的编辑)会作用于**下一次**搜索,因为提供方按调用投影分节,而非在注册时捕获。因此 seam 的提供方选择在实例或过滤器变化时不会闪烁。不含 `baseURL` 的分节仍能通过 schema,但提供方保持不可用——不会猜测任何端点。 + +```yaml +# $DSH_HOME/settings.yaml +web-search-searxng: + baseURL: https://searx.example + language: auto + timeRange: day +``` + +SearXNG 不提供按请求控制结果数量的方式——页面大小属于实例配置——因此没有 `numResults` 选项;seam 会在结果返回时强制执行 `maxResults`。 + +## 映射 + +SearXNG 返回扁平 `results[]`,不返回生成答案,因此省略 `content`。每项结果映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `content`、`publishedAt` ← `publishedDate`。与 Exa 提供方不同,此提供方**不会**丢弃无 snippet 的结果:URL 与标题仍然有用,因此保留每项结果。提供方失败(HTTP 错误、网络失败、响应体无法解析或结构不符)以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在访问 `Location` 指向的目标之前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。请求不携带 `Authorization` 头,因此不会有凭据泄露给重定向目标。 + +## 模型体验 + +通过 [`dsh-tool-web`](../tool-web/README.md) 间接影响;该工具保留此提供方经 `maxResults` 限制的 URL、标题、snippet 与发布日期,或将确切的错误消息 `SearXNG search aborted`、`SearXNG search request failed: ` 和 `SearXNG returned an unprocessable response body: ` 置于消费方的错误包装层内;生成答案与提供方私有字段不进入上下文。 + +#### KV Cache 影响 + +不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。 + +## 已知限制与暂缓事项 + +- **请求端不限制结果数量**:SearXNG 页面大小属于实例配置,因此提供方不发送数量,由 seam 在返回时截断;只抓取单页(通常约 20 条结果)。 +- **不呈现聚合答案或 infobox 内容**:SearXNG 的 `answers` 与 `infoboxes` 不映射进 `content`。不信任任何生成答案。 +- **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-searxng/package.json b/packages/web/web-search-searxng/package.json new file mode 100644 index 0000000000..7d68fb6303 --- /dev/null +++ b/packages/web/web-search-searxng/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-web-search-searxng", + "description": "SearXNG-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/web-search-searxng" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/web/web-search-searxng/src/index.ts b/packages/web/web-search-searxng/src/index.ts new file mode 100644 index 0000000000..b034017406 --- /dev/null +++ b/packages/web/web-search-searxng/src/index.ts @@ -0,0 +1,79 @@ +/** + * `@deepseek-ai/dsh-web-search-searxng`: registers a SearXNG-backed + * `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): a search provider does not own the `ctx.web` key — + * it registers INTO the seam's provider registry, exactly as + * `@deepseek-ai/dsh-web-search-exa` does. The key is owned by + * `@deepseek-ai/dsh-web`. + * + * @module @deepseek-ai/dsh-web-search-searxng + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import type {} from '@deepseek-ai/dsh-web' +import { SearXngSearchProvider } from './provider.ts' + +export { + SEARXNG_DEFAULT_LANGUAGE, + SEARXNG_NO_TIME_RANGE, + SEARXNG_PROVIDER_ID, + SEARXNG_TIME_RANGES, + SearXngSearchProvider, +} from './provider.ts' +export type { + SearXngSearchProviderOptions, + SearXngSearchProviderSource, + SearXngTimeRange, +} from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-searxng' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +/** Plugin config. `baseURL` is optional in the schema so an omitted value + * surfaces as an unavailable provider (the seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`) + * rather than failing boot; `apply` fills constant defaults. */ +export interface Config { + /** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */ + baseURL?: string + /** SearXNG `language` to request. Defaults to `auto`. */ + language?: string + /** SearXNG `time_range` recency filter. Omitted = no filter. */ + timeRange?: 'day' | 'week' | 'month' | 'year' +} + +export const Config: z = z.object({ + baseURL: z.string(), + language: z.string(), + timeRange: z.union(['day', 'week', 'month', 'year'] as const), +}) + +/** Settings namespace carrying the SearXNG instance and any per-search filters. */ +export const SEARXNG_SETTINGS_NAMESPACE = settingsNamespace('web-search-searxng') + +/** Register the SearXNG search provider with `ctx.web`, reading the live section + * per search so an in-session settings edit applies without re-registration. */ +export function apply(ctx: Context, config: Config): void { + let current: () => Config = () => config + installSettingsSection(ctx, SEARXNG_SETTINGS_NAMESPACE, Config, config, { + setSource: (source) => { + current = source + }, + // The registration carries no resolved value: the provider projects the + // section per search, so a committed change needs no re-registration. + onChange: () => {}, + }) + ctx.web.registerSearchProvider(new SearXngSearchProvider(() => ({ + // SearXNG exposes no uniform public endpoint and the product requires + // evidence over an unsupported default, so `baseURL` has no constant + // fallback: an omitted value surfaces as an unavailable provider (the + // seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`), not a silent endpoint. + baseURL: current().baseURL ?? '', + ...current().language !== undefined ? { language: current().language } : {}, + ...current().timeRange !== undefined ? { timeRange: current().timeRange } : {}, + }))) +} diff --git a/packages/web/web-search-searxng/src/invariant.ts b/packages/web/web-search-searxng/src/invariant.ts new file mode 100644 index 0000000000..640cfcf535 --- /dev/null +++ b/packages/web/web-search-searxng/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-searxng`. + * @module @deepseek-ai/dsh-web-search-searxng/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-searxng' + +/** Cordis companion plugin name. */ +export const name = 'web-search-searxng-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-searxng/src/provider.ts b/packages/web/web-search-searxng/src/provider.ts new file mode 100644 index 0000000000..b1dff83cfc --- /dev/null +++ b/packages/web/web-search-searxng/src/provider.ts @@ -0,0 +1,166 @@ +/** + * `SearXngSearchProvider`: a `WebSearchProvider` backed by a SearXNG instance's + * JSON API (`GET /search?format=json`). It maps against the flat `results[]`, + * keeps entries even without a snippet (URL and title remain useful), and + * omits `content` because SearXNG returns no generated answer. The provider + * carries no credentials, so a request carries no `Authorization` header. + * @module @deepseek-ai/dsh-web-search-searxng/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { SearXngError, SearXngResult, SearXngSearchResponse } from './types.ts' + +/** Stable id this provider registers under. */ +export const SEARXNG_PROVIDER_ID = 'searxng' + +/** Default language: let SearXNG decide per user preferences. */ +export const SEARXNG_DEFAULT_LANGUAGE = 'auto' + +/** `time_range` sentinel meaning "no recency filter". */ +export const SEARXNG_NO_TIME_RANGE = 'none' + +/** Valid `time_range` values SearXNG accepts. */ +export const SEARXNG_TIME_RANGES = ['day', 'week', 'month', 'year'] as const + +/** One of SearXNG's `time_range` values, passed through to the endpoint. */ +export type SearXngTimeRange = (typeof SEARXNG_TIME_RANGES)[number] + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ +export interface SearXngSearchProviderOptions { + /** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */ + baseURL: string + /** SearXNG `language` to request. `auto` lets the instance decide. */ + language?: string + /** SearXNG `time_range` recency filter. */ + timeRange?: SearXngTimeRange +} + +/** A snapshot or a per-search resolver (settings live-reload hands the latter). */ +export type SearXngSearchProviderSource = SearXngSearchProviderOptions | (() => SearXngSearchProviderOptions) + +/** + * Map one SearXNG result to a normalized source. The URL is always carried; a + * blank title or snippet is omitted rather than emitted as empty. + * + * @param result - one entry of SearXNG's `results[]`. + * @returns the normalized source. + */ +export function mapSearXngResult(result: SearXngResult): WebSearchSource { + return { + url: result.url, + ...result.title != null && result.title.trim().length > 0 ? { title: result.title } : {}, + ...result.content != null && result.content.trim().length > 0 ? { snippet: result.content } : {}, + ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}, + } +} + +/** + * Map a SearXNG response envelope to a normalized search result. + * + * @param response - the parsed `format=json` response body. + * @returns the normalized result; `content` is omitted (no generated answer). + */ +export function mapSearXngResponse(response: SearXngSearchResponse): WebSearchResult { + // SearXNG cannot bound per-request result count (page size is instance + // configuration), so the request carries no count and the web service owns + // the final `maxResults` truncation; this provider reports `truncated: false`. + return { sources: (response.results ?? []).map(mapSearXngResult), truncated: false } +} + +/** The SearXNG-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ +export class SearXngSearchProvider implements WebSearchProvider { + readonly id = SEARXNG_PROVIDER_ID + + constructor(source: SearXngSearchProviderSource) { + this.source = source + } + + private readonly source: SearXngSearchProviderSource + + /** Read the current options: a snapshot stays fixed, a resolver reads live settings. */ + private resolveOptions(): SearXngSearchProviderOptions { + return typeof this.source === 'function' ? this.source() : this.source + } + + available(): boolean { + const options = this.resolveOptions() + return isValidBaseUrl(options.baseURL) + && (options.language === undefined || options.language.length > 0) + && (options.timeRange === undefined || SEARXNG_TIME_RANGES.includes(options.timeRange)) + } + + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { + const options = this.resolveOptions() + const base = trimTrailingSlashes(options.baseURL) + const params = new URLSearchParams({ q: request.query, format: 'json' }) + if (options.language !== undefined) params.set('language', options.language) + if (options.timeRange !== undefined) params.set('time_range', options.timeRange) + + let response: Response + try { + response = await fetch(`${base}/search?${params}`, { + method: 'GET', + redirect: 'error', + headers: { + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + ...signal !== undefined ? { signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`SearXNG search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `SearXNG API error (HTTP ${status})` + try { + const parsed = await response.json() as SearXngError + const detail = parsed.error ?? parsed.message ?? parsed.content + if (detail !== undefined && detail.length > 0) message = detail + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + try { + const payload = await response.json() as SearXngSearchResponse + return mapSearXngResponse(payload) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`SearXNG returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + } +} + +/** True when `baseURL` parses as an absolute URL (a cheap local config check). */ +function isValidBaseUrl(baseURL: string): boolean { + return URL.canParse(baseURL) +} + +/** Strip a trailing slash so `${base}/search` never doubles a separator. */ +function trimTrailingSlashes(baseURL: string): string { + return baseURL.replace(/\/+$/, '') +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} diff --git a/packages/web/web-search-searxng/src/types.ts b/packages/web/web-search-searxng/src/types.ts new file mode 100644 index 0000000000..eff628b5f6 --- /dev/null +++ b/packages/web/web-search-searxng/src/types.ts @@ -0,0 +1,30 @@ +/** + * Wire types for the SearXNG JSON API (`GET {base}/search?q=...&format=json`). + * Types only — no runtime code. SearXNG returns a flat `results[]`; each entry + * carries a URL, an optional title, an optional `content` snippet, and an + * optional `publishedDate`. + * + * @module @deepseek-ai/dsh-web-search-searxng/types + */ + +/** One entry of SearXNG's flat `results[]`. */ +export interface SearXngResult { + url: string + title?: string | null + /** The page snippet SearXNG derives for the result. */ + content?: string | null + publishedDate?: string | null +} + +/** SearXNG's JSON search response envelope. */ +export interface SearXngSearchResponse { + results?: SearXngResult[] +} + +/** SearXNG's error response envelope (best-effort; fields vary by failure). */ +export interface SearXngError { + error?: string + message?: string + /** Some SearXNG error responses carry the message under `content`. */ + content?: string +} diff --git a/packages/web/web-search-searxng/tests/searxng.e2e.ts b/packages/web/web-search-searxng/tests/searxng.e2e.ts new file mode 100644 index 0000000000..6c09e73d65 --- /dev/null +++ b/packages/web/web-search-searxng/tests/searxng.e2e.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { SearXngSearchProvider } from '@deepseek-ai/dsh-web-search-searxng' + +/** + * Real-API smoke for the SearXNG search provider against a live instance. + * Self-skips without `$SEARXNG_BASE_URL` (CI has no endpoint), per the + * with-key e2e policy in docs/testing.md. + */ +const baseURL = process.env.SEARXNG_BASE_URL +const maybe = baseURL !== undefined && baseURL.length > 0 ? describe : describe.skip + +maybe('SearXngSearchProvider real API', () => { + it('returns sources for a live query', async () => { + const provider = new SearXngSearchProvider({ + baseURL: baseURL!, + ...process.env.SEARXNG_LANGUAGE !== undefined && process.env.SEARXNG_LANGUAGE.length > 0 + ? { language: process.env.SEARXNG_LANGUAGE } + : {}, + }) + const result = await provider.search({ query: 'DeepSeek Harness', maxResults: 5 }) + expect(result.sources.length).toBeGreaterThan(0) + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 30_000) +}) diff --git a/packages/web/web-search-searxng/tests/searxng.spec.ts b/packages/web/web-search-searxng/tests/searxng.spec.ts new file mode 100644 index 0000000000..dbccd6218f --- /dev/null +++ b/packages/web/web-search-searxng/tests/searxng.spec.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import WebRuntime from '@deepseek-ai/dsh-web' +import { SearXngSearchProvider, SEARXNG_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-searxng' +import * as searxngPlugin from '@deepseek-ai/dsh-web-search-searxng' +import { mapSearXngResponse, mapSearXngResult } from '../src/provider.ts' + +const options = { baseURL: 'https://searx.test' } + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('SearXng result mapping', () => { + it('maps a full result entry', () => { + expect(mapSearXngResult({ + url: 'https://a.test', + title: 'A', + content: 'an excerpt', + publishedDate: '2026-01-01', + })).toEqual({ url: 'https://a.test', title: 'A', snippet: 'an excerpt', publishedAt: '2026-01-01' }) + }) + + it('keeps a URL-only result rather than dropping it', () => { + expect(mapSearXngResult({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' }) + expect(mapSearXngResult({ url: 'https://a.test', content: ' ' })).toEqual({ url: 'https://a.test' }) + }) + + it('omits null/empty optional fields rather than emitting them', () => { + expect(mapSearXngResult({ url: 'https://a.test', title: null, content: null, publishedDate: null })) + .toEqual({ url: 'https://a.test' }) + expect(mapSearXngResult({ url: 'https://a.test', title: '', content: '', publishedDate: '' })) + .toEqual({ url: 'https://a.test' }) + }) + + it('maps a response to a result with no content and all sources kept', () => { + const result = mapSearXngResponse({ + results: [ + { url: 'https://a.test', title: 'A', content: 'one' }, + { url: 'https://b.test' }, + { url: 'https://c.test', content: 'three' }, + ], + }) + expect(result).toEqual({ + sources: [ + { url: 'https://a.test', title: 'A', snippet: 'one' }, + { url: 'https://b.test' }, + { url: 'https://c.test', snippet: 'three' }, + ], + truncated: false, + }) + expect(result.content).toBeUndefined() + }) + + it('tolerates a missing results array', () => { + expect(mapSearXngResponse({}).sources).toEqual([]) + }) +}) + +describe('SearXngSearchProvider availability', () => { + it('is unavailable without a base URL', () => { + expect(new SearXngSearchProvider({ baseURL: '' }).available()).toBe(false) + }) + + it('is available with a base URL', () => { + expect(new SearXngSearchProvider(options).available()).toBe(true) + }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new SearXngSearchProvider({ baseURL: 'not a url' }).available()).toBe(false) + }) + + it('is misconfigured when language is empty or timeRange is invalid', () => { + expect(new SearXngSearchProvider({ ...options, language: '' }).available()).toBe(false) + expect(new SearXngSearchProvider({ ...options, timeRange: 'decade' as never }).available()).toBe(false) + expect(new SearXngSearchProvider({ ...options, timeRange: 'week' }).available()).toBe(true) + }) +}) + +describe('SearXngSearchProvider request mapping', () => { + it('issues a GET with query and json format and no authorization header', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test' }] })) + vi.stubGlobal('fetch', fetchMock) + + await new SearXngSearchProvider(options).search({ query: 'hello world' }) + + expect(fetchMock).toHaveBeenCalledOnce() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://searx.test/search?q=hello+world&format=json') + expect(init.method).toBe('GET') + expect(init.redirect).toBe('error') + expect((init.headers as Record)['authorization']).toBeUndefined() + }) + + it('sends language and time_range when configured', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new SearXngSearchProvider({ ...options, language: 'en', timeRange: 'week' }).search({ query: 'q' }) + const [url] = fetchMock.mock.calls[0] as unknown as [string] + expect(url).toBe('https://searx.test/search?q=q&format=json&language=en&time_range=week') + }) + + it('omits language and time_range when unset', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new SearXngSearchProvider(options).search({ query: 'q' }) + const [url] = fetchMock.mock.calls[0] as unknown as [string] + expect(url).toBe('https://searx.test/search?q=q&format=json') + }) + + it('does not double the separator when baseURL carries a trailing slash', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new SearXngSearchProvider({ baseURL: 'https://searx.test/' }).search({ query: 'q' }) + const [url] = fetchMock.mock.calls[0] as unknown as [string] + expect(url).toBe('https://searx.test/search?q=q&format=json') + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new SearXngSearchProvider(options).search({ query: 'q' }, controller.signal) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('SearXngSearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'missing instance' }, { status: 401 }))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'missing instance' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('gateway down', { status: 502 }))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'SearXNG API error (HTTP 502)' })) + }) + + it('reads the message from content when present', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: 'json disabled' }, { status: 403 }))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'json disabled' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 }))) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new SearXngSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) +}) + +describe('web-search-searxng plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] }))) + const ctx = new Context() + await ctx.plugin(WebRuntime, { searchProvider: SEARXNG_PROVIDER_ID }) + const fiber = await ctx.plugin(searxngPlugin, { baseURL: options.baseURL }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false }) + await fiber.dispose() + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in searxngPlugin).toBe(false) + }) + + it('threads language and timeRange config into the request', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebRuntime, { searchProvider: SEARXNG_PROVIDER_ID }) + const fiber = await ctx.plugin(searxngPlugin, { baseURL: options.baseURL, language: 'en', timeRange: 'week' }) + await ctx.web.search({ query: 'q' }) + const [url] = fetchMock.mock.calls[0] as unknown as [string] + expect(url).toBe('https://searx.test/search?q=q&format=json&language=en&time_range=week') + await fiber.dispose() + }) + + it('is unavailable when baseURL is omitted', async () => { + const ctx = new Context() + await ctx.plugin(WebRuntime, { searchProvider: SEARXNG_PROVIDER_ID }) + await ctx.plugin(searxngPlugin, { baseURL: '' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + }) +}) diff --git a/packages/web/web-search-searxng/tests/settings.spec.ts b/packages/web/web-search-searxng/tests/settings.spec.ts new file mode 100644 index 0000000000..85073868d6 --- /dev/null +++ b/packages/web/web-search-searxng/tests/settings.spec.ts @@ -0,0 +1,120 @@ +/** The `web-search-searxng` settings section layered over the composition entry. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import { SettingsProvider } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import WebRuntime from '@deepseek-ai/dsh-web' +import * as searxngPlugin from '@deepseek-ai/dsh-web-search-searxng' +import { SEARXNG_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-web-search-searxng' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends SettingsProvider { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +/** The smallest SearXNG-shaped answer the provider accepts — enough to observe the request. */ +const ONE_RESULT = { + results: [{ url: 'https://a.test', title: 'A', content: 'snip' }], +} + +async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; pluginFiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(WebRuntime, {}) + const settingsFiber = ctx.plugin(MemorySettings) + await settingsFiber.await() + const pluginFiber = ctx.plugin(searxngPlugin, { baseURL: 'https://search.entry.test' }) + await pluginFiber.await() + return { ctx, settingsFiber, pluginFiber } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +/** + * Run one search and answer the endpoint it reached. A fresh `Response` per + * call because a body can only be read once, and the call history is cleared + * because repeated `spyOn` returns the same spy. + * @param ctx - context whose `ctx.web` serves the search. + * @returns the URL the provider fetched. + */ +async function searchOnce(ctx: Context): Promise { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockImplementation(() => Promise.resolve(jsonResponse(ONE_RESULT))) + fetchSpy.mockClear() + await ctx.web.search({ query: 'anything' }) + return String((fetchSpy.mock.calls.at(-1)?.[0] as URL | string | undefined) ?? '') +} + +describe('web-search-searxng settings section', () => { + it('serves a stored endpoint to the next search without re-registering the provider', async () => { + const bench = await boot() + expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test') + + await bench.ctx.settings.update(SEARXNG_SETTINGS_NAMESPACE, { + baseURL: 'https://search.stored.test', + }) + + expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test') + await bench.ctx.fiber.dispose() + }) + + it('applies a stored language and time range to the next request', async () => { + const bench = await boot() + await bench.ctx.settings.update(SEARXNG_SETTINGS_NAMESPACE, { + baseURL: 'https://search.entry.test', + language: 'de', + timeRange: 'week', + }) + + const url = new URL(await searchOnce(bench.ctx)) + expect(url.searchParams.get('language')).toBe('de') + expect(url.searchParams.get('time_range')).toBe('week') + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the settings provider detaches', async () => { + const bench = await boot() + await bench.ctx.settings.update(SEARXNG_SETTINGS_NAMESPACE, { + baseURL: 'https://search.stored.test', + }) + expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test') + + await bench.settingsFiber.dispose() + + expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test') + await bench.ctx.fiber.dispose() + }) + + it('releases the namespace when the plugin unloads', async () => { + const bench = await boot() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('web-search-searxng') + + await bench.pluginFiber.dispose() + + expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('web-search-searxng') + await bench.ctx.fiber.dispose() + }) +}) \ No newline at end of file diff --git a/packages/web/web-search-searxng/tsconfig.json b/packages/web/web-search-searxng/tsconfig.json new file mode 100644 index 0000000000..2354f9b112 --- /dev/null +++ b/packages/web/web-search-searxng/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/web/web/README.i18n.yaml b/packages/web/web/README.i18n.yaml index 0b2f806c2a..5ff2ae7d85 100644 --- a/packages/web/web/README.i18n.yaml +++ b/packages/web/web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web/README.md -README.md: 8dfc7f032e25e40207bb3880777b814e67175a20 -README.zh.md: 3354037bfcd77ca2b1a07da710b565065baf543b +README.md: 0071d12d36c4fb696c8b35bef35bf1c1d62b22f1 +README.zh.md: 9146b78778dd9e8e208c2ae3d997fee72829c18a diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 8dfc7f032e..0071d12d36 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -10,6 +10,7 @@ This package owns the Service Definition role of the web capability. Unlike shel |---|---| | `@deepseek-ai/dsh-web` (this) | Service Definition: the service, provider registries, selection policy, request/result vocabulary, the `WebError` taxonomy | | `@deepseek-ai/dsh-web-search-exa` | Search provider: Exa | +| `@deepseek-ai/dsh-web-search-searxng` | Search provider: SearXNG | | `@deepseek-ai/dsh-web-search-perplexity` | Search provider: Perplexity | | `@deepseek-ai/dsh-web-fetch-http` | Fetch provider: anonymous public HTTP(S) | | `@deepseek-ai/dsh-tool-web` | Consumer: the model-facing `web_search` / `web_fetch` tool schemas over `ctx.web` | diff --git a/packages/web/web/README.zh.md b/packages/web/web/README.zh.md index 3354037bfc..9146b78778 100644 --- a/packages/web/web/README.zh.md +++ b/packages/web/web/README.zh.md @@ -10,6 +10,7 @@ |---|---| | `@deepseek-ai/dsh-web`(本包) | Service Definition:服务、提供方注册表、选择策略、请求/结果词汇、`WebError` 分类体系 | | `@deepseek-ai/dsh-web-search-exa` | 搜索提供方:Exa | +| `@deepseek-ai/dsh-web-search-searxng` | 搜索提供方:SearXNG | | `@deepseek-ai/dsh-web-search-perplexity` | 搜索提供方:Perplexity | | `@deepseek-ai/dsh-web-fetch-http` | 抓取提供方:匿名公共 HTTP(S) | | `@deepseek-ai/dsh-tool-web` | Consumer:面向模型的 `web_search`/`web_fetch` 工具 schema,构建于 `ctx.web` 之上 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcc517d651..c52a1b74c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-openrouter-usage': + specifier: workspace:^ + version: link:../../llm/openrouter-usage '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1324,6 +1327,9 @@ importers: '@deepseek-ai/dsh-client-ui-model-selection': specifier: workspace:^ version: link:../../client/ui-model-selection + '@deepseek-ai/dsh-client-ui-openrouter-usage': + specifier: workspace:^ + version: link:../../client/ui-openrouter-usage '@deepseek-ai/dsh-client-ui-permission-presets': specifier: workspace:^ version: link:../../client/ui-permission-presets @@ -1408,6 +1414,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-openrouter-usage': + specifier: workspace:^ + version: link:../../llm/openrouter-usage '@deepseek-ai/dsh-session-log-export': specifier: workspace:^ version: link:../../session-query/session-log-export @@ -2232,6 +2241,54 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-openrouter-usage: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-openrouter-usage': + specifier: workspace:^ + version: link:../../llm/openrouter-usage + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-permission-presets: devDependencies: '@deepseek-ai/cordis': @@ -5341,6 +5398,52 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/llm/openrouter-usage: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/llm/token-meter: dependencies: '@deepseek-ai/schemastery': @@ -8127,6 +8230,25 @@ importers: specifier: workspace:^ version: link:../web + packages/web/web-search-searxng: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + packages/workflow/tool-ralph: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 8585d16982..6569fbbf0f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,5 @@ # AGENTS.md — Repository scripts Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. + +Air-gap dependency caches live under ignored `.offline-store/` and `.offline-cache/`; [`offline/download-deps`](offline/download-deps.ps1) prefetches the official Node distro and lockfile while networked, and [`offline/install-deps`](offline/install-deps.ps1) unpacks that Node, then runs `pnpm install --offline` from the store (POSIX twins: `.sh`). Run download and install on the same OS and CPU. `DSH_NPM_HTTP_PROXY` overlays HTTP proxy env only for Corepack/pnpm and restores the caller session; curl keeps inherited SOCKS or HTTP. These scripts do not fetch LLM weights or a local inference server. The root `dsh` script runs [`run-source-dsh.ts`](run-source-dsh.ts), which respawns Node with HTTP proxy variables from `DSH_HTTP_PROXY` or gitignored [`.dsh-http-proxy.env`](../.gitignore) so `fetch` binds them at process start — not the calling shell, and not `.env`, which rejects bootstrap proxy names. diff --git a/scripts/apply-source-dsh-http-proxy.spec.ts b/scripts/apply-source-dsh-http-proxy.spec.ts new file mode 100644 index 0000000000..f62654940b --- /dev/null +++ b/scripts/apply-source-dsh-http-proxy.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import { + applySourceDshHttpProxy, + isSocksProxyUrl, + parseSourceDshHttpProxyFile, + shouldReexecSourceDshForFetchProxy, + SOURCE_DSH_PROXY_REEXEC_MARKER, + sourceDshHttpProxyOverlayPresent, +} from './apply-source-dsh-http-proxy.ts' + +describe('applySourceDshHttpProxy', () => { + it('leaves the inherited environment unchanged when no overlay is set', () => { + const env: NodeJS.ProcessEnv = { HTTPS_PROXY: 'socks5h://127.0.0.1:1080' } + applySourceDshHttpProxy({ env, fileText: null }) + expect(env.HTTPS_PROXY).toBe('socks5h://127.0.0.1:1080') + expect(env.NODE_USE_ENV_PROXY).toBeUndefined() + }) + + it('overwrites inherited SOCKS with DSH_HTTP_PROXY for this process only', () => { + const env: NodeJS.ProcessEnv = { + HTTPS_PROXY: 'socks5h://127.0.0.1:1080', + ALL_PROXY: 'socks5h://127.0.0.1:1080', + } + applySourceDshHttpProxy({ env, fileText: null, overlayUrl: 'http://127.0.0.1:3067' }) + expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:3067') + expect(env.HTTP_PROXY).toBe('http://127.0.0.1:3067') + expect(env.ALL_PROXY).toBe('http://127.0.0.1:3067') + expect(env.https_proxy).toBe('http://127.0.0.1:3067') + expect(env.NODE_USE_ENV_PROXY).toBe('1') + expect(env.NO_PROXY).toBe('localhost,127.0.0.1,::1') + }) + + it('applies a checkout overlay file and outranks it with DSH_HTTP_PROXY', () => { + const env: NodeJS.ProcessEnv = {} + const fileText = [ + 'HTTPS_PROXY=http://127.0.0.1:3067', + 'NODE_USE_ENV_PROXY=1', + '# comment', + '', + ].join('\n') + applySourceDshHttpProxy({ env, fileText }) + expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:3067') + expect(env.NODE_USE_ENV_PROXY).toBe('1') + + applySourceDshHttpProxy({ env, fileText, overlayUrl: 'http://127.0.0.1:8080' }) + expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:8080') + }) + + it('replaces inherited SOCKS on names the overlay file omitted', () => { + const env: NodeJS.ProcessEnv = { + HTTPS_PROXY: 'socks5h://proxy.home:4067', + ALL_PROXY: 'socks5h://proxy.home:4067', + } + applySourceDshHttpProxy({ + env, + fileText: 'HTTPS_PROXY=http://127.0.0.1:3067\nNODE_USE_ENV_PROXY=1\n', + }) + expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:3067') + expect(env.ALL_PROXY).toBe('http://127.0.0.1:3067') + expect(env.HTTP_PROXY).toBe('http://127.0.0.1:3067') + expect(env.NODE_USE_ENV_PROXY).toBe('1') + }) + + it('rejects a SOCKS overlay before the CLI boots', () => { + expect(() => applySourceDshHttpProxy({ + env: {}, + fileText: null, + overlayUrl: 'socks5h://127.0.0.1:1080', + })).toThrow(/cannot fetch through SOCKS/) + expect(() => applySourceDshHttpProxy({ + env: {}, + fileText: 'ALL_PROXY=socks5://127.0.0.1:1080\n', + })).toThrow(/cannot fetch through SOCKS/) + }) +}) + +describe('parseSourceDshHttpProxyFile', () => { + it('parses quoted values and export lines', () => { + expect(parseSourceDshHttpProxyFile('export HTTPS_PROXY="http://127.0.0.1:3067"\n')).toEqual({ + HTTPS_PROXY: 'http://127.0.0.1:3067', + }) + }) + + it('detects SOCKS schemes', () => { + expect(isSocksProxyUrl('socks5h://127.0.0.1:1080')).toBe(true) + expect(isSocksProxyUrl('http://127.0.0.1:3067')).toBe(false) + }) +}) + +describe('shouldReexecSourceDshForFetchProxy', () => { + it('respawns once when an overlay is present so fetch binds HTTP_PROXY at start', () => { + expect(sourceDshHttpProxyOverlayPresent(undefined, null)).toBe(false) + expect(shouldReexecSourceDshForFetchProxy({}, false)).toBe(false) + expect(shouldReexecSourceDshForFetchProxy({}, true)).toBe(true) + expect(shouldReexecSourceDshForFetchProxy({ [SOURCE_DSH_PROXY_REEXEC_MARKER]: '1' }, true)).toBe(false) + }) +}) diff --git a/scripts/apply-source-dsh-http-proxy.ts b/scripts/apply-source-dsh-http-proxy.ts new file mode 100644 index 0000000000..36175a511a --- /dev/null +++ b/scripts/apply-source-dsh-http-proxy.ts @@ -0,0 +1,175 @@ +/** + * Overlay HTTP proxy variables onto the source `dsh` Node process only. + * Bootstrap proxy names cannot come from `.env` files; this overlay is the + * checkout-local way to give OpenRouter (and other HTTPS fetches) an HTTP + * proxy without changing the calling shell. + * @module + */ + +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +/** Repo-root file the source launcher reads when present; gitignored. */ +export const SOURCE_DSH_HTTP_PROXY_FILE = '.dsh-http-proxy.env' + +/** Process variables Node, undici, and pi-ai consult for HTTP(S) proxies. */ +export const SOURCE_DSH_PROXY_VAR_NAMES = [ + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'ALL_PROXY', + 'https_proxy', + 'http_proxy', + 'all_proxy', +] as const + +const PROXY_VAR_NAME_SET = new Set(SOURCE_DSH_PROXY_VAR_NAMES) + +const LOCAL_NO_PROXY = 'localhost,127.0.0.1,::1' + +/** + * Write one HTTP(S) proxy URL onto every name Node and pi-ai read, including + * leftover inherited SOCKS values the overlay file did not mention. + */ +function installHttpProxy(env: NodeJS.ProcessEnv, url: string): void { + for (const name of SOURCE_DSH_PROXY_VAR_NAMES) { + env[name] = url + } + env.NODE_USE_ENV_PROXY = '1' + if (!env.NO_PROXY && !env.no_proxy) { + env.NO_PROXY = LOCAL_NO_PROXY + env.no_proxy = LOCAL_NO_PROXY + } +} + +/** + * First non-SOCKS proxy URL among the names Node consults. + * @param env - environment after overlay assignments. + */ +function firstHttpProxyUrl(env: NodeJS.ProcessEnv): string | undefined { + for (const name of SOURCE_DSH_PROXY_VAR_NAMES) { + const value = env[name]?.trim() + if (value !== undefined && value !== '' && !isSocksProxyUrl(value)) return value + } + return undefined +} + +/** + * Set on the child after this wrapper respawns so `fetch` sees the HTTP overlay + * at process start. Node `--env-file` does not replace an inherited SOCKS + * `HTTPS_PROXY`, and `NODE_USE_ENV_PROXY` assigned after start is ignored. + */ +export const SOURCE_DSH_PROXY_REEXEC_MARKER = 'DSH_SOURCE_HTTP_PROXY_APPLIED' + +/** @returns whether `value` is a SOCKS URL Node cannot use for fetch/pnpm. */ +export function isSocksProxyUrl(value: string): boolean { + return /^socks5h?:\/\//i.test(value.trim()) +} + +/** + * Parse a dotenv-like overlay file into assignments. + * @param text - file contents. + * @returns name → value; empty lines and `#` comments are skipped. + */ +export function parseSourceDshHttpProxyFile(text: string): Record { + const out: Record = {} + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim() + if (line === '' || line.startsWith('#')) continue + const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line + const eq = body.indexOf('=') + if (eq <= 0) continue + const name = body.slice(0, eq).trim() + let value = body.slice(eq + 1).trim() + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + if (name !== '') out[name] = value + } + return out +} + +/** + * Inputs for {@link applySourceDshHttpProxy}. Tests pass a fake env and file text. + */ +export interface ApplySourceDshHttpProxyInput { + /** Mutable environment of this Node process (normally `process.env`). */ + env: NodeJS.ProcessEnv + /** Contents of `.dsh-http-proxy.env`; `null` when the file is absent. */ + fileText: string | null + /** Optional explicit HTTP overlay URL (`DSH_HTTP_PROXY`), which outranks the file. */ + overlayUrl?: string | undefined +} + +/** + * Apply an HTTP proxy overlay onto `env` for this process only. + * @param input - env, optional file, optional `DSH_HTTP_PROXY`. + * @throws when the winning overlay is a SOCKS URL. + */ +export function applySourceDshHttpProxy(input: ApplySourceDshHttpProxyInput): void { + const overlayUrl = input.overlayUrl?.trim() + if (overlayUrl) { + if (isSocksProxyUrl(overlayUrl)) { + throw new Error( + `DSH_HTTP_PROXY=${overlayUrl}. Node cannot fetch through SOCKS. Use an HTTP proxy (for example http://127.0.0.1:3067).`, + ) + } + installHttpProxy(input.env, overlayUrl) + return + } + if (input.fileText === null) return + const assignments = parseSourceDshHttpProxyFile(input.fileText) + for (const [name, value] of Object.entries(assignments)) { + if (PROXY_VAR_NAME_SET.has(name) && isSocksProxyUrl(value)) { + throw new Error( + `${name}=${value}. Node cannot fetch through SOCKS. Use an HTTP proxy (for example http://127.0.0.1:3067).`, + ) + } + input.env[name] = value + } + const httpUrl = firstHttpProxyUrl(input.env) + if (httpUrl !== undefined) installHttpProxy(input.env, httpUrl) +} + +/** + * HTTP overlay URL now on `env`, if any. + * @param env - environment after {@link applySourceDshHttpProxy}. + */ +export function sourceDshAppliedHttpProxyUrl(env: NodeJS.ProcessEnv): string | undefined { + return firstHttpProxyUrl(env) +} + +/** Absolute path of the gitignored overlay file beside this repository root. */ +export function sourceDshHttpProxyFilePath(): string { + return fileURLToPath(new URL(`../${SOURCE_DSH_HTTP_PROXY_FILE}`, import.meta.url)) +} + +/** + * Read the overlay file when it exists. + * @param path - absolute path; defaults to the repo-root gitignored file. + * @returns file text, or `null` when absent. + */ +export function readSourceDshHttpProxyFile(path = sourceDshHttpProxyFilePath()): string | null { + if (!existsSync(path)) return null + return readFileSync(path, 'utf8') +} + +/** + * Whether an overlay source is present (file or `DSH_HTTP_PROXY`). + * @param overlayUrl - `DSH_HTTP_PROXY` value. + * @param fileText - overlay file contents, or `null` when absent. + */ +export function sourceDshHttpProxyOverlayPresent(overlayUrl: string | undefined, fileText: string | null): boolean { + return Boolean(overlayUrl?.trim()) || fileText !== null +} + +/** + * Whether this process must respawn so `fetch` binds the overlay at start. + * @param env - the environment after {@link applySourceDshHttpProxy}. + * @param overlayPresent - {@link sourceDshHttpProxyOverlayPresent}. + */ +export function shouldReexecSourceDshForFetchProxy(env: NodeJS.ProcessEnv, overlayPresent: boolean): boolean { + return overlayPresent && env[SOURCE_DSH_PROXY_REEXEC_MARKER] !== '1' +} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ed622a12ee..be6d96d23b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -560,6 +560,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { TypertDisposer: 'Typert lifecycle contract is owned by packages/typert/protocol/README.md', InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', LocaleDict: 'service-local dictionary fields are owned by packages/client/i18n/src/index.ts', + OpenRouterBalance: 'service-local balance payload is owned by packages/llm/openrouter-usage/src/types.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', WebUpgradeRoute: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0ee6ae5a13..c0dd9e91e7 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -483,7 +483,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'web', title: 'Web access provider registry', mode: 'seam', - implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-http'], + implementations: ['web-search-exa', 'web-search-searxng', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-http'], consumers: ['tool-web'], note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.', }, @@ -547,6 +547,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['connection'], note: 'The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb.', }, + { + key: 'openRouterUsage', + pkg: 'openrouter-usage', + title: 'OpenRouter usage and balance gateway', + mode: 'core', + consumers: ['ui-openrouter-usage'], + note: 'The host gateway folds token-usage projections and a live balance snapshot; the web UI client reaches the same service over the wire through its remote namespace.', + }, { key: 'dynamicCordisRunner', pkg: 'cordis-host-runner', diff --git a/scripts/offline/download-deps.ps1 b/scripts/offline/download-deps.ps1 new file mode 100644 index 0000000000..165e786b19 --- /dev/null +++ b/scripts/offline/download-deps.ps1 @@ -0,0 +1,206 @@ +# Prefetch official Node, Corepack pnpm, and lockfile packages into in-repo caches. +# Run on the same OS/CPU as the later install, while nodejs.org and the npm registry +# are reachable. Node/pnpm cannot use socks5h:// proxies; pass an HTTP proxy via +# DSH_NPM_HTTP_PROXY (example: http://127.0.0.1:3067). That overlay is applied only +# around Corepack/pnpm and is restored before the script returns, so a caller SOCKS +# proxy stays in the current PowerShell session. curl keeps the inherited proxy +# (SOCKS included). Optional DSH_NODE_VERSION (no leading v) pins the Node distro; +# otherwise the running Node is used, or the newest v24.x from nodejs.org/dist/index.json. +# +# Usage (repo root): powershell -File scripts/offline/download-deps.ps1 +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $Root + +$ProxyVarNames = @('HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'https_proxy', 'http_proxy', 'all_proxy') + +function Get-ProcessVar([string]$Name) { + return [Environment]::GetEnvironmentVariable($Name, 'Process') +} + +function Get-InheritedProxy { + foreach ($name in $ProxyVarNames) { + $value = Get-ProcessVar $name + if ($value) { return $value } + } + return $null +} + +function Resolve-NodeHttpProxy { + $override = Get-ProcessVar 'DSH_NPM_HTTP_PROXY' + if ($override) { + if ($override -match '^socks5') { + throw "DSH_NPM_HTTP_PROXY=$override. Node and pnpm cannot fetch through SOCKS. Use an HTTP proxy (for example http://127.0.0.1:3067)." + } + return $override + } + $socks = $null + foreach ($name in $ProxyVarNames) { + $value = Get-ProcessVar $name + if (-not $value) { continue } + if ($value -match '^socks5') { + $socks = "$name=$value" + continue + } + return $value + } + if ($socks) { + throw "$socks. Node and pnpm cannot fetch through SOCKS. Set DSH_NPM_HTTP_PROXY to an HTTP proxy (for example http://127.0.0.1:3067)." + } + return $null +} + +function Invoke-WithNodeHttpProxy([scriptblock]$Action) { + $http = Resolve-NodeHttpProxy + if (-not $http) { + & $Action + return + } + $saved = @{} + foreach ($name in $ProxyVarNames) { + $saved[$name] = Get-ProcessVar $name + } + try { + foreach ($name in $ProxyVarNames) { + [Environment]::SetEnvironmentVariable($name, $http, 'Process') + } + & $Action + } finally { + foreach ($name in $ProxyVarNames) { + [Environment]::SetEnvironmentVariable($name, $saved[$name], 'Process') + } + } +} + +function Get-CurlProxyArgs { + $proxy = Get-InheritedProxy + if (-not $proxy) { $proxy = Get-ProcessVar 'DSH_NPM_HTTP_PROXY' } + if ($proxy) { @('--proxy', $proxy) } else { @() } +} + +function Save-Url([string]$Url, [string]$Destination) { + $dir = Split-Path -Parent $Destination + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($null -eq $curl) { + throw 'curl.exe is required to download Node (bundled with Windows 10+).' + } + & $curl.Source -fsSL --retry 3 @(Get-CurlProxyArgs) -o $Destination $Url + if ($LASTEXITCODE -ne 0) { + throw "Download failed ($LASTEXITCODE): $Url" + } +} + +function Get-NodePlatform { + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -eq 'ARM64') { return 'win-arm64' } + if ($arch -eq 'AMD64') { return 'win-x64' } + throw "Unsupported Windows architecture: $arch" +} + +function Get-NodeArchiveName([string]$Version, [string]$Platform) { + return "node-v$Version-$Platform.zip" +} + +function Resolve-NodeVersion([string]$Platform) { + if ($env:DSH_NODE_VERSION) { + return $env:DSH_NODE_VERSION.TrimStart('v') + } + $node = Get-Command node -ErrorAction SilentlyContinue + if ($null -ne $node) { + $running = (& $node.Source --version).Trim().TrimStart('v') + $parts = $running.Split('.') + $major = [int]$parts[0] + $minor = [int]$parts[1] + if (-not (($major -eq 22 -and $minor -ge 19) -or ($major -ge 24))) { + throw "Running Node v$running is outside engines.node (^22.19.0 || >=24.0.0). Set DSH_NODE_VERSION." + } + return $running + } + $indexPath = Join-Path $env:TEMP 'dsh-node-index.json' + $distBase = if ($env:DSH_NODE_DIST_BASE) { $env:DSH_NODE_DIST_BASE.TrimEnd('/') } else { 'https://nodejs.org/dist' } + Save-Url "$distBase/index.json" $indexPath + $releases = Get-Content $indexPath -Raw | ConvertFrom-Json + $wantedFile = if ($Platform -eq 'win-arm64') { 'win-arm64-zip' } else { 'win-x64-zip' } + foreach ($release in $releases) { + $ver = [string]$release.version + if ($ver -notmatch '^v24\.') { continue } + if (@($release.files) -notcontains $wantedFile) { continue } + return $ver.TrimStart('v') + } + throw "No Node v24.x zip published for $Platform at $distBase." +} + +function Assert-NodeChecksum([string]$SumsPath, [string]$ArchivePath, [string]$ArchiveName) { + $expected = $null + foreach ($line in Get-Content $SumsPath) { + if ($line -match ('^[0-9a-fA-F]{64} ' + [regex]::Escape($ArchiveName) + '$')) { + $expected = $line.Substring(0, 64) + break + } + } + if (-not $expected) { + throw "SHASUMS256.txt has no entry for $ArchiveName" + } + $actual = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash + if ($actual.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { + throw "SHA256 mismatch for $ArchiveName" + } +} + +$manifest = Get-Content (Join-Path $Root 'package.json') -Raw | ConvertFrom-Json +$packageManager = [string]$manifest.packageManager +if ($packageManager -notmatch '^pnpm@') { + throw "package.json packageManager must be pnpm@, got $packageManager" +} + +$distBase = if ($env:DSH_NODE_DIST_BASE) { $env:DSH_NODE_DIST_BASE.TrimEnd('/') } else { 'https://nodejs.org/dist' } +$platform = Get-NodePlatform +$nodeVersion = Resolve-NodeVersion $platform +$archiveName = Get-NodeArchiveName $nodeVersion $platform +$nodeCache = Join-Path $Root '.offline-cache\node' +$archivePath = Join-Path $nodeCache $archiveName +$sumsPath = Join-Path $nodeCache "SHASUMS256-v$nodeVersion.txt" +$runtimeDir = Join-Path $nodeCache 'runtime' +$nodeHome = Join-Path $runtimeDir "node-v$nodeVersion-$platform" + +Write-Host "Prefetch Node v$nodeVersion ($platform)" +Save-Url "$distBase/v$nodeVersion/SHASUMS256.txt" $sumsPath +Save-Url "$distBase/v$nodeVersion/$archiveName" $archivePath +Assert-NodeChecksum $sumsPath $archivePath $archiveName +if (-not (Test-Path (Join-Path $nodeHome 'node.exe'))) { + if (Test-Path $runtimeDir) { Remove-Item -Recurse -Force $runtimeDir } + New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null + Expand-Archive -LiteralPath $archivePath -DestinationPath $runtimeDir +} +if (-not (Test-Path (Join-Path $nodeHome 'node.exe'))) { + throw "Unpacked Node is missing: $nodeHome\node.exe" +} + $json = @{ + version = $nodeVersion + archive = $archiveName + platform = $platform + } | ConvertTo-Json + [System.IO.File]::WriteAllText((Join-Path $nodeCache 'runtime.json'), $json) + +$storeDir = Join-Path $Root '.offline-store' +$corepackHome = Join-Path $Root '.offline-cache\corepack' +New-Item -ItemType Directory -Force -Path $storeDir, $corepackHome | Out-Null +$env:COREPACK_HOME = $corepackHome +$env:PATH = "$nodeHome;$env:PATH" + +Write-Host "Node $((& (Join-Path $nodeHome 'node.exe') --version)); prefetch $packageManager into $storeDir" +Invoke-WithNodeHttpProxy { + & (Join-Path $nodeHome 'corepack.cmd') prepare $packageManager --activate + if ($LASTEXITCODE -ne 0) { throw "corepack prepare failed ($LASTEXITCODE)" } + & (Join-Path $nodeHome 'corepack.cmd') pnpm fetch --frozen-lockfile --store-dir $storeDir + if ($LASTEXITCODE -ne 0) { throw "pnpm fetch failed ($LASTEXITCODE)" } +} + +Write-Host @" +Prefetch complete (Node v$nodeVersion zip + pnpm store). +Copy this checkout (including .offline-store and .offline-cache) to the offline machine. +Then run: powershell -File scripts/offline/install-deps.ps1 +"@ diff --git a/scripts/offline/download-deps.sh b/scripts/offline/download-deps.sh new file mode 100644 index 0000000000..6bb7c92879 --- /dev/null +++ b/scripts/offline/download-deps.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Prefetch official Node, Corepack pnpm, and lockfile packages into in-repo caches. +# Run on the same OS/CPU as the later install, while nodejs.org and the npm registry +# are reachable. Node/pnpm cannot use socks5h:// proxies; pass an HTTP proxy via +# DSH_NPM_HTTP_PROXY (example: http://127.0.0.1:3067). That overlay is applied only +# around Corepack/pnpm and is restored before the script returns, so a caller SOCKS +# proxy stays in the current shell. curl keeps the inherited proxy (SOCKS included). +# Optional DSH_NODE_VERSION (no leading v) pins the Node distro; otherwise the +# running Node is used, or the newest v24.x from nodejs.org/dist/index.json. +# +# Usage (repo root): bash scripts/offline/download-deps.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +process_var() { + local name="$1" + if [[ -n "${!name+x}" ]]; then + printf '%s' "${!name}" + fi +} + +inherited_proxy() { + local name value + for name in HTTPS_PROXY HTTP_PROXY ALL_PROXY https_proxy http_proxy all_proxy; do + value="$(process_var "$name")" + if [[ -n "$value" ]]; then + printf '%s' "$value" + return + fi + done +} + +resolve_node_http_proxy() { + local override socks name value + override="$(process_var DSH_NPM_HTTP_PROXY)" + if [[ -n "$override" ]]; then + if [[ "$override" == socks5* ]]; then + echo "DSH_NPM_HTTP_PROXY=$override. Node and pnpm cannot fetch through SOCKS. Use an HTTP proxy (for example http://127.0.0.1:3067)." >&2 + exit 1 + fi + printf '%s' "$override" + return + fi + socks= + for name in HTTPS_PROXY HTTP_PROXY ALL_PROXY https_proxy http_proxy all_proxy; do + value="$(process_var "$name")" + if [[ -z "$value" ]]; then + continue + fi + if [[ "$value" == socks5* ]]; then + socks="$name=$value" + continue + fi + printf '%s' "$value" + return + done + if [[ -n "$socks" ]]; then + echo "$socks. Node and pnpm cannot fetch through SOCKS. Set DSH_NPM_HTTP_PROXY to an HTTP proxy (for example http://127.0.0.1:3067)." >&2 + exit 1 + fi +} + +run_with_node_http_proxy() { + local http status + http="$(resolve_node_http_proxy)" + if [[ -z "$http" ]]; then + "$@" + return + fi + local HTTPS_PROXY_BAK="${HTTPS_PROXY-}" HTTP_PROXY_BAK="${HTTP_PROXY-}" ALL_PROXY_BAK="${ALL_PROXY-}" + local https_proxy_bak="${https_proxy-}" http_proxy_bak="${http_proxy-}" all_proxy_bak="${all_proxy-}" + local had_HTTPS_PROXY=0 had_HTTP_PROXY=0 had_ALL_PROXY=0 + local had_https_proxy=0 had_http_proxy=0 had_all_proxy=0 + if [[ -n "${HTTPS_PROXY+x}" ]]; then had_HTTPS_PROXY=1; fi + if [[ -n "${HTTP_PROXY+x}" ]]; then had_HTTP_PROXY=1; fi + if [[ -n "${ALL_PROXY+x}" ]]; then had_ALL_PROXY=1; fi + if [[ -n "${https_proxy+x}" ]]; then had_https_proxy=1; fi + if [[ -n "${http_proxy+x}" ]]; then had_http_proxy=1; fi + if [[ -n "${all_proxy+x}" ]]; then had_all_proxy=1; fi + export HTTPS_PROXY="$http" HTTP_PROXY="$http" ALL_PROXY="$http" + export https_proxy="$http" http_proxy="$http" all_proxy="$http" + set +e + "$@" + status=$? + set -e + if (( had_HTTPS_PROXY )); then export HTTPS_PROXY="$HTTPS_PROXY_BAK"; else unset HTTPS_PROXY; fi + if (( had_HTTP_PROXY )); then export HTTP_PROXY="$HTTP_PROXY_BAK"; else unset HTTP_PROXY; fi + if (( had_ALL_PROXY )); then export ALL_PROXY="$ALL_PROXY_BAK"; else unset ALL_PROXY; fi + if (( had_https_proxy )); then export https_proxy="$https_proxy_bak"; else unset https_proxy; fi + if (( had_http_proxy )); then export http_proxy="$http_proxy_bak"; else unset http_proxy; fi + if (( had_all_proxy )); then export all_proxy="$all_proxy_bak"; else unset all_proxy; fi + return "$status" +} + +curl_proxy_args=() +curl_proxy="$(inherited_proxy)" +if [[ -z "$curl_proxy" ]]; then + curl_proxy="$(process_var DSH_NPM_HTTP_PROXY)" +fi +if [[ -n "$curl_proxy" ]]; then + curl_proxy_args=(--proxy "$curl_proxy") +fi + +save_url() { + local url="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + curl -fsSL --retry 3 "${curl_proxy_args[@]}" -o "$dest" "$url" +} + +node_platform() { + local sys arch + sys="$(uname -s)" + arch="$(uname -m)" + case "$sys:$arch" in + Linux:x86_64) echo linux-x64 ;; + Linux:aarch64) echo linux-arm64 ;; + Darwin:x86_64) echo darwin-x64 ;; + Darwin:arm64) echo darwin-arm64 ;; + MINGW*|MSYS*|CYGWIN*) + echo "Use scripts/offline/download-deps.ps1 on Windows." >&2 + exit 1 + ;; + *) + echo "Unsupported OS/CPU: $sys $arch" >&2 + exit 1 + ;; + esac +} + +archive_name() { + local version="$1" + local platform="$2" + case "$platform" in + linux-*) echo "node-v${version}-${platform}.tar.xz" ;; + darwin-*) echo "node-v${version}-${platform}.tar.gz" ;; + *) echo "node-v${version}-${platform}.zip" ;; + esac +} + +file_tag() { + case "$1" in + linux-x64) echo linux-x64 ;; + linux-arm64) echo linux-arm64 ;; + darwin-x64) echo osx-x64-tar ;; + darwin-arm64) echo osx-arm64-tar ;; + win-x64) echo win-x64-zip ;; + win-arm64) echo win-arm64-zip ;; + esac +} + +engines_ok() { + local version="$1" + local major minor + major="${version%%.*}" + minor="${version#*.}" + minor="${minor%%.*}" + if (( major == 22 && minor >= 19 )); then return 0; fi + if (( major >= 24 )); then return 0; fi + return 1 +} + +resolve_node_version() { + local platform="$1" + local dist_base="$2" + if [[ -n "${DSH_NODE_VERSION:-}" ]]; then + echo "${DSH_NODE_VERSION#v}" + return + fi + if command -v node >/dev/null 2>&1; then + local running + running="$(node --version)" + running="${running#v}" + if ! engines_ok "$running"; then + echo "Running Node v$running is outside engines.node (^22.19.0 || >=24.0.0). Set DSH_NODE_VERSION." >&2 + exit 1 + fi + echo "$running" + return + fi + local index_path tag version + index_path="$(mktemp)" + save_url "$dist_base/index.json" "$index_path" + tag="$(file_tag "$platform")" + if command -v python3 >/dev/null 2>&1; then + version="$(python3 -c 'import json,sys +releases=json.load(open(sys.argv[1])) +tag=sys.argv[2] +for r in releases: + if r["version"].startswith("v24.") and tag in r["files"]: + print(r["version"][1:]) + break +' "$index_path" "$tag")" + else + echo "python3 or node is required to pick a Node v24.x from index.json, or set DSH_NODE_VERSION." >&2 + rm -f "$index_path" + exit 1 + fi + rm -f "$index_path" + if [[ -z "$version" ]]; then + echo "No Node v24.x archive published for $platform at $dist_base." >&2 + exit 1 + fi + echo "$version" +} + +assert_checksum() { + local sums_path="$1" + local archive_path="$2" + local archive="$3" + local expected actual + expected="$(awk -v name="$archive" '$2 == name { print $1; exit }' "$sums_path")" + if [[ -z "$expected" ]]; then + echo "SHASUMS256.txt has no entry for $archive" >&2 + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive_path" | awk '{ print $1 }')" + else + actual="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" + fi + if [[ "$actual" != "$expected" ]]; then + echo "SHA256 mismatch for $archive" >&2 + exit 1 + fi +} + +extract_node() { + local archive_path="$1" + local runtime_dir="$2" + mkdir -p "$runtime_dir" + case "$archive_path" in + *.tar.xz) tar -xJf "$archive_path" -C "$runtime_dir" ;; + *.tar.gz) tar -xzf "$archive_path" -C "$runtime_dir" ;; + *.zip) unzip -q "$archive_path" -d "$runtime_dir" ;; + *) + echo "Unknown Node archive: $archive_path" >&2 + exit 1 + ;; + esac +} + +PACKAGE_MANAGER="$(sed -n 's/.*"packageManager": "\(pnpm@[0-9.]*\)".*/\1/p' package.json | head -n 1)" +case "$PACKAGE_MANAGER" in + pnpm@*) ;; + *) + echo "package.json packageManager must be pnpm@, got ${PACKAGE_MANAGER:-}" >&2 + exit 1 + ;; +esac + +DIST_BASE="${DSH_NODE_DIST_BASE:-https://nodejs.org/dist}" +DIST_BASE="${DIST_BASE%/}" +PLATFORM="$(node_platform)" +NODE_VERSION="$(resolve_node_version "$PLATFORM" "$DIST_BASE")" +ARCHIVE_NAME="$(archive_name "$NODE_VERSION" "$PLATFORM")" +NODE_CACHE="$ROOT/.offline-cache/node" +ARCHIVE_PATH="$NODE_CACHE/$ARCHIVE_NAME" +SUMS_PATH="$NODE_CACHE/SHASUMS256-v${NODE_VERSION}.txt" +RUNTIME_DIR="$NODE_CACHE/runtime" +NODE_PREFIX="$RUNTIME_DIR/node-v${NODE_VERSION}-${PLATFORM}" + +echo "Prefetch Node v$NODE_VERSION ($PLATFORM)" +save_url "$DIST_BASE/v$NODE_VERSION/SHASUMS256.txt" "$SUMS_PATH" +save_url "$DIST_BASE/v$NODE_VERSION/$ARCHIVE_NAME" "$ARCHIVE_PATH" +assert_checksum "$SUMS_PATH" "$ARCHIVE_PATH" "$ARCHIVE_NAME" +if [[ ! -x "$NODE_PREFIX/bin/node" ]]; then + rm -rf "$RUNTIME_DIR" + extract_node "$ARCHIVE_PATH" "$RUNTIME_DIR" +fi +if [[ ! -x "$NODE_PREFIX/bin/node" ]]; then + echo "Unpacked Node is missing: $NODE_PREFIX/bin/node" >&2 + exit 1 +fi +printf '%s\n' "{\"version\":\"$NODE_VERSION\",\"archive\":\"$ARCHIVE_NAME\",\"platform\":\"$PLATFORM\"}" > "$NODE_CACHE/runtime.json" + +STORE_DIR="$ROOT/.offline-store" +COREPACK_HOME="$ROOT/.offline-cache/corepack" +mkdir -p "$STORE_DIR" "$COREPACK_HOME" +export COREPACK_HOME +export PATH="$NODE_PREFIX/bin:$PATH" + +echo "Node $("$NODE_PREFIX/bin/node" --version); prefetch $PACKAGE_MANAGER into $STORE_DIR" +run_with_node_http_proxy corepack prepare "$PACKAGE_MANAGER" --activate +run_with_node_http_proxy corepack pnpm fetch --frozen-lockfile --store-dir "$STORE_DIR" + +cat <&2 + exit 1 + ;; + esac +done + +NODE_CACHE="$ROOT/.offline-cache/node" +META_PATH="$NODE_CACHE/runtime.json" +if [[ ! -f "$META_PATH" ]]; then + echo "Missing $META_PATH. Run scripts/offline/download-deps.sh on a networked machine first, then copy the checkout." >&2 + exit 1 +fi + +NODE_VERSION="$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' "$META_PATH" | head -n 1)" +ARCHIVE_NAME="$(sed -n 's/.*"archive":"\([^"]*\)".*/\1/p' "$META_PATH" | head -n 1)" +PLATFORM="$(sed -n 's/.*"platform":"\([^"]*\)".*/\1/p' "$META_PATH" | head -n 1)" +ARCHIVE_PATH="$NODE_CACHE/$ARCHIVE_NAME" +RUNTIME_DIR="$NODE_CACHE/runtime" +NODE_PREFIX="$RUNTIME_DIR/node-v${NODE_VERSION}-${PLATFORM}" + +if [[ ! -x "$NODE_PREFIX/bin/node" ]]; then + if [[ ! -f "$ARCHIVE_PATH" ]]; then + echo "Missing Node archive $ARCHIVE_PATH. Re-run download-deps.sh." >&2 + exit 1 + fi + rm -rf "$RUNTIME_DIR" + mkdir -p "$RUNTIME_DIR" + case "$ARCHIVE_PATH" in + *.tar.xz) tar -xJf "$ARCHIVE_PATH" -C "$RUNTIME_DIR" ;; + *.tar.gz) tar -xzf "$ARCHIVE_PATH" -C "$RUNTIME_DIR" ;; + *.zip) unzip -q "$ARCHIVE_PATH" -d "$RUNTIME_DIR" ;; + *) + echo "Unknown Node archive: $ARCHIVE_PATH" >&2 + exit 1 + ;; + esac +fi +if [[ ! -x "$NODE_PREFIX/bin/node" ]]; then + echo "Unpacked Node is missing: $NODE_PREFIX/bin/node" >&2 + exit 1 +fi +export PATH="$NODE_PREFIX/bin:$PATH" + +STORE_DIR="$ROOT/.offline-store" +COREPACK_HOME="$ROOT/.offline-cache/corepack" +if [[ ! -d "$STORE_DIR" ]]; then + echo "Missing $STORE_DIR. Run scripts/offline/download-deps.sh on a networked machine first, then copy the checkout." >&2 + exit 1 +fi +if [[ ! -d "$COREPACK_HOME" ]]; then + echo "Missing $COREPACK_HOME. Re-run download-deps.sh so Corepack pnpm is cached in-repo." >&2 + exit 1 +fi + +export COREPACK_HOME +export COREPACK_ENABLE_NETWORK=0 + +PACKAGE_MANAGER="$(sed -n 's/.*"packageManager": "\(pnpm@[0-9.]*\)".*/\1/p' package.json | head -n 1)" + +echo "Offline install $PACKAGE_MANAGER from $STORE_DIR (Node $(node --version))" +corepack pnpm install --offline --frozen-lockfile --store-dir "$STORE_DIR" + +if [[ "$BUILD" -eq 1 ]]; then + echo 'Building host/client libs, then the web frontend' + npm run build:lib + corepack pnpm --dir apps/web run build + echo "Build complete. Start the UI with: $NODE_PREFIX/bin/corepack pnpm dsh web" +else + echo 'Install complete. For dsh web also run: bash scripts/offline/install-deps.sh --build' +fi diff --git a/scripts/run-source-dsh.ts b/scripts/run-source-dsh.ts new file mode 100644 index 0000000000..ee8ac4b24b --- /dev/null +++ b/scripts/run-source-dsh.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Source `pnpm dsh` entry: overlay a checkout-local HTTP proxy, respawn this + * Node process so `fetch` binds it at start, then dispatch the CLI. The + * calling shell is unchanged. + * @module + */ + +/* v8 ignore file -- overlay behavior is tested in apply-source-dsh-http-proxy.spec.ts. */ + +import { spawn } from 'node:child_process' +import { + applySourceDshHttpProxy, + readSourceDshHttpProxyFile, + shouldReexecSourceDshForFetchProxy, + SOURCE_DSH_PROXY_REEXEC_MARKER, + sourceDshAppliedHttpProxyUrl, + sourceDshHttpProxyOverlayPresent, +} from './apply-source-dsh-http-proxy.ts' + +const fileText = readSourceDshHttpProxyFile() +const overlayUrl = process.env.DSH_HTTP_PROXY +try { + applySourceDshHttpProxy({ + env: process.env, + fileText, + overlayUrl, + }) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`dsh: ${message}\n`) + process.exit(1) +} + +const appliedProxy = sourceDshAppliedHttpProxyUrl(process.env) +if ( + appliedProxy !== undefined + && process.env[SOURCE_DSH_PROXY_REEXEC_MARKER] !== '1' +) { + process.stderr.write(`dsh: HTTP proxy overlay ${appliedProxy} (this Node process only)\n`) +} + +if (shouldReexecSourceDshForFetchProxy( + process.env, + sourceDshHttpProxyOverlayPresent(overlayUrl, fileText), +)) { + process.env[SOURCE_DSH_PROXY_REEXEC_MARKER] = '1' + const child = spawn(process.execPath, [...process.execArgv, ...process.argv.slice(1)], { + env: { ...process.env }, + stdio: 'inherit', + }) + child.on('error', error => { + process.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`) + process.exit(1) + }) + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) + }) +} else { + await import('../apps/cli/src/bin.ts') +} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d38b4743c5..664aaf8e5b 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -162,6 +162,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, 'packages/web/web-fetch-http': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, 'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, + 'packages/web/web-search-searxng': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, 'packages/workflow/workflow': { kind: 'indirect', reason: 'The service delegates parent and child model rendering to its consumer and engine.' }, } diff --git a/tsconfig.client.json b/tsconfig.client.json index e599f76f1b..00757e9e88 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -77,6 +77,7 @@ { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, + { "path": "./packages/client/ui-openrouter-usage" }, { "path": "./packages/client/ui-message-feedback" }, { "path": "./packages/client/ui-model-selection" }, { "path": "./packages/client/ui-agent-preset" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 459036247a..a2919d4009 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -127,6 +127,7 @@ { "path": "./packages/attachment/attachment-local" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, + { "path": "./packages/llm/openrouter-usage" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, { "path": "./packages/typert/protocol" }, @@ -227,6 +228,7 @@ { "path": "./packages/compaction/compaction-tool-result-pruner" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, + { "path": "./packages/web/web-search-searxng" }, { "path": "./packages/web/web-search-perplexity" }, { "path": "./packages/web/web-search-deepseek" }, { "path": "./packages/web/web-fetch-http" },