@eddyskywalker/dsh-chatgpt-subscription 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - **修复 Ollama「同步模型列表」报 `Failed to execute 'json' on 'Response': Unexpected end of JSON input`**([issue #36](https://github.com/Aa728848/dsh-chatgpt-subscription/issues/36))。
6
+ - **根因不在 Ollama Cloud**:issue 里推测的端点与鉴权都没问题——`CLOUD_BASE_URL` 一直就是 `https://ollama.com`,`/api/tags` 也一直带着 `Authorization: Bearer <key>`,只是从来没有任何一条断言钉住这两点(本次补上)。真正的原因是**本插件自己的路由**:`/ollama/api` 是全插件唯一没有用 `try/catch` 包起来的设置路由处理器,一旦它抛错,DSH 的 web server(`packages/host/webserver`)只会回一个**空的 400**(`res.writeHead(400); res.end()`)。卡片再把这个空响应体交给 `response.json()`,浏览器抛出的解析异常就成了用户看到的全部错误——既没有状态码,也没有请求信息,真实原因只留在主机日志里。同步路径上 `storeCatalog()` 当时是唯一没有保护的 `await`,设置文件写不进去就会走到这里。
7
+ - **修法(主机侧)**:路由处理器整体包进 `try/catch`,兜底用与 Claude / Kimi / WorkBuddy 等同级线路相同的 `{ok:false,error}` 500 信封,并补上它们都有、本线路唯独没有的 404 兜底;`storeCatalog()` 单独捕获,区分「列表读到了但本地存不下」与「读不到」。
8
+ - **修法(错误信息)**:新增 `fetchCatalog()` 保留失败原因——`auth`(401/403,含 `https://ollama.com/settings/keys`)、`upstream`(其他状态码)、`unreachable`(DNS/TLS/代理/超时)、`malformed`(200 但不是 `/api/tags` 文档,例如代理返回的 HTML);`loadCatalog()` 保持「返回空列表、绝不抛」的既有契约不变。另外,「取不到凭据」原本把「一个 key 都没有」和「所有 key 都在冷却中」合并成同一句「请先添加 API Key」,后者会被误导去加一个毫无作用的 key,现在分开提示。
9
+ - **修法(客户端侧)**:`src/client/ollama/api.ts` 先读 text 再解析,响应体为空或不是信封时报告状态码与 `content-type`,不再让 `response.json()` 的原生异常冒到界面上。
10
+ - **测试**:新增 `test/ollama-routes.test.ts`(12 条),每条都断言**响应体可解析**而不只是状态码——`#36` 的故障形态正是「一个字节都没写」;其中最承重的一条让设置存储在同步中途抛错。`test/ollama-client.test.ts` 增 6 条(四类失败分类、端点与 Bearer 头断言、空列表属于成功而非 malformed),`test/ollama-section.test.tsx` 增 2 条(空响应体 / 非 JSON 响应体下界面显示可读错误且**不含** `Unexpected end of JSON input`)。**已实测承重**:把 `src/host/ollama/{routes,client}.ts` 回退到修复前,路由测试 12 条中 8 条立即失败。
11
+ - **验证**:`npm run typecheck` 0 错误;全量 `npm test` **2367 passed / 7 skipped / 0 失败**(151 文件通过 / 1 跳过)。
12
+ - **边界**:未用真实 Ollama Cloud 账号端到端复验——结论来自对 DSH web server 抛错路径的源码路径与本地复现。`src/client/api.ts`(Codex 卡片)存在同一处 `response.json()` 写法,但不在本 issue 范围内,未改动。
13
+
14
+ - **压缩恢复排查(进行中)**:MiniMax、Kimi、Codex、Claude、Command Code、WorkBuddy 与 Antigravity 的已接入 HTTP/SSE 上下文超限错误现在映射为 `CONTEXT_WINDOW_EXCEEDED`,使支持该机制的 Harness 能进入溢出压缩恢复,而不是按普通 provider 错误终止。认证、限流、配额及输出上限错误保持原分类;未改写历史或放宽请求大小限制。MiniMax 的真实 Harness LLM 服务四组合回归已验证修复前失败、修复后通过;相关 584 条测试、强制源码类型检查、测试类型检查与构建通过;另运行 Harness 自动/手动压缩 138 条现有测试全部通过。新增 MiniMax 摘要成功及输出截断的模拟流回归,确认截断仍以 max-tokens 结束。Command Code OpenAI 与 Antigravity 的流内错误帧不再被忽略。尚未进行真实账号端到端验证;强制压缩具体失败原因及其他线路仍在排查,不能据此宣称所有压缩故障已解决。
15
+
16
+ - **修复 Claude 与 Command Code 登录时弹出两个一模一样的授权页**([#33](https://github.com/Aa728848/dsh-chatgpt-subscription/pull/33),由 @Anuii 提交)。
17
+ - **根因**:授权页被打开了两次。设置卡片在 `/login` 返回后调用 `window.open(authUrl)`(桌面端主窗口会把 http(s) 的 `window.open` 交给 `shell.openExternal`,即在系统浏览器中打开),而主机端 `/login` 路由调用 `beginLogin` 时没有传 `openBrowser`,于是走了默认实现,在主机上用 `cmd /c start`(macOS `open`、Linux `xdg-open`)把同一个地址又打开一次。
18
+ - **修法**:保留卡片打开,去掉主机打开。卡片打开的位置就是用户所在的位置(远程访问 GUI 时也正确),且与 Antigravity / WorkBuddy / Codex 早已采用的「只在卡片打开」一致。Claude 两个入口(登录、按账号重新登录)共用一份 `loginOptions`,默认 `openBrowser` 为空操作;测试仍可通过 `options.login` 注入自己的实现。Command Code 的 `beginWebLogin` 同样传入空操作。已核对生产接线(`registerClaudeRoutes` 的 options)不传 `login`,所以空操作在生产下不会被覆盖。
19
+ - **逐线路排查过的范围**:Antigravity 的 `openBrowser` 调用在 `loginAndSave()` 里(无头 CLI `bin/antigravity-login.mjs` 走的路径),其 `beginWebLogin` 不开浏览器,因此它原本就只在卡片打开,无需改动;Kimi 无卡片 `window.open`、只在主机打开,同样只开一次;WorkBuddy / Codex 的登录流程根本没有 `openBrowser` 选项,也是只在卡片打开。**双开的只有 Claude 与 Command Code 两条线路**。
20
+ - **测试**:新增 `test/login-opens-browser-once.test.ts`(2 条)——Claude 按生产接线(不注入 `login`)调用 `/login`,断言返回授权地址且没有任何 `cmd`/`open`/`xdg-open` 启动;Command Code 断言路由给登录流程传了空操作的打开函数,调用它不会启动浏览器。测试拦截浏览器启动而不真正执行,失败时也不会弹窗。已实测**承重**:把两处 `routes.ts` 回退到合并前,前两条立即失败(Claude 用例捕获到一次 `cmd` 启动,Command Code 用例 `openBrowser` 为 `undefined`)。
21
+ - **验证**:`npx tsc -b --force` 与 test tsconfig 均 0 错误;全量 **2313 passed** / 7 skipped,**0 失败**(148 文件通过 / 1 跳过);CI 的 ubuntu 与 windows 两项均通过。
22
+ - **已知边界(合并时发现,未在本 PR 内改动)**:Command Code 卡片只有 `window.open`、**没有**像 Claude(`authorizeUrl` + `openAuthorizeUrl` 两个 i18n key)和 Codex 那样渲染可点击的手动链接。因此在会拦截弹窗的远程/网页 GUI 里,弹窗被拦后该线路没有兜底入口——不过这与 Antigravity / WorkBuddy 的现状一致(它们同样只有 `window.open`),是插件既有的模式差异,不是本次改动独有。是否补齐需要动 `command-code/locales.ts` 的文案,留作后续决定。
23
+
24
+ - **修复 Codex 线路响应流中断被判为 `UNKNOWN` 而一次都不重试,整轮直接失败**([issue #32](https://github.com/Aa728848/dsh-chatgpt-subscription/issues/32))。
25
+ - **根因**:请求**之前**的每一条失败路径都已经定好类型——`request()` 抛 `NETWORK`、`responseError()` 抛状态码判定、SSE 解析抛协议或 provider 判定——唯独**响应体消费阶段**没有。连接在响应读完之前断掉时,undici 抛的是 `TypeError: terminated`:它本身不带任何 code,真实原因(`ECONNRESET`、对端关闭 socket、body 超时)只挂在 `.cause` 上。`wrapStreamWithWatchdog()` 对非超时、非取消的错误原样重抛,于是这个裸 TypeError 一路走到 `@deepseek-ai/dsh-llm` 的适配器边界,被 `normalizeLlmFailure()` 归一化成 `code: "UNKNOWN"`。而本线路声明的 `retryableCodes` 是 `['RATE_LIMIT','SERVER_ERROR','SERVER','NETWORK','TIMEOUT','TRANSPORT']`,**不含 UNKNOWN**,`dsh-llm-retry` 因此直接放行——**一次传输中断就终止整轮,一次都不重试**。同一次会话里 429 会重试 3 次,正是因为 `RATE_LIMIT` 在表内。
26
+ - **同文件内的 Claude 适配器早已做对**(`src/host/claude/adapter.ts` 的 `stream()` 有 `catch`),所以这是 Codex 线路漏掉的一步,不是设计选择。
27
+ - **修法**:在 `ResponsesClient.stream()` 消费流的 `try` 上补 `catch`,交给新增的 `streamFailure()` 归类:已是 `LlmError` 的(本线路自己的判定)原样放行,调用方取消归 `ABORTED`,其余一律归 `TRANSPORT`,并把 `errorChain()` 渲染的完整 cause 链写进 message——于是落盘的不再是干巴巴的 `terminated/UNKNOWN`,而是 `Codex stream failed: terminated: read ECONNRESET`。
28
+ - **已出过 chunk 也仍然按可重试归类**(这点与 Claude 的轮换规则不同,是有意为之):DSH 把失败尝试的半截输出结算为被丢弃的 `assistant/attempt`,重试会另起一次新尝试;工具调用只在流**正常结束后**才执行。因此重试既不会重复已提交输出,也不会重跑工具。Claude 那条「出过输出就不轮换」约束的是**换账号**(会换凭据重发),不是同凭据重试,不能照搬。
29
+ - **测试**:`test/responses-client.test.ts` 新增 `Codex stream failure classification` 四条——用 undici 同形的 body(先吐一个 delta 再以 `TypeError: terminated` 断开,cause 为 `ECONNRESET`)断言 `TRANSPORT` 且 message 同时含 `terminated` 与 `ECONNRESET`;断言该 code 落在本线路 `providerRetryPolicy()` 的 `retryableCodes` 内(把「不重试」直接钉在策略上);调用方取消仍归 `ABORTED`;流内 `response.failed` 的 `RATE_LIMIT` 判定不被改写。已实测**承重**:临时 `git stash` 掉 `responses-client.ts` 后,前两条立即失败(2 failed / 20 passed)。
30
+ - **验证**:`npx tsc -b --force` 与 test tsconfig 均 0 错误;全量 **2311 passed** / 7 skipped,**0 失败**(147 文件通过、1 跳过);`npm run build` 与 `npm pack --dry-run` 干净,产物 `lib/index.js` 含新分支。`errorChain` 是 `@deepseek-ai/dsh-llm` 自 0.1.0-rc.7 之前就已存在的导出(`git tag --contains` 核对,引入它的提交已包含在 dsh-v0.1.0-rc.7),覆盖本插件 peer 下限 0.1.2-alpha.5,因此旧世代无需 clean-room 复跑;未改动任何依赖版本或兼容缝。
31
+ - **未在真实订阅账号上端到端复验**:结论来自 undici 同形错误的本地复现与 DSH 自身的 `agent/request-error` 归约路径阅读,未用真实凭据真的在中途断一次长流观察 `llm/retry` 事件。
32
+
5
33
  - **修复 Antigravity Claude 普通文本回放碎片化**:不再把每个 SSE 文本 delta 回放成独立内容块,仅合并相邻纯文本 part,保留签名/工具/其他元数据边界,Gemini 不变。目标会话三个缓存断点前的回答分别含 385、365、20 个纯文本 part,离线重建均合并为 1 个且正文不变;这与 Claude 20-position 缓存回看限制吻合,但未进行线上缓存 A/B。旧会话无需改写;首次切换新编码可能重新预热缓存。
6
34
 
7
35
  - **修复 Antigravity Claude 多轮 400 `thinking.signature: Field required`**:回放时将连续思考分片和独立签名合并为签名完整的 thinking part;省略缺签名及跨模型 reasoning,保留正文和工具调用,Gemini 原始分片回放不变。修复作用于已有历史的请求转换,不改写会话记录。
package/README.md CHANGED
@@ -764,6 +764,10 @@ npm pack --dry-run
764
764
 
765
765
  ## 故障排查
766
766
 
767
+ ### 压缩失败
768
+
769
+ MiniMax、Kimi、Codex、Claude、Command Code、WorkBuddy 和 Antigravity 的错误处理会将明确的上下文超限错误交给 Harness 的溢出恢复机制。该机制需要宿主启用压缩后端;它不是无条件重发同一个超限请求。认证、配额、请求体字节限制和输出截断不会因此被当作上下文超限。手动压缩仍需生成完整摘要;如果摘要请求本身超限、被截断或缺少正文,修正错误分类也不能保证它成功。请保留失败会话中的 `compaction/end` 错误、provider/model、宿主及插件版本,以便定位。
770
+
767
771
  | 现象 | 处理 |
768
772
  | --- | --- |
769
773
  | 1455 端口占用 | 结束旧登录任务或占用该端口的进程后重试;插件卸载会关闭 listener |
package/lib/client.js CHANGED
@@ -8841,10 +8841,48 @@ window.__ModuleLoader__.load({
8841
8841
  ...init,
8842
8842
  credentials: "same-origin"
8843
8843
  });
8844
- const envelope = await response.json();
8844
+ const envelope = await readEnvelope(response);
8845
8845
  if (!response.ok || !envelope.ok) throw new Error(envelope.error || `HTTP ${response.status}`);
8846
8846
  return envelope.value;
8847
8847
  }
8848
+ /**
8849
+ * Read the {ok,value,error} envelope, or say why there was not one.
8850
+ *
8851
+ * `response.json()` is not a way to report an error. A body that is empty —
8852
+ * which is what DSH's web server sends for a route handler that rejected, and
8853
+ * what a proxy sends for a blocked request — throws the browser's own
8854
+ * "Failed to execute 'json' on 'Response': Unexpected end of JSON input" (issue
8855
+ * #36), a sentence that names neither the status nor the request. Reading the
8856
+ * text first costs one string and turns every unreadable answer into a message
8857
+ * that says what the Host actually replied.
8858
+ *
8859
+ * The body is never echoed back: it can be an HTML page of arbitrary size, and
8860
+ * its content-type alone is what identifies the failure.
8861
+ */
8862
+ async function readEnvelope(response) {
8863
+ let text;
8864
+ try {
8865
+ text = await response.text();
8866
+ } catch (cause) {
8867
+ throw new Error(unreadable(response, cause));
8868
+ }
8869
+ if (text.trim() === "") throw new Error(unreadable(response));
8870
+ let parsed;
8871
+ try {
8872
+ parsed = JSON.parse(text);
8873
+ } catch {
8874
+ throw new Error(unreadable(response));
8875
+ }
8876
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(unreadable(response));
8877
+ const envelope = parsed;
8878
+ if (typeof envelope.error !== "string") delete envelope.error;
8879
+ return envelope;
8880
+ }
8881
+ /** The one sentence every unreadable answer turns into. */
8882
+ function unreadable(response, cause) {
8883
+ const kind = response.headers.get("content-type") ?? "no content-type";
8884
+ return `The Ollama settings API answered with ${response.status} and no JSON body (${kind})` + (cause instanceof Error ? `: ${cause.message}` : "") + ". The Host log holds the underlying error.";
8885
+ }
8848
8886
  //#endregion
8849
8887
  //#region src/client/ollama/locales.ts
8850
8888
  const NS_OLLAMA = "dsh-ollama";