Repository navigation
Conversation
streamAttempt() 原本严格串行:先等完 resolveApiKey()(账号池选择、凭据读取, 必要时还有探测),再从零开始读图并装配正文。两者其实互不依赖 —— 正文从不携带 密钥,健康账号池的解析也只读内存 —— 所以它们的等待时间一直是相加而非重叠的。 带图请求尤其明显:readImage.prepare() 的图片读取与 JSON 装配全部排在凭据之后。 现在把与密钥无关的通道判断(强制 cli、只有 messages 端点服务的模型、Provider API 默认值)先算成 provisionalProtocol,并让 buildBody() 与 resolveApiKey() 并发; 凭据返回后再调用真正的 resolveProtocol(),目标一致就直接复用已装配的正文,不一致 才重建一次(与既有的协议回退、账号轮换同价)。 线上字节不变: - 首次装配发生在任何账号切换之前,budget.max 仍等于初始值,所以 maxTokens 与原 实现一致;凭据等待期间若别的请求学到更低的上限,会补写进正文并同步收紧 budget.max。 - 图片预算那条 withSurfaceOffload() 仍先于任何 I/O 执行,超额仍在读图和解析凭据 之前失败;只有「已看过的图片」这一 CLI 专属缓存项需要真实通道,因此在拿到密钥 后按真实通道再判一次(只可能抛错,不会改变档位)。 - 凭据解析之后仍保留 throwIfAborted(),取消语义不变。 新增回归用「凭据尚未返回时图片读取已经开始」钉住重叠,不依赖墙钟时间:回退到串行 实现时该用例必然失败。
listModels() 每次打开都无条件 await gatewayFacts.refresh():模型选择器和设置页 的过滤编辑器都只是把目录画出来,却因此要等一次完整的网关往返,才能画出本进程 手里早已有、而且刚刚还准确的同一批行。 现在只要本进程已经加载过该网关的目录,就用内存里的行立即返回,并把刷新放到后台 (不 await、吞掉可能的拒绝),好让下次打开拿到新行。进程首次加载仍然照旧等联网: 没有诚实的行可画,而过滤编辑器确实需要真实列表。 新判据 GatewayFacts.loaded() 刻意与 snapshot() 区分:后者会读磁盘,而磁盘缓存 没有年龄校验,拿它当首屏会让冷启动显示一份不知道多久以前的目录。loaded() 只读 内存 —— 要么刚联网取回,要么本进程刚刚刷新成功 —— 因此可以安全地先画再更新。 resolveModel() 与 stream() 的路径不变(仍 await 真实刷新),目录 TTL 与发布代 序号语义也不变:refresh() 在有效期内本就是内存命中,所以常见路径没有新开销。 新增回归让后台刷新故意晚一步:若本次调用去等它,拿到的会是新行而不是旧行。 把实现改回无条件 await 时该用例失败。
gradeHeaderTimeout() 判断「这次超时是不是刚刚那份载荷」时调用 fingerprintValue(body),而它内部又做一次 JSON.stringify —— 正文在发出去之前 已经序列化过一遍了。带图历史可达数 MB,而连续超时恰恰是最常重复的路径,等于每次 重试都为同一个答案多走一遍整棵 body。 现在把实发字符串直接传给定级函数,用 sha256Hex() 对已经序列化好的文本求摘要: 只哈希字节,不再 stringify。这同时更准确 —— 实发字符串就是网关看到的字节, 而重新 stringify 会在 surrogate 清洗真正删掉过字符时描述到不同的字节,把一次连续 超时拆成两条 streak。序列化那份字符串仍由 serializedBodies 持有,正文重建或预算 下调时照旧 delete,所以指纹仍恰好在线上字节改变时改变。 没有实发字符串时(正文从未上过线)保留对象哈希作为兜底。 新增回归用 toJSON 计数器钉住遍历次数:回退到重新 stringify 时该用例失败。
每个传输通道在每次尝试时都会重建工具列表,而账号轮换、协议回退、以及降档重试都 会让同一批工具再走一遍。根节点归一化只读参数对象本身,答案不会因为问了第二次而 改变,但从前每次都要重新判一遍:读 type、判 $ref、展开组合子、必要时再克隆。 现在按 parameters 的对象身份用 WeakMap 记住根节点结果。工具对象来自宿主,在整个 回合里是稳定的,所以 identity 就是精确的键:真正不同的 schema 是另一个对象,自然 不命中。只有根节点记忆化;嵌套 $ref / 组合子分支仍走 normalizeToolParameters(), 不在热路径上。 同时纠正一处与实现对不上的注释:原注释声称「每条路径都返回副本」,但已有 type: 'object' 的快路径一直返回调用方自己的对象 —— 这也是既有行为,调用方靠它拿到 同一个已冻结的 schema。注释现在如实说明只改根节点、构造出来的对象都是新的。 新增回归用 params.type 的 getter 计数钉住「只归一化一次」,并复查每次尝试的线缆上 仍带着归一化后的根节点。去掉记忆化时该用例失败。
设置卡片的账户区与选择器的套餐过滤读的是同一批端点、同一个账号:whoami 一次, 再并行 credits + subscriptions 一次。两处各自独立读取,等于同一份答案付两遍钱; 而卡片还在定时刷新,于是选择器会反复为它早就拿到过的行再付一次往返。 现在把成功的账户端点响应按「网关 + 密钥 + 完整 URL」记进一张短命表 (ACCOUNT_FACTS_TTL_MS,30 秒),两条路径共用。选择器只缓存自己那套 BILLING_ACCESS_TTL_MS,所以卡片的即时结果不会绕过它自己的 5 分钟语义。 刻意只缓存成功响应:失败必须重试,否则刚刚恢复的端点会继续报告昨天的故障,报告的 failures 与 classifyTotalFailure 结论还会描述一次本进程没真正发出的请求。 usage/summary 明确不参与共享(share=false):那是用户正盯着的数字,卡片旁边写着 读取时间,回放一次早先的响应会让数字与时间同时对不上。身份与套餐事实相反 —— 30 秒 前的 org id 或套餐档位仍然是事实,而它们才正是选择器重复读取的部分。 把密钥校验(usableAccountKey)与请求头构造(accountHeadersFor)拆开,使两条路径都 能只解析一次密钥就用同一个身份去查表。 新增回归钉住三点:选择器复用卡片的 whoami/subscriptions;用量汇总每次都真的重读; 把共享关掉时该用例失败。
一份用量报告是四次上游调用,而多个界面会在无人操作的情况下各自发起读取:设置页 账户卡片与面板都在首屏取一次,重挂载或重渲染还会让同一批 effect 再跑一遍。这是 同一账号、同一时刻的同一份答案,付两遍钱买不到任何东西。 现在 refresh() 接受 `auto` 标记,只有自动路径(首屏、后台 tick)会在 USAGE_AUTO_REFRESH_COALESCE_MS(5 秒)内复用刚刚settled 的结果,面板与卡片都改走 这条路径。窗口刻意很小:它只用来折叠一次重复,不是用来展示旧数据;过期之后照旧 重新读取,fetchedAt 因此仍然如实反映这些数字被读取的时刻。 用户按下刷新、保存配置、更换/清除凭据等显式路径一律不传 auto,永远去读 —— 用户 要的就是新数字。 新增回归钉住「自动合并、显式重读、窗口过后重读」三点,以及面板首屏与 tick 都确实 带上 auto 标记;去掉合并逻辑时该用例失败。
后台标签页的 React 树仍然挂载着,而自动刷新循环并不知道这件事:它继续每两分钟 为一个没人看的页面付四次上游调用(whoami + credits + subscriptions + summary)。 现在 tick 会先问可见性:文档隐藏时跳过读取,但照旧重排定时器(循环不能断,否则 恢复可见后就再也不会跑)。重新可见正是唯一「已知屏幕上数据已过期」的时刻,因此 额外立即补读一次。首屏那次仍然无条件执行 —— 那一刻行刚要出现,没有数据就没得 显示。 可见性做成可注入的接缝(AutoRefreshVisibility),默认读 document.visibilityState 并订阅 visibilitychange;非浏览器环境视为始终可见而不是永不刷新。停止最后一个 挂载时解绑监听,避免重挂载累积。 新增回归钉住:首屏必读、隐藏时 tick 不读但继续重排、恢复可见立刻补读、停止后 解绑监听;去掉可见性判断时该用例失败。
CHANGELOG 增补 Unreleased 条目,逐项说明六处改动的动机与保证;adapter-protocol 更正工具 schema 归一化的「每条路径都返回副本」说法并记录记忆化;model-catalog 记录目录首屏读内存与账户端点共享;client-and-settings 记录自动/显式刷新区分与 隐藏页面不轮询;accounts-and-retry 补记超时指纹取自实发字符串。 同时如实写明验证边界:web-search 的 16 条用例在 main 上同样失败,与本批无关; 本批未在真实宿主中人工验证。
上一提交声称「首屏 effect 与面板 tick 都走 auto」,但账户卡片的首屏读取实际调用的是
injected 的 refreshUsage,而它就是显式路径 —— 于是最先发现的那处重复(面板与卡片在
同一秒各读一次同一份报告)根本没有被合并,提交说明与实现不符。
现在把两条路径拆开:injected 新增 refreshUsageAuto()(内部 refresh({ auto: true })),
AccountsCard 的首屏 effect 改调它,刷新按钮继续走 refreshUsage。两者在类型上分开,
顺带让「这个能合并、那个不能」在调用点就看得出来。
新增用例断言 injected 面同时提供两条路径;类型检查与构建通过,全量 1120 条测试除
web-search 那 16 条(main 上同样失败)外全部通过。
补上 refreshUsageAuto()/refreshUsage() 的区分,与上一处修正保持一致。
Owner
Mars-Sea
added a commit
that referenced
this pull request
Oct 24, 2026
fix: 修复 #98 审查发现的图片卸载顺序、目录来源与监听泄漏问题(含其全部提交)
GT-610
pushed a commit
to GT-610/dsh-commandcode-provider
that referenced
this pull request
Oct 25, 2026
# Conflicts: # CHANGELOG.md
GT-610
pushed a commit
to GT-610/dsh-commandcode-provider
that referenced
this pull request
Oct 25, 2026
1. 图片预算门抢在凭据之前抛错(R1/R2,src/adapter.ts) 预算门改为纯会计(projectSurfaceBudget),抛错推迟到凭据与真实通道 落定之后:无凭据时先报 MISSING_CREDENTIAL,不再让宿主对一次发不出去 的请求先做不可逆的持久化图片卸载;预算门与缓存门一次算全 (max(预算, 缓存)),不再退化成两步卸载、也不会把 cache 溢出报成 budget。 withSurfaceOffload() 保留为 413 降档/协议回退的一次性形式。 2. loaded() 只认联网来源(R3/R4,src/gateway-facts.ts) CatalogState 增加 origin(none/disk/network):snapshot() 灌入的磁盘行 与刷新失败后的空目录都不再被当成选择器首屏;冷启动先聊天再开选择器 不会画出无年龄校验的旧目录,刷新失败后也不会先闪一次空列表。 3. 可见性监听按任意卸载顺序解绑(R6,src/client/panel.ts) unsubscribe 句柄从函数局部提升到模块层,启动循环的 surface 先卸载时 不再泄漏 visibilitychange 监听。 4. 账户卡片首屏接线补真实渲染测试(R7) 新增 tests/accounts-card-render.test.ts(react-test-renderer + 既有 CSS/primitives loader):首屏必须调 onRefreshAuto(),刷新按钮必须调 onRefresh();改回旧接线时用例失败。此前只有两个入口存在性断言。 其余测试:gateway-facts 新增 4 条 loaded() 来源判据,adapter 新增 2 条 预算门顺序用例,panel 新增 1 条任意卸载顺序解绑用例。新增 react-test-renderer / @types/react-test-renderer 开发依赖。 验证:typecheck 退出码 0;npm test 1125/1125 通过;npm run build 完成, lib/ 已重建。
GT-610
pushed a commit
to GT-610/dsh-commandcode-provider
that referenced
this pull request
Oct 25, 2026
- CHANGELOG:修正「只可能抛错,不会改变档位」「loaded 只读内存」等 过度声明;记录 R1/R2/R3/R4/R6 的修复与真实渲染测试;如实写明账户 事实共享只覆盖 whoami/subscriptions、Go 账号会多装配一次正文。 - adapter-protocol:图片预算段改为「会计与抛错分离」的口径。 - model-catalog:loaded() 以联网来源为准,磁盘行与空目录不算热数据。 - client-and-settings:显式刷新在途时仍按 inFlight 折叠;可见性监听 句柄在模块层、任意卸载顺序都解绑;补首屏接线的渲染用例指引。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概述
本 PR 只做请求路径与控制面的性能优化:减少每次请求里的重复计算、重复联网和重复轮询,不改变任何对外行为契约——线上字节、缓存前缀、重试预算、协议回退顺序、用量语义全部保持原样。
10 个提交,18 个文件,+1025 / -103,基于
main的ac06537(v0.15.1 之后)。每处改动都有对应测试,且每个新增用例都先对着回退后的实现跑过,确认会红。优化清单
Batch 1:纯本地计算,不改变 I/O 行为
凭据解析与正文装配并行 (
c92ee87)streamAttempt()原本严格串行:先等完resolveApiKey()(账号池选择、凭据读取,必要时还有探测),再读图并装配正文。两者其实互不依赖——正文从不携带密钥,健康账号池的解析也只读内存——等待时间一直是相加而非重叠的。现在把与密钥无关的通道判断先算成provisionalProtocol,buildBody()与resolveApiKey()并发;凭据返回后目标一致即复用已装配的正文,不一致才重建一次。选择器在目录已在内存时不再等一次联网刷新 (
2413e6b)listModels()去掉无条件await gatewayFacts.refresh():本进程已加载过该网关目录时直接返回内存里的行,刷新放到后台。新判据GatewayFacts.loaded()刻意与snapshot()区分——后者读磁盘,而磁盘缓存没有年龄校验,拿它当首屏会画出不知道多久以前的目录。进程首次加载仍照旧等联网。超时定级复用已序列化的正文 (
ed3785c)gradeHeaderTimeout()判断「这次超时是不是刚刚那份载荷」时原本调fingerprintValue(body),内部又JSON.stringify一遍——而正文在发出去之前已经序列化过了。带图历史可达数 MB,连续超时恰恰是最常重复的路径。现在直接对实发字符串sha256Hex(),只哈希字节。这同时更准确:实发字符串就是网关看到的字节,重新 stringify 会在 surrogate 清洗真的删掉过字符时描述到不同的字节,把一次连续超时拆成两条 streak。工具 schema 归一化按对象身份记忆化 (
cd8c240)每个通道在每次尝试时都重建工具列表,而账号轮换、协议回退、降档重试都会让同一批 schema 再走一遍。现在按
parameters的对象身份用WeakMap记住根节点结果;工具对象来自宿主、整个回合稳定,所以 identity 就是精确的键。只有根节点记忆化,嵌套$ref/组合子仍走原递归路径。顺带纠正一处与实现对不上的注释(type: 'object'快路径一直返回调用方自己的对象)。Batch 2:控制面去重(不碰请求主路径)
账户事实在用量卡片与套餐过滤之间共享 (
f28fcfb)设置卡片的账户区与选择器的套餐过滤读的是同一批端点、同一个账号:
whoami一次,再并行credits+subscriptions一次,而卡片还在定时刷新,选择器于是反复为它早就拿到过的行再付一次往返。现在把成功的账户端点响应按「网关 + 密钥 + 完整 URL」记进短命表(ACCOUNT_FACTS_TTL_MS,30 秒)共用。刻意只缓存成功响应:失败必须重试,否则刚恢复的端点会继续报告昨天的故障。/alpha/usage/summary明确不参与共享(share=false)——那是用户正盯着、旁边写着读取时间的数字。没人点的刷新在短窗口内合并 (
9aa836a)refresh()接受auto标记:只有自动路径(首屏、后台 tick)会在USAGE_AUTO_REFRESH_COALESCE_MS(5 秒)内复用刚刚 settled 的结果;按钮、保存配置、更换凭据等显式调用一律不传auto,永远去读。窗口刻意很小——它只折叠一次重复,不是用来展示旧数据,fetchedAt因此仍如实反映读取时刻。隐藏的页面不再轮询 (
7634528)后台标签页的 React 树仍然挂着,而自动刷新循环继续每两分钟为一个没人看的页面付四次上游调用。现在 tick 先问可见性:文档隐藏时跳过读取但照旧重排定时器(循环不能断),重新可见立即补读一次——那是唯一「已知屏幕上数据已过期」的时刻。首屏仍无条件读取。可见性做成可注入接缝(
AutoRefreshVisibility),默认读document.visibilityState;非浏览器环境视为始终可见;最后一个挂载停止时解绑监听。首屏自动路径与显式路径分开 (
17b2b5d,d206c13)把 injected 面拆成
refreshUsageAuto()(内部refresh({ auto: true }))与refreshUsage():AccountsCard的首屏 effect 走前者,刷新按钮走后者。两者在类型上分开,让「这个能合并、那个不能」在调用点就看得出来。明确没有改动的部分
reasoning_contentin the thinking mode must be passed back to the API. #34)accept-encoding: identity(见 无法使用了 #59)offloadSeenImagesForCache的默认值temperature: options.temperature ?? 0.3config块TRANSIENT_MAX_RETRIES/DEFAULT_TRANSPORT_MAX_RETRIES/MAX_ACCOUNT_ROTATIONS/ 头部超时连击上限)另有两批优化没有进这个 PR:
stream_options: { include_usage: true }(需要先做一次线上探针确认网关行为)与历史 reasoning 策略(默认关闭、风险更高),留作后续单独讨论。验证情况
npm run typecheck:退出码 0npm ci干净安装后npm test:1120 / 1120 通过npm run build:两段Build complete,重建后git status对lib/无差异(提交的 bundle 与源码一致)ac06537,10 个提交,无 merge commit建议的人工验证
正确性靠"行为不变"来保证,以下几项值得人工过一遍(按优先级):
cache_control前缀字节没变回滚
按批次与优化点分别提交,可单独 revert 任意一条;Batch 2 的几条彼此独立。