dsh-llm-zcode
Verifieddsh-llm-zcode · v0.1.18 · MIT · Web UI
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.
Install
dsh plugin add dsh-llm-zcode Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
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),只做三件事:
- 用 DSH 的
authorization服务注册一个登录流程(浏览器里点一次授权), 把订阅凭据存进 DSH 自己的凭据记录; - 每次请求前解析一次凭据,直连
api.z.ai的 Anthropic 兼容端点; - 把 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 先过一遍Configschema 再交给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 |
真机还暴露了两个只有在真接口上才看得见的事实,都已在代码里处理:
- TLS 握手期的
ECONNRESET很常见(20 秒间隔下 8 次里仍有 3 次)。这是纯网络 抖动,所以有了"输出前失败就换连接重试"的机制(默认 4 次尝试,退避带抖动)。 - 限流会伪装成鉴权失败:突发请求时端点回
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 小字
三条规则,每条都对应一个具体的失败模式:
- 收起时只有一行:状态芯片(状态圆点 +
ZCode · 已登录 / 等待登录… / 尚未登录 / 登录失败 / 不可用)+ 主操作。两个控件,一行高。 - 长字符串一律进折叠区:账号名、"已在新窗口打开登录页"、以及所有次要动作
(
刷新/切换账号/重新登录)。折叠时它们不占布局;title(悬停提示)仍然带账号, 因为悬停不吃版面。自检the seat is one line until the disclosure is opened直接断言 "收起时正文里不出现账号、展开后出现"。 - 危险操作不当主按钮,但主按钮也不许点不动:主按钮永远是"把人带出坏状态"的那个
(
登录/重新登录),真在流程中(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。真正让人"要滚动"的是这个高度变化,
不是方向。三个修法:
position:absolute+ 芯片自己的锚盒:菜单挂在.zcodeLoginAnchor(position:relative包在芯片外)里,left:100%从芯片右侧长出来,width:max-content只占内容宽、max-width:min(200px,calc(100vw - 32px))兜住长账号。展开前后行高都是 24px 一行。- 删掉占位撑杆:
.zcodeLoginSpacer{flex:1 1 0}就是"主按钮被顶到最右边、状态芯片孤零零 在左边"的原因;现在两个控件挨着,主操作留在芯片旁边。 - 方向按实测选:这个槽位在输入卡片下面,中间只有
--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() 按顺序试,且去重:
- 消息端点的兄弟路径(
/api/anthropic/v1/messages→/api/anthropic/v1/models), 放在第一位是因为它就是发消息的那个面,存在这里的凭据必然被接受; /api/coding/paas/v4/models、/api/paas/v4/models;- 最后才是通用的
<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)。
改代码时的两个坑
- 不要在 Windows PowerShell 5.1 里用
Set-Content/Out-File改这些文件。 它按 ANSI 读、写 UTF-8 时会顺手改掉非 ASCII 字符:本项目已经因此丢过一次 模板字面量的收尾反引号(于是一个字符串吞掉了文件剩下的一半),以及三条注释里 的破折号。要用[System.IO.File]::ReadAllText/WriteAllText加显式UTF8Encoding($false)。 - 改完先跑
pnpm check(scripts/pack-check.mjs):它只报语法层诊断, 比tsc的全量报错更容易看出"从哪一行开始解析跑偏了"。 - 重试的判定函数不能有副作用。 这一版踩过一个很隐蔽的坑:进度标记做成了
() => boolean(顺手把标志置位并回答"之前是否已有输出"),于是任何判定都得调用 一个有副作用的函数;在一次调试里它被调用了两次(条件里一次、日志里一次), 第二次读到的是自己刚置位的值,结论就成了"已经有输出了"——重试因此静默失效, 而所有单测都是绿的。现在它是只读的hasOutput(),置位只发生在真正yield的地方。
发布前必须过的门
pnpm run build通过 —— 已通过。- 按逆向工程的事实把协议与登录链落实 —— 已落实(
protocol.ts/auth.ts里每条都标了出处;没有TODO(protocol)残留)。 - 在真机上端到端跑通一次:一轮对话 → 同会话续接 → 图片输入 → 工具调用 —— 已通过(见上表,用的是本机 ZCode 订阅凭据)。
- 登录按钮的真机往返:在 DSH 里对本路由发起一次「浏览器登录 ZCode」, 确认授权页打开、回调后凭据落进记录、下一次请求就用上了。这一条只有你能验。
- 确认
peerDependencies的范围与发布时的 DSH 运行时版本一致:安装前会强制校验, 不匹配时用户会看到incompatible-version,且需要逐版本风险豁免才能绕过。
验证续接
同一会话里先让模型记住一个暗号,再让它复述。复述不出来就说明续接判定过保守或
过激进——判定逻辑在 src/adapter.ts,宁可全量重发也不要截断上下文。