npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-llm-zcode

v0.1.18

Published

ZCode provider for the DeepSeek Harness: spend Start Plan / Coding Plan quota through a browser sign-in, with no API key. Real streaming, image input, tool calls.

Readme

dsh-llm-zcode

状态:源码与 npm 都是 [email protected], 并在装有 ZCode 的真机上跑通了真实请求 (浏览器登录 z.ai 成功、凭据落盘、随后请求不再 AUTH_REQUIRED,见文末「已验证到什么程度」)。 98 项自检 + 打包安装检查(含浏览器半边)+ 真机验证都通过;登录链的每一步(初始化 / 轮询 / 换业务令牌 / 开通 Key / 写凭据)都有单测覆盖,浏览器里点授权那一下无法由脚本代劳。

把 ZCode 的 Start Plan / Coding Plan 订阅额度接进 DeepSeek Harness: 浏览器登录一次,不需要 API Key,之后每次模型请求由插件解析出凭据、直连 z.ai 的 Anthropic 兼容端点,真流式、图片输入、工具调用、推理内容都完整支持。

它做什么、不做什么

做什么。 它是一个标准的 DSH 模型提供方(LlmAdapter),只做三件事:

  1. 用 DSH 的 authorization 服务注册一个登录流程(浏览器里点一次授权), 把订阅凭据存进 DSH 自己的凭据记录;
  2. 每次请求前解析一次凭据,直连 api.z.ai 的 Anthropic 兼容端点;
  3. 把 SSE 流翻译成 Harness 的 StreamChunk:文本、推理、工具调用、用量、结束原因。

不做什么。 不启动、也不依赖 ZCode 的命令行客户端;不解析它的 HTTP 流量; 不读它的登录缓存文件;不要环境变量里的 API Key。

早期版本读的是"把 CLI 当常驻 app-server 通道"的思路。经逆向工程确认, ZCode 对外没有可用的 app-server 线协议;订阅身份最终会落成一把普通的 HTTP API Key,所以那层进程通道只是白拿一个私有依赖,没有任何能力增益。 因此架构改成了直连——这也顺带让流式、图片、续接全部由本插件自己掌控。

为什么必须是新插件

@deepseek-ai/dsh-llm-pi-ai 已经带了 zai 与 zai-coding-cn 两条路由,而且 传输层正是我们要的那一套(同样的端点、同样的 zai 思考格式)。但它只能读 apiKeyEnv(一个环境变量名),pi-ai 自带的 OAuth 流程清单里 (anthropic / openaiCodex / githubCopilot / openrouter / kimiCoding / meta / xai / radius)没有 z.ai。也就是说 pi-ai 会说话,但不会登录。

本插件补的就是这段缺失的登录,顺带把凭据放进 DSH 的凭据记录而不是环境变量。

路由 id 刻意不叫 zai / zai-coding-cn:registerAdapter 对已被占用的 provider 会抛 DUPLICATE_ADAPTER,而且注册是全有全无的。

结构

src/protocol.ts  线上协议事实与模型目录:端点、模型表、思考档位、SSE 解析、JSON 取值工具
src/auth.ts      登录链:CLI 轮询登录 / 浏览器回调 → 业务 JWT → 开通 API Key → 凭据记录
src/browser.ts   自带登录入口:同一套登录链,改用默认浏览器打开授权页并轮询到完成
src/session.ts   一次登录的状态机:谁先开始都走这条链,凭据只在这一个地方落盘
src/tools.ts     zcode_login 工具:把上面那条链接到模型可调用的工具面上
src/routes.ts    页面通道:/zcode/login/{state,start,cancel},驱动的是同一批会话
src/discovery.ts 模型目录发现:问路由自己的端点要模型表,问不到就保留内置表
src/adapter.ts   ZcodeAdapter extends LlmAdapter:请求翻译、流翻译、图片、会话续接、
                 错误映射、重试(输出前失败才重试)
src/types.ts     Host 侧服务的窄接口(authorization、credentials、tools),避免依赖私有类型
src/index.ts     插件入口:注册适配器、可配置路由、登录流程、登录工具与页面通道,配置 schema
client.js        浏览器半边:输入框下方的单行状态座(芯片 + 菜单,手写,只依赖 react)
test/smoke.mjs   98 项自检,注入假 fetch / 假 Host,不联网(唯一下真实浏览器的
                 启动器测试可用 `DSH_SMOKE_NO_BROWSER=1` 跳过,避免日常跑测试弹标签页)
scripts/         真机与诊断脚本:live-check(真实端点)、live-pace(限流节奏)、
                 live-probe / live-repeat / live-image-trace(原始帧与错误链)、
                 inspect-zcode-install / inspect-zcode-credentials(只读、脱敏)、
                 pack-check(打包后按包名导入,并跑一遍浏览器半边)

安装

DSH Desktop: 插件页 →「添加插件」→ 安装源选官方源(registry.npmjs.org) → 填包名 dsh-llm-zcode → 安装。 CLI 版: dsh plugin add dsh-llm-zcode。(DSH Desktop 不带 dsh 命令, 所以别去找终端。)

本包声明了组合包,而且必须在 package.json 里显式声明:

"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }

安装器读的是这个字段,不是"有没有 cordis.patch.yml 这个文件"。只放文件、 不写字段,插件页会拒绝并提示 这个包没有声明组合包,无法作为插件安装: dsh-llm-zcode declares no dsh.bundle —— 0.1.0 就是这样装不上的,0.1.1 修好了 (pnpm test:pack 现在会断言这个字段存在、且它指向的文件真的在 tarball 里)。

组合包不声明 config——省略 providers 就是挂载两条内置路由 (zcode、zcode-cn)并带上完整模型目录,所以装上、登录一次就能在模型选择器里 看到它们。想改模型或端点,见 cordis.patch.example.yml(注意补丁是整段替换 config,不是合并)。

刚发布时可能查不到。 插件页默认安装源是 https://registry.npmmirror.com/, 镜像同步有延迟;刚 npm publish 完立刻装可能报 not-found,等同步或换成 https://registry.npmjs.org/ 即可。

装完不会自动更新。 插件页写明暂不支持自动更新:升级要先卸载再装新版。 所以版本号要一次给对——已发布的版本收不回来,只能发新版本。

使用

登录有两条路,用哪条取决于你的 DSH 有没有挂载 authorization 服务。

① DSH 里有「授权/登录」入口时(CLI 发行版): 在那里选 ZCode,流程会给你一个 zcode.z.ai 的授权链接;浏览器里确认后流程自己完成,不需要你复制任何 token 或密钥。

② 没有这个入口时(DSH Desktop 就是这样,宿主补丁把上游账号页禁掉了): 走下面两条, 它们驱动的是同一个登录会话,所以从按钮开始的登录,模型调用工具时看得到,反之也一样。

②a 输入框下方的「登录 ZCode」按钮(0.1.7 新增)。按钮自己不可能调用工具——浏览器只认 它自己构建期挂载的那些 Remote 命名空间,第三方插件拿不到——但它能 fetch 自己这一页的来源, 所以插件在同一台 webserver 上挂了 /zcode/login/{state,start,cancel},按钮读状态、开始、取消。 按钮上报的只有计划名、状态、Key ID、账号,密钥不出进程。

这一页是不是真的能访问到那条路由,不是推测:@linxin666/dsh-client-ui-skill-explorer (同一台机器上已装的第三方插件)就是这么做的——宿主侧 ctx.webServer.register 挂 /api/dsh-skill-explorer/*,浏览器侧同源 fetch,围栏是"回环地址 + 非跨站来源"。 本插件用的是同一套围栏和同一种通道,所以页面加载得出来,按钮就打得通。

浏览器半边的 id 必须等于包名。 宿主用 exports["./client"] 取到 bundle,按图里的行 id (= 包名 dsh-llm-zcode)去等 window.__ModuleLoader__.load({id}) 注册的那个 factory; id 写错(比如写成 @scope/... 或别的名字)会直接抛 bundle <url> loaded without registering "<id>" via __ModuleLoader__.load。整条链是 dsh.client: { platform: "web", immediately: true, inject: [dsh-client-ui-conversation] } → exports["./client"]: "./client.js" → bundle 里的 load({ id: "dsh-llm-zcode" })。

②b 对模型说「帮我登录 ZCode」,或者让它在报 AUTH_REQUIRED 后自己调用 zcode_login 工具。这个工具:

  • 用默认浏览器打开 zcode.z.ai 授权页,你在页面里点一次确认;
  • 在后台轮询到授权完成,接着换业务令牌、开通 API Key、写进凭据记录;
  • 不需要重启,下一轮请求立刻能用;
  • 结果里只报账号和执行结果(Key ID、组织/项目),密钥本身不回显;
  • 浏览器打不开(无桌面、远程会话)时,结果里会带上授权链接让你手动访问。

两条路走的是同一套登录链、写同一条凭据记录(llm-zcode/<provider>), 所以先用工具登录、之后从真正的授权界面再登录一次,两边不会各写一份。

登录成功后 zcode(国际版)与 zcode-cn(国内版)两条路由就可以用了。

凭据可以在 DSH 的凭据界面里看到状态(已登录 / 未登录 / 只读),也可以在那里删除。 删除后下一个请求会以 AUTH_REQUIRED 失败——这是预期行为,不是崩溃。

设计要点(每条都对应一个会静默出错的失败模式)

登录写进凭据记录,不写自己的文件。 key 是 llm-zcode/<provider>,走 credentials.modifyRecord(DSH 对同一 key 提供跨进程串行读-改-写), 所以两个 DSH 窗口不会互相覆盖同一条凭据。令牌不进环境变量、不打日志。

grant 记录而不是 api-key 记录。 拿到的凭据是"订阅身份开通出来的一把密钥", 不是一个环境变量的值;存成 kind: 'grant' 才不会被 DSH 当成环境变量暴露出去。

开通 API Key 时先找同名的复用。 登录流程会先列出现有的 zcode-api-key, 有就直接读它的 secret,不会每登一次就在账号里堆一把新密钥。

授权链接只认 z.ai 及其子域、且必须是 https。 登录链接来自服务端响应, 如果不校验就打开,一个被篡改的响应可以把用户引到钓鱼站点并骗走授权。 校验失败直接报错,不降级、不"先打开看看"。

自带登录入口(zcode_login),因为授权界面不一定存在。 路由注册、模型也在 模型选择器里,但整个部署没有 authorization 服务时,这些路由会永远以 AUTH_REQUIRED 失败且没有任何界面能发起登录——DSH Desktop 就是这样,它的宿主 补丁把上游账号页禁掉了。插件仍然会往 authorization 里注册流程(有界面时那条路 更好用),但登录不能只有那一条路:工具是任何部署都存在、且模型自己就能调用的入口。 轮询期间一次网络抖动不算登录失败(服务端流程还开着,继续等到超时);只有状态 真的不是 pending/ready 才结束。浏览器打不开也不吞掉登录:结果里带上链接。 凭据写不进去就不许报成功——报成功而记录没落盘,等于骗用户去踩下一次 AUTH_REQUIRED。

服务存在但"我 apply 的时候还没挂上"也是一种缺席。 这条是 0.1.8 修的,也是最贵的 一个坑:webServer 与 credentials 在这台宿主上都真实存在,但在本插件 apply() 执行的那一刻都还没挂载,于是 ctx.get() 返回 undefined,那个接缝就被判死一整 个进程。表现是两件看起来毫无关系的事:按钮发出请求收到 404(宿主 webserver 的兜底 把无人认领的路径答成了"这不是一条路由"),以及宿主日志里写着 llm-zcode: the `credentials` service is unavailable;every request will report AUTH_REQUIRED ——而那份 .credentials.yaml 就躺在磁盘上。修法是把两者写进模块级 inject,让 cordis 等它们就绪再把本插件带起来。这跟上面 authorization 的决定不矛盾: authorization 在这台宿主上根本不存在,等一个不存在的东西会把 fiber 永远挂住、模型 选择器直接空掉;等一个存在但稍后就绪的东西是免费的。

工具参数的根必须是 type: 'object'。 调用方把 parameters 当 JSON Schema 校验, 根上没有 type 会被整条拒绝,报 schema must be a JSON Schema of 'type: "object"', got 'type: null',工具连执行都进不 去。0.1.7 就是漏了这一个字段,zcode_login 每次调用都以 INVALID_REQUEST 失败。 自检里现在有一条专门断言这个根类型,而不再只断言"字段都在"。output.schema 则反过来 按宿主 JsonSchemaNode 的全可选形态声明:注册表从来没要求 additionalProperties, 插件不该替它收紧契约。

Windows 上不要用 cmd /c start 开授权页。 0.1.9 修的。这条 URL 长这样: /api/oauth/authorize?client_id=…&redirect_uri=…&state=…&response_type=code——中间三个 & 在 cmd.exe 里是命令分隔符,于是浏览器只收到 ?client_id=…,服务端如实回 {"detail":[{"type":"missing","loc":["query","response_type"]…},{"loc":["query","redirect_uri"]…}, {"loc":["query","state"]…}]}(422 Field required)。换成 rundll32 url.dll,FileProtocolHandler <url>(URL 单独一个参数,中间没有命令解释器)后四个 参数都到齐。自检里那条测试不检查"我调用了哪个命令",而是在本机起一个临时 HTTP 服务,断言 浏览器实际收到的查询串含全部四个参数——否则这类截断在单测里永远是绿的。

流没有终结帧 = 这一轮失败,不当成回复。 合成一个 stop 会把半截回复当成 模型说完了写进会话;合成 tool-calls 还会让 Harness 去执行断流前恰好到达的那 几个工具调用。两者都是静默污染,所以这里以 TRANSPORT 错误结束这一轮。

请求上限要留出思考预算。 reasoning_effort 会按模型自己的档位表映射成 thinking.budget_tokens,并保证预算严格小于 max_tokens;服务端不允许两者 相等,相等会被拒。模型没有档位表时就不发 thinking 字段。

续接靠已发送消息的摘要链。 判断"能否续接"用工具集摘要 + system + 已发消息 哈希链:工具集变化、system 变化、历史被编辑或压缩过、或记录长度超过现有历史, 都会重新全量发送——宁可多花一次,也不能让模型看到被截断的上下文。

图片按预算取字节。 图片字节经 attachments.readImageRequest 按请求预算 (尺寸 + 字节上限)取,而不是拿原图;超预算的会走 IMAGE_OFFLOAD_REQUIRED。

归属头。 适配器接口要求每个 provider 请求都带 Harness 的归属 User-Agent, 本插件的 HTTP 请求里确实带了(自检里有一条专门断言这个头存在)。

providers 不声明、或显式写空字典 = 挂载两条内置路由。 两种写法都会挂载 zcode 与 zcode-cn(这样装上就能用)。这不是设计偏好,是被上游定死的:Harness 在 调用 apply() 之前会拿本插件的 Config schema 归一化每一行的 config,而 schemastery 会把缺失的键实体化成 {},所以"没写 config"和"写了 providers: {}"到达插件时是同一个值,无法区分,也就不能赋予不同含义。0.1.1 与 0.1.2 把空字典当成"休眠",结果就是装好之后模型选择器空着、日志里一个字都没有。

要"什么都不挂"请禁用整行(- id: llm-zcode 加 disabled: true),不要用空字典。

按钮的状态要能自愈,不能靠"只在 pending 时轮询"。 0.1.11 修的。只在一段时间里 轮询的链条,错过一步就永远错下去:一次请求挂住、人在授权标签页时被浏览器节流的定时器、 或者页面刷新丢掉一次回复,都会让按钮一直停在"正在登录…",而主机那边其实早就写好了凭据 (真机上就撞到了这一条:/zcode/login/state 已经是 ready,按钮还在转)。现在每次读取 都有 4 秒自己的预算、超过就作废重来,定时器链永不终止,切回标签页或窗口获得焦点时立刻 再读一次——主机上"登录完成"是终态,多读一次的代价只是一次本机请求,而上面每一种失败都 因此变成自愈的。

已验证到什么程度

本包能编译、能加载、能跑;下面这些是在本机实测过的,不是推断:

  • pnpm run typecheck → 退出码 0,零错误。
  • pnpm run build → 退出码 0,产出 lib/{index,adapter,auth,browser,session,tools,routes,discovery,protocol,types}.js 与 lib/types/*.d.ts。
  • pnpm test → 98 项全过,跑的是编译产物 lib/*.js 对着注入的假 fetch 与 假 Host,覆盖:SSE 逐字节切分重组、bare data 帧、缺尾空行、请求形状(Bearer、 归属头、system、工具 schema、图片块)、分块顺序(block-start → deltas → block-end → usage → finish)、思考预算的三条约束(clamp、下限、放不下就不发)、 档位选择器只在该模型声明支持时出现、工具调用参数的 JSON 增量、max_tokens 与 tool_use 的结束原因、没终结帧时以 TRANSPORT 结尾而不是 stop、401 → AUTH 且提示重新登录、429 → RATE_LIMIT、凭据缺失 → AUTH_REQUIRED、 登录链接的域校验、开通密钥时复用同名密钥、注册的流程按 route 各占一个 key 并 提交记录、把 config 先过一遍 Config schema 再交给 apply()(这正是 Harness 的做法,也是 0.1.3 修的那个坑的回归测试)、自定义 route 的完整链路,以及重试的 四条规则(连接断在输出之前 → 换一条连接重试;断在输出之后 → 不重试,保留半截 回复;用尽次数 → 报 TRANSPORT;限流型 401 → 重试,真错的 Key → 不重试)。
  • 登录工具(0.1.6 新增)单独四条:工具随路由一起注册;没有 tools 服务也不致命 (路由照常挂载);工具跑完整条链并把适配器读得到的记录写进凭据;浏览器打不开时 结果里仍带授权链接;凭据写不进去时不许报成功。
  • 登录会话(0.1.7)单独八条:谁先开始都只有一条链(第二次调用加入已开的流程,不新开 一个授权页);取消只结束等待、绝不算成失败;流程自身的过期时间是唯一的硬停点; 已完成的登录可以被后来者读到,不必重登。
  • 页面通道(0.1.7)单独十条:一条 prefix 路由占住整个 /zcode/login 子树(不落进 SPA 兜底、不吞掉页面);state 只读、不启动登录;start 在授权链接出现时就回答(202), 不等人在浏览器里点完;没有页面标识头的状态变更请求一律 403;未知计划报名字并列出可用 的;任何一条响应里都不出现密钥或 apiKey 字段(有一条专门断言这件事)。
  • 账号切换(0.1.13)单独两条,锁的是"按钮点了没反应"那个坑:一次已完成的登录也留着 authorizeUrl,所以复用判断必须看 pending 而不是"有 URL"——路由那条断言第二次 POST /start({fresh:true})真的又跑了一次流程并给出新 URL;工具那条断言 zcode_login({ fresh: true }) 在已有凭据时确实重新登录,返回的是新账号的 keyId 而不是文件里那一个。
  • 重启后恢复(0.1.18)单独五条,锁的是"重启后显示尚未登录"那个坑:进程内的状态会随重启 消失,凭据不会(适配器每次请求都重新读它),所以会话现在会认领文件里已有的凭据—— restore() 认领后报 ready 并带上凭据里的机构/项目名;老记录没存名字时退回用 id 拼; 空库、以及读不出来的库都仍然算"没登录"(读失败不是登录过的证据);进程里已经在跑的 流程不会被认领覆盖;工具层则断言 zcode_login 认领凭据而不开浏览器、不跑流程 (browserOpened: false,不再像以前那样默认 true)。
  • 浏览器半边(0.1.7):pack-check 会把 client.js 放进一个只有假 window.__ModuleLoader__ 和假 react 的进程里跑一遍,断言它注册了唯一一个模块、apply() 后精确落在 conversation.composer.dock#zcode-login 上,并且除 react 之外不 require 任何东西 (浏览器模块表里没有的东西,require 了就是页面白屏)。
  • 浏览器半边的轮询(0.1.11)单独一条,跑的是真实的 client.js(假 react、假 window/document、假 fetch,手工驱动 effect):一个永远不回答的请求会被下一次 读取取代,切回该标签页时立刻重读并落到真实状态,404 则只报一次、不再空转。
  • dock 半边的外观(0.1.15 起,0.1.16 跟进下拉,0.1.17 改成向右浮层)单独五条,跑的还是真实的 client.js:收起时只渲染一行(状态芯片 + 主操作两个控件,且正文里不出现账号)、点芯片 弹出的菜单里才有账号与 重新登录/切换账号/刷新、已登录时菜单里是 重新登录 而绝不是 取消、pending 时主按钮变成 取消登录且仍可点(原来的 disabled: pending 是个死锁)、 以及没有页面接口的构建(HTTP 404)把芯片写成"不可用"、不声明菜单、原因直接写在行上。 0.1.16 另加两条锁菜单的关闭行为:点菜单外要关且连监听器一起摘掉、点芯片本身要不关、 Escape 要关(0.1.17 把这套逻辑完整保留,只是菜单改成了浮层);以及一条点菜单项要真的发请求并把菜单收起来。0.1.17 再加一条锁 CSS 契约 (见上节)。为了让这些能读,测试里的假 react 维护 hook 槽位、按槽位比较 effect 依赖并支持 再渲染(harness.render() / commit()),另外加了 treeText / treeChildren / byClass 几个把元素树读成文字、子节点和按类名查找的小工具。
  • 页面真的吃到了这一版(0.1.15 起用 ../probe-live-bundle.mjs 验过):从 harness.log 最后一条 dsh web: 行取 token,读回 DSH 外壳,里面写着 "url":"plugins/??dsh-llm-zcode/client.js&rev=0a415c2c4ce4";再按这个 combo 地址 取回 client.js(HTTP 200,21500 字节),里面 zcodeLoginStatus / zcodeLoginDot / zcodeLoginAnchor / zcodeLoginMenu / zcodeLoginMenuItem / data-dsh-zcode-menu-trigger / 切换账号 / 取消登录 / 重新登录 全在, zcodeLoginRow / zcodeLoginMessage / zcodeLoginIcon / zcodeLoginMore / zcodeLoginSpacer / 登录 ZCode' 全没了,与 live 目录里的那份只差 结尾的 ;\n//# sourceMappingURL=…(服务端拼模块时加的)。两个容易踩的点: combo 地址的 rev 必须从外壳里读、不能自己猜(猜就是 404); 而且 Node 的 fetch 读不了这个外壳——webserver 用 303 把 URL token 换成一个 dsh-auth-<随机串>=v1.… 的 cookie,只有会自动跟随并保存 cookie 的客户端才拿得到 (PowerShell 的 -WebSession 可以),拿到 cookie 之后再取 combo 地址就通了。
  • npm pack 之后再装一遍(pnpm test:pack,会自己打包、解包、按包名导入): 也就是 DSH loader 的解析路径——确认入口解析到 lib/index.js、name/inject 正确、两条 route 与目录项都注册、登录流程各占一个 key、10 个模型与思考档位都在。 这一步能抓到「源码能跑但发包漏文件 / exports 写错」这类只在用户那边才暴露的 问题。

真机实测(这台机器上有 ZCode 订阅)

scripts/live-check.mjs 用本机 ZCode 客户端自己存在 ~/.zcode/v2/credentials.json 里的订阅凭据(AES-256-GCM,密钥由机器信息派生)直连真实端点,逐项验证:

| 验证项 | 结果 | | --- | --- | | 文本轮(glm-5.3 / glm-5.3-flash) | 通过:真流式、block-start → deltas → block-end → usage → finish、用量两侧正确合并 | | 扩展思考 | 通过:thinking 块作为独立 reasoning 块输出(例:9.11 和 9.9 哪个大 → 推理里出现 9.11 = 9.110 后得 9.9 大) | | 工具调用 | 通过:get_weather 被调用,arguments 是原始 JSON 增量且可解析,结束原因 tool-calls | | 图片输入 | 通过:1×1 PNG 走 attachments 缝(readImageRequest + base64)→ 模型答 "暗红色" | | 多轮续接 | 通过:第 1 轮记暗号、第 2 轮复述出来(菠萝) | | max_tokens 截断 | 结束原因 max-tokens,不是伪造的 stop |

真机还暴露了两个只有在真接口上才看得见的事实,都已在代码里处理:

  1. TLS 握手期的 ECONNRESET 很常见(20 秒间隔下 8 次里仍有 3 次)。这是纯网络 抖动,所以有了"输出前失败就换连接重试"的机制(默认 4 次尝试,退避带抖动)。
  2. 限流会伪装成鉴权失败:突发请求时端点回 401 token expired or incorrect, 20 秒间隔下 8 次 0 次被拒,而存储的 Key 指纹全程不变——它对账号限流,却报 token expired。对照实验:真正错误的 Key 回的是 401 {"error":{"message":"Authentication Failed","type":"1000"}}。两者可区分, 所以前者重试(5s/10s/15s 退避),后者立刻报错,不会把限流误导成"请重新登录"。

浏览器登录那一步的完整往返:已验过(0.1.14)。 真人在这台机器上点了一次 「切换账号登录」→ 私密英文窗口 → Continue with Google,登录链全程跑通:

| 环节 | 真机结果 | | --- | --- | | 私密窗口打开授权页 | 成功;英文页出现 Continue with Google(中文页没有这个按钮,见下) | | 轮询到授权完成 | state: ready、pending: false | | 凭据落盘 | llm-zcode/zcode 的 keyId 从 2dc79bb9… 变成 df2549514d7f4516a6fc85546c9af98b,机构从 默认机构(886304) 变成 默认机构(038689)——确实换成了另一个账号,不是复用旧会话 | | 新账号的真实推理请求 | POST https://api.z.ai/api/anthropic/v1/messages(glm-4.6,stream: true)→ HTTP 200,真流式:message_start → ping → content_block_start(thinking)→ thinking_delta 增量 → …,request-id: b748e677-…,流以 message_stop 正常结束 | | 旧账号(手机号)的对照 | 同一个端点、同一段代码回 429 code 1113(见下一节)——所以差别确实在账号,不在插件 |

仍没验到的:DSH 界面里那一轮真实对话(GUI 里选 ZCode 模型发一条消息)—— 端点这一层已经用上面那条真实请求证明可用,但"GUI 里点一次"只有你能验。

下方那个登录小模块(0.1.17:一行状态座 + 向右浮层菜单):线上确实是这一版,观感等你看。 用会话 cookie 取回页面真正加载的 client.js(combo 地址的 rev 从外壳里读)比对过, 内容就是 live 目录里那一份,这一版的类名(zcodeLoginStatus / zcodeLoginDot / zcodeLoginAnchor / zcodeLoginMenu / zcodeLoginMenuItem / data-dsh-zcode-menu-trigger) 都在、旧类名(zcodeLoginRow / zcodeLoginMessage / zcodeLoginIcon / zcodeLoginMore)都不在; 取消登录 可点、刷新 不禁用、菜单向右弹这几件事由上面那五条自检锁住(跑的是真实 client.js)。剩下只有"在真机里看起来顺不顺眼、位置对不对"这件事需要人眼。

账号不对时:切到另一个 z.ai 账号(0.1.12,0.1.13 修按钮)

真机上量到一个和插件无关、但看起来像插件坏了的失败:凭据有效(/api/anthropic/v1/models 能列出目录、登录界面全绿),但每一次推理请求都被拒:

HTTP 429 {"type":"error","error":{"type":"rate_limit_error","code":"1113",
  "message":"[1113][Insufficient balance or no resource package. Please recharge.]"}}

code 1113 是账单拒绝,不是鉴权拒绝——/api/anthropic/v1/messages 正是 coding plan 的端点(protocol.ts 里就是这么定的),所以不是端点选错。它通常意味着当前登录的账号 没有资源包:一个人有两个 z.ai 账号、coding plan 只在其中一个上,是很常见的情况。

问题在于默认那条登录路挡不住这个错误:如果浏览器里已经登录了 z.ai,重新登录会直接 对那个会话授权,于是又给同一个账号发一把新 Key,人根本没有机会换账号。所以 0.1.12 加了 第二条路:

  • 输入框下方的 「切换账号登录」 按钮(工具里是 zcode_login 的 fresh: true),
  • 它用 Edge 的 --inprivate / Chrome 的 --incognito 打开授权页——私密窗口不共享 已有 cookie,所以登录页会真的让我选账号,可以用 Google 登那个持 plan 的账号;
  • 找不到 Chromium 浏览器时不假装成功:退回默认浏览器,并在提示里说明"没自动打开, 请用链接手动打开"。

按钮和工具走的是同一条链、同一条凭据记录,所以用哪条都行。私密窗口的启动命令是纯函数 freshBrowserHandOff(url) 算出来的,自检直接断言它(Edge 优先、--inprivate、URL 作为 独立参数),不会在跑测试的机器上真的弹窗。URL 也绝不经过 cmd.exe:& 是 cmd 的 分隔符,而这个授权 URL 里有三个 &(默认浏览器那条路的 422 就是这么来的)。

注意:如果切到有 plan 的账号后仍然回 1113,那说明这个 coding plan 在 api.z.ai 的推理面上确实没有额度,需要去 z.ai 充值或确认 plan 覆盖的端点—— 插件这边已经把它能做的(认证 + 端点 + 账号选择)都做到位了。

实测结果(0.1.14):换到 Google 账号后就通了。 旧账号(手机号)回 429 code 1113,换账号后同一条链回 HTTP 200 + 真流式。所以 1113 那次确实是 "登错账号",不是端点或 plan 覆盖问题。

0.1.13:这个按钮一开始点不动

0.1.12 发出去后,真机上点「切换账号登录」没有任何反应。原因是那个复用判断:

// 修之前
const before = session.snapshot()
if (typeof before.authorizeUrl === 'string' && before.authorizeUrl !== '') return before

一次已经完成的登录也留着 authorizeUrl(成功快照里就带着它),所以"有 URL"被当成了 "有正在跑的流程":按钮的 POST 一进来就被这个早期返回吃掉,session.open({ fresh: true }) 根本没被调用。界面于是什么都不会变——没有 pending、不会弹窗,正是"点了没反应"。

修法是只认真的在跑的流程(pending),而不是"有 URL":

// 修之后
if (before.pending && typeof before.authorizeUrl === 'string' && before.authorizeUrl !== '') return before

同一条链上还有第二个同样的坑:工具的 ensure() 见到"已有结果"就直接返回,所以 zcode_login({ fresh: true }) 也会拿到旧凭据、同样换不了账号。现在 fresh: true 是唯一 会主动重开一次登录的条件(正在跑的时候仍然加入,不会弹出第二个登录页)。两个坑各有 一条自检锁住:the account switch still starts a sign-in after a finished one(路由)和 the tool signs in again when it is asked to switch accounts(工具),都断言"流程真的跑了 第二次"。教训:authorizeUrl 是历史字段,表示"这次登录用过的授权地址",不是状态 字段;判断流程是否在跑只能看 pending/isPending()。

0.1.14:登录页给不给 Google,取决于浏览器语言

按钮修好之后,私密窗口确实弹出来了,但页面是**「手机号登录」——只有手机号 + 短信验证码, 没有任何 Google / 邮箱入口(用户原话:依旧没有看到google oauth或者邮箱的选项)。 而持 coding plan 的账号是用 Google 登的**,手机号那个账号没有 plan。所以问题变成了: 这一页到底有没有 Google?

量下来的结论是有,但要看语言。同一个 https://chat.z.ai/auth、同一个 /api/config(allowed_login_methods 三种语言下都是 ["phone","email","oauth_consumer"], oauth.providers.google 也一直在),无头 Edge 渲染出的是两个完全不同的登录页:

| 启动语言 | 页面 | | --- | --- | | --lang=zh-CN | 欢迎来到 Z.ai:邮箱 + 密码 + 滑块验证码 + 「快捷登录:」(没有 Google 按钮) | | --lang=en-US | Welcome to Z.ai:Continue with Google / Continue with Email / Continue with Github,三个都是真的 <button> |

英文页里的 Google 按钮带完整四色 SVG,是页面里唯一通往 Google 的入口。中文页不是 "少了一个按钮",而是另一套登录 UI。所以 0.1.14 让私密窗口固定用英文渲染:

const FRESH_WINDOW_LANGUAGE_FLAGS = ['--lang=en-US', '--accept-lang=en-US,en;q=0.9']
// msedge --inprivate --lang=en-US --accept-lang=en-US,en;q=0.9 <authorize-url>

--lang 和 --accept-lang 是两个不同的开关(前者管浏览器 JS 的 locale,后者管请求头), 实测两个都要给。这不是"偏好设置",而是让 Google 按钮存在的那个参数。自检 the account switch launches a private window, with the URL as one argument 现在按整条 命令断言,并单独断言 --lang=en-US 在里面。

重启之后为什么还认得这个账号(0.1.18 修)

真机实测发现的坑:重启 DSH 之后,右下角的状态座显示「尚未登录」,但同一个账号的模型请求 照常能用。原因是状态和凭据活在两个地方:

  • 会话状态(pending / ready / error)是进程内的字段,重启就没了;
  • 凭据(llm-zcode/zcode 的 grant)写在磁盘上,重启还在,而且适配器每次请求都重新读 (adapter.ts 里那句 readStoredApiKey 故意不缓存,这样另一个窗口重新登录能立刻生效)。

于是重启后的 snapshot() 只会走 if (this.controller === undefined) return { state: 'idle' },从不看凭据库——报的和做的互相 矛盾。这不只是标签错:看到"尚未登录"的人会再登一次,而再登一次会给同一个账号再开一把 Key。

修法不是让 snapshot() 去读磁盘(它是同步的,必须保持同步),而是加一个 restore():进程里 没有结果、也没有在跑的流程时,认领文件里的凭据,合成一个 restored 状态(state: 'ready', 账号名取自凭据里的 organizationName/projectName;老记录没存名字就退回用 id)。三处调用它: 页面通道的 GET /state(状态座轮询的就是它)、工具的 ensure()(所以重启后调工具不会再开 浏览器),以及并发的第一次读取共享同一次读(restoring)。两条安全边界:读失败/库为空一律算 "没登录"(读不出来不是登录过的证据),已经在跑的流程不会被认领覆盖(认领只在 settled 和 controller 都为空时发生,且 await 之后会再检查一次)。显式的 fresh(切换账号)跳过认领—— 文件里那把 Key 正是它要换掉的东西。

为了让认领行能显示人名而不是 org-0EFA…,凭据 payload 现在也存 organizationName / projectName(ZcodeGrantPayload 新增两个可选字段), 由 describeStoredGrant() 拼成 默认机构 / 默认项目。

这块 UI 为什么是一行,而不是一排按钮(0.1.15 重做,0.1.16 改下拉菜单,0.1.17 改浮层方向)

最初它长这样:三个并排按钮(重新登录 ZCode / 切换账号登录 / 刷新),下面再压一整行 13px 的说明文字(已登录 ZCode Start Plan,账号 默认机构(038689) / 默认项目)。放在输入框 底部,它比旁边的小组件重得多——那不是"信息量大",而是把一个面板塞进了一个本该只占一行 的环境位:conversation.composer.dock 的说明就是 "Ambient entries below the composer card",同槽位的其它条目(stats / cost-meter)都是单行小字。

现在的形状(0.1.16 起:次要动作进下拉,点芯片本身打开;0.1.17 改成向右浮层):

● ZCode · 已登录  [重新登录]      ← 收起:芯片和主操作挨着,两个控件一行
● ZCode · 已登录  [重新登录]  ┌────────────┐
                              │ 重新登录    │  ← 点芯片展开:菜单从芯片右侧长出来
                              │ 切换账号    │
                              │ 刷新        │
                              └────────────┘
                              默认机构(038689) / 默认项目   ← 账号随菜单走,仍是 12px 小字

三条规则,每条都对应一个具体的失败模式:

  1. 收起时只有一行:状态芯片(状态圆点 + ZCode · 已登录 / 等待登录… / 尚未登录 / 登录失败 / 不可用)+ 主操作。两个控件,一行高。
  2. 长字符串一律进折叠区:账号名、"已在新窗口打开登录页"、以及所有次要动作 (刷新 / 切换账号 / 重新登录)。折叠时它们不占布局;title(悬停提示)仍然带账号, 因为悬停不吃版面。自检 the seat is one line until the disclosure is opened 直接断言 "收起时正文里不出现账号、展开后出现"。
  3. 危险操作不当主按钮,但主按钮也不许点不动:主按钮永远是"把人带出坏状态"的那个 (登录 / 重新登录),真在流程中(pending)才是 取消登录——而它不能被 disabled:唯一需要取消的时刻就是它出现的时刻。同理 刷新 永远可点(这个状态本来就是 1.5s 轮询出来的,"现在再看一眼"正是人在等登录时想做的事)。切换账号 只在 !pending 时出现:一边等登录一边再开一个私密窗口只会把状态搅乱。

0.1.16:为什么把三个动作收进菜单,以及菜单该怎么关

一排按钮挤在输入框下方,视觉重量和"这只是一个登录开关"不匹配,所以 刷新 / 重新登录 / 切换账号 全部收进点芯片弹出的菜单里。菜单替换主按钮而不是叠在它下面——展开时仍然只有 "一行 + 菜单块",不会出现两排控件。

下拉菜单的失败模式是"关不掉",所以关它的三条路都有自检锁住(the menu closes on an outside click and on Escape):

  • 点菜单外的任意位置 → 关,并且监听器一起摘掉(自检断言监听器数量回到 0);
  • 按 Escape → 关;
  • 点芯片本身 → 不关。

最后一条不是小事:打开菜单的那一次点击本身也会冒泡到 document,如果芯片不算"自己人", 菜单会在同一帧里开了又关——点了等于没点。判断"这一下是不是点在芯片上"靠的是芯片上的 data-dsh-zcode-menu-trigger 标记(用 target.matches(...) 查),不是 ref:target 是 DOM 节点,而 ref 指向的是被 React 挂载的元素。0.1.16 之前的 ⋯ 按钮和这套菜单的逻辑一样, 但那次用的是 ref——在自检里没法模拟 React 提交 ref 的时机,于是组件改成用标记,测试也就不必 去模拟 DOM 包含关系了。

字号从 13px 收到 12px,和槽位里其它条目同档;控件统一 24px 高(芯片 min-height:24px, 主操作与菜单行同为 24px,也正好是 WCAG 2.2 SC 2.5.8 的点击目标下限),圆角一律 --dsw-radius-sm,间距仍走 4px 栅格(芯片内 padding:0 8px、行间距 gap:8px、菜单 padding:4px / 行间距 gap:2px)。颜色只用别名 token,--dsw-alias-state-* 只画那 6px 的 圆点——ready 绿、pending 黄、失败红、未登录中性:状态色当 12px 文字用是不合格的 (实测绿色在白底约 2.3:1),当圆点用才对。焦点环用宿主自己的那对 --dsw-focus-ring-width / --dsw-focus-ring-color。

配图 ../seat-preview.png 来自 ../preview-zcode-seat.html。这份预览不是凭印象画的: 它的 STYLES 数组与 client.js 里的逐条相同(../scratch/probe-preview-css.mjs 按空白 归一化后逐条比对,0.1.17 是 21 条规则全等),每个状态块的类名与按钮文案也照着组件的渲染分支 写。所以"一行高、账号在折叠区、菜单从右侧长出来"这些版式结论可以直接看它;--dsw-* 的取值 现在也换成了从宿主安装包里读出来的真实暗色值,只有未在预览里出现的主题才需要到真机上看。

0.1.17:菜单别铺满一行、别把主按钮顶到最右边、别往上弹

四个设计角色(用户体验 / UI / 视觉验收 / UX 架构)各自独立看了这台机器上的实测,结论一致, 报的是同一个 bug:菜单根本不是浮层。原来的 .zcodeLoginMenu 是 flex-basis:100% 的普通 flex 子项,所以它只能作为"第二行"出现——一行 760px 宽,每行再用 width:100% 铺开,四个字的 标签拿到 700px 的点击区,展开还把行高从 32px 顶到 159px。真正让人"要滚动"的是这个高度变化, 不是方向。三个修法:

  1. position:absolute + 芯片自己的锚盒:菜单挂在 .zcodeLoginAnchor(position:relative 包在芯片外)里,left:100% 从芯片右侧长出来,width:max-content 只占内容宽、 max-width:min(200px,calc(100vw - 32px)) 兜住长账号。展开前后行高都是 24px 一行。
  2. 删掉占位撑杆:.zcodeLoginSpacer{flex:1 1 0} 就是"主按钮被顶到最右边、状态芯片孤零零 在左边"的原因;现在两个控件挨着,主操作留在芯片旁边。
  3. 方向按实测选:这个槽位在输入卡片下面,中间只有 --dsh-composer-stack-gap(6px)。 往上弹实测压住输入框 134px(几乎整张卡),往右弹只压到 27px(卡片下边框),右边还有 600px 以上的空位——所以 0.1.17 改成向右弹,这一条是拿着数字跟用户确认过才改的。

另外三处顺手修掉的真问题:取消登录 原来 disabled: pending(唯一能用的时刻点不动)、 刷新 也是 pending 时禁用(与它自己的注释矛盾)、芯片在没有菜单时仍声明 aria-haspopup="menu"(声称一个永远打不开的菜单)。现在没有菜单的芯片既不声明菜单、点了也 是"再看一眼状态",失败原因则直接写在座位那一行,不再藏在点击后面。

自检 the menu is an overlay, opens right, and tints nothing but the dot 把这版的三条 CSS 事实钉住(绝对定位 / left:100% / 无 flex-basis / 状态色只落在圆点上),因为这类回归在 组件树里看不出来——重新写回一个 flex-basis:100% 会全绿通过,然后在真机上又变回一整行。

模型目录是怎么找的(0.1.12 修)

api.z.ai 没有 <base>/models 这个目录接口(实测 404)。自动拉取模型列表因此是静默 失效的——它一直在问一个不存在的地址。现在 discoveryUrls() 按顺序试,且去重:

  1. 消息端点的兄弟路径(/api/anthropic/v1/messages → /api/anthropic/v1/models), 放在第一位是因为它就是发消息的那个面,存在这里的凭据必然被接受;
  2. /api/coding/paas/v4/models、/api/paas/v4/models;
  3. 最后才是通用的 <base>/models 与 <base>/v1/models。

实测这个账号能一次拿到 11 个模型(glm-4.5 / glm-4.5-air / glm-4.6 / glm-4.7 / glm-5 / glm-5-turbo / glm-5.1 / glm-5.2 / glm-5.3 / glm-5.3-flash / glm-5.3-flashx)。

改代码时的两个坑

  1. 不要在 Windows PowerShell 5.1 里用 Set-Content/Out-File 改这些文件。 它按 ANSI 读、写 UTF-8 时会顺手改掉非 ASCII 字符:本项目已经因此丢过一次 模板字面量的收尾反引号(于是一个字符串吞掉了文件剩下的一半),以及三条注释里 的破折号。要用 [System.IO.File]::ReadAllText/WriteAllText 加显式 UTF8Encoding($false)。
  2. 改完先跑 pnpm check(scripts/pack-check.mjs):它只报语法层诊断, 比 tsc 的全量报错更容易看出"从哪一行开始解析跑偏了"。
  3. 重试的判定函数不能有副作用。 这一版踩过一个很隐蔽的坑:进度标记做成了 () => boolean(顺手把标志置位并回答"之前是否已有输出"),于是任何判定都得调用 一个有副作用的函数;在一次调试里它被调用了两次(条件里一次、日志里一次), 第二次读到的是自己刚置位的值,结论就成了"已经有输出了"——重试因此静默失效, 而所有单测都是绿的。现在它是只读的 hasOutput(),置位只发生在真正 yield 的地方。

发布前必须过的门

  1. ~~pnpm run build 通过~~ —— 已通过。
  2. ~~按逆向工程的事实把协议与登录链落实~~ —— 已落实(protocol.ts / auth.ts 里每条都标了出处;没有 TODO(protocol) 残留)。
  3. ~~在真机上端到端跑通一次:一轮对话 → 同会话续接 → 图片输入 → 工具调用~~ —— 已通过(见上表,用的是本机 ZCode 订阅凭据)。
  4. 登录按钮的真机往返:在 DSH 里对本路由发起一次「浏览器登录 ZCode」, 确认授权页打开、回调后凭据落进记录、下一次请求就用上了。这一条只有你能验。
  5. 确认 peerDependencies 的范围与发布时的 DSH 运行时版本一致:安装前会强制校验, 不匹配时用户会看到 incompatible-version,且需要逐版本风险豁免才能绕过。

验证续接

同一会话里先让模型记住一个暗号,再让它复述。复述不出来就说明续接判定过保守或 过激进——判定逻辑在 src/adapter.ts,宁可全量重发也不要截断上下文。