Skip to content

feat(atomcode):接入 AtomCode(AtomGit / GitCode)渠道,含轮询式网页登录与按需领取免费 CodingPlan - #312

Open
shadyrispy wants to merge 12 commits into
aimod-cc:betafrom
shadyrispy:feat/atomcode-provider
Open

shadyrispy wants to merge 12 commits into
aimod-cc:betafrom
shadyrispy:feat/atomcode-provider

Conversation

@shadyrispy

@shadyrispy shadyrispy commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

接入 AtomGit / GitCode 的 AtomCode 渠道。上游本身就是 OpenAI Chat Completions,转发层不做协议翻译;另含服务端中介的轮询式网页登录,以及把「按需领取免费 CodingPlan」挂进每日签到链。

渠道本体

三条平面各一个域,不混用:

用途 基址 端点
登录代理(OAuth) https://acs.atomgit.com /auth/login?provider=atomgit → 轮询 /auth/check?state= → /auth/token?state=;续期 POST /oauth/refresh
CodingPlan REST https://api.gitcode.com/api/v5 models-v2?plan_type=<Max|Pro|Lite>、status-v2、claim-v2、usage
聊天网关 https://api-ai.gitcode.com/v1 POST /chat/completions
  • 网关用 api-ai.gitcode.com,不是官方 CLI 默认的 llm-api.atomgit.com:后者要求一套闭源请求签名(atomcode-codingplan-crypto,开源树里只有一个 unreachable!() 占位)。而网关按 User-Agent 决定要不要签名 —— 同一条有效 bearer、同一个请求体、只改 UA 的判别式实测(2026-10-01,打在 POST /v1/chat/completions):

      (不带 User-Agent)          -> 200 正常回答
      curl/8.5.0                 -> 200 正常回答
      cpa-jethub-atomcode/0.1.0  -> 200 正常回答
      atomcode/5.2.0             -> 403 {"detail":{"code":"ATOMCODE_SIG_MISSING"}}
      atomcode/5.1.0             -> 403 同上
    

    所以 ADAPTER_USER_AGENT 是功能性常量,endpoints.rs 里有回归断言钉住「不得自称官方客户端」。

  • models-v2 每个条目自带的 base_url 指向需要签名的官方网关,只展示不采用。

  • 条目里 id 是数字,模型名是 display_model_name(如 glm5.3-flash)。发数字 id 不会报错,网关回 200 + 正文「参数错误」—— 字段取错会伪装成「参数不被接受」。

  • 令牌不是 JWT(代理下发不透明随机串,读不到 exp),过期只能由 created_at + expires_in 推。

  • 续期轮换:/oauth/refresh 同时下发新 access 与新 refresh,旧 access 立即失效。因此续期做了单飞(键含两个令牌的指纹),且新值非空才覆盖 —— 代理会省略 refresh_token,整包替换等于让账号失去续期能力。

网页登录

授权回调由代理自己在 acs.atomgit.com/callback 终结,不回本机。所以本家没有 loopback 监听、没有回调端口,与 ZCode / OfficeAce 同形(core/login/atomcode.rs)。

supports_web_login() 保持默认 false 是有意的:那三个钩子(build_login_url / exchange_login_code)是给「回调带回一次性 code」那类用的,覆写会让路由层去等一个永远不到达本机的回调(症状是用户授权成功后界面一直挂着)。OfficeAce 同样没有覆写这个位。分派走 api::session::login_start 的按 provider 分支;壳侧只在 allowed_hosts 里显式放行 —— 落进默认分支会拿到 WorkBuddy 的白名单,窗口白屏且日志无痕。

签到:按需领取 CodingPlan

免费套餐是一次性授权,不是每天发额。挂在签到链上,是因为「新账号必须领一次,否则目录为空、什么都调不动」与「套餐到期要能续领」都需要一个逐账号、每天跑一次、带台账的调度。

  • 先探测 status-v2:codingplan_free.expires_at 非空或 remaining_days > 0 即有效,直接跳过领取,只回读套餐名与额度窗口,报「套餐有效中,无需领取」(success:false + 说明 —— 报成功会被日志与界面说成「今天签到了」,而其实什么都没发生)。无效才级联。

  • 级联 Max → Pro → Lite,停规则照官方 setup.rs:825-918:duplicate:true 停(已持有)、success:true 停(本次发放)、两者皆 false 记文案试下一档、传输或 5xx 整条中止(换档位修不好连接)。

  • 判成功不能只看 HTTP 200:响应里带 duplicate 字段,报文是 {"success":false,"duplicate":true,"message":"已领取"}。把它当失败会让级联继续往下试,把用户从 Pro 降到 Lite(官方 client.rs:203-207 写了这条)。判定顺序 duplicate 先于 success,有用例钉住。

    这一处纠正了一份文档的错:cpa-jethub-plugins 的 atomcode README 写「claim-v2 没有幂等标记、重复领取只会再回 success:true」,与源码不符 —— Duplicate 字段就在响应结构里(credits.go:149)。以源码为准。

  • 领到或档位变化后强制刷目录:models-v2 的 plan_available 是相对被问的档位算的,不刷会出现「领到了却看不到新模型」。

额度读数(一并修一处会误拦账号的缺陷)

限额判定走 usage_records::extract_remaining,规则是 unlimited → 判不出;否则 totalLeft;否则 available 一旦出现就当成真实读数。

原先在没有解析出任何窗口时也写 available: 0,于是「这份响应我没读懂」被当成「余额确实为 0」,账号被限制器标成「余额不足 / 已跳过」。dev 库里的记录正是这个形态:available=0 / wallets=[] / error=None(查询成功、一个窗口都没解出来)。现在只在确实解析出窗口时才写 available,subscription 里的 remainQuota / totalQuota 同样收紧;读不到就只回 wallets: [],记录被 usage_records 摘除、不拦账号,余额列留空。

顺带一处取证便利:shape_usage 把上游原文放在归一化结果的 raw 键里,而 account_usage_records.usage 一列就是它,从库里能读到完整 status-v2 原文;另加了一条只记顶层键名与两条额度数组的详细日志。

未做

网关拒绝时不返回错误状态,而是回一条看起来正常的回答:模型不在套餐内或参数不被接受 → 200 + 正文「参数错误」;模型被列出但后端坏了 → 200 + 正文是上游 HTML 报错页。识别与渲染写在 errors.rs(含「模型正常复述这些词时不算失败」的误判护栏),但本仓 SSE 出口目前只有「改 model 名」「丢纯换行分片」两个标志位,没有按帧判错误的钩子,所以还没有调用点。

没接的原因:已知的「200 + 参数错误」是非流式实测,流式形态未测,判据选错会双向出错(把正常答复判成错误,或继续漏判);照搬 upstream::refusal 那套(整段预读)会让本家所有请求退化成「生成完才下发」。实测还发现套餐正常时这条路走不到 —— 哨兵由模型名驱动(未知模型名 / 后端坏模型),而入口按广告清单校验、未知名字回 400 不转发;temperature:99 这类非法参数被网关静默忽略,不触发哨兵。能触发它的只有「套餐到期或被撤销后仍请求已广告的模型」,或从未领过套餐的账号。

验证

dev(agent2api:dev-3.0.8-3)上逐段跑通:

  • 登录:账号落库并启用。
  • 领取:点签到 → claimed_at 2026-10-17、status 1、到期 10-24、Lite 档,签到记录 outcome=success。
  • 目录:/v1/models 广告 2 条,qwen3.8-27b(262144 上下文)与 glm5.3-flash(512000),都可读图、都会思考。
  • 额度:available 200,窗口 200 次 / 5 小时,resetAt 17:18。
  • 非流式:200,真回答 + usage(prompt 17 / completion 32 / total 49)。
  • 流式:10 帧,content「收到」 → finish_reason "stop" → usage 帧 → [DONE]。
  • 记账:requests 三行,provider=atomcode、status 200、token 与上游一致。

后两段是拿 dev 网关 key 自测的,目录那段从 NAS 本机免 key 即可读。顺带一个观测:领取前后目录 3 → 2 条(同一个 Lite 档位),上游的 plan_available 依赖套餐是否已激活,不只是档位。

cargo test -p agent2api-server:基点 beta 4f0040f 同命令实跑 638 passed / 8 ignored,本 PR 699 passed / 8 ignored ⇒ 净增 61 条。

影响面

新家,不改既有各家的转发与选路行为。改动落在这些登记点(供完整性核对):providers/mod.rs 五处(pub mod / ProviderKind / PROVIDERS / kind_from_id / kind_id)、adapter.rs 两处(adapter_for / implemented_kinds)、catalog.rs、catalog_cache.rs 的 scope、account_store 的 mod.rs 与 store_view.rs 加新模块 atomcode_accounts.rs(公开形态同时摘掉 accessToken 与 refreshToken)、api/accounts.rs 的添加与刷新两个分派、api/session.rs 与 core/login.rs 的登录分派、签到链四处(CHECKIN_PROVIDERS 13→14、PROVIDERS_VERSION 3→4 及增量登记、checkin_for 分支、签到页描述与五语言词典)、ui-islands 三处、壳侧 allowed_hosts、以及图标(官网产品图,归一成 256×256)。

ui/islands/* 与 ui/i18n/** 都是入库产物:改了 i18n-src 要跑 npm --prefix desktop-tauri/i18n run build(该包的繁中依赖 opencc-js,需先 npm ci),否则 frontend-artifacts 会判「产物与源码不同源」。

…号暂以粘贴令牌对添加

1. 新增:`atomcode` provider。上游本身就是 OpenAI Chat Completions,所以转发层只做三件事 —— 模型名映射(客户端点的是展示名,网关只认自己的 id)、补/删 `stream_options`(官方客户端无条件带 `include_usage`)、带自有 User-Agent;messages / tools / tool_choice / temperature / max_tokens / reasoning_effort / 图片分片一律原样透传,`is_stateful` 恒 false
2. 新增:三条平面与四个实测坑都写进模块头 —— ① 网关**刻意**不用官方默认的 `llm-api.atomgit.com/v1`(那个要闭源请求签名),改走 `api-ai.gitcode.com/v1`;② 网关**按 User-Agent** 决定要不要签名,自称 `atomcode/<版本>` 会直接 `403 ATOMCODE_SIG_MISSING`(同 token 同 body 只改 UA 的判别式实测表在 `endpoints.rs`);③ 令牌**不是 JWT**,过期只能由 `createdAt + expiresIn` 推;④ 续期**轮换** refresh token(旧 access 立即失效),所以必须单飞、且新值非空才覆盖
3. 新增:账号层 `account_store/atomcode_accounts.rs`(粘贴令牌对落账号;公开形态**同时摘掉 accessToken 与 refreshToken** —— 只摘前者会让长期有效的续期凭据随账号列表下发)+ 目录(`coding-plan/models-v2`,只注册 `plan_available` 的条目,**不内置任何兜底清单**:实测有几个模型「可达但不在名单里」、还有「在名单里但正文是上游报错页」)+ 额度(`status-v2` 的**每窗口调用次数**,免费档实测 200 次 / 5 小时,归成界面认的 `available/unit/wallets[]` 形状)
4. 新增:登记点按「灵犀」那次接入的先例逐处补齐 —— `providers/mod.rs` 五处(pub mod / ProviderKind / PROVIDERS / kind_from_id / kind_id)、`adapter.rs` 两处(adapter_for / implemented_kinds)、`catalog.rs` refresh_meta、`catalog_cache.rs` scope、`account_store/mod.rs` 两处、`store_view.rs` 公开形态、`api/accounts.rs` 的添加与刷新两个分派、`ui-islands` 两处、`i18n` 五语言
5. 说明:两处**如实留着**的缺口,不假装完成 —— ① 网关把失败包装成 HTTP 200(正文可能是「参数错误」,或一页上游 HTML 报错 `三方请求失败`),识别与渲染已写在 `errors.rs`(含「模型正常复述这些词时不算失败」的误判护栏),但本仓 SSE 出口目前只有「改 model 名」「丢纯换行分片」两个标志位、没有按帧判错误的钩子,**调用点待接**;② 浏览器登录的服务端链路(发起 → 轮询 `auth/check` → 取令牌)已就位,但登录任务表与壳侧未接,故 `supports_web_login()` 保持 false —— 界面会如实提示不支持,而不是弹一个永远转不完的窗口
6. 测试:556 passed / 6 ignored(其中本家 53 条)。过程中单测抓到一个真 bug:`expires_at_from` 把秒与毫秒混用(`createdAt` 是秒、本项目 `addedAt`/`expiresAt` 是毫秒),三条断言当场红;单位口径现已写进该函数的文档
1. 新增:`core/login/atomcode.rs` —— 起后台任务向代理现取授权地址(`GET {broker}/auth/login` 拿 login_url + state)、回填 auth_url 供前端打开、按 3 秒间隔轮询 `auth/check` 判完成、再 `auth/token` 取令牌对落账号。与 OfficeAce / ZCode 同形(那两支也是「轮询云端、壳侧不开回调监听」)
2. 说明:本家的授权回调由代理自己在 `acs.atomgit.com/callback` 终结,**永远不回到**运行网关的这台机器 —— 所以 `supports_web_login` **保持默认 false** 是有意的,不是遗漏:那三个钩子(`build_login_url` / `exchange_login_code`)是给「回调带回一次性 code」那类用的,覆写成 true 会让路由层去等一个永远不会到达的回调。OfficeAce 同样没有覆写这个位。分派走 `api::session::login_start` 的按 provider 分支(响应形状与其余登录链一致:`{state, authUrl, edition, provider}`)
3. 更新:壳侧 `allowed_hosts` 显式放行 atomcode(不设限 = 走 None,因为授权页会按用户选的登录方式继续跳不可穷举的主机)。**不落默认分支**是必须显式写出的选择:落下去会拿到 WorkBuddy 的白名单,症状是窗口白屏而无日志
4. 更新:界面从「只渲染粘贴凭据」改为同时渲染「打开授权页」(webLogin 段 + 五语言键)。此前故意不渲染:服务端链路没通时渲染一个点了就报错的按钮比不渲染更糟
5. 修复:`BrokerLogin::start` 的超时参数由 `i64` 改为 `u64`(与 `LOGIN_TIMEOUT_MS` 同型,避免调用点每次都要转换)
6. 测试:638 passed / 6 ignored(净增 0 条:登录任务表这一层在本仓历来由 `api::session` 的分派路径与 E2E 覆盖,本轮按同样口径不新起单测;`supports_web_login` 那条断言改为钉住「必须保持 false」并写明理由)
1. 新增:`providers/atomcode/checkin.rs`。本家的「签到」不是每天发额,而是**领取一次性授权的免费套餐**(`POST /coding-plan/claim-v2`)——挂在签到链上是因为「新账号必须领一次,否则目录为空、什么都调不动」与「套餐到期要能续领」这两件事都需要一个逐账号、每天跑一次、带台账的调度
2. 新增:领取口径是**按需**。先探测 `status-v2`:`codingplan_free.expires_at` 非空或 `remaining_days>0` 即有效 ⇒ **跳过领取**,只回读套餐名与额度窗口,报中性文案「套餐有效中,无需领取」(`success:false` + 说明,刻意不报成功——那会被日志与界面说成「今天签到了」,而其实什么都没发生;与 chatglm 对访客账号的中性处置同一口径)。无效才走级联
3. 新增:级联 **Max → Pro → Lite**,停规则逐条照官方 `setup.rs:825-918`:`duplicate:true` → **停**(已持有,继续往下会重复报或把用户降级)、`success:true` → **停**(本次发放)、两者皆 false → 记文案试下一档、**传输/5xx/解析失败 → 整条中止**(换档位修不好连接,只会加压)
4. 修复我先前的一处错误结论:曾据插件 README 说「claim-v2 没有幂等标记、重复领取只会再回 success:true」——**读源码后确认线上有 `duplicate` 字段**(`credits.go:149`,官方 `types.rs:526`)。真正的坑是**只看 HTTP 200 判成功**:那会把 `duplicate` 当失败、级联继续下行并降级。判定顺序 `duplicate` 先于 `success` 已钉进单测
5. 新增:领到(或档位变化)后**强制刷目录** —— `models-v2` 的 `plan_available` 是相对被问的档位算的,不刷会出现「领到了却看不到新模型」
6. 更新:签到链登记 —— `CHECKIN_PROVIDERS` 13→14、`PROVIDERS_VERSION` 3→4 并登记 `("atomcode", 4)`(新家对存量定时条目默认在范围内、且只补一次,取消勾选仍然生效)、`checkin_for` 的 atomcode 分支、签到页描述 + 五语言译文
7. 测试:699 passed / 8 ignored(净增 8 条:duplicate 优先于 success、success 终止、双 false 继续、畸形载荷不 panic、有效性判据的四种形态、额度文案只读展示窗口、级联顺序钉死)
1. 修复(用户实测报的现象「领完套餐还是显示余额不足」的真因):`shape_usage` 在没有解析出任何窗口时也写 `available: 0`。而限额判定走 `usage_records::extract_remaining`,它的规则是「`unlimited` → 判不出;否则 `totalLeft`;否则 **`available` 一旦出现就是真实读数**」⇒「这份响应我没读懂」被理解成「余额确实为 0」,账号当场被限制器拦下(dev 库里实测那条记录正是 `available=0 / wallets=[] / error=None`,即查询成功但一个窗口都没解析出来)。现在改为**只在确实解析出窗口时**才写 `available`,并同样收紧了 `subscription` 里的 `remainQuota`/`totalQuota`;读不到就只回 `wallets: []`(记录被 `usage_records` 摘除、不拦账号,余额列留空 —— 与本仓「读不到的数值留空显示」同一口径)
2. 新增:`status-v2` 的**字段形态诊断日志**(详细日志,只记顶层键名与 `rate_limit_windows` / `current_usage` 两条额度数组,不含凭据)。额度是按字段名解析的,名字一变就静默归零 —— 今天就是栽在这,留一条能事后对账的痕迹
3. 测试:atomcode 62 条全绿(净增 2 条,其中一条把错误的旧断言反过来钉住:**读不到窗口时不得写 available**)
…「产物与源码不同源」

1. 修复:本 PR 前四个提交只改了 `i18n-src/<locale>/{add-account,checkin}.json`,没有重建 `ui/i18n/**`。CI 的 `frontend-artifacts` 工作流会重跑
   `npm --prefix desktop-tauri/ui-islands run build` 与 `npm --prefix desktop-tauri/i18n run build`,再以
   `git status --porcelain -- desktop-tauri/ui` 必须为空为判据 —— 缺产物就会报「入库的前端产物与源码不同源」。
   补跑 i18n 构建后产出 10 个文件(5 个语言 × add-account/checkin)。岛产物(`ui/islands/ui.js|css`)本地与入库逐字节相同,无需变动。
2. 说明:`ui/i18n/**` 与 `ui/islands/*` 都是**构建产物**(不得手改,重建即可);`desktop-tauri/i18n/build.mjs` 的繁中依赖 `opencc-js`,因此该包也要先 `npm ci` 才能重建。
@shadyrispy shadyrispy changed the title 新增:接入「AtomCode」(AtomGit / GitCode)—— 同方言直通转发 + 轮询式网页登录 + 按需领取免费 CodingPlan feat(atomcode):接入 AtomCode(AtomGit / GitCode)渠道,含轮询式网页登录与按需领取免费 CodingPlan Oct 17, 2026
1. 新增:`lobsterai` provider。上游是 **OpenAI 兼容的原生协议**(`POST /api/proxy/v1/chat/completions`)且**只收 SSE**(`stream:false` 回 HTTP 500);本网关的转发入口恒以 `stream:true` 请求上游(`core/upstream/mod.rs` 的原文),这条天然满足 —— 适配器**不动** `stream` 字段。**没有任何签名**,纯 Bearer(无 HMAC / DPoP / PKCE,也没有腾讯系那批归属头)
2. 新增:两个 `X-LobsterAI-Client-*` 头是功能性的,不是装饰 —— 少了 `kimi-k3-agentic-v1`,目录从 26 个模型变 25 个(`kimi-k3` 消失);少了 `thinking-level-control-v1`,发 `reasoning_effort:"off"` 直接 500。版本走日期式(`2026.9.4`),从更新接口的 **`data.value.version`** 取
3. 修复我写错的一处并在本提交内改正:`version.rs` 最初把 `data.value` 当**标量**读,而 Go 参考(`version.go:57-71`)是逐层取到 `data.value.version`(**嵌套对象**)。更值得记的是**我的单测夹具是照着自己的错法编的,于是测试全绿、代码全错**(线上表现是版本头永远退化成兜底常量)—— 现夹具按上游真实形状编,并加了一条「标量 value 必须读不到」的反向断言
4. 新增:网页登录是**浏览器授权 + 本机 loopback 回调**(`redirect_uri = http://127.0.0.1:<网关端口>/auth/callback`)。⚠️ 回调路径**刻意不带家名后缀**(与 `/auth/callback-accio` 等不一致):`redirect_uri` 是我们交给上游门户的,门户是否对回调路径有白名单**没有实测支撑**,所以跟随参考实现那条已验证的形状,不为了命名风格赌一次未经验证的兼容性(`http.rs` 那条路由的注释写了原委)
5. 新增:添加账号**只有网页登录一条路**,粘贴分支如实拒绝并说明理由 —— 续期需要 `uuid` 与两个 `keyfrom`,而它们只在换码那一刻由上游下发,用户从别处粘不出来。续期必须把这三样**原样回传**且 `latestKeyfrom` **永不更新**(照参考实现唯一经生产验证的形态);载荷件缺失时按 `canRefresh` 如实标出而不是拒绝落账号(那种账号能用到 access 过期)
6. 新增:出站三处改写,每处都有实测依据 —— `developer` 角色降级成 `system`(否则上游 502「角色信息不正确」)、`tool_choice` 为 `""`/`"none"`/`null` 时删掉、永不发 `prompt_cache_key`;图片**不做转换**(上游只认 `{type:"image_url"}`,转错形状不如让上游如实报错)
7. 新增:目录 `GET /api/models/available`(keyfrom 走 **query**、特意不带 refresh token —— query 会进服务端访问日志;信封是**单层** `data[]` 数组,不复用对象信封解析器)+ 19 条静态兜底(照 Go 参考逐字搬,远端成功时远端优先);余额 `GET /api/user/profile-summary` 的 `totalCreditsRemaining`(**不是** `/api/user/quota` —— 那个只报 300 免费额度,实测少算 5297.72);签到是上游「每日活动」三步(查活动位 → 取上下文 → `check_in`,实测 +100 积分/号/天),幂等靠客户端 `idempotencyKey` + 上游 `claimedToday` **两道预检**
8. 说明:`credits[]`(界面「积分明细」那一列读的键)只写 `name` + `remain` —— 上游明细条目只有 `type` 与 `creditsRemaining`(参考实现也把 total/used 留空,理由是上游没有周期总量概念);**到期那一栏故意不写**:上游给的是**字符串**时刻而界面那一栏要**毫秒数**,格式我们没核实过,宁可留空也不塞错(塞错会渲染成 1970 年)。另:读不到余额时**不写** `available`(沿用上一版在 AtomCode 上踩到的教训:写 0 会被限额判定当成「余额为 0」而拦账号)
9. 登记点:`providers/mod.rs` 五处、`adapter.rs` 两处、`catalog.rs`、`catalog_cache.rs`、账号存储两处 + `lobsterai_accounts.rs`(公开形态把**令牌本体与载荷件三件套一起摘掉**,只留 `canRefresh` 布尔)、`api/accounts.rs` 的添加与刷新两个分派、`api/session.rs` + `core/login.rs` + `http.rs` + `server/mod.rs`(loopback 端口)、`web_shim.rs` 的**远程面板手动回调兜底**(Docker 面板里 loopback 回不去容器)、壳侧 `login.rs` 四处、签到链四处(`CHECKIN_PROVIDERS` 14→15、`PROVIDERS_VERSION` 4→5 及增量登记、`checkin_for` 分支、签到页描述 + 五语言)、`ui-islands` 三处、图标(Go 参考里那份作者已核过来源的 SVG,栅格化成 256×256)
10. 未做(如实列出):**思考档位映射**(线上字段是 `openclawLevel`、`max` 档在线上不存在 ⇒ 客户端设的档位可能被上游忽略,按参考实现的说法是「等同没发」而非报错)、`max_tokens` 兜底填充、「HTTP 200 + 正文是 `{error}`」这种静默失败**仍无调用点**(与 AtomCode 同一个结构性原因:SSE 出口没有按帧判错误的钩子)
11. 测试:本次净增 65 条(本家 64 条 + 1 条 `credits[]` 契约);整条分支相对基点 beta `4f0040f` 的 **638 passed / 8 ignored** 现为 **764 passed / 8 ignored** ⇒ 净增 126 条
生产实测 1423 行「200 + 零 token」,其中 145 行连摘要都没有(`error` 是 NULL 不是
空串)。这 145 行其实是两种相反的病,库里记成同一种:

  · 一个字节都没下发(客户端拿到空流,必报 incomplete_stream)—— codearts 28 / loomy 2
  · 内容下发齐了、上游没给协议收尾帧 —— zcode 70 / kuku 2 / codearts 43

两类的修法相反:后者补收尾帧近乎纯收益;前者补了会把「上游没给内容」洗成
「模型答了个空」。所以先把判据采下来,不急着补帧。

新增两个观测,都不参与成功/失败判定:
  - `attempt_details[].forwardedBytes`:实际下发给客户端的响应字节数。计数点在
    `RecordingStream`,两条收尾路径都写。不能用 `RawCapture` 的缓冲长度代替 ——
    那个在调试模式关闭时第一个分支就 return,会让 0 字节这一类恒为 0。
    与已有的 `bodyBytes`(出站请求体,只有 codearts 写)是两个方向,故另起键名。
  - `notice` = 「上游流已结束,未下发协议收尾帧」,只在 EOF 且从未下发收尾帧时写。
    **写 notice 不写 error**:`is_success()` 是「2xx 且 error.is_none()」,写 error 会
    连带触发「失败清零 token」的归一 —— 那正是 terminal_seen() 当初救回来的
    1176 行 CodeArts 的病灶。文案刻意不含「未完整下发」,避免与真中断混成一条。

测试补三条,成对写以免空跑:EOF 无收尾必须留提示且用量不清零 / EOF 带收尾必须
不打提示 / 中断路径也要有 forwardedBytes。

顺带修了夹具本身的一个坑:`context_for` 只调 `note_attempt`(累加计数与账号),
明细条目实际由 `note_attempt_started` 推入 —— 少了后者,三个 `note_attempt_*`
观测全部静默丢失,而行里 `attempts` 因下限钳位仍显示 1,看起来"有明细"。
这一条是被新断言抓出来的,不是先想到再写的。
上一提交采了 `forwardedBytes`(实际下发给客户端的响应字节数),但库里有了数、
界面上仍然看不见 —— 尝试明细表原来只有「体积」一列,那是**出站请求体**
(title 写明「实际发给上游」)。两个方向必须在同一张表里并排,否则
「上游给了流但一个字节都没到客户端」与「内容齐了但上游没收尾帧」这两类
还是分不出来。

两个细节:
  · **0 如实显示成 `0 B`,不退化成「—」**:响应侧的 0 就是「空流」这个信号本身,
    把它显示成没数据等于把要抓的东西藏起来。缺省(字段不存在)才显示「—」。
    因此共用的 `formatBytes` 只管正数、返回 `null`,零与缺省交回调用方判断 ——
    发出体积的 0 含义相反(压根没发出去),两处规则不能合并。
  · 格式化提成一个函数,两处共用。原先请求体那列是内联写的,再抄一遍
    就是本仓第 N 份同形代码。

i18n 补 `已下发` / `实际下发给客户端 {bytes} 字节` 两键(5 个语言),
并按 `desktop-tauri/i18n/build.mjs` 重生成 `ui/i18n/**` 与 `ui/islands/ui.js`
—— 上个提交就是因为改了源没重建产物被 CI 的 frontend-artifacts 打回。
本仓转发层用 `client.post(url).body(bytes)` 发包,reqwest 的 `.body()` 不会
自动补 `Content-Type`(只有 `.json()` 会)。LobsterAI 的 chat 端点缺了它就回
`{"code":500,"message":"服务器内部错误"}`(HTTP 500,约 46ms 即返),跨模型
稳定复现,症状像「网关坏了」而不是「少了一个头」。

判据两条:① 伪令牌探针证明上游先鉴权(无论带不带该头都是 401 code 40100),
所以 500 出在鉴权之后;② 同一账号打同一域名的其它 POST(换码 /api/auth/exchange、
签到)都带了这个头且成功。两个参考实现也都把它列在四个基础头里。

同时补一条单测钉住这个头,免得再被静默去掉(原来的头集合里只测了
developer 角色归一)。
上游的路径是三段式 `{code}/context?configRevision=…`,我们把中间那段写丢了,
请求落到 `/api/client-activities/{code}` 上直接被回 HTTP 404,于是签到每次都
落在「活动上下文查询返回 HTTP 404」(dev 的 checkin_records 里就是这么记的)。

对照参考实现(Go `fetchActivityContext`、TS `lobsterai-credits.ts`)逐字同形
之后判定是漏段而不是上游改了路径;同一批 URL 里 `/actions/check_in` 那段是对的。

顺带把断言补到位:原先只有一条「活动码要转义」的用例,它测的是 query 片段,
`/context` 段整个缺失也不报错 —— 现在直接钉住两条 URL 的路径形状。
这几家(trae / accio / codearts / autoclaw-intl / lobsterai)的回调是「浏览器
带你回到本机端口」。原实现把 `waitForManualCallback` 直接放进 `Promise.race`,
弹框几乎与授权页同时出现 —— 用户自然会去粘贴,而自动那条往往几秒就完成了。

改成先给自动路径 10 秒窗口:窗口内完成就直接结束、不弹框;超时仍未完成才弹
兜底框(浏览器与网关不同机且没有端口映射时仍能救)。顺带把框里那句说明改成
「授权完成后账号没有自动出现,再粘」的口吻,跟它的兜底身份对齐。

这是让「面板经回环地址访问」(桌面端、或远程机器上做了端口映射)的路径真正
无需人工干预 —— 底层的回调路由本来就通(实测经端口映射打
/oauth/callback 回的是网关自己的页面),卡人的只是这个弹框时机。
后端 `submit_login_callback` 缺 `LobsterAi` 分支:粘贴进来的 URL 会落到函数末尾
那句「该登录任务不接收授权码回调」,能用的功能变成 400。现按标准 code/state
查询串解析(与 Accio / Antigravity 同形)并复用 `/auth/callback` 的收尾。

前端 `waitForManualCallback` 的称号链与 `needsManualCallback` 白名单对不上:
lobsterai 落到 AutoClaw、autoclaw-intl 顶着国内版名字。补齐两支,兜底改用
provider id 本身 —— 宁可不那么好看,也不要给一家安上别家的名字。

两处同属一条路径:声明了要粘贴,收尾就必须有落点。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant