# deepseek-harness-codearts **Repository Path**: iJetLi/deepseek-harness-codearts ## Basic Information - **Project Name**: deepseek-harness-codearts - **Description**: deepseek-harness的插件,支持codearts登录和模型调用 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 32 - **Forks**: 12 - **Created**: 2026-08-14 - **Last Updated**: 2026-09-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # dsh-codearts-auth deepseek-harness 插件:执行 CodeArts(华为云)登录流程,默认走新式 IAM OAuth (portal `/authorize` 授权 → 本地 `/oauth/callback` 回调 → STS token 端点换取含 `refresh_token` 的凭据),到期前静默续期,无需再次打开浏览器;旧 ticket 流程保留 为显式回退(`flow: 'ticket'`)。插件还注册一个 `codearts` LLM provider 路由,使该 凭证可直接用于 CodeArts 后端模型调用。 此外插件内置另外四个 provider 路由: - **buddy(腾讯 CodeBuddy)** — 见 [buddy provider](#buddy-provider); 另支持「一键领取积分」(每日签到)。 - **workbuddy(腾讯 WorkBuddy 国际版)** — 见 [WorkBuddy provider](#workbuddy-provider)。 - **lobsterai(有道 LobsterAI / 龙虾)** — 见 [LobsterAI provider](#lobsterai-provider); 另支持「一键领取积分」(每日签到)。 - **qoder(阿里系 Qoder)** — 见 [Qoder provider](#qoder-provider); **支持积分余额与每日领取**(每日 100 Credits,10:00(UTC+8)刷新); 走**加密推理端点**,模型池与客户端一致(含 Qwen3.8 系列)。 `codearts` 面板同样支持**积分账户检测、积分余额与「一键领取积分」** (华为云「每日签到得积分」活动,走 `SDK-HMAC-SHA256` 签名)—— 见 [CodeArts 积分](#codearts-积分华为云每日签到得积分)。 五个 provider 的 Jet Hub 面板都提供「**显示列表**」按钮,可逐个开关模型以控制其 是否出现在对话框的模型选择里(黑名单制,默认全部显示)—— 见 [模型列表开关](#模型列表开关黑名单)。 ## 安装 该包尚未发布到 npm registry。提供两种安装方式:**git 仓库安装**(推荐,自动拉取 并构建)和**源码目录安装**(本地开发联调)。 ### 方式一:从 git 仓库安装(推荐) 先在 profile 的 `pnpm-workspace.yaml` 中放行该包的 build 脚本 (路径形如 `~/.dsh/profiles//pnpm-workspace.yaml`): ```yaml allowBuilds: dsh-codearts-auth@git+https://gitee.com/iJetLi/deepseek-harness-codearts.git: true ``` 再用 `dsh plugin add` 从 gitee 拉取并安装: ```sh dsh plugin --profile add "https://gitee.com/iJetLi/deepseek-harness-codearts.git" ``` `add` 以 `git+https` 方式安装,pnpm 会运行 `prepare` 脚本自动构建 `lib/`,无需 手动 `pnpm build`。每次升级时重新 `add` 即可拉取最新版本并重建。 ### 方式二:从源码目录安装(本地开发) 先在本仓库中构建 `lib/`,再用 `dsh plugin install` 将本地检出安装为 pnpm `link:` 依赖(指向本目录): ```sh pnpm build:all dsh plugin --profile install ``` > `dsh plugin install` 以 `link:` 方式安装,pnpm 不会为 `link:` 依赖运行 > `prepare` 脚本,因此必须先手动执行 `pnpm build:all` 生成 `lib/`,否则 dsh 启动时 > 报 `ERR_MODULE_NOT_FOUND: ... dsh-codearts-auth/lib/index.js`。 > 注意必须用 `build:all` 而非 `build`:后者只编译宿主侧,不产出 > `lib/client/jet-hub.js`。 每次修改 `src/` 或 `plugin-src/` 后都需要重新执行 `pnpm build:all`——dsh 启动时 不会自动重建。 ### 通用说明 该包声明了 `dsh.bundle` 补丁(`cordis.patch.yml`),因此 profile 的 layer 栈会 自动拾取 `codearts-auth` 行。插件注入由 dsh base 提供的 `credentials`、 `commands` 和 `llm` 服务。 ## 用法 **登录入口:Jet Hub 设置页的 CodeArts 面板**(与其余五个 provider 一致)。 ⚠️ **不注册任何斜杠命令**。早期的 `/codearts-login`、`/codearts-status`、 `/codearts-refresh` 三个命令已移除 —— 登录、状态查看与续期统一在 Jet Hub 完成。 编程式调用仍可用:`ctx.codeartsAuth.login()` / `startLogin()` / `refreshAccountCredential(refName)` / `refreshAll(pool)`。 ## LLM provider 插件在 `ctx.llm` 上注册了一个 `codearts` provider 路由(OpenAI 兼容端点 `https://snap-access.cn-north-4.myhuaweicloud.com/api/v2`)。每个模型请求都使用 存储的 AK/SK/SecurityToken 按华为 `SDK-HMAC-SHA256` 方案签名,并附带 `Chat-Id`/`Session-Id` 请求头。默认广告的模型为 GLM-5.2、GLM-5.1、 GLM-5、GLM-5.3 Flash(`glm-5.3-flash`,1M 上下文)、盘古 openpangu-2.0-flash (92B) / openpangu-2.0-pro (505B), 以及 DeepSeek V4 deepseek-v4-flash / deepseek-v4-pro(UI 标注每日 1000 万免费 Tokens 福利)。 登录后在 dsh Models 页面选择该 provider 即可。 > 注 1:CodeArts Agent IDE 模型列表显示的 flash ID 为 `deepseek-v4-flash-0731` > (带日期后缀),但后端实际注册的可用 ID 是 `deepseek-v4-flash`(无后缀)。 > 用 `deepseek-v4-flash-0731` 调用会返回 `InferHub.002002009.404 The model is > not registered`,因此本插件只注册无后缀的 `deepseek-v4-flash`。 > > 注 2:`glm-5.3-flash`(GLM-5.3 Flash,2026-08 加入,1M 上下文)是 benefit > (免费额度)模型:其 chat 请求必须携带 `maas_type: benefit` 请求头且该头 > 参与 `SDK-HMAC-SHA256` 签名,否则后端返回 `InferHub.002002009.404 The model > is not registered`。适配器已自动处理,无需手动配置。 > (逆向自 CodeArts Agent IDE mitmproxy 抓包,对齐 deveco-code-rust 90aeb17d。) 凭据来自默认的新式 IAM OAuth 流程(含 `refresh_token`)。请求发起时会解析最新 凭据,若已过期则先静默续期,再用新 AK/SK/SecurityToken 签名,无需重新打开浏览器。 除 `codearts` 外,插件另注册五个独立的 provider 路由:`buddy`(见 [buddy provider](#buddy-provider))、`workbuddy`(见 [WorkBuddy provider](#workbuddy-provider))、`lobsterai`(见 [LobsterAI provider](#lobsterai-provider有道龙虾))、`qoder`(见 [Qoder provider](#qoder-provider))与 `trae`(见 [TRAE provider](#trae-provider字节跳动-trae))。六者互不覆盖,可同时使用。 ## 凭证 - ⚠️ **只支持账号池**:每个账号的凭据存储在 `CODEARTS_ACCOUNT_XXX`(Ref 为 POSIX 标识符格式的凭证 ref),由 Jet Hub 设置页管理。 - **单凭据模式已移除**:早期那条「登录写固定 ref `CODEARTS_ACCESS_TOKEN`、 适配器在账号池取不到时回退读它」的路径已删除。固定的 `CODEARTS_ACCESS_TOKEN` 不再被写入或读取 —— 若你此前只用它登录过, 模型列表会变空,请在 Jet Hub 的 CodeArts 面板重新登录一次。 - 值:JSON 字符串 `{ access_key_id, secret_access_key, security_token, expires_at, domain_id?, user_id?, user_name? }` — AK/SK 对用于给每个 CodeArts 后端 API 请求签名。 ## 续期(refresh) - 默认登录流程为**新式 IAM OAuth**(PKCE + DPoP):portal `/authorize` 授权 → 本地 `/oauth/callback` 回调收取 `code` → `sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换取含 `refresh_token` 的凭据。 - 凭据在过期前 1 小时静默续期(`getFirstRefreshTime` 语义:距过期 ≤1h 立即刷, 否则 `now+1h` 叠加随机秒偏移),全程无浏览器、无人工操作。 - 刷新失败后 10 分钟重试(异常网络 1 分钟);`refresh_token` 失效后停止续期并提示 重新登录。 - 旧 ticket 流程保留为显式回退:编程式调用 `ctx.codeartsAuth.login({ flow: 'ticket' })`。ticket 凭据没有 `refresh_token`, 其续期仍意味着重新运行浏览器登录流程。 - **续期按账号**:`refreshAccountCredential(refName)`(账号卡片的「刷新」按钮) 与 `refreshAll(pool)`(定时调度器)。早期的 `refresh()` / `logout()` / `status()` / `scheduleRefresh()` / `scheduleModelRefresh()` 只服务于单凭据路径, 已随该模式一并移除。 - 运行时依赖新增 `jose`(用于 DPoP JWS 签发,与 CodeArts Agent 插件实现一致)。 ### ⚠️ 停用的账号同样会被续期 `refreshAll()` 与续期调度器**只按 `refreshable` 过滤,不看 `enabled`**。 停用只应影响「账号池的自动选号」,与「凭据是否需要保持新鲜」无关 —— 停用账号仍然出现在 Jet Hub 里,也仍然参与积分领取。 > **真实缺陷**:两个**曾停用**的 CodeBuddy 账号显示「凭证过期」,点「一键领取 > 积分」报 `Unexpected token '<', " 根因是两处都按 `enabled` 过滤: > > - `refreshAll()` 里的 `if (!entry.enabled || !entry.refreshable) continue` > → 停用期间 `refresh_token` 一路放到失效; > - `src/index.ts` 的 `accounts.some(a => a.refreshable && a.enabled)` > → **所有账号都停用时,续期定时器根本不启动**。 > > 用户重新启用后拿到的是死凭据,只能重新登录。四个 provider 的 `refreshAll` > 与调度器都必须保持只看 `refreshable`。 ### 凭据失效时不再抛 `Unexpected token '<'` 积分请求原本直接 `await response.json()`。凭据失效时腾讯网关返回的是 **HTML 错误页**,于是抛出 `Unexpected token '<', " **流式工具调用 id 稳定性**:CodeBuddy 仅首个工具调用分片携带真实 id > (`chatcmpl-tool-xxx`),后续参数分片只有 `index`。适配器按 index 缓存并沿用 > 真实 id(缺失时回退 `call_{index}`),保证同一工具的所有分片 id 一致——否则 > 跨轮次(每轮都从 `call_0` 重新编号)会把 `tool/result` 配对到错误的历史条目。 ### 单次输出上限(`max_tokens`) **远端下发 `maxOutputTokens`,适配器必须消费它并写进请求体。** 这是「回答被 截断、UI 报『已达到输出 token 上限』」的唯一根因修复。 - 请求体 `max_tokens` 取值优先级: **`options.maxTokens`(DSH 注入)→ 远端 `data.models[].maxOutputTokens` → 产品兜底表**。 三者皆无则**不发该字段**,交回网关默认值(不编造数值)。 - `resolveModel()` 同时把该值声明为 `defaultMaxTokens`。DSH 只在调用方未显式 给值时用声明的默认值兜底;适配器不声明就等于把上限永久交给网关默认。 - ⚠️ **远端非法值必须过滤**:DSH 对 `defaultMaxTokens` 有硬校验(非安全整数 或 ≤0 直接抛 `INVALID_MODEL_MAX_TOKENS`,整轮对话起不来),故远端是外部 输入,`0` / 负数 / `NaN` 一律视为未声明(见 `positiveMaxTokens`)。 实测(2026-09-19,`node scripts/dump-max-output.mjs`): | 模型 | 中国版 scoped 端点 | 中国版 `/v3/config` | 国际版 `/v3/config` | |---|---|---|---| | `deepseek-v4.1-flash` | 128000 | 131072 | 128000 | | `deepseek-v4-pro` | 128000 | 131072 | — | | `deepseek-v4-flash` | 50000 | 50000 | — | | `hy4-preview` | 64000 | 64000 | 64000 | ⚠️ **网关默认值恰好是 32000**(远端 `auto` / `glm-4.6` / `kimi-k2.6` 等模型 声明的就是 32000),这正是未下发 `max_tokens` 时 `deepseek-v4.1-flash` 在 32000 处被截断的原因 —— 不是「网关固定上限」,而是上游声明的额度被适配器丢了。 网关**确实接受** `max_tokens` 且**精确生效**(`node scripts/verify-max-tokens.mjs`, 国际版 `deepseek-v4.1-flash`,当时处于官方限免期 `credit: 0`): | 请求 | 结果 | |---|---| | 不带 `max_tokens` | HTTP 200,`finish_reason=stop` | | `max_tokens: 128000` | HTTP 200,接受 | | `max_tokens: 64` | HTTP 200,**`finish_reason=length`、`completion_tokens=64`** | 第三行是关键证据:输出被精确截断在 64,证明该字段被服务端真实消费,而不是 被静默忽略。 > **单次请求 ≠ 单轮**:每个 step 都是独立请求、各有各的预算。因此 > 「拆多步写文件」仍是超出单次额度时最有效的手段;`max_tokens` 只是把单次 > 额度提升到上游声明的真实值。 > > **思考档位与输出预算共享同一额度**:`reasoning_tokens` 计入 > `completion_tokens`(实测 `thinking` 内容与正文同池),故思考开到 `max` > 时正文更早撞上上限。 ### 模型计费倍率与同名模型 模型切换列表里每个模型后面会显示它的**计费倍率**: ``` Deepseek-V4.1-Flash · x0.03 GLM-5.3 · x0.79→x0.50 ← 有促销活动时显示 原价→促销价 ``` 倍率拼在**模型名**(`name`)后面,而不是说明(`description`)里 —— composer 的模型切换菜单**只渲染 `name`**,`description` 仅用于 `/model` 弹窗。 `name` 纯属展示,DSH 的选择与持久化只用 `id`,所以附加价格不会影响会话。 各 provider 的倍率字段**形态互不相同**,实现里是分开解析的: | provider | 远端字段 | 真实形态 | |---|---|---| | `buddy` / `workbuddy` | `data.models[].credits` | 字符串 `"x0.29"`(可为空串) | | `buddy` / `workbuddy` | `modelPromotions[].discount.discountedCredits` | 字符串 `"0.50x"`(**x 在后**);`factor: 0` = 免费。⚠️ **只由 `/v3/config` 下发**,企业模型端点没有;且须按 `schedule` 判时段 | | `lobsterai` | `data[].costMultiplier` | 裸数字 `0.05` | | `qoder` | 目录 `chat[].price_factor` | 裸数字,**`0` = 免费** | | `trae` | `display_contact_config.consumption_rate.data.rate` | **裸数字** `0.08`;`0` = 免费(⚠️ `display_contact_config` 本身是 **JSON 字符串**,须二次解析) | | `codearts` | 无 | 两个目录端点都不含计费字段 | 腾讯系的促销值带 `schedule`(每日时段 + 有效期 + 时区),**必须按当前时间本地 推算此刻是否生效**,不能只看 `enabled`;`factor: 0` 表示**免费**(如 `hy4-preview` 的夜间免费),只有**没有时间窗口**的 `"0x"` 才当作「已结束」占位。 TRAE 的活动折扣只用**当前确实生效**的那一档:`activity_discount.enable` 为 `true` 也可能是「无折扣」(`discount_type: "none"`、原价==折后价,实测 `off_peak` 型即如此),照显会得到 `x0.13→x0.13`;已过 `end_at` 的活动同样不展示。 Qoder 的免费模型(`price_factor: 0`)显示为「免费」而不是 `x0`;有错峰折扣的 模型在**折扣时段内**显示「原价→折后价」,时段外只显示原价: ``` Qwen3.8-Flash · 免费 Qwen3.8-Max · x0.5→x0.2 ← 22:00–08:00(UTC+8)内 Qwen3.8-Max · x0.5 ← 时段外 DeepSeek-V4-Pro · x0.5 ``` > ⚠️ **折扣形态三个 provider 统一为「原价→折后价」**(TRAE `x0.4→x0.2`、 > buddy `x0.79→x0.50`、Qoder `x0.5→x0.2`)。Qoder 早期是「只有折后价 + > 中文角标」(`x0.2 错峰 4 折`),问题有二:① 看不出原价与折扣幅度; > ② 角标与数字**冗余**(0.2/0.5 本就是 4 折)。 > ⚠️ `credits` 与 `discountedCredits` 的 `x` **位置相反**(`"x0.29"` vs > `"0.50x"`)。早期版本只认前缀写法,导致促销价被静默丢弃。 > > ⚠️ **腾讯系两个端点下发的模型 id 集合不同,必须取并集** —— 促销可能只挂在 > 其中一个端点独有的 id 上。实测 `hy4-preview-f`(新用户限时免费)**只由 > `/v3/config` 下发**且被 agent 引用,而 scoped 端点给的是 `hy4-preview` > (无促销)。只采信 scoped 就会显示 `x0.29` 而 IDE 显示免费(用户报障 > 「hy4 preview 现在 ide 是免费我们还是 0.29」)。**不同账号下发的变体 id > 也不同**,排查时须多账号对照。 > > ⚠️ **腾讯系的促销只在 `/v3/config` 下发,企业模型端点没有** —— 而后者被优先 > 返回,所以早期实现**永远不显示促销**(用户报障「codebuddy 的倍率显示也是 > 没折扣的,GLM-5.2 是 0.5,现在显示 0.79」)。现补取促销表并合并。 > 且**必须按 `schedule` 判此刻是否生效**(`glm-5.2` 夜间/白天是两条互补活动, > 不看时段会全天显示折扣价);**`factor: 0` 是「免费」而非「已结束」** > (`hy4-preview` 夜间免费,用户报障「夜间 0,现在显示 0.29」)。 > ⚠️ `/v3/config` **有 UA 校验**,UA 不对返回 HTTP 200 + `code:12403`, > 极易误判为「该端点没有促销数据」。 > > ⚠️ 产品兜底表是**白名单**(不在表里的 id 会被丢弃),但**被 agent 引用的 > 模型例外保留**(服务端自己的「可选」信号)—— 否则 `hy4-preview-f` 会被丢掉。 > 判据**不是**猜 id 后缀(`-f`/`-x`/`-sg` 含义各异,猜错会放进不可用的模型)。 > > ⚠️ Qoder 的字段是 `price_factor`,**不是** `cost_multiplier`(后者是 > LobsterAI 的)。而 `price_factor: 0` 是**合法的免费值**,不能用 `> 0` > 过滤掉。 > > ⚠️ 倍率**不能**放进 `description`:那在切换模型列表里根本不可见 > (用户报障「消耗倍率没有显示在切换模型列表的后面」)。 > > ⚠️ **Qoder 的错峰时段按 `windowStart`/`windowEnd` 本地推算,不采信目录的 > `promotion.active`** —— 后者是目录下发那一刻的快照,客户端长时间不重启 > 就会与真实时段脱节(用户在时段外看到折后价、或时段内看不到折扣)。 > 生效价由 `beforePromotionPriceFactor × discountFactor` 推出(实测三条全部吻合)。 > **真实缺陷**:早期表里存的是「采集时刻的生效价」却当成恒定值展示, > 且 14 个模型的数值本身也是过期估值(`smodel` 写 3.2 实际 8), > 用户报障「qwen3.8-max 是 0.5 打折到 0.2,界面显示的是 0.5」。 #### 同名模型会自动区分 服务端会给**不同 id 配同一个展示名**,实测三组: | 模型 id | 远端展示名 | 实际差异 | |---|---|---| | `deepseek-v4.1-flash` / `deepseek-v4.1-flash-sg` | 都是 `Deepseek-V4.1-Flash` | 新加坡区,倍率 x0.00 vs x0.03 | | `hy3` / `hy3-x` | 都是 `Hy3` | — | | `hy4-preview-f` / `hy4-preview` | 都是 `Hy4 preview` | — | 由于选择器按展示名渲染,这些会变成无法区分的重复条目(用户报障: 「workbuddy 国际版同时显示 2 个 ds v4.1 flash,IDE 只有一个」——IDE 按展示名 归并,本插件按 id 列出)。二者是**不同区域的独立计费实体**,不能简单丢弃其一, 故对撞车的 id 求公共前缀、把剩余段追加到名字后: ``` Deepseek-V4.1-Flash · x0.00 (deepseek-v4.1-flash) Deepseek-V4.1-Flash · x0.03 SG (deepseek-v4.1-flash-sg) Hy3 · x0.00 (hy3) Hy3 · x0.05 X (hy3-x) ``` 用公共前缀而不是硬编码 `-sg`,是因为撞车组会随服务端上新变化(本次实测三组 里只有一组带 `-sg`)。LobsterAI 实测无同名(28 个模型 0 组重名),故不做消歧。 ## WorkBuddy provider(国际版) 独立路由 `workbuddy`(腾讯 **WorkBuddy 国际版 / WorkBuddy AI**),与 [buddy provider](#buddy-provider) **同源**:共用同一 CLI 内核与同一认证协议 (cli-external-link 轮询式),Bearer `access_token` 鉴权。差异收敛在 `src/product.ts` 的产品配置里: | 项 | CodeBuddy(中国) | WorkBuddy(国际版) | |---|---|---| | `endpoint` | `https://copilot.tencent.com` | **`https://www.workbuddy.ai`** | | `platform` | `ide` | **`workbuddy-ai`** | | 登录 URL 附加参数 | 无 | **`version` / `loginSessionId`** | | `pluginVersion` | — | `5.5.2` | **模型列表不能与中国版共用**:两者的路径与响应解析完全相同 (`GET /v3/config` → `data.data.models` / `data.data.agents`),差异只来自 `endpoint` —— 不同区域的后端返回不同模型池(中国版含 glm / hy / deepseek 系, 国际版含 claude / gpt / gemini / kimi 系)。因此 `endpoint` 必须随产品切换, 不能被当成全局常量。 登录流程与 CodeBuddy 一致(`auth/state` → 浏览器授权 → 轮询 `auth/token` → 轮询 `login/account`),仅身份标识与端点按上表区分。`X-Product-Code` 为 `workbuddy`,`X-Domain` 随 `apiDomain` 切换为 `www.workbuddy.ai`。 **没有每日签到积分**:国际版后端不提供**签到**接口(内核中只有 `/v2/billing/meter/get-dosage-notify` 用量通知),因此 Jet Hub 的 WorkBuddy 面板**不显示「一键领取积分」按钮**;签到领取在 CodeBuddy 面板完成。 > **但积分余额(Credits Balance)可以查。** 签到与余额是两项独立能力:国际版 > 确实没有签到,但**有**积分余额查询接口,见下节。不要因为"没有签到"就推断 > 也查不到余额。 - **登录入口:Jet Hub 设置页的 WorkBuddy 面板**(支持多账号与账号池自动切换)。 同样不注册斜杠命令。 - 编程式调用:`ctx.workbuddyAuth.login()` / `status()` / `refresh()` / `logout()` / `fetchModels()`。 - 凭据 ref: - 单账号:`WORKBUDDY_ACCESS_TOKEN`,值为含 `access_token` / `refresh_token` / `expires_at` 的 JSON 字符串(与 `BUDDY_ACCESS_TOKEN` 同构)。 - 多账号:`WORKBUDDY_ACCOUNT_`,由 Jet Hub 设置页「+ 新建账号」 登录时自动生成并登记到账号池;每条账号记录带 `provider: 'workbuddy'`, 与 CodeBuddy 的 `BUDDY_ACCOUNT_*` 相互隔离,不会串用凭据或限流标记。 - **从中国版升级**:本插件早期版本把 `workbuddy` 指向中国版 (`copilot.tencent.com`)。启动时会自动清理凭据 `domain` 与当前 `apiDomain` 不符的旧账号(这类凭据在新端点必然失败),清理结果记入日志, 请在 Jet Hub 重新登录。 - 续期:与 CodeBuddy 共用同一套机制,插件启动后每 30 分钟对可续期账号静默刷新 (`refresh_token` 经 `X-Refresh-Token` 头提交),无需重新打开浏览器。 - 请求头、模型列表拉取与流式工具调用 id 处理均与 CodeBuddy 一致,详见上一节。 ### 与 Jet Hub 设置页的关系 Jet Hub(设置页)的账号面板按 provider 分组展示,WorkBuddy 是其中一栏: - 面板提供账号列表、新建账号(浏览器登录入池)、启用/停用、删除、**拖拽排序**, 以及「重测 / 重测所有 / 重置 / 重置所有」限流标记操作,行为与 CodeBuddy 面板一致,但只操作 `provider: 'workbuddy'` 的账号。 - 账号卡片展示 credentialRef、有效期(含「自动续期」标记)、限流状态与**积分 余额**(见下节)。「一键领取积分」按钮**仅 CodeBuddy 面板提供**,结果来自 RPC 端点 `credits.claimAll`(实现见 `src/jet-hub-rpc.ts`,签到客户端见 `src/credits.ts`)。 - 后端另实现了 `credits.status`(查询某 provider 下全部启用账号的签到状态), 但**前端尚无消费者**:`plugin-src/client/jet-hub.js` 只调用 `credits.claimAll`, `credits.status` 目前仅供外部脚本或直接 RPC 调用使用。 - 对应 LLM provider 的设置命名空间为 `llm-workbuddy`。 ### 「+ 新建账号」必须走两步式登录(四个 provider 一致) `account.create` 对**全部四个 provider** 都遵守同一契约:**在用户完成授权之前 就返回 `loginUrl`**,由前端立即 `window.open`,后台再异步等回调。 这不是风格偏好,而是浏览器安全模型的硬约束:`window.open` 只在用户点击后的 **transient activation** 窗口(约 5 秒)内被允许。若 `account.create` 阻塞到 用户授权完成才返回(数十秒),拿到 URL 时手势早已过期,弹窗必被拦截并返回 `null`,前端若兜底执行 `window.location.href = loginUrl`,就会把**整个设置页** 导航到外部登录页 —— 用户报障「codearts 新建账号应该弹出新的页面,现在主页面 直接跳转过去了」正是此因。 | provider | 后端实现 | 前端交互 | |---|---|---| | `buddy` / `workbuddy` | `runBuddyLoginFlow` 不 await,立即返回 URL | 弹小窗 + 轮询 `login.poll` | | `codearts` | `CodeArtsAuth.startLogin()` | 同上 | | `lobsterai` | `LobsteraiAuth.startLogin()` | 同上 | - 两步式的入口在 `src/login.ts` 的 `startOAuthFlow` 与 `src/lobsterai-oauth.ts` 的 `startLobsteraiLoginFlow`;原阻塞式 `runOAuthFlow` / `runLobsteraiLoginFlow` 保留(CLI、e2e 仍用),现由前者实现。 - 两步式路径**没有外层 `try/finally` 兜底**,故超时与「结果落定即关闭回调 服务器」都收在 `start*` 内部,避免泄漏监听端口。 - 前端**不再**保留 `window.location.href` 兜底:弹窗被拦截时改为展示一个可点击 的登录链接(`loginUrlForManual`),轮询照常进行,用户手动打开也能完成登录。 - 回归测试见 `tests/unit/jet-hub-rpc.spec.ts` 的 「account.create 必须立即返回 loginUrl」:用「授权永不完成」的替身模拟用户 尚未操作 —— 旧实现会超时失败,新实现立即返回。 ### 账号拖拽排序(顺序 = 选号优先级) Jet Hub 各 provider 面板的账号卡片可**拖动调整顺序**,卡片左上角显示序号。 **这不是纯 UI 装饰**:账号列表的数组顺序就是 `getAvailableAccount` 的候选 优先级 —— 自动选号、以及限流后换号重试,都按这个顺序取「第一个可用账号」。 把常用账号拖到前面,它就会优先被使用。 语义是「**手动顺序优先,限流豁免**」: - 顺序完全由用户决定(不按任何服务端字段重排); - 但当前正处于**限流期**的账号会被跳过,不会选到 —— 即使它排在第一位。 > ⚠️ 早期实现有一个 `candidates.sort((a,b) => resetAtA - resetAtB)`(按限流 > 重置时间最早到期优先)。它会让拖拽形同虚设:用户把某账号拖到首位,只要 > 另一个账号的重置时间更早,实际选中的仍是后者。该排序已移除, > `tests/unit/account-pool.spec.ts` 有回归用例锁死。 交互细节: - 拖动整张卡片,或抓住左侧的 `⠿` 手柄; - 插入位置按指针落在目标卡片的**上半 / 下半**决定(前插 / 后插), 并以卡片上方或下方的蓝线指示。只支持「前插」时把卡片往下拖一格会变成 空操作,因此必须区分方向; - 顺序**乐观更新**(本地先变、再提交),失败则回滚并提示; - 提交期间禁用拖拽,避免并发提交互相覆盖; - 只有 1 个账号时不启用拖拽(排序无意义)。 实现:RPC `account.reorder` → `AccountPool.reorderAccounts()` (只动本 provider 占用的下标,其他 provider 账号位置不变); 前端拖拽逻辑在 `plugin-src/client/account-order.js`(纯函数,可单测)。 ### 积分余额(Credits Balance) 账号卡片上的「积分」一行显示该账号的**可用积分**,与 IDE 顶部显示的 `Credits Balance` 是同一个数值。鼠标悬停可看到各资源包的明细与到期时间。 **两个产品通用**——CodeBuddy 中国版与 WorkBuddy 国际版都实现同一接口 (只是 baseURL 随 `product.endpoint` 切换): ``` POST /v2/billing/meter/get-user-resource body {} ``` ### 模型列表开关(黑名单) Jet Hub 面板标题栏的「**显示列表**」按钮展开该 provider 的**全部模型**,每个模型 后面带一个开关,**默认打开**。关闭后该模型不再出现在对话框的模型选择列表里。 采用**黑名单制**:只有被显式关闭的模型会被隐藏,未记录的模型(含服务端后续新增的 模型)一律默认显示。这与白名单制的关键差别在于——新模型上线时无需任何配置就会 自动出现在选择器里,不会被静默挡在门外。 - 开关状态持久化在 `jet-hub` settings 命名空间的 `disabledModels` 字段 (形如 `{ buddy: { 'glm-5.2': true } }`),与账号池同处一个 namespace。 - 模型列表来自 `ctx.llm.listModels()`,**即对话框模型选择器读取的同一份目录** (会话控制器的 `buildModelCatalog`),因此设置页展示的模型与实际可选集合始终 一致,不会出现「设置里有、选择器里没有」的错位。 - 过滤发生在适配器的 `listModels`(`src/llm-adapter.ts` / `src/buddy-adapter.ts` / `src/lobsterai-adapter.ts`), 每次调用都直接读账号池的黑名单,因此**改开关后下一轮模型目录刷新即生效**, 无需重启或重建适配器。 - **只影响目录播报,不改变路由能力**:被关闭的模型仍可被 `resolveModel` 解析、 仍能正常收发请求。这是 DSH 对 `listModels` 的约定(目录是建议性的,缺省不构成 请求拒绝)。好处是已有会话若正用着某个被关闭的模型,不会被强制中断。 - 开关按 provider 隔离,CodeArts / CodeBuddy / WorkBuddy / LobsterAI 四份黑名单互不影响。 - 相关 RPC 端点:`model.list`(列出模型并回填 `disabled`)、`model.setDisabled` (打开/关闭单个模型),实现见 `src/jet-hub-rpc.ts`。 ### 没有已登录账号就不显示该 provider(目录门控) **需求**:若某供应商没有已登录的账号,就不显示该供应商的所有模型 —— 这样对 大多数用户来说模型选择选项卡臃肿的问题能改善很多。 **机制**:DSH 的 `buildModelCatalog` 显式 `.filter(group => group.models.length > 0)` (注释 *"successful non-empty provider groups"*),所以适配器 `listModels` 返回**空数组**即可让整个 provider 分组从模型选择器消失 —— **无需任何前端改动**。 - **判据是「凭据能否解析」**,不是「有没有账号条目」:登出(`logout()`)只清凭据、 保留条目,若只看条目则登出后模型仍会显示,门控形同虚设。 - **不看 `enabled`**:停用只影响自动选号,与「是否已登录」无关。把所有账号停用的 用户仍能看到模型(与「续期只看 `refreshable`」是同一条约定)。 - ⚠️ **CodeArts 是唯一保留单凭据模式的 provider**:它额外把 `CODEARTS_ACCESS_TOKEN` 计入判据,只用单凭据登录的老用户不会受影响。 其余五个 provider 只看账号池。 - ⚠️ **返回空数组而非抛错**:抛错会被归入 catalog 的 `failures`,界面反而多出 一条 provider 报错。 - ⚠️ **不影响路由**:`routableProviders` 单独生成(不经该 filter),已持久化的 模型仍可正常收发 —— 与黑名单同一契约。 - ⚠️ **门控只作用于对话框目录**;Jet Hub 的「显示列表」(`listAllModels`)仍列出 全部模型,否则用户关掉模型后连开关都看不到、无法重新打开。 - **判据不可用时保守放行**(无账号池 / 替身未实现 / 读凭据异常):门控是展示优化 而非安全边界,宁多勿少。 - 开关:`DSH_HIDE_MODELS_WITHOUT_ACCOUNT`(**默认开启**,设 `0`/`false`/`no`/`off` 可关闭)。实现见 `src/account-pool.ts` 的 `hasLoggedInAccount` / `providerCatalogVisible`。 ### 积分余额(Credits Balance) 账号卡片上的「积分」一行显示该账号的**可用积分**,与 IDE 顶部显示的 `Credits Balance` 是同一个数值。鼠标悬停可看到各资源包的明细与到期时间。 **支持范围**:四个 provider 都支持,但**三套协议各不相同**: **CodeBuddy 中国版与 WorkBuddy 国际版**(同一接口,只是 baseURL 随 `product.endpoint` 切换): ``` POST /v2/billing/meter/get-user-resource body {} ``` **LobsterAI**:`GET /api/user/profile-summary` → `data.totalCreditsRemaining`。 **CodeArts**(华为云,见 `src/codearts-credits.ts`): ``` GET {snapEngineUrl}/snap-manager/v1/statistics/plugin ``` 余额取自响应的 `metrics[]` 中 `usageTotalPackageCredit` 的 `package_credit_remain`(**不累加**基础/按需/赠送分类明细——它们是总额的 构成项,相加会重复计算)。非积分计费账户不显示「查询失败」,而是如实提示 「Token 计费账户,无积分余额」——那是账户类型差异,不是故障。 > 能力矩阵在 `plugin-src/client/credits-capabilities.js`,由客户端在 > **请求前**判定,而非等后端返回错误再吞掉。 > > 历史缺陷:早期客户端在面板挂载时对所有 provider 无条件调用 > `credits.balances`,而当时 CodeArts 没有积分能力,于是每次打开 CodeArts > 面板都会在控制台报 `unsupported provider: codearts`,并把每个账号卡片的 > 「积分」渲染成「查询失败」。修法是不发起该请求——后端 `productById()` 的 > 拒绝是正确的契约行为,不该被当作运行时故障展示。 ### 一键领取积分(每日签到) **四个 provider 中三个提供**该按钮(三套签到协议完全不同,实现各自独立)。 WorkBuddy 国际版后端没有签到接口,故其面板不显示。 在 Jet Hub 对应面板标题栏点击「**一键领取积分**」,插件会对该面板下 **全部账号**顺序执行每日签到领取: > **含已停用账号。** 停用只影响账号池的自动选择与限流切换,不改变账号本身 > 是否已签到——用户点「一键领取」时期望所有账号都尝试一遍。 **CodeBuddy(两步)**: 1. 先查签到活动状态(`POST /v2/billing/meter/checkin-activity-status`); 2. 活动未开启或今日已签到则跳过领取请求,只报告状态; 3. 否则调用领取端点(`POST /v2/billing/meter/daily-checkin`)领取当日积分。 **LobsterAI(三步,见 `src/lobsterai-credits.ts`)**: 1. 查活动槽位(`GET /api/client-activities/slot`,带固定的 `placement` / `containerApiVersion` / `platform` 参数); 2. 查活动上下文(`GET /api/client-activities/{code}/context`), 读 `claimedToday` 与 `actions` 决定是否可领; 3. 领取(`POST /api/client-activities/{code}/actions/check_in`, 请求带客户端幂等键 `idempotencyKey`)。 > LobsterAI 的 `clientVersion` 是签到**必填**参数,由插件动态拉取 > (`api-overmind.youdao.com` 的更新接口,缓存 12 小时); > 拉取失败时回退内置兜底版本并在日志告警 —— 比参考实现的 > 「取不到就完全放弃签到」更宽容。 **CodeArts(四步,见 [CodeArts 积分](#codearts-积分华为云每日签到得积分))**: 账户类型检测 → 活动列表预检 → `POST /v1/ops/claim` →(必要时) `POST /v1/ops/confirm`。 完成后按钮下方给出结果摘要(如「3 个账号领取成功(+300 积分),1 个今日已领取」)。 领取按账号隔离:单个账号凭据缺失、损坏或请求失败不会中断整批,只计入失败数; 摘要**只显示各类计数**(如「1 个失败」),不展示每个账号的失败原因——原因保留在 `results[].outcome.message` 中,需要时请通过 RPC 响应或日志查看。 几点实现约定: - 领取是**顺序执行**的,避免并发触发风控;账号较多时需要等待片刻。 - **CodeBuddy** 重复领取是幂等的:服务端返回 HTTP 400 + `code 10001`(「今天已签到, 请明天再来」),插件把它识别为 `already-claimed` 而非失败。 - **LobsterAI** 的幂等由**客户端**保证:请求带 `idempotencyKey`,且领取前先读 `context` 的 `claimedToday` 与 `actions`;重复领取会被识别为 `already-claimed`。 - **CodeArts** 的幂等由**活动列表预检**保证:没有幂等键、也没有「今天已签到」 业务码可依赖,唯一的保护是 `claimable` / `status` 预检(见下节)。 - CodeBuddy 的状态查询用 `checkin-activity-status` 而非 `checkin-status`;后者返回 占位数据(`active:false`、`checkin_dates:null`),会让人误判为活动未开启。 - CodeBuddy 的请求**不需要** `X-Device-Token`(图灵盾)——已实测验证。 - LobsterAI 的签到**不需要签名**,只用 `Authorization: Bearer`;也**不发**腾讯系的 `X-Domain` / `X-Product` / `X-Product-Code` 头。 想单独验证领取闭环(会真实改动账号当日签到状态)可运行 `pnpm test:e2e:workbuddy-claim`、`pnpm test:e2e:lobsterai-claim` 或 `pnpm test:e2e:codearts-claim`,说明见 `tests/e2e/README.md`。 ### CodeArts 积分(华为云「每日签到得积分」) 实现见 `src/codearts-credits.ts`。活动规则见 [华为云官方文档](https://support.huaweicloud.com/offers-codeartsagent/codeartsagent_offers_0004.html): 完成每日签到得 **1000 积分**,积分自发放起 30 天内有效;**活动参与者限定 「已经升级到积分计费模式的用户」**——这正是必须先做账户类型检测的原因。 #### 认证走签名,不走 Cookie(关键结论) 官方文档给出的是**网页版**路径(`https://codearts.huaweicloud.com/portal/...`), 那是 portal BFF 接口,**依赖浏览器会话 Cookie**:实测不带 Cookie 时,无论是否 携带 AK/SK 签名,都返回 IAM 登录跳转 HTML(HTTP 200 + `text/html`)。 本插件没有可用的浏览器会话,因此**不能**复用那条路径。 可用的是**码道 IDE 直连**的 `snap-access` 端点,它接受 **`SDK-HMAC-SHA256` 签名**——与本仓库 `src/sign.ts` 逐字一致, 凭据就是现有的 `CodeArtsCredential`,**无需任何新的登录流程**。 协议逆向自本机安装的码道 IDE(`out/main.js` 的 `PackageInfoService`、 `out/vs/workbench/workbench.desktop.main.js` 的 `ActivityWelfarePane`): | 用途 | 端点(base = `snapEngineUrl`) | |---|---| | **账户类型检测** | `GET /snap-manager/v1/statistics/plugin` | | 活动列表 | `GET /v1/ops/delivery?channel=IDE` | | 领取 | `POST /v1/ops/claim` `{ campaignId, channel: 'IDE' }` | | 领取确认 | `POST /v1/ops/confirm` `{ campaignId }` | `snapEngineUrl` = `https://snap-access.cn-north-4.myhuaweicloud.com` (与 `src/models.ts` 的 `SNAP_MODEL_BUILTIN_URL` **同域**)。 所有请求需带 `Agent-Type: PromptCenter` 与 `X-Language: zh-cn`,但 **这两个头必须在签名之后追加,绝不能参与签名计算**: > ⚠️ **实测(2026-09-18)**:把它们作为 `signRequestHuawei` 的 `extraHeaders` > 传入(即进入 canonical request 与 SignedHeaders),服务端会回 > `401 {"error_code":"APIG.0301","error_msg":"...verify ak sk signature fail"}`; > 改为**签名后追加**则同一端点返回 200 与真实数据。 > > 这与 `src/models.ts` 的 `fetchSignedGet` 一致 —— 其参数注释明确写着 > 「签名后追加的头(不参与 SDK-HMAC-SHA256 签名计算)」。 > 本模块早期版本误当作签名头,界面因此显示「积分:账户信息查询失败」。 > 回归测试见 `tests/unit/codearts-credits.spec.ts` 的 > 「Agent-Type / X-Language 不得出现在 SignedHeaders 中」。 > > 注意 `src/llm-adapter.ts` 的 `maas_type: benefit` 是**反例** —— 那个头确实 > 需要参与签名(见其注释),不要据此推断其他头也该签名。 非 2xx 响应会把服务端的 `error_code` / `error_msg` 带进提示文案 (`describeHttpFailure`)。这一点很关键:只报 `HTTP 401` 会让「签名头位置错」 「AK 调用超限(`AK access failed to reach the limit`)」「凭据过期」这些 **处置方式完全不同**的问题看起来一模一样。 #### 账户类型检测 ``` GET /snap-manager/v1/statistics/plugin → package.is_credit_package === true ⇒ 积分账户(可领取) → package.is_token_package === true ⇒ 旧的 Token 计费账户(活动范围外) ``` 领取流程**第一步**就判它:非积分账户返回 `inactive`(正常业务状态), 而不是 `failed`——后者会让用户去排查并不存在的故障。 #### 领取流程与幂等 1. 查账户类型 —— 查询失败 → `failed`;非积分账户 → `inactive`; 2. 查活动列表,取 `type === 'USER_LOGIN'` 的那项(**不是** `INVITE_USER` / `NEW_USER_REGISTER` / `STUDENT_CERTIFIED`,那些不是每日签到); 3. 不可领取且 `status` ∈ {`CLAIMED`,`CONFIRMED`,`CONSUMED`} → `already-claimed`; 其余不可领取 → `inactive`; 4. `POST /v1/ops/claim`;响应 `id !== null` 时补 `POST /v1/ops/confirm` (漏掉会让积分停在「待确认」而不入账); 5. 成功 → `claimed`。 第 3 步是**唯一的幂等保护**:本协议没有幂等键,也没有服务端「今天已签到」 业务码可依赖,故预检不能省。 #### ⚠️ 活动列表的字段类型/名字与直觉不符 实测(2026-09-18)`GET /v1/ops/delivery` 的一个 item: ```json { "campaignId": 1, "type": "USER_LOGIN", "title": "每日签到领1000 积分", "benefitAmount": 1000, "benefitUnit": "CREDIT", "claimable": true, "status": "ELIGIBLE", "pendingCount": 1, "pendingTotalAmount": 1000 } ``` 三个与直觉不符之处,任一处理错都会导致**领取失败或金额为 0**: | 字段 | 真实形态 | 踩坑后果 | |---|---|---| | `campaignId` | **数字** `1`,不是字符串 | 用只收字符串的解析会得空串 → 判 `failed`「缺少 campaignId」 | | 可领积分 | 字段名是 **`benefitAmount`** | 读 `amount`(不存在)→ 恒为 0 | | `status` | 不可领取时是 **`null`** | 解析必须容忍 null | > **真实缺陷**:上述前两条叠加,导致点「一键领取积分」后显示 > 「1 个活动未开启,1 个失败」——**积分实际没有领到**。 > 第一个账号(Token 计费)判 `inactive` 是正确的;第二个(积分账户) > 因 `campaignId` 解析为空而失败。 > > 早期单测没抓到,是因为用例喂的是**编造的** `campaignId: 'c-1'` 与 > `amount: 1000`。现在的用例直接使用上面这份实测字段集合。 #### 领取结果会逐账号显示原因 面板的领取摘要除计数外,还会列出每个账号的**具体原因** (如「xxx:失败 — 活动缺少 campaignId,无法领取」)。这不是装饰: 上述缺陷最初只能靠翻代码 + 抓包定位,就是因为 UI 只显示「1 个失败」。 后端一直返回 `results[].outcome.message`,前端不该丢掉它。 #### ⚠️ refresh_token 是一次性轮换的 华为的 `refresh_token` **用一次即作废**(服务端回 `STS5.1806 the refresh token has been used`)。因此: - 任何刷新都必须**立刻回写**新凭据,否则该账号只能重新登录; - `tests/e2e/codearts-credential.ts` 只读凭据、**绝不刷新**——探针消耗掉 refresh_token 会让用户的账号失效。这个坑在开发本功能时已真实踩过一次。 ## LobsterAI provider(有道龙虾) 独立路由 `lobsterai`(有道 **LobsterAI**),OpenAI 兼容端点 `https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions`, Bearer `access_token` 鉴权。 该 provider 与腾讯系**协议完全不同**,因此实现是独立一套 (`src/lobsterai*.ts`),只共用架构模式(产品配置驱动、账号池、限流切换、 模型黑名单)。关键差异: | 项 | 腾讯系(CodeBuddy / WorkBuddy) | LobsterAI | |---|---|---| | 登录方式 | 轮询后端 API(无本地服务器) | **本地回调服务器**收 `authCode` 后换 token | | 登录/API 域名 | 同一个 `endpoint` | **两个域名**(portal 与 apiBase) | | 请求头 | `X-Domain` / `X-Product` / `X-Product-Code` / `X-IDE-*` | 仅 `X-LobsterAI-Client-Capabilities` / `X-LobsterAI-Client-Version` | | 续期请求体 | 只带 `refreshToken`(走 `X-Refresh-Token` 头) | 还要带 `firstKeyfrom` / `latestKeyfrom` / `uuid` | | `clientVersion` | 编译期常量 | **运行时从第三方接口动态拉取** | | 每日签到 | 两步(状态 + 领取) | **三步**(slot + context + check_in) | | 图片输入 | 支持 | **不支持**(`inputModalities` 仅 `text`) | | 思考等级 | 支持(按模型声明档位) | **不声明**(是否支持未实测) | > 上表是 **LobsterAI 与腾讯系**的对照。第三个协议族 **CodeArts(华为云)** 的 > 差异见 [CodeArts 积分](#codearts-积分华为云每日签到得积分):它用 > `SDK-HMAC-SHA256` **签名**(无 Bearer)、领取为**四步** > (账户类型 + 活动列表 + claim + confirm),且 `refresh_token` **一次性轮换**。 - **登录入口:Jet Hub 设置页的 LobsterAI 面板**(支持多账号与账号池自动切换)。 不注册斜杠命令。 - 编程式调用:`ctx.lobsteraiAuth.login()` / `status()` / `refresh()` / `logout()` / `fetchModels()` / `resolveClientVersion()`。 - 凭据 ref: - 单账号:`LOBSTERAI_ACCESS_TOKEN`; - 多账号:`LOBSTERAI_ACCOUNT_`,由 Jet Hub「+ 新建账号」生成。 - 凭据结构(JSON 字符串):除 `access_token` / `refresh_token` / `expires_at` 外, 还持久化 `uuid` / `first_keyfrom` / `latest_keyfrom` 三个**身份字段** —— 它们是续期请求体的必填项,丢失会导致静默续期失败、只能重新登录。 - 模型列表:远端 `GET /api/models/available` 优先(它是权威来源), 失败时回退 `src/lobsterai-product.ts` 的 19 个内置模型。 - 续期:启动后每 30 分钟对可续期账号静默刷新(与其他 provider 同一调度器)。 **终态判定比参考实现更精确**:只有 HTTP 401/403 或业务码 40100/40101 才判为 `refresh_token` 失效;网络抖动走可重试路径,不会误让用户重新登录。 > **已知待实测项**(见 `docs/lobsterai-integration-plan.md` §7.2): > 是否支持 `reasoning_effort`、各模型真实上下文窗口(内置表统一填 131072, > 是桥接层的估计值)、图片输入、`prompt_cache_key`。这些在实现里都取了 > **保守默认**(不声明 / 不发送),不会因未知而失败。 ## Qoder provider 独立路由 `qoder`(阿里系 AI 编程 IDE **Qoder**)。**推理走加密端点** (请求体与签名头由客户端自带的 WASM 生成),因此能拿到与客户端 **完全一致**的模型池(含 Qwen3.8 系列)。详见下方「两条推理路径」。 该 provider 与其余四者**协议都不同源**,实现是独立一套 (`src/qoder*.ts` + `src/qoder-wasm.ts` + `src/qoder-envelope.ts` + 复用的 `src/openai-compat.ts`): | 项 | 其余四个 provider | Qoder | |---|---|---| | 登录方式 | OAuth 回调 / external-link 轮询 / 本地回调 | **PKCE 设备码轮询**(不开监听端口) | | 续期请求体 | 只带 `refresh_token`(+ 各自身份字段) | 还要带 **`machine_id`** | | 推理鉴权 | 华为 HMAC / 纯 Bearer | **请求体加密 + 签名头**(不能自行构造) | | 模型列表 | 远端接口(权威) | **本地静态表**(远端需签名)+ 加密端点使用目录 key | | 积分能力 | 余额 ✓(`sash/api/v2/me/usage`)/ 签到 ✗ | ### 登录(设备码轮询) 浏览器打开 `https://qoder.com/device/selectAccounts?...`(PKCE `S256` + `client_id`),用户在网页完成授权后,插件轮询 `https://openapi.qoder.sh/api/v1/deviceToken/poll` 取回 `{ token, refresh_token }`。 - **`404` 表示「用户尚未完成授权」,不是错误**,必须继续轮询 (官方客户端同样如此)。实测依据:该端点返回 404 而任意不存在的路径 返回 401,说明它被网关豁免认证、由业务层报「会话未就绪」。 - **必须走两步式**:`account.create` 在用户授权**之前**返回 `loginUrl`, 由前端立即 `window.open`(浏览器 transient activation 约束, 见 [+ 新建账号](#-新建账号必须走两步式登录四个-provider-一致))。 ### 两条推理路径(**认两套不同的模型名**) 这是本项目**最容易踩的坑**。Qoder 有两个推理端点: | 路径 | 端点 | 模型名 | 能力 | |---|---|---|---| | **加密(本插件使用)** | `api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation` | **目录 key**(`qfmodel` / `dmodel`) | 客户端真实链路;**能拿到 Qwen3.8 系列** | | 公开 | `api2-v2.qoder.sh/model/v1/chat/completions` | 通用名(`qwen-flash` / `qwen-plus`) | 标准 OpenAI 格式;**目录 key 一律被拒** | ⚠️ **两个 host 不同**(`api2` vs `api2-v2`),混用会 404。 **真实缺陷**(用户报障):「向 qwen3.8-flash 发消息后**没收到回复就终止**」。 根因是早期把**目录 key 发给了公开端点**(得到 `Unsupported model`, 且该错误帧又被解析器静默吞掉)。 随后又误判为「目录 key 不可用」,把模型表换成了通用名 —— 于是拿到的是 **Qwen3.5 / Qwen-2.5**,而不是目录里的 Qwen3.8 系列(用户再次报障)。 ### 加密推理(`src/qoder-wasm.ts`) Qoder 的**真实**推理链路要求请求体加密、并带一套服务端认可的身份签名头, 否则请求被拒。客户端把实现该协议的 WASM 内嵌在自身产物里,本插件按 wasm-bindgen 约定把它接起来**复用**(`src/qoder-wasm.ts`)。 - **不是「破解密码学」** —— WASM 自己就导出了成对的编解码函数,我们只是 调用它,等同「用客户端自己的钥匙开自己的锁」。 - **响应不需要解密** —— 只在每帧外套一层信封,内层是标准 OpenAI chunk; `src/qoder-envelope.ts` 负责剥信封,剥完交给 `src/openai-compat.ts`。 - ⚠️ **签名头必须原样透传**,不能用 `Bearer ` 覆盖(会被判签名无效)。 > 实现细节(glue 约定、请求体字段、签名载荷、三个已踩过的坑)记录在 > **不入库**的内部文档 `docs/qoder-encryption-notes.md` 中 —— > 该文档含逆向分析,刻意不随仓库分发。 ### 模型列表:17 个目录 key(**实测数据**) `listModels` 是**静态表**(不发网络请求)—— 远端目录需签名,运行时不做。 表里是客户端目录下发的 **17 个 key**,全部实测可用: | 分组 | 模型 | |---|---| | Qoder 档位 | `auto` / `ultimate` / `performance` / `efficient` | | 内部代号 | `smodel`(Sonus) / `cmodel`(Cantus) | | **Qwen** | `qmodel_38max`(3.8-Max) / **`qfmodel`(3.8-Flash)** / `qmodel_latest`(3.7-Max) / `qmodel`(3.7-Plus) | | Kimi | `kmodel_latest`(K3) / `kmodel`(K2.8-Preview) | | GLM | `gmodel`(5.3) / `gfmodel`(5.3-Flash) | | DeepSeek | `dmodel`(V4-Pro) / `dfmodel`(Flash) | | MiniMax | `mmodel`(M3) | ⚠️ **请求体必须带 `business` 字段** —— 缺了服务端会把请求路由到故障节点, 而**其余模型恰好不受影响**,所以现象像「只有 `qfmodel` 一个模型坏掉」, 极易误判成「服务端故障」。**判据是「Qoder IDE 能否用同一模型」**: IDE 能用即说明是我们的请求缺东西。 **免费额度模型**(`is_free=true`):`qmodel_38max` 与 `qfmodel`。 e2e 探针默认用 `qmodel_38max` 以免消耗积分。 - 该表**会逐渐过时**(新模型上线后不会自动出现); - 按 DSH 约定「`listModels` 结果仅供参考」,**表外的 key 仍可手动指定**; - 需要刷新时按下方「升级 WASM」流程重新采集,并**逐个验证可推理**再入库。 ### 升级 WASM(Qoder 版本更新时) Qoder 升级后签名协议可能变化,表现为**难以解释的 `Signature invalid`** 或 `[FAIL]node:...`。此时刷新: ```bash pnpm qoder:wasm # 自动取本机 Qoder 最新版本的内嵌 WASM pnpm qoder:wasm 0.3.5 # 或指定版本 pnpm build:assets # 同步到 lib/ ``` 脚本取 `.qoder-versions/` 而非 `resources/` —— 后者可能是与 IDE **实际运行**不同的版本。刷新后**务必实测一次对话**(`qfmodel` 或 `qmodel_38max`)确认签名仍被接受。 ### 积分余额(Credits Balance) `qoder` 面板**支持积分余额**: ``` GET https://openapi.qoder.sh/sash/api/v2/me/usage Authorization: Bearer Cosy-ClientType: 5 ``` ⚠️ 两个易错点: 1. **路径前缀是 `/sash/`**,不是 `/api/`。早期因为只按 `/api/` 前缀搜索 而误判「Qoder 无积分端点」。 2. **余额不只在 `userQuota` 里**。实测某账号 `userQuota.remaining = 0` 而 `addOnQuota.remaining = 100`(资源包);只读 `userQuota` 会显示 0。 该端点**只需 Bearer**,不需要模型列表那样的 WASM 签名。企业版账号 (`displayMode: "enterprise"`)不下发额度数字、只给外部链接,此时返回 「查询失败」而非 0。 ### 每日领取(每日 100 Credits) ``` GET https://openapi.qoder.sh/sash/api/v1/me/campaigns POST https://openapi.qoder.sh/sash/api/v1/me/campaigns/{campaignId}/claim ← body 空 ``` 活动**每日 10:00(UTC+8)刷新**,领取后 30 天有效。 ⚠️ **幂等判据是响应体的 `replayed`,不是 HTTP 状态码**:重复领取同样返回 **200**,但 `replayed:true`、**不含 `benefit`**,且 `claimedAt` 是上一次领取的 旧时间。只看状态码会把「今天已领」误报成「领取成功 +100」。 ⚠️ 只领 `actionType === 'CLAIM_BENEFIT' && claimStatus === 'CLAIMABLE'` —— 实测还有 `VIEW_DETAILS` 型活动(如「Pro 首月翻倍」),对它发 claim 是错的。 > **这段协议是抓包解出来的**:早期依据 `/sash/api/v1/me/campaigns` 返回 > `claimable:false` 判定「Qoder 无签到」,真相是**那天已领**。 ### 凭据与续期 - **登录入口:Jet Hub 设置页的 Qoder 面板**(支持多账号与账号池自动切换)。 不注册斜杠命令。 - 凭据 ref:单账号 `QODER_ACCESS_TOKEN`;多账号 `QODER_ACCOUNT_`(由 Jet Hub「+ 新建账号」生成)。 - 凭据结构:`security_oauth_token` 与 `access_token` **双写同值** (服务端取用顺序是前者优先),外加 `refresh_token` / `expire_time` / `refresh_token_expire_time` / **`machine_id`**。 - ⚠️ **`machine_id` 必须持久化**:续期请求体需要它。本插件生成**随机 UUID** 并随凭据保存(不复制官方客户端的硬件指纹逻辑 —— 那依赖 `@napi-rs` 原生 模块取 SMBIOS UUID,属设备指纹且不可移植)。**这是本实现最大的未验证 假设**:若服务端校验设备一致性,续期会被拒。`pnpm test:e2e:qoder-chat` 的续期用例专门验证这一点。 - 续期:与其他 provider 同一调度器(启动后每 30 分钟对**可续期**账号静默 刷新)。终态判定:HTTP 401/403 或响应缺 token → `RefreshTokenExpiredError` (停止重试);网络抖动与 5xx 走可重试路径。 ### 适用范围 **仅支持国际版**(`qoder.com` / `qoder.sh`)。中国版(`qoder.com.cn`)的 端点与 client id 不同,本实现未覆盖。 ## TRAE provider(字节跳动 TRAE) 独立路由 `trae`(字节跳动 **TRAE**),走 SOLO 免费对话通道, 端点 `https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`, 以 `Cloud-IDE-JWT ` 头鉴权。 该 provider 是**第三个独立协议族**(实现见 `src/trae*.ts`),与腾讯系、 LobsterAI 都不同源。它也是唯一一个**请求与响应都要转换**的 provider: | 项 | 其它 provider | TRAE | |---|---|---| | 消息序列化 | 有(`tool-call` → `tool_calls`) | **必须有**(DSH 原生块 → OpenAI wire);漏掉会让**模型看不到工具调用与结果**(真实缺陷,已修复) | | 请求体 | 基本透传 OpenAI 格式 | **必须转换**为 SOLO 格式(`function: "solo_work_lite"`、`config_name`、`tools.parameters` 序列化为字符串、`tool_calls.function` → `function_call`) | | 响应流 | OpenAI 标准 SSE | **SOLO 自定义事件**(`output` / `token_usage` / `done` / `error`),需转成 OpenAI chunk | | 鉴权头 | `Bearer` / 签名 | `Cloud-IDE-JWT` + 十余个 `X-*` 身份头 | | 换 token | 轮询 / authCode | **ExchangeToken**(`refresh_token` 会**轮换**) | | 设备指纹 | 无 | **必须持久化** `machine_id` 与 `device_id`(均为 32 位 hex) | | **登录回调** | 各不相同 | **老流程直接回传 token**(`refreshToken` / `userInfo` / `userJwt`);**并存 PKCE 新流程**(带 `code` / `authCodeInfo`),两套都要认;参数名是 **`auth_callback_url`** | | 回调端口 | 各不同 | 默认 `127.0.0.1:18080`,**被占用时自动回退随机端口** | | 图片输入 | Buddy / LobsterAI 支持 | **支持**,但**逐模型**判定(远端 `display_config.multimodal`;本插件可见集里约 15/19 为 `true`) | | 历史长度 | 无硬约束 | 超约 **500K 字符上游会静默断流** → 自动裁剪(保最新、不切断工具配对) | | 单次输出上限 | 采信远端声明 | 收敛到 **64000**(上游安全线,可配) | | 模型列表 | `GET` | `POST /api/ide/v1/get_detail_param`(响应 `config_info_list[]`) | > **图片输入(逐模型)**:判据是远端 `display_config.multimodal`。 > `true` → 声明 `['text','image']` 并把图片转成 `{type:'image_url',image_url:{url}}` > 的 data URL 发出;`false` / 未声明 → 收到图片时明确报错且**不发请求**。 > > 实测(2026-09-21):直发纯红图答「红色」、纯蓝图答「蓝色」、不带图答「无法确定」 > —— 三次答案不同,证明模型真读到了像素。而 `multimodal: false` 的模型 > (如 `DeepSeek-V4-Pro-Official`)收到图后答「无法确定」、思考链明说「但没有图片」, > 与不带图的回答一致 → 该标志是**权威准入判据**。 > > ⚠️ **用户贴图**与**工具结果内嵌图**是两个独立字段 > (`multimodal` / `tool_response_multimodal`):实测 `deepseek-v4.1-flash` > 前者 `true`、后者 `false`,不可合并判断。 - **登录入口:Jet Hub 设置页的 TRAE 面板**(支持多账号与账号池自动切换)。 不注册斜杠命令。 - 编程式调用:`ctx.traeAuth.login()` / `startLogin()` / `status()` / `refresh()` / `logout()` / `fetchModels()`。 - 凭据 ref: - 单账号:`TRAE_ACCESS_TOKEN`; - 多账号:`TRAE_ACCOUNT_`,由 Jet Hub「+ 新建账号」生成。 - 凭据结构(JSON 字符串):除 `access_token` / `refresh_token` / `expires_at` / `uid` 外,还持久化 **`machine_id`** 与 **`device_id`**(两者均为 32 位 hex): - `machine_id` 是设备指纹,续期时**绝不可重新生成**(服务端按它标识设备); - `device_id` 是签到设备号,**账号间必须互异** —— 同一天两账号共用会被 「该设备已签到」拦截,为空则签到报 9004。 - 登录 URL 含 **18 个参数**(对齐唯一权威实现 `login.sh`),包括 `auth_from=solo`、`login_channel=native_ide`、`plugin_version`、 `login_trace_id` 与 `x_*` 客户端形态系列。少发参数会让登录页停在授权中。 - **两套回调流程都要认**: - **老流程**(当前 `auth_type=local` 实际走的):直接回传 token,形如 `?refreshToken=...&userInfo={...}&userJwt={...}`,不做授权码交换; - **新流程**(PKCE):回带 `code` / `authCodeInfo`。本实现会**识别**它并给出 「上游走了 PKCE 流程,暂不支持」的**精确报错**,而不是笼统地说 「缺少 refreshToken」(把合法回调误判为无效会把排查方向带偏)。 另外 `userInfo` 的中文昵称存在双重编码乱码,插件会自动回转修复。 - ⚠️ **任何回调路径都必须落定登录结果 Promise**:早期实现里解析失败分支只 `res.end()` 就 return,导致 `login.poll` 永远拿不到 `done:true`, 前端**永久停在「认证中」**。这与「参数名写错」是两个独立根因、同一个症状。 - 模型列表:走**批量**端点 `POST /api/ide/v1/batch_get_detail_param`(真实 CN IDE 的用法),**一次拉取多个「通道」(`function`)各自一套模型目录**;失败时回退 `src/trae-product.ts` 的 32 个内置模型。 - ⚠️ **模型只在列出它的通道里可调用**。实测:`glm-5.1` 在 `solo_agent_remote` 正常、在 `solo_work_lite` 回流内 `4001`;`glm-5-turbo` / `sagitta` 恰好相反。 因此插件会**按每个模型所属通道分别下发 `function`** —— 旧实现把 `function` 写死 `solo_work_lite`,agent 专有模型一用就报 `trae: We're sorry, the param is invalid. (code=4001)`。 - ⚠️ **两种「不可用」要分开看**: - `display_config.is_custom_model === true`(需在 IDE 内自行绑定供应商) → **必然 4001,插件剔除**。实测 2026-09-19 该账号有 5 个,但**该名单已过期** (复测 2026-09-20:3 个下架、2 个转为 `false` 可调用,全目录 custom 条目数为 0) —— 判据是**标志的值**而非模型名,别把某一刻的快照写成规则。 - `is_invisible_to_user === true`(**官方 picker 不展示**)→ **硬性剔除**, 使目录与官方 Auto Mode 选择器一致(代价是看不到 `glm-5.1` 等可调用模型)。 - 若仍选中了不可调用的模型(如会话里持久化的旧 id),报错文案会直接点明 「模型不被上游接受」,而不是让人去查参数格式。 - **远端参数会被消费**:`context_window_tokens.dev` → 上下文窗口 (实测主流 **200000**;`max` 的 1M 需官方 max_mode,本插件不实现); `model_detail_list[].max_tokens` → 输出上限(实测主流 **32000**)。 - **`4001` 还有一个自伤成因**:请求头里 `Content-Type` 与 `content-type` 各写一次会被 `Headers` 合并成 `"application/json, application/json"`, 上游回 HTTP 400 + `code=4001`。写探针时请用 `new Headers(base).set(...)`。 - 续期:启动后每 30 分钟对可续期账号静默刷新(与其他 provider 同一调度器)。 终态判定有三条依据(HTTP 401/403、`session-dead` 分类、2xx 但无 `accessToken`); 网络抖动与 5xx 走可重试路径。 - 失败模式:`4008`(`ide_credits` 耗尽)与 `1005`(plan 权益不足)是最主要的 两个,会被分类为需要冷却的类别并触发多账号轮换。 - **签到所得积分如实上报**:`checkin_credits/claim` 的响应**只有** `{"code":0,"message":"success"}`,**不含积分数** —— 故领取成功后会补查一次 `checkin_credits/status`(其 `credits` 字段即所得,实测 `150`,与积分余额里 「签到奖励」包的 `credits_limit` 吻合)。早期从 claim 响应读 `credits`, 于是恒为 0,界面显示「领取成功 **+0 积分**」。 - **已签到必须靠状态判定**:claim 对「今天已签到」是**幂等**的,重复领取同样返回 `code:0`,与真正成功**无法区分** —— 故 `claimAll` 的 TRAE 分支**必须开启状态 预检**(`checked_in`),不能用 `precheckStatus: false`,否则已签到的账号会被 报成「领取成功」。 - **签到 `9074`(人数过多)不再换设备号重试**:设备身份已由 `uid` 确定性派生、 每账号独立,「换个 id 就能成功」的前提不成立。命中即归为业务错误(300s 冷却) 并如实上报。 - **空响应(HTTP 200 但零事件)重试一次**,且**仅在首个模型事件之前** —— 已有输出后绝不放,避免重复计费与重复执行工具。 - 可调环境变量: - `DSH_TRAE_CHANNELS`(默认 `solo_work_lite,solo_agent_remote`)要拉取的通道, **顺序即优先级**(前面的通道优先决定同名模型走哪个通道); - `DSH_TRAE_HIDE_INTERNAL=1` 连官方隐藏的条目一并从目录剔除(与真实 CN IDE 的选择器一致,代价是看不到 `glm-5.1` 等可调用模型); - `DSH_TRAE_MAX_COMPLETION_TOKENS`(默认 `64000`,设 `0` 关闭收敛); - `DSH_TRAE_MAX_HISTORY_CHARS`(默认 `480000`); - `DSH_TRAE_ROTATE_MACHINE_ID=1`(**默认关闭**)启用机器指纹轮换以应对集中风控。 > 尚未实现:真实 CN IDE 的 `llm_utils_chat` / `create_agent_task` **请求体是加密的** > (配 `x-helios` / `x-medusa` / `x-neptune`);实测仅换版本头解不开依赖它的模型 > (`deepseek-v4-flash` 等)。属独立工作量。 > 实现依据见 `docs/trae-integration-plan.md`(协议逆向自 > [`trae2api`](https://github.com/Sliverkiss/traework2api) 及其衍生项目)。