Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]


### Changed

- **请求路径与账户读取的一批性能优化(线上字节不变)。**
- `streamAttempt()` 的凭据解析与正文装配改为并发:两者互不依赖(正文从不携带密钥,健康账号池的解析只读内存),从前却是严格串行,带图请求因此把图片读取与 JSON 装配整个排在 `resolveApiKey()` 之后。与密钥无关的通道判断先算成 `provisionalProtocol`,凭据返回后目标一致即复用,不一致才重建一次(与既有协议回退、账号轮换同价)。代价是 Go 计划/已学 CLI 通道的账号每次请求会多装配一次正文(线上字节不变,纯 CPU 开销)。图片预算的**会计**仍先于任何 I/O 执行,但**抛错**推迟到凭据与真实通道落定之后:预算门与缓存门一次算全、一次抛出。
- `listModels()` 在**本进程联网成功过**该网关目录时不再等一次联网刷新,改用内存中的行立即返回、后台刷新留给下次打开;进程首次加载仍照旧等待。新判据 `GatewayFacts.loaded()` 以「来源」为准而不是「内存里有行」:`snapshot()` 从磁盘灌入的行、以及刷新失败后的空目录都不算热数据(磁盘副本没有年龄校验)。`resolveModel()` 与 `stream()` 不变,仍等真实刷新。
- `gradeHeaderTimeout()` 的指纹改用**已经序列化好的实发字符串**求摘要(`sha256Hex()`),不再对 `body` 重新 `JSON.stringify`:超时是最常重复的路径,而带图历史可达数 MB。这也更准确——实发字符串就是网关看到的字节,重新 stringify 会在 surrogate 清洗真的删掉过字符时描述到不同的字节,把一次连续超时拆成两条 streak。
- `toolParametersSchema()` 的根节点结果按 `parameters` 对象身份记忆化(`WeakMap`)。每个通道在每次尝试时都重建工具列表,而账号轮换、协议回退、降档重试都会让同一批 schema 再走一遍。同时纠正原注释「每条路径都返回副本」——`type: 'object'` 快路径一直返回调用方自己的对象,调用方靠它拿到同一个已冻结的 schema。
- 用量卡片与选择器的套餐过滤共享账户端点响应:两处读的是同一批端点、同一个账号,成功响应按 `[apiBase, key, 完整 URL]` 记入短命表(`ACCOUNT_FACTS_TTL_MS` 30 秒)。实际共享的是 `whoami` 与 `subscriptions`——`credits` 的 URL 形状带 `orgId`,两处不同,不会互相命中。只缓存成功响应(失败必须重试,否则报告的 `failures` 与 `classifyTotalFailure` 结论会描述一次没真正发出的请求);`/alpha/usage/summary` 明确不共享——那是用户正盯着、旁边写着读取时间的数字。
- 用量刷新区分自动与显式路径:`refresh({ auto: true })` 用于账户卡片首屏、面板首屏与面板 tick(injected 面因此分成 `refreshUsageAuto()` 与 `refreshUsage()` 两个入口),可在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读到的结果;按钮、保存、换凭据等显式调用不查合并窗口(请求在途时仍按既有 `inFlight` 规则折叠),`fetchedAt` 因此仍如实反映读取时刻。
- 面板自动刷新在文档隐藏时跳过读取但照旧重排定时器,重新可见立即补读一次(该补读走 auto,5 秒内仍可能被合并);首屏仍无条件读取。可见性做成可注入接缝(`AutoRefreshVisibility`),非浏览器环境视为始终可见;监听句柄随循环状态放在模块层,任意卸载顺序都能解绑。

### Fixed

- **图片预算门不再抢在凭据之前抛错。** 预算门只做会计,凭据解析失败时先报 `MISSING_CREDENTIAL`:避免宿主对一次根本发不出去的请求先做不可逆的持久化图片卸载。预算门与缓存门在凭据与真实通道落定后一次算全(`max(预算, 缓存)`),不再退化成两步卸载,也不会把 cache 溢出报成 budget。(`src/adapter.ts`)
- **`loaded()` 只认联网来源。** `snapshot()` 灌入的磁盘行与刷新失败后的空目录都不再被当成选择器首屏:冷启动"先聊天再开选择器"不会画出无年龄校验的旧目录,刷新失败后也不会先闪一次空列表。(`src/gateway-facts.ts`)
- **可见性监听按任意卸载顺序解绑。** `unsubscribe` 句柄从函数局部提升到模块层,启动循环的 surface 先卸载时不再泄漏 `visibilitychange` 监听。(`src/client/panel.ts`)
- **账户卡片首屏接线补真实渲染测试。** 新增 `tests/accounts-card-render.test.ts`(`react-test-renderer`):首屏必须调 `onRefreshAuto()`、刷新按钮必须调 `onRefresh()`;把组件改回旧接线时该用例失败。此前只有"两个入口存在"的断言,回退后测试仍全绿。

用例:`tests/adapter.test.ts` 新增「凭据解析与图片读取并行」「目录已在内存时不再等一次联网刷新」「超时定级复用已序列化的正文」「同一份工具 schema 只归一化一次」「用量卡片与选择器共享同一份账户事实」「凭据缺失时预算门不抢跑」「双超限一次报数」;`tests/gateway-facts.test.ts` 新增 4 条 `loaded()` 来源判据;`tests/panel.test.ts` 与 `tests/usage-client.test.ts` 新增自动/显式刷新、可见性与任意卸载顺序解绑用例;`tests/accounts-card-render.test.ts` 新增 2 条真实渲染接线用例。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1125/1125** 通过;`npm run build` 重建 `lib/`。未在真实宿主中人工验证。

## [0.15.4] - 2026-10-24

### Changed
Expand Down
5 changes: 5 additions & 0 deletions docs/agent-reference/accounts-and-retry.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t
自动回收,改为容量上限 200 条 + 先进先出淘汰。无 `sessionId` 的一次性调用退回旧的单槽全局行为。
`tests/adapter.test.ts` 新增并发会话独立累计、无会话回退两个用例。参见
[《决策记录》第一章第 3 条](../决策记录.md#一请求失败恢复)。
指纹取自**已经序列化好的实发字符串**(`serializedBodies` 里的那份,用 `sha256Hex()` 求摘要),
不再对 `body` 重新 `JSON.stringify`:超时是最常重复的路径,而带图历史可达数 MB,重走整棵 body
只为算第二个意见。这也更准确——实发字符串就是网关看到的字节,重新 stringify 会在 surrogate
清洗真的删掉过字符时描述到不同的字节,把一次连续超时拆成两条 streak。没有实发字符串时
(正文从未上过线)才回落到对象哈希。
- **设置页账号卡片 / 侧边栏统计的"当前使用"徽章必须和真实请求路由共用同一套自愈逻辑,否则它显示的是过期标记**
(`CommandCodeAccountPool.activeAccount()` in `src/accounts.ts`; issue #51 的后续报告:账号卡片和侧边栏
统计一起被"切到另一个账号",且比真实请求恢复得慢得多)。`resolveKey()`(真实请求路径)对**显式选中**
Expand Down
27 changes: 19 additions & 8 deletions docs/agent-reference/adapter-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,14 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t
a root `$ref` is inlined from its local `$defs`/`definitions`; an `allOf`/`anyOf`/`oneOf` root is
flattened (branch properties unioned, `required` kept only where every alternative demands it); anything
else degrades to a permissive free-form object, since a refused request helps no one. Only the root is
touched and every path returns a copy (the harness may deep-freeze tool schemas), and the walk is
depth-bounded so a self-referential JS schema cannot spin. `toolParametersSchema()` is applied at all
three call sites, so the CLI `input_schema`, the OpenAI `function.parameters` and the Messages
`input_schema` cannot drift apart.
touched; the object-root fast path returns the caller's own object (the harness may deep-freeze tool
schemas, and callers rely on that identity), while everything the function CONSTRUCTS is a fresh object.
The walk is depth-bounded so a self-referential JS schema cannot spin. The depth-0 result is memoized
per `parameters` object in a `WeakMap`, because every transport rebuilds its tool list on every attempt:
a rotation, a protocol fallback or a downshift re-walks the same handful of schemas for an answer that
cannot have changed. Harness tool objects are stable for the life of a turn, so identity is an exact key.
`toolParametersSchema()` is applied at all three call sites, so the CLI `input_schema`, the OpenAI
`function.parameters` and the Messages `input_schema` cannot drift apart.
- Image parts on `/alpha/generate` use the official CLI's FINAL `toWireMessages()` shape: `{ type:
'image', image: 'data:<mime>;base64,...', mimeType: '<mime>' }`. The `{ type: 'image', source: { type:
'base64', media_type, data } }` shape is an internal CLI input block, not what it posts. OpenAI
Expand Down Expand Up @@ -278,11 +282,18 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t
dozens of screenshots) used to cross the cap once and then fail EVERY later request on this route with
HTTP 413, while the same conversation worked on another provider. `stream()` therefore builds every
body from a PROJECTED history, never the raw one, and the offload set is a DURABLE session fact:
`withSurfaceOffload()` renders the surface's `offloaded` marks through the engine's
`projectOffloadedImages` (so an evicted image can never be re-sent as pixels) and an over-budget history
FAILS with `IMAGE_OFFLOAD_REQUIRED` + the count from `requiredImageOffload`, which the default
`projectSurfaceBudget()` renders the surface's `offloaded` marks through the engine's
`projectOffloadedImages` (so an evicted image can never be re-sent as pixels) and counts what the byte
budget still needs; `withSurfaceOffload()` is the one-shot form (byte budget + CLI cache gate) used by
the 413 downshift and the protocol fallback, and an over-budget history FAILS with
`IMAGE_OFFLOAD_REQUIRED` + the count from `requiredImageOffload`, which the default
`dsh-compaction-image-offload` plugin records as one `image/offload` event and retries — the omission
then survives restore and fork, and the token meter stops counting evicted images as context. Those
then survives restore and fork, and the token meter stops counting evicted images as context. On the
stream path the accounting and the throw are deliberately SEPARATE: the count runs before any I/O (a
history that must commit an offload costs neither a credential round trip nor an image read), but the
ONE error (`max(byte budget, CLI cache gate)`) is raised only after the credential and the real
transport are known — a request that cannot authenticate must report `MISSING_CREDENTIAL` instead of
first making the host offload images irreversibly. Those
three symbols are STATIC imports of `@deepseek-ai/dsh-llm` (they are the reason the peer range is pinned
so tightly: a named import of an absent export is a link-time failure that takes the whole plugin down,
issue #43). `REQUEST_IMAGE_BUDGETS` is a two-rung ladder: rung 0 is the standing budget (32 MiB base64 /
Expand Down
2 changes: 2 additions & 0 deletions docs/agent-reference/client-and-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t
- **凭据事实与销毁**:凭据返回成功即为写入确认,描述仅提供存在性;描述失败或缺失保留最后确认事实,首次尚无确认时明确显示状态未知,重试入口只读状态。按引用及读取世代屏蔽迟到描述。普通切页不关闭共享控制器;插件销毁停止未开始任务,已发调用自然收束,不进入后续阶段。
- **凭据写入不再被环境变量遮蔽(alpha.3)**:凭据服务的 `describe()` 恒返回 `writable: true`,写入不再因环境变量遮蔽而被拒,服务内部的层序同步反转为「托管存储 → 继承环境 → 项目 `.env` → `$DSH_HOME/.env`」。**headless(无头)profile 有意不挂载 `credentials`/`authorization`/`deepseek-account` 三行**,没有凭据服务可读写,一次性任务只认启动环境与 `.env`。密钥解析侧见[账号与重试](accounts-and-retry.md)。
- **自动轮换**:取消固定账号时基础层仍固定账号,则显式写 `activeAccount: ''`;仅清除用户覆盖会重新露出基础层固定账号,不等于自动轮换。普通字段重置仍恢复继承。
- **用量读取的自动与显式路径**:一份用量报告是四次上游调用,而面板与账户卡片都会在无人操作时各自读取。`CommandCodeUsageController.refresh({ auto: true })` 标记「没人点过」的刷新,只有它在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读取的结果;首屏 effect 与面板 tick 都走这条路径。按钮、保存、换/清凭据等显式调用不传 `auto`,不查合并窗口,永远真的去读(请求在途时仍按既有 `inFlight` 规则折叠,不会因点击再发一份)。窗口刻意很小,`fetchedAt` 因此仍如实反映数字被读取的时刻。首屏接线的真实渲染用例见 `tests/accounts-card-render.test.ts`:首屏必须调 `onRefreshAuto()`,刷新按钮必须调 `onRefresh()`。
- **隐藏页面不轮询**:`startPanelAutoRefresh()` 的 tick 先问 `AutoRefreshVisibility.isVisible()`(默认读 `document.visibilityState` 并订阅 `visibilitychange`),隐藏时跳过读取但照旧重排定时器;重新可见立即补读一次(该补读走 `auto`,5 秒内仍可能被合并)。首屏那次无条件读取 —— 那一刻没有数据可显示。可见性是可注入接缝,非浏览器环境视为始终可见。监听句柄与循环状态同层(模块级),因此启动循环的 surface 先卸载时也照样解绑,任意卸载顺序都不泄漏。
- **运行中请求的配置生效**:已确认的网关、账号列表、固定账号和模型规则用于新调用,已经开始的适配器调用继续使用自己的快照。引用凭据每次账号选择仍重新解析;清除后可能按既有优先级使用默认账号的授权文件兜底,不撤回已发请求或当前尝试已取得的密钥。
- **账户移除与清理**:`accounts`、`modelAccountRules`、固定账户及 `credentialCleanupRefs` 在一次带版本检查的 `remote.settings.mutate` 中提交,再清理凭据。失败队列持久化,页面刷新后仍显示重试;重新引用、继承配置和默认密钥受保护。无法确认凭据事实时保留队列。宿主配置与凭据服务并无共同事务,不能承诺任意外部并发重建与凭据删除跨服务原子化。

Expand Down
17 changes: 17 additions & 0 deletions docs/agent-reference/model-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,23 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t
one rejected attempt), and it costs three requests per account per TTL, cached per key exactly like the
single-account path (an unknown account counts as 'may include it', the same fail-open rule, and a host
without the seam falls back to the serving key).
- **Reading the catalog never blocks on the network once this process has FETCHED it.** `listModels()` asks
`GatewayFacts.loaded()` for rows this process successfully fetched; only a source that never completed a
network refresh awaits `refresh()`. A warm source refreshes in the background for the NEXT open. The
source matters, not just "rows in memory": `snapshot()` loads the on-disk cache into the SAME state for
the generation path, and that disk copy carries no age check — serving it as a first paint would draw a
catalog of unknown vintage. An empty row set (a failed refresh inside its retry window) is not warm data
either; it falls back to `refresh()` instead of painting an empty picker. `resolveModel()` and `stream()`
still await a real refresh — they must not route on a guess.
- **The picker and the usage card share the account endpoints.** `getUsage()` and `fetchBillingAccess()`
both read `/alpha/whoami`, then credits + subscriptions in parallel, for the same key. Successful
responses are memoized per `[apiBase, key, exact URL]` for `ACCOUNT_FACTS_TTL_MS` (30 s), so whichever
surface asked last pays for the duplicate. Only successes are shared — a failed endpoint must be retried
rather than replayed, or the report's `failures` and `classifyTotalFailure` verdict would describe a
request this process never made. `/alpha/usage/summary` is deliberately NOT shared (`share: false`): its
numbers are what the card displays next to a read timestamp, so replaying an earlier answer would
misreport both. The two URL shapes differ (`orgId` is added by `getUsage()`'s scoped path), so a `credits`
read is only ever reused by the surface asking with the identical query.
- The picker also supports a **visible-model allowlist** (`Config.visibleModels: string[]`, the settings
page's Visible models card): a non-empty list narrows `listModels()` to those catalog ids AFTER the plan
filter; empty/unset shows everything. It never gates named requests (`resolveModel` still serves every
Expand Down
Loading
Loading