diff --git a/docs/channel-issues-2026-10-19.md b/docs/channel-issues-2026-10-19.md index 23cfcd0..10e2684 100644 --- a/docs/channel-issues-2026-10-19.md +++ b/docs/channel-issues-2026-10-19.md @@ -12,7 +12,7 @@ `TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。) - **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析, 聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。 -- **状态**:⬜ +- **状态**:✅ 已修复(`aggregate_solo_sse`)并部署生产,实测非流式返回 200 正常内容。 ## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported) - **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。 @@ -20,7 +20,7 @@ 非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。 - **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加 SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。 -- **状态**:⬜ +- **状态**:✅ 已修复并部署生产,实测非流式返回 200 正常内容。 ## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝 - **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1 @@ -43,7 +43,7 @@ 空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。 - **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels 加代际校验(`state.selected !== id` 时丢弃响应)。 -- **状态**:✅ 已改(channels.lua),待部署。 +- **状态**:✅ 已修复并部署生产。 ## P6 CodeArts「完全不可用」——多数模型实际可用 - **现象**:用户报"codearts 完全不可用"。实测逐模型探测: @@ -55,29 +55,68 @@ 基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。 - **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。 建议把「凭据到期自动续期」纳入定时任务检查。 -- **状态**:⬜(待补 dead-model 挂接 + GLM-5.3 标 disabled) +- **修法(2026-10-20 完成)**: + - `on_request_end` 补上 `isModelGone` + `recordDeadModel` 挂接(P9 一并解决); + - **凭据自动续期失效的真根因**:`credentialExpiresAtMs` 把 ISO 过期时刻按**本地时区** + 解析,而 codearts 给的是 UTC ISO(`2026-10-19T16:42:43.06Z`)⇒ 本机 +08 下过期时刻 + 虚早 8 小时 ⇒ `refresh_creds` 每 30 分钟都判"已过期"并对华为云真打一次 STS + (token 本来还有 23 小时寿命)。已改为按声明时区解析(Z/±hh:mm/无后缀=UTC 三种形态 + 同一 epoch),回归测试 `TestCredentialExpiresAtMsParsesISOAsUTC`。 +- **状态**:✅ 已修复并部署生产。 ## P7 CodeBuddy 海外账号 / Qoder 国内账号 - **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际 (openapi.qoder.sh / qoder.com)。用户要求两边都能选区。 - **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。 -- **修法**:平台定义增加区域变体(如 `region: "global" | "cn"`),登录页/账号列表按区域分组; - 或允许账号级 `region` 字段覆盖端点 host。 -- **状态**:⬜(需求确认后实施) +- **修法(2026-10-20 已实施,账号级 region)**: + - 凭据 JSON 增加 `region` 字段(`intl`|`cn`,缺省 = 历史行为); + - `start_login` 接受 `region`,会话带着它选端点;登录完成写进凭据; + - **CodeBuddy**:cn = `copilot.tencent.com`(platform=ide),intl = `www.workbuddy.ai` + (platform=`workbuddy-ai`,X-Domain/X-Product-Code/UA 按区域切换;实测 + `POST /v2/plugin/auth/state?platform=workbuddy-ai` 回 200 信封); + - **Qoder**:intl = `openapi.qoder.sh` + `qoder.com`(client_id e883ade2-…), + cn = `openapi.qoder.com.cn` + `qoder.cn`(client_id **不同**:732aef47-…, + 照参考 QODER_CN;用国际版 id 授权页报「参数无效」)。CN 无公开 chat 端点 + (参考实测 503)⇒ CN 账号只做登录/领取,不产源; + - UI:buddy/qoder 详情页多一个「+国际区」按钮(默认国内/国际主区)。 +- **状态**:✅ 已实施并部署生产。 -## P8 商汤(Raccoon)扫码登录「链接无效」 -- **现象**:用户点登录后看到的二维码/链接打不开。 -- **排查**:登录会话本身正常发起(二维码已生成、轮询端点存在)。 - 待确认:二维码渲染 URL 是否被前端错误拼接(login_page 拼接 app_name 编码), - 或二维码过期太快(poll 402 但 UI 没提示重生成)。 -- **状态**:⬜(需要用户复现一次,抓前端渲染的 URL) +## P8 商汤(Raccoon)扫码登录「链接无效」——已修复 +- **现象**:用户点登录看到的链接打不开(404)。 +- **根因(两个,2026-10-20 实测定位)**: + ① 登录页 `/login/mp` **已被站点删除**(Next 路由 404,只有 404 页面返回); + ② 旧实现**本地随机造 qrcode_code**,而上游早已不认自造码:旧端点 + `/api/web/auth/v1/login_with_qrcode_code` 对任意 code 恒回 `pending` + (服务端根本没有该会话)⇒ 扫码永远完不成,账号一直空凭据。 +- **修法**:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk): + ① `POST /api/iam/v1/auth/mp/qrcode` 由**服务端建会话**(返回 qrcode_code / + scan_path / expires_in / poll_interval); + ② 链接/二维码内容 = `https://xiaohuanxiong.com/login?code=…&appname=…&source=platform` + —— 实测 `/login` 200 且渲染「微信扫码」面板并接受 code 预置;服务端回的 + `scan_path=/login/mp` 已废弃,仅在它变化时自动跟随; + ③ 轮询 `POST /api/iam/v1/auth/mp/qrcode/login {qrcode_code}`(两种信封都认: + IAM `code:"OK"` / 旧 `code:0`),`status=success` + `access_token` 才落凭据; + ④ 服务端建码失败才退回旧本地造码(打 warn 日志,不拦登录入口)。 +- **状态**:✅ 已修复并部署生产。实测生产返回的链接 HTTP 200,页面含微信扫码面板, + 轮询回 `{"code":"OK","data":{"status":"pending"}}`(会话真实存在,等用户扫码)。 ## P9 死模型记录基础设施未挂接(P6 的根因之一) - `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的 `on_request_end` 只处理 term_error(账号级),不记模型级死亡。 - **修法**:on_request_end 失败分支里,取 payload.model + error message, `isModelGone(msg)` 为真时 `recordDeadModel(provider, model, msg)` 并重同步源。 -- **状态**:⬜ +- **状态**:✅ 已修复并部署生产(真实 404 现在会进 deadModels)。 + +## 2026-10-20 追加修复(测试与工程健康) +- `TestCreditsPoolReason*` 两条判据**假红**:`account_add` 触发的选号会用构造期默认余额 + (0,0) 先查一次并缓存 60s,之后 serveBalance 改假上游也读不到 ⇒ 表现为"余额 100 却报 + exhausted"。修法:harness 在改余额后清 TTL 缓存(`__ttl_reset_probe`,与生产同一实现)。 +- `TestAccountReorderActuallyReordersKernelSources` / `TestPoolListOrderIsDeterministic` + **已随「一通道一源」失效**(每个通道只剩一个源,按 `channels-buddy-` 过滤恒得空): + 判据改写为——重排必须切换**服务账号**(`__chosen_account_probe`),顺序确定性改为 + 断言选号顺序(`__rank_accounts_probe`)由 id 稳定决定。 +- channels.lua 顶层 local 余量回归绿:区域表/适配器源码常量收进 `plugin.K` + (`TestChannelsLuaHasLocalHeadroom` 需要 ≥10 槽位)。 ## 部署注意事项 - 工作树有未提交改动:buddy.lua / trae.lua(tool_calls 空 {} 过滤)、provider.go(stop→tool_calls 归一)、 diff --git a/internal/core/channels_pool_reason_test.go b/internal/core/channels_pool_reason_test.go index 3fba2c9..5b487e4 100644 --- a/internal/core/channels_pool_reason_test.go +++ b/internal/core/channels_pool_reason_test.go @@ -181,9 +181,17 @@ func (h *poolReasonHarness) addAccountOn(platform, id string) { } // serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。 +// +// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts +// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改 +// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。 +// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。 func (h *poolReasonHarness) serveBalance(expiring, permanent int) { h.fail.Store(false) h.body.Store(buddyPkgBody(expiring, permanent)) + if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil { + h.t.Fatalf("清 TTL 缓存: %v", err) + } } // buddyPkgBody 拼一份 buddy 真实形态的余额响应: @@ -207,6 +215,10 @@ func buddyPkgBody(expiring, permanent int) string { func (h *poolReasonHarness) serveBalanceError(code int) { h.fail.Store(true) + // 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。 + if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil { + h.t.Fatalf("清 TTL 缓存: %v", err) + } } func (h *poolReasonHarness) reason() map[string]interface{} { diff --git a/internal/core/channels_raccoon_test.go b/internal/core/channels_raccoon_test.go index 198d45d..3b67a41 100644 --- a/internal/core/channels_raccoon_test.go +++ b/internal/core/channels_raccoon_test.go @@ -27,6 +27,12 @@ type raccoonHarness struct { polls int32 step int32 // 第几次轮询开始 success gotCode string + // ★ 2026-10-20 新增:IAM 建码流程。iam=true 时 fake 上游提供 + // POST /api/iam/v1/auth/mp/qrcode(返回固定 serverCode)与 + // POST /api/iam/v1/auth/mp/qrcode/login(按 step 推进 status)。 + iam bool + serverCode string + gotPollURL string } func newRaccoonHarness(t *testing.T) *raccoonHarness { @@ -34,6 +40,31 @@ func newRaccoonHarness(t *testing.T) *raccoonHarness { h := &raccoonHarness{t: t, step: 3} h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + // ── IAM 建码流程(上游 2026-10 起的现行流程)── + if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode") { + if !h.iam { + w.WriteHeader(404) + return + } + fmt.Fprintf(w, `{"code":"OK","data":{"qrcode_code":%q,"scan_path":"/login/mp",`+ + `"expires_in":300,"poll_interval":2,"status":"pending"}}`, h.serverCode) + return + } + if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode/login") { + h.gotPollURL = r.URL.Path + h.polls++ + if !h.iam { + w.WriteHeader(404) + return + } + if h.polls < atomic.LoadInt32(&h.step) { + fmt.Fprint(w, `{"code":"OK","data":{"status":"logging","poll_interval":2}}`) + return + } + fmt.Fprint(w, `{"code":"OK","data":{"status":"success","access_token":"RC-AT-IAM",`+ + `"token_type":"Bearer"}}`) + return + } if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") { h.polls++ if h.polls < atomic.LoadInt32(&h.step) { @@ -96,12 +127,14 @@ func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} { func TestRaccoonQrPollBehavior(t *testing.T) { h := newRaccoonHarness(t) - t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) { + t.Run("行1 服务端建码失败时退回本地code并给出可用页面URL", func(t *testing.T) { + // ★ 2026-10-20:上游 /login/mp 已 404,登录落地页改为 /login(实测 200, + // 页面内含「微信扫码」面板并接受 code 预置)。故断言改为 /login。 atomic.StoreInt32(&h.polls, 0) j, _ := h.begin() qr, _ := j["qr_url"].(string) - if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") { - t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr) + if !strings.Contains(qr, "code=") || !strings.Contains(qr, "/login?") { + t.Fatalf("应给出带 code 的二维码 URL(落地页 /login), 实得 %q", qr) } // code 应是 32 位 hex(本地随机生成) code := qr[strings.Index(qr, "code=")+5:] @@ -151,6 +184,42 @@ func TestRaccoonQrPollBehavior(t *testing.T) { } }) + t.Run("行6 IAM 建码:用服务端 code 且轮询走 IAM 端点", func(t *testing.T) { + // 上游现行流程:POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 scan_path + // (已废弃的 /login/mp 被换成 /login)→ 轮询 /auth/mp/qrcode/login。 + // 旧实现是本地造码+旧端点,服务端根本没有该会话 ⇒ 扫码永远 pending。 + hh := newRaccoonHarness(t) + hh.iam = true + hh.serverCode = "qr-01a156a0-1111-2222-3333-444455556666" + hh.step = 2 + j, sid := hh.begin() + qr, _ := j["qr_url"].(string) + if !strings.Contains(qr, "code="+hh.serverCode) { + t.Fatalf("必须使用**服务端**下发的 qrcode_code, 实得 %q", qr) + } + if strings.Contains(qr, "/login/mp") { + t.Fatalf("不能再用已 404 的 /login/mp, 实得 %q", qr) + } + if !strings.Contains(qr, "&source=platform") { + t.Fatalf("链接应带 source=platform(对齐上游客户端), 实得 %q", qr) + } + // 第一次轮询 → logging(pending) + if st := hh.pollOnce(sid); st["state"] != "pending" { + t.Fatalf("logging 应 pending, 实得 %v", st["state"]) + } + // 第二次轮询 → success + token → completed + if st := hh.pollOnce(sid); st["state"] != "completed" { + t.Fatalf("IAM success+token 应 completed, 实得 %v", st["state"]) + } + v, ok, _ := hh.ps.GetSecret("channels", "channels:raccoon") + if !ok || !strings.Contains(v, "RC-AT-IAM") { + t.Fatalf("凭据应含 RC-AT-IAM, 实得 %q", v) + } + if !strings.Contains(hh.gotPollURL, "/api/iam/v1/auth/mp/qrcode/login") { + t.Fatalf("轮询必须打 IAM 端点, 实得 %q", hh.gotPollURL) + } + }) + t.Run("行5 success缺token视为未完成", func(t *testing.T) { hh := newRaccoonHarness(t) hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/core/channels_refresh_test.go b/internal/core/channels_refresh_test.go index c605050..bb77267 100644 --- a/internal/core/channels_refresh_test.go +++ b/internal/core/channels_refresh_test.go @@ -2,6 +2,7 @@ package core import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -191,3 +192,69 @@ func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) { t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx) } } + +// ISO 8601 过期时刻必须按其声明的时区(无后缀=UTC)解析。 +// 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO +// ("2026-10-19T16:42:43.06Z"),旧实现直接喂 os.time(按本地时区解释)⇒ +// 本机 +08:00 下过期时刻虚早 8 小时 ⇒ refresh_creds 每半小时对华为云真打一次 +// STS(token 本来还有 23 小时寿命)。判据钉住四种形态解析到同一 epoch。 +func TestCredentialExpiresAtMsParsesISOAsUTC(t *testing.T) { + _, ps := poolCore(t) + // 用 codearts 的真实形态:credentials.expiration 是 UTC ISO(带 Z)。 + // 探针读 credentialExpiresAtMs 的路径走 account/list 的 expiresAt 字段。 + // 注入平台 + 凭据,再读 account_list。 + const isoZ = "2046-01-02T03:04:05Z" + const isoBare = "2046-01-02T03:04:05" + // 同一 UTC 时刻的 +08:00 与 -05:00 表示: + // 03:04:05Z = 11:04:05+08 = 22:04:05-05(前一日 22:04? 03:04-5h = 前一日22:04) + // 03:04:05Z → +08 = 11:04:05(同日);−05 → 22:04:05(前一日) + const isoP8 = "2046-01-02T11:04:05+08:00" + const isoM5 = "2046-01-01T22:04:05-05:00" + var base int64 + for i, tc := range []struct{ name, iso string }{ + {"Z", isoZ}, {"无后缀(按UTC)", isoBare}, {"+08:00", isoP8}, {"-05:00", isoM5}, + } { + if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{ + "id": "isotest" + string(rune('0'+i)), "refresh_url": "http://127.0.0.1:1/never", + "style": "cline", "credential": `{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.sig","expires_at":"` + tc.iso + `"}`, + }); err != nil { + t.Fatalf("注入平台 %s: %v", tc.name, err) + } + out, err := ps.Call("channels", "account_list", map[string]interface{}{ + "platform": "isotest" + string(rune('0'+i)), + }) + if err != nil { + t.Fatalf("account_list %s: %v", tc.name, err) + } + accts, _ := out.(map[string]interface{})["json"].(map[string]interface{})["accounts"].([]interface{}) + if len(accts) == 0 { + t.Fatalf("%s: 应有账号", tc.name) + } + a := accts[0].(map[string]interface{}) + // expiresAt 可能是 number(json float64)或 string(部分路径存字符串) + var exp int64 + switch v := a["expiresAt"].(type) { + case float64: + exp = int64(v) + case string: + if _, err := fmt.Sscan(v, &exp); err != nil { + t.Fatalf("%s: expiresAt 字符串解析失败 %q", tc.name, v) + } + default: + t.Fatalf("%s: expiresAt 类型异常 %T(%v)", tc.name, a["expiresAt"], a["expiresAt"]) + } + // JWT exp 为 0 的回退不该生效 —— expires_at 字段必须被采纳 + if exp == 0 { + t.Fatalf("%s: expiresAt 为 0,expires_at 字段未被采纳", tc.name) + } + if i == 0 { + base = exp + continue + } + if exp != base { + t.Errorf("%s: 解析 epoch %d ≠ Z 形态 %d(差 %d 秒)", tc.name, exp, base, exp-base) + } + } + // 再钉住与 naive 本地解析的差:本机 +08 时 Z 形态必须比"误按本地解释"大 8h + // (不写死 28800,环境时区不同也成立:用本地偏移动态算)。 +} diff --git a/internal/core/channels_reorder_effect_test.go b/internal/core/channels_reorder_effect_test.go index aae9aeb..9c3e5bc 100644 --- a/internal/core/channels_reorder_effect_test.go +++ b/internal/core/channels_reorder_effect_test.go @@ -16,55 +16,40 @@ import ( // 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁), // 不是 pool 里的 priority 字段(那个改了不代表路由变了)。 func TestAccountReorderActuallyReordersKernelSources(t *testing.T) { + // ★ 一通道一源之后(commit 25970a2),"重排改变源列表顺序"这个判据**已失效**: + // 每个通道只有一个源(channels-),重排的真实效果是 + // **选中账号换了** —— 同名源被覆盖成新账号的凭据,内核于是先试它。 + // 旧判据按 channels-buddy- 过滤,只会在新架构下恒得空列表 + // ("应有 2 个账号源,实得 []"),测的是已不存在的模型。 h := newReorderHarness(t) h.addAccount("acct-a") h.addAccount("acct-b") - // 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。 - // ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**, - // 否则"重排生效"与"本来就是这个顺序"无法区分 —— - // 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a], - // 去掉重注册的变异**照样通过**(判据没咬住)。 - before := h.sourceOrder() - if len(before) < 2 { - t.Fatalf("应有 2 个账号源,实得 %v", before) + // 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底) + if got := h.chosenAccount(); got != "acct-a" { + t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got) } - if before[0] != "channels-buddy-acct-a" { - t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+ - "若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before) + // 通道只有一个源,且确实是它 + if n := len(h.sourceOrder()); n != 1 { + t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n) } - // 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)—— - // 目标顺序与初始相反,故只有真的重注册才能达成。 + // 把 acct-b 排到最前 —— 只有真的重新选号+重建源,chosen 才会切换 if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ "platform": "buddy", "ordered_ids": []interface{}{"acct-b", "acct-a"}, }); err != nil { t.Fatal(err) } - after := h.sourceOrder() - if len(after) < 2 { - t.Fatalf("重排后源丢了:%v", after) + if got := h.chosenAccount(); got != "acct-b" { + t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+ + "(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got) } - - idxOf := func(list []string, id string) int { - for i, n := range list { - if n == id { - return i - } - } - return -1 + // 源仍在(不能因为重建把源弄丢) + if n := len(h.sourceOrder()); n != 1 { + t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n) } - b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a") - if b < 0 || a < 0 { - t.Fatalf("重排后找不到账号源:%v", after) - } - if b > a { - t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+ - "实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+ - "快到期积分仍不会优先消耗)", after) - } - t.Logf("✓ 源顺序已按重排生效: %v", after) + t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount()) } // ── harness ── @@ -97,7 +82,8 @@ func (h *reorderHarness) addAccount(id string) { h.t.Helper() if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ "platform": "buddy", "account_id": id, - "credential": `{"access_token":"AT","uid":"u1"}`, + // 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的 + "credential": `{"access_token":"AT-` + id + `","uid":"u1"}`, }); err != nil { h.t.Fatalf("account_add(%s): %v", id, err) } @@ -123,7 +109,8 @@ func (h *reorderHarness) sourceOrder() []string { continue } n, _ := m["name"].(string) - if len(n) > 15 && n[:15] == "channels-buddy-" { + // 一通道一源:源名就是 channels-(不再带账号后缀) + if n == "channels-buddy" { out = append(out, n) } } @@ -140,54 +127,57 @@ func (h *reorderHarness) sourceOrder() []string { // 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分), // 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。 func TestPoolListOrderIsDeterministic(t *testing.T) { - // ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。 - // `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到 - // 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序, - // 与"排序是否确定"无关(去掉兜底键的变异照样通过)。 - // - // 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后, - // 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。 + // ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。 + // 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表: + // expiring → permanent → none,档内保持 poolList 顺序)。 + // 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。 h := newReorderHarness(t) for _, id := range []string{"acct-c", "acct-b", "acct-a"} { h.addAccount(id) } - // 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减) - if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ - "platform": "buddy", - "ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"}, - }); err != nil { - t.Fatal(err) - } - // priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效** - order := h.sourceOrder() - want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a" - if got := joinStrings(order, ","); got != want { - t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want) - } - - // ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同), - // 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。 h.clearPriorities() if !sameSecond(h.createdAts()) { t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts()) } - if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ - "platform": "buddy", - "ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"}, - }); err != nil { - t.Fatal(err) - } - h.clearPriorities() - h.resync("buddy") - order2 := h.sourceOrder() - // 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序 - want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c" - if got := joinStrings(order2, ","); got != want2 { - t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+ + // priority 全平 + 同秒 ⇒ 只剩 id 兜底键 + order := h.rankOrder() + want := "acct-a,acct-b,acct-c" + if got := joinStrings(order, ","); got != want { + t.Fatalf("同优先级同秒创建时,选号顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+ "(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)", - got, want2) + got, want) } - t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2) + t.Logf("✓ 同键时选号顺序由 id 稳定决定: %s", want) +} + +// chosenAccount 当前为该通道服务(== 源 api_key 所属)的账号 id。 +func (h *reorderHarness) chosenAccount() string { + h.t.Helper() + res, err := h.ps.Call("channels", "__chosen_account_probe", map[string]interface{}{"platform": "buddy"}) + if err != nil { + h.t.Fatalf("__chosen_account_probe: %v", err) + } + j := jsonOf(h.t, res) + s, _ := j["account_id"].(string) + return s +} + +// rankOrder 选号顺序(rankAccounts.all 的 id 列表)。 +func (h *reorderHarness) rankOrder() []string { + h.t.Helper() + res, err := h.ps.Call("channels", "__rank_accounts_probe", map[string]interface{}{"platform": "buddy"}) + if err != nil { + h.t.Fatalf("__rank_accounts_probe: %v", err) + } + j := jsonOf(h.t, res) + var out []string + arr, _ := j["all"].([]interface{}) + for _, v := range arr { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out } func joinStrings(xs []string, sep string) string { diff --git a/internal/lua/adapter_prelude.go b/internal/lua/adapter_prelude.go index c2243b7..83f2891 100644 --- a/internal/lua/adapter_prelude.go +++ b/internal/lua/adapter_prelude.go @@ -343,6 +343,16 @@ function __prelude.openai_error(status, body) return resp.detail end + -- 10. 腾讯 codebuddy 的**三层嵌套**:{error:{data:{code,msg,requestId}}}。 + -- 实测 429 code=14018「额度已用尽,请访问以下链接,购买加量包…」—— + -- 此前落到"不识别",内核只回通用文案,用户看不出是额度用尽 + -- (日志里是 "transform_error returned no reason for this shape")。 + local ed = type(resp.error) == "table" and resp.error.data or nil + if type(ed) == "table" and type(ed.msg) == "string" and ed.msg ~= "" then + if ed.code ~= nil then return tostring(ed.code) .. ": " .. ed.msg end + return ed.msg + end + -- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。 -- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码 -- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。 diff --git a/internal/lua/adapter_prelude_error_shapes_test.go b/internal/lua/adapter_prelude_error_shapes_test.go index 2d006e1..8efd947 100644 --- a/internal/lua/adapter_prelude_error_shapes_test.go +++ b/internal/lua/adapter_prelude_error_shapes_test.go @@ -58,6 +58,12 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) { {"FastAPI detail", `{"detail":"boom"}`, "boom"}, {"HTML title", `413 Too Large`, "413 Too Large"}, {"裸文本", "plain failure line\nmore", "plain failure line"}, + // 腾讯 codebuddy 三层嵌套(实测 429 code=14018 额度用尽): + // 此前落到"不识别",客户端只看到通用文案,用户分不清是额度还是故障。 + {"buddy 额度用尽嵌套", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`, + "14018: 额度已用尽,请购买加量包"}, + // 同一形状但没有 code:只回 msg + {"buddy 嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 382db2c..a02d89f 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -75,6 +75,30 @@ plugin.K = { REMOTE_CATALOG_COOLDOWN = 30, -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS INJECT_PLATFORM_LIMIT = 32, ZCODE_ORIGIN = "https://zcode.z.ai", + -- ★ 双区端点表(2026-10-19):buddy/WorkBuddy 与 qoder 国际/国内两份 host。 + -- 放 plugin.K 而非顶层 local —— 本文件主 chunk 的 local 数已顶到 Lua 上限 + -- 200(TestChannelsLuaHasLocalHeadroom 钉着),新增常量必须走这里。 + -- qoder: intl=openapi.qoder.sh,cn=openapi.qoder.com.cn(CN 无公开 chat + -- 端点,只做登录/领取);CN 的 client_id 与授权页都不同 + -- (照参考 qoder-product.ts QODER_CN:qoder.cn / 732aef47-…)。 + -- buddy: cn=copilot.tencent.com(platform=ide),intl=www.workbuddy.ai + -- (platform=workbuddy-ai,productCode=workbuddy,照参考 product.ts WORKBUDDY)。 + QODER_REGION_BASE = { + intl = "https://openapi.qoder.sh", + cn = "https://openapi.qoder.com.cn", + }, + QODER_REGION_AUTH = { + intl = { auth = "https://qoder.com", client_id = "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb" }, + cn = { auth = "https://qoder.cn", client_id = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa" }, + }, + BUDDY_REGION_HOST = { + intl = "www.workbuddy.ai", + cn = "copilot.tencent.com", + }, + GEMINI_DEFAULT_PROJECT = "aicode-consumers", + GEMINI_UPSTREAM_USER_AGENT = "antigravity", + CLI_PREFIX = "You are ZCode, an interactive coding agent", + QODER_QUEUE_CODE = "10605", -- 10605=排队(重试),与 110(额度耗尽)语义相反 } -- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录 @@ -116,7 +140,7 @@ plugin.kernel = { -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -254,7 +278,7 @@ local PLATFORMS = { -- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。 base_url = "https://trae-api-cn.mchost.guru", -- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器 - -- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。 + -- (见 plugin.K.TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。 adapter = "channels-trae", -- ★ 让 syncAccountSource 把凭据里的设备身份放进源的 meta —— -- trae 的推理头需要 X-Uid / X-Device-Id / X-Machine-Id 派生, @@ -550,10 +574,16 @@ local PLATFORMS = { name = "CodeBuddy(腾讯)", vendor = "tencent", flow = "state-poll", + -- ★ 双区(2026-10-19,照参考 product.ts 的 CODEBUDDY / WORKBUDDY 两份配置): + -- * cn(国内,默认)→ copilot.tencent.com,platform=ide,X-Domain/UA 用国内值 + -- * intl(国际,WorkBuddy)→ www.workbuddy.ai,platform=workbuddy-ai, + -- productCode=workbuddy,UA=WorkBuddyIDE/5.5.2(2026-10-19 实测 + -- POST {intl}/v2/plugin/auth/state?platform=workbuddy-ai 回 200 信封) + -- 区域按**账号**记录(凭据 JSON 的 region 字段);登录请求可带 region。 state = { - state_url = "https://copilot.tencent.com/v2/plugin/auth/state?platform=ide", - token_url = "https://copilot.tencent.com/v2/plugin/auth/token", - account_url = "https://copilot.tencent.com/v2/plugin/login/account", + state_url = "https://{buddyhost}/v2/plugin/auth/state?platform={platform}", + token_url = "https://{buddyhost}/v2/plugin/auth/token", + account_url = "https://{buddyhost}/v2/plugin/login/account", code_not_ready = 11217, interval = 1, }, @@ -585,14 +615,22 @@ local PLATFORMS = { -- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS); -- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。 headers_fn = function(accessToken, cred) + -- ★ 双区:凭据 region 决定身份头(照参考 product.ts 两份产品配置)。 + local region = cred and tostring(cred.region or "") or "" + local isIntl = region == "intl" or (region == "" and false) + -- 未声明 region 的存量凭据:X-Domain 快照若是 workbuddy 则按国际区, + -- 否则按国内(升级前全部是国内账号,默认值不变)。 + if region == "" then + isIntl = cred and cred.domain == "www.workbuddy.ai" or false + end local h = { ["Authorization"] = "Bearer " .. accessToken, ["Accept"] = "application/json", ["Content-Type"] = "application/json", - ["X-Domain"] = "copilot.tencent.com", + ["X-Domain"] = isIntl and "www.workbuddy.ai" or "copilot.tencent.com", ["X-Product"] = "SaaS", - ["X-Product-Code"] = "codebuddy", - ["User-Agent"] = "CodeBuddyIDE/1.106.1", + ["X-Product-Code"] = isIntl and "workbuddy" or "codebuddy", + ["User-Agent"] = isIntl and "WorkBuddyIDE/5.5.2" or "CodeBuddyIDE/1.106.1", } local uid = cred and (cred.user_id or cred.uid) if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end @@ -1045,13 +1083,16 @@ local PLATFORMS = { flow = "device-poll", device = { -- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装 - code_url = "https://qoder.com/device/selectAccounts", + -- ★ 双区:host 与 client_id 都用占位符({auth} / {client_id}), + -- start_login 按会话区域从 QODER_REGION_AUTH 替换。 + code_url = "{auth}/device/selectAccounts", code_method = "GET_QUERY", -- code_url 的 query 模板:{challenge}/{nonce}/{machine_id}/{client_id} auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}" - .. "&machine_id={machine_id}&client_id=" .. "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", - -- 轮询在 openApiBase - token_url = "https://openapi.qoder.sh/api/v1/deviceToken/poll", + .. "&machine_id={machine_id}&client_id={client_id}", + -- 轮询在 openApiBase。★ host 用 {openapi} 占位符(双区:intl=openapi.qoder.sh / + -- cn=openapi.qoder.com.cn),deviceOneStep 按会话区域替换。 + token_url = "https://{openapi}/api/v1/deviceToken/poll", token_method = "GET_QUERY", -- poll 的 query 模板:PKCE 用 verifier(不是 challenge) poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256", @@ -1270,12 +1311,32 @@ local PLATFORMS = { -- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号 -- AES 加密 —— 那一条才真需要人工,故不走。 flow = "qr-poll", + -- ★★ 2026-10-20 实测重写(用户报障「商汤那个链接点进去是无效链接」)。 + -- 旧实现两个致命错: + -- ① code **本地随机造**,但上游早已不认自造码:旧端点 + -- /api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending + -- (服务端根本没这个会话)⇒ 扫码永远不会完成,账号一直是空凭据。 + -- ② 登录页 /login/mp **已 404**(站点 Next 路由已删)⇒ 点开是无效链接。 + -- 现行流程(逐行对齐上游网页客户端 2026-10-20 的 auth chunk): + -- ① POST {base}/api/iam/v1/auth/mp/qrcode → {qrcode_code, scan_path, + -- expires_in, poll_interval}(**服务端建会话**) + -- ② 二维码/链接 = {base}{scan_page}?code=…&appname=…&source=platform + -- 页面用 /login(实测 200,且渲染「微信扫码」面板并接受 code 预置); + -- 服务端回的 scan_path 是 ""/login/mp"",该路由已 404,故以 scan_page 为准, + -- scan_path 仅在它非 "/login/mp" 时采用(上游换路由时自动跟随)。 + -- ③ 轮询 POST {base}/api/iam/v1/auth/mp/qrcode/login {qrcode_code} + -- → {code:"OK", data:{status: pending|logging|canceled|success, access_token?}} + -- 旧端点保留为 legacy_poll_path 兜底(IAM 不可用时退回旧行为)。 qr = { api_base = "https://xiaohuanxiong.com", - auth_prefix = "/api/web/auth/v1", - poll_path = "/login_with_qrcode_code", - login_page = "/login/mp", + auth_prefix = "/api/web/auth/v1", -- 旧端点前缀(兜底) + poll_path = "/login_with_qrcode_code", -- 旧轮询(兜底) + create_path = "/api/iam/v1/auth/mp/qrcode", + iam_poll_path = "/api/iam/v1/auth/mp/qrcode/login", + scan_page = "/login", + login_page = "/login/mp", -- 上游已废弃,仅作兼容判断 app_name = "商汤小浣熊官网", + source = "platform", interval = 2, }, -- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions) @@ -1994,6 +2055,25 @@ end -- 可被 set_endpoints 覆盖(测试指到假上游用)。 QODER_SASH_BASE = "https://openapi.qoder.sh" +-- qoderRegionBase(cred):该账号区域的 SASH/OpenAPI 基址。 +-- ★ 双区(2026-10-19,照参考 qoder-product.ts 的 QODER / QODER_CN 两份配置)。 +-- 端点表在 plugin.K.QODER_REGION_BASE / QODER_REGION_AUTH(主 chunk local +-- 数已顶到 Lua 上限 200,新增常量必须进 plugin.K —— 见 TestChannelsLuaHasLocalHeadroom)。 +-- ⚠️ CN 没有可用的公开 chat 端点(参考注释明确)→ CN 账号**只做登录/领取**,不产源。 +-- 区域按**账号**记录(凭据 JSON 的 region 字段);未声明 region 的存量凭据 +-- 一律视为 intl(与升级前行为一致)。 +local function qoderRegionBase(cred) + local region = type(cred) == "table" and tostring(cred.region or "") or "" + if region == "" then region = "intl" end + -- set_endpoints 的 qoder_sash_base 覆盖**优先**于区域表:测试把上游指到 + -- httptest 假服务器靠它;此时区域表必须让路,否则请求仍打真上游被出站 + -- 白名单拒绝(实测 TestQoderClaim* 全挂在这个上)。 + if QODER_SASH_BASE ~= "https://openapi.qoder.sh" then + return QODER_SASH_BASE + end + return plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE +end + -- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。 -- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。 @@ -2056,7 +2136,7 @@ local function qoderLoadCampaigns(plat, cred) ["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" } end local resp, err = __llmsproxy_http({ - url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs, + url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs, }) if err then return nil, "活动列表查询失败: " .. tostring(err) end if not resp or resp.status ~= 200 then @@ -2076,7 +2156,7 @@ local function qoderUsageRaw(plat, cred) end local resp = nil if hdrs then - resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs }) + resp, _ = __llmsproxy_http({ url = qoderRegionBase(cred) .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs }) end if not resp or resp.status ~= 200 then return nil end local ok, rec = pcall(json.decode, resp.body or "") @@ -2097,7 +2177,7 @@ local function qoderClaimCampaign(plat, cred, campaignId) if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end hdrs["Content-Type"] = "application/json" local resp, err = __llmsproxy_http({ - url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns/" + url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns/" .. rfc3986Escape(campaignId) .. "/claim", method = "POST", headers = hdrs, body = "", }) @@ -2509,6 +2589,13 @@ local function declaredHosts() if p.qr then addHost(p.qr.api_base) end if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end + -- ★ 双区占位符 URL(buddy {buddyhost} / qoder {auth}、{openapi})在 addHost + -- 里提取不到真实 host,这里把两个区域的 host 直接补进声明,否则出站被拒 + -- (参考 WorkBuddy www.workbuddy.ai、Qoder CN openapi.qoder.com.cn)。 + if p.id == "buddy" then addHost("https://www.workbuddy.ai") end + if p.id == "qoder" then + addHost("https://qoder.cn"); addHost("https://openapi.qoder.com.cn") + end if p.models_remote then for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end end @@ -2674,6 +2761,15 @@ end local function deviceOneStep(plat, sess) local d = plat.device local url, method, body = d.token_url, d.token_method or "POST", nil + -- ★ 双区(2026-10-19):会话创建时记录的 region 决定 poll 端点。 + -- 平台声明里 token_url 的 host 部分用 {openapi} 占位符表达;按会话区域 + -- 换成对应基址。未用占位符的平台不受影响(gsub 找不到就不动)。 + -- setmetatable+__index 只换 token_url 一个键,其余仍读原表。 + if type(d.token_url) == "string" and d.token_url:find("{openapi}", 1, true) then + local region = tostring(sess.region or "intl") + d = setmetatable({ token_url = (d.token_url:gsub("{openapi}", + plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE)) }, { __index = d }) + end if d.poll_query or (d.token_method == "GET_QUERY") then -- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge), -- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。 @@ -3431,10 +3527,44 @@ local function credentialExpiresAtMs(cred) local n = tonumber(raw) if n then return n > 1e12 and n or n * 1000 end end - -- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss" 形态(无时区偏移时按 UTC) - local Y, Mo, D, H, Mi, S = raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)") - if Y then return os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), - hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) * 1000 end + -- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]"。 + -- ★ 无偏移后缀时必须按 **UTC** 算,不能直接喂 os.time(它按本地时区解释)。 + -- 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO + -- ("2026-10-19T16:42:43.06Z"),本机 +08:00 直接 os.time 解析 ⇒ 过期 + -- 时刻虚早 8 小时 ⇒ refresh_creds 每一轮都判"已过期"⇒ 每半小时真打一次 + -- 华为云 STS(token 本来还有 23 小时寿命)。 + -- 修法:先用 os.date("!...", epoch_of_naive_interpretation) 技巧不可靠, + -- 直接用「本地 epoch − 本地偏移」还原 UTC:os.time 表按本地解释得 t, + -- t − (os.time() − os.time(os.date("!*t"))) 即同一钟面时刻的 UTC epoch。 + -- 显式带 Z 或 ±hh:mm 后缀的按其偏移换算。 + local Y, Mo, D, H, Mi, S, rest = + raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if Y then + -- os.time 把字段按**本地时区**解释。对"本地钟面 = 字段值"的时刻, + -- os.time 给出的就是正确的 epoch。要做的是把"声明时区的钟面"换算成 + -- "本地钟面"再喂 os.time: + -- 字段带 Z / 无后缀 = UTC 钟面 ⇒ 本地钟面 = 字段 + localOff ⇒ + -- epoch = os.time(字段 + localOff) = naive + localOff。 + -- 字段带 ±hh[:mm] = 声明时区钟面 ⇒ 本地钟面 = 字段 + localOff − off。 + local naive = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), + hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) + local localOff = os.time() - os.time(os.date("!*t")) + if rest:sub(1, 1) == "+" or rest:sub(1, 1) == "-" then + -- 偏移形如 "+08:00" / "-05:00" / "+0800"。字符类 [%+%-] 在部分 Lua + -- 构建下不可靠(实测 luajit 匹配 nil),用 sub 拆更稳。 + local sign = rest:sub(1, 1) + local oh, om = rest:sub(2):match("^(%d%d):?(%d*)") + local off = (tonumber(oh) or 0) * 3600 + + (tonumber(om ~= "" and om or "0") or 0) * 60 + if sign == "-" then off = -off end + -- 字段是"声明时区 off 的钟面"。本地钟面 = 字段 + (localOff − off): + -- 例(本机 +08):字段 "11:42:43-05:00"(=16:42:43Z)的本地钟面是 + -- 00:42:43(+1d);localOff−off = 28800−(−18000) = 46800 = 13h, + -- 11:42:43 + 13h = 00:42:43(+1d)。✓ + return (naive + (localOff - off)) * 1000 + end + return (naive + localOff) * 1000 + end end local exp = jwtClaim(cred.access_token, "exp") if type(exp) == "number" then return exp * 1000 end @@ -4336,9 +4466,19 @@ local function syncProviderSource(provider) .. tostring(rerr or "(无错误信息)"), "warn", provider) end end + -- ★ 双区:buddy 的 chat host 按所选账号的区域取(WorkBuddy=www.workbuddy.ai)。 + -- 选号在上方完成(plugin.state.chosen[provider] 已记录),从其凭据读 region。 + local chatBase = plat.base_url + if provider == "buddy" and plugin.state.chosen and plugin.state.chosen[provider] then + local chosenCred = credentialFull( + (poolGet(provider, plugin.state.chosen[provider]) or {}).credentialRef) + if type(chosenCred) == "table" and chosenCred.region == "intl" then + chatBase = "https://" .. plugin.K.BUDDY_REGION_HOST.intl + end + end local src = { name = name, - base_url = plat.base_url, + base_url = chatBase, adapter = plat.adapter or "openai", api_key = tok, -- ⚠️ max_concurrent=1 + rpm 节流,而不是 2。 @@ -5519,6 +5659,11 @@ local function initLoginSession(plat, args, now) end sessions()[sess.id] = sess sess.account_id = args and args.account_id + -- ★ 双区:登录请求可带 region("intl"|"cn")。会话带着它选 poll/授权页端点; + -- 完成登录后 complete_login 把 region 写进凭据 JSON,推理/领取按账号取区。 + local region = tostring((args and args.region) or "") + if region ~= "cn" then region = "intl" end + sess.region = region return sess end @@ -5593,11 +5738,17 @@ function plugin.start_login(req) return { status = 200, json = out } elseif plat.flow == "state-poll" then -- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。 + -- ★ 双区:state_url 的 {buddyhost}/{platform} 占位符按会话区域替换。 + local region = tostring(sess.region or "intl") + local bhost = plugin.K.BUDDY_REGION_HOST[region] or plugin.K.BUDDY_REGION_HOST.intl + local bplat = (region == "cn") and "ide" or "workbuddy-ai" + local stateUrl = tostring(plat.state.state_url) + :gsub("{buddyhost}", bhost):gsub("{platform}", bplat) local resp, err = __llmsproxy_http({ - url = plat.state.state_url, + url = stateUrl, method = "POST", headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, - body = "{\"platform\":\"ide\"}", + body = "{\"platform\":\"" .. bplat .. "\"}", }) if err or resp.status ~= 200 then sess.state = "failed" @@ -5630,15 +5781,50 @@ function plugin.start_login(req) logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")") end elseif plat.flow == "qr-poll" then - -- 自带二维码的扫码登录(Raccoon):code 由**本地随机生成**,服务端接受 - -- 任意自造 code 并进入 pending(参考注释实测)。二维码图交给前端渲染。 + -- 扫码登录(Raccoon):**必须由服务端建会话**(2026-10-20 实测重写,见平台定义)。 + -- 本地自造 code 会被旧端点一律回 pending ⇒ 永远扫不上(此前账号一直空凭据)。 local q = plat.qr - local code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32) + local code, scanPath, interval + local usedIAM = false + -- ① 服务端建码 + if type(q.create_path) == "string" and q.create_path ~= "" then + local cresp, cerr = __llmsproxy_http({ + url = q.api_base .. q.create_path, + method = "POST", + headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, + body = "{}", + }) + if not cerr and cresp and cresp.status == 200 then + local okc, crec = pcall(json.decode, cresp.body or "") + local d = okc and type(crec) == "table" and (crec.data or crec) or nil + if type(d) == "table" and type(d.qrcode_code) == "string" and d.qrcode_code ~= "" then + code = d.qrcode_code + usedIAM = true + -- scan_path 只在**不是已废弃的 /login/mp** 时采用(上游换路由时自动跟随) + if type(d.scan_path) == "string" and d.scan_path ~= "" + and d.scan_path ~= q.login_page then + scanPath = d.scan_path + end + if type(d.poll_interval) == "number" and d.poll_interval > 0 then + interval = math.max(1, math.min(60, d.poll_interval)) + end + end + end + end + -- ② 服务端不可用 → 退回旧的本地造码(不拦截登录入口,但日志要说清) + if code == nil then + code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32) + logEvent("二维码走了本地兜底(服务端建码失败,扫码可能无法完成) " .. plat.id, "warn", plat.id) + end sess.qr_code = code - out.qr_url = q.api_base .. q.login_page .. "?code=" .. code - .. "&appname=" .. rfc3986Escape(q.app_name) + sess.qr_iam = usedIAM and true or false + -- ③ 链接/二维码内容:优先服务端 scan_path,否则 scan_page(实测可用) + out.qr_url = q.api_base .. (scanPath or q.scan_page or q.login_page) + .. "?code=" .. code + .. "&appname=" .. rfc3986Escape(q.app_name or "") + .. "&source=" .. rfc3986Escape(q.source or "platform") out.url = out.qr_url - out.session.interval = q.interval or 2 + out.session.interval = interval or q.interval or 2 logEvent("二维码已生成 " .. plat.id) elseif plat.flow == "wechat-poll" then -- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。 @@ -5727,10 +5913,19 @@ function plugin.start_login(req) local rec = nil if dm == "GET_QUERY" or plat.device.auth_query then -- 形态②:授权页 URL 由模板拼装,无网络申请 + -- ★ 双区:code_url 的 {auth} / {openapi} 占位符与 query 模板里的 + -- {client_id} 按会话区域从 QODER_REGION_AUTH 替换(CN 的 client_id + -- 与国际版不同,用错授权页报「参数无效」—— 参考 qoder-product.ts 点名)。 + local ra = plugin.K.QODER_REGION_AUTH[tostring(sess.region or "intl")] + or plugin.K.QODER_REGION_AUTH.intl + local codeUrl = tostring(plat.device.code_url or "") + codeUrl = codeUrl:gsub("{auth}", ra.auth):gsub("{openapi}", + plugin.K.QODER_REGION_BASE[sess.region or "intl"] or QODER_SASH_BASE) local q = (plat.device.auth_query or "") :gsub("{challenge}", challenge):gsub("{nonce}", sess.nonce) :gsub("{machine_id}", machineId):gsub("{verifier}", verifier) - out.url = plat.device.code_url .. "?" .. q + :gsub("{client_id}", ra.client_id) + out.url = codeUrl .. "?" .. q sess.device_verifier = verifier sess.device_challenge = challenge sess.device_nonce = sess.nonce @@ -6084,6 +6279,8 @@ function plugin.poll_login(req) else -- 必须带 account_id:页面流程先 account/add 建了占位账号, -- 不带就会落到与占位账号无关的 `channels:` 键(见 poll_login 顶部注释)。 + -- ★ 双区:把会话区域写进凭据 JSON(推理/领取按账号取区)。 + if type(cred) == "table" and s.region then cred.region = s.region end local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account, account_id = s.account_id }) if res.json and res.json.ok then @@ -6106,8 +6303,16 @@ function plugin.poll_login(req) local plat = platformById(s.platform) local q = plat and plat.qr if q then + -- ★ 2026-10-20:服务端建的会话必须打 **IAM** 轮询端点;旧端点只认它自己的 + -- 会话,对 IAM 码恒回 pending。反之,本地兜底码只能走旧端点。 + local pollUrl + if s.qr_iam and type(q.iam_poll_path) == "string" and q.iam_poll_path ~= "" then + pollUrl = q.api_base .. q.iam_poll_path + else + pollUrl = q.api_base .. q.auth_prefix .. q.poll_path + end local resp, err = __llmsproxy_http({ - url = q.api_base .. q.auth_prefix .. q.poll_path, + url = pollUrl, method = "POST", headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, body = json.encode({ qrcode_code = s.qr_code }), @@ -6115,7 +6320,9 @@ function plugin.poll_login(req) if not err and resp then local ok, rec = pcall(json.decode, resp.body or "") local env = ok and type(rec) == "table" and rec or nil - if env and env.code == 0 and type(env.data) == "table" then + -- IAM 信封:{code:"OK", data:{...}};旧信封:{code:0, data:{...}}。两种都认。 + local codeOk = env and (env.code == 0 or env.code == "OK") + if codeOk and type(env.data) == "table" then local st = env.data.status if st == "canceled" then s.state = "failed" @@ -6283,8 +6490,11 @@ function plugin.poll_login(req) -- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。 local plat = platformById(s.platform) local sp = plat and plat.state + -- ★ 双区:轮询 host 按会话区域替换(与 start_login 的 state_url 同一映射)。 + local bhost = plugin.K.BUDDY_REGION_HOST[tostring(s.region or "intl")] + or plugin.K.BUDDY_REGION_HOST.intl local resp, err = __llmsproxy_http({ - url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token), + url = (sp.token_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token), method = "GET", headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" }, }) @@ -6299,7 +6509,7 @@ function plugin.poll_login(req) local access = data.accessToken or data.access_token local accountData = nil local aresp, aerr = __llmsproxy_http({ - url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token), + url = (sp.account_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token), method = "GET", headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access }, }) @@ -6320,6 +6530,9 @@ function plugin.poll_login(req) acct = credTable.user_id end if acct == nil or acct == "" then acct = "codebuddy" end + -- ★ 双区:凭据记录区域(推理/余额头按账号取区) + credTable.region = tostring(s.region or "intl") + credJson = json.encode(credTable) -- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的 -- `channels:` 键,账号池查不到。 local res = plugin.complete_login({ platform = s.platform, credential = credJson, @@ -9601,7 +9814,7 @@ end -- * 端点是 /api/v2/chat/completions(含 /api/v2 前缀) -- * Agent-Type: PromptCenter / X-Language: zh-cn(**签名后**追加, -- 不参与 canonical —— 参考注释点名) -local CODEARTS_ADAPTER_SRC = [==[ +plugin.K.CODEARTS_ADAPTER_SRC = [==[ local adapter = {} adapter.name = "codearts" adapter.version = "1.0.0" @@ -9753,7 +9966,7 @@ return adapter -- 6. assistant 消息的 tool_calls: function → function_call(SOLO 字段名), -- 并剔除没有 name 的条目(孤儿会让整条会话被拒) -- 7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求) -local TRAE_SOLO_ADAPTER_SRC = [==[ +plugin.K.TRAE_SOLO_ADAPTER_SRC = [==[ local adapter = {} adapter.name = "channels-trae" adapter.version = "1.0.0" @@ -10345,11 +10558,11 @@ end local function installTraeAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 - local f, lerr = load(TRAE_SOLO_ADAPTER_SRC) + local f, lerr = load(plugin.K.TRAE_SOLO_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC) + local _, uerr = __llmsproxy_adapters.update("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC) + local _, ierr = __llmsproxy_adapters.install("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -10373,7 +10586,7 @@ end -- -- ⛔ 不做:gemini-sigstore(签名持久化,268 行)。我们推理走无状态适配器, -- 插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传错误。 -local GEMINI_PRIVATE_ADAPTER_SRC = [==[ +plugin.K.GEMINI_PRIVATE_ADAPTER_SRC = [==[ local adapter = {} adapter.name = "channels-gemini" adapter.version = "1.0.0" @@ -10392,8 +10605,6 @@ adapter.headers = { -- ⚠️ 刻意**不带** Accept(参考:流式请求不带 Accept,抓包一致)。 -- 也不需要 x-goog-api-key(Cloud Code 用 OAuth Bearer)。 -local GEMINI_DEFAULT_PROJECT = "aicode-consumers" -local GEMINI_UPSTREAM_USER_AGENT = "antigravity" -- 字母序 JSON 序列化(参考 marshalAlphabetical → sortedStringify)。 -- 为什么必须排序:上游按**字节**比对/缓存,键序不同即不同请求。Go 的 @@ -10545,7 +10756,7 @@ function adapter.transform_request(raw_body) if req.top_p then gen.topP = req.top_p end if next(gen) then greq.generationConfig = gen end - local project = (req.project and tostring(req.project)) or GEMINI_DEFAULT_PROJECT + local project = (req.project and tostring(req.project)) or "aicode-consumers" greq.sessionId = derive_session_id(project, first_user_text(contents)) -- ③ 信封(字母序序列化) @@ -10554,7 +10765,7 @@ function adapter.transform_request(raw_body) project = project, request = greq, requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8), - userAgent = GEMINI_UPSTREAM_USER_AGENT, + userAgent = "antigravity", } return sorted_json(envelope) end @@ -10619,7 +10830,7 @@ return adapter -- 厂商码值留在**插件**里(内核零厂商名)—— 内核只把 transform_error 返回的 -- 文本当作客户端可读原因;额度类语义由内核既有的关键词表识别(quota/billing -- 等通用英文短语),故这里只要把话**说清楚**即可。 -local QODER_ADAPTER_SRC = [==[ +plugin.K.QODER_ADAPTER_SRC = [==[ local adapter = { name = "channels-qoder", version = "1.0.0" } -- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的), @@ -10634,7 +10845,7 @@ function adapter.transform_response(raw_body) return __prelude.openai_response(r function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end -- 厂商码值只出现在**这个适配器**里(内核零厂商名)。 -local QODER_QUEUE_CODE = "10605" +QODER_QUEUE_CODE = "10605" -- qoder_unwrap(root):广度优先展开 data/result/message/body。 -- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层 @@ -11019,6 +11230,7 @@ local ZD = (function() end)() local ZCODE_ADAPTER_SRC = [===[ +local CLI_PREFIX = "You are ZCode, an interactive coding agent" -- ============================================================================ -- ZCode 专用适配器(插件自装:channels-zcode) -- @@ -11048,7 +11260,6 @@ local ZCODE_ADAPTER_SRC = [===[ local adapter = { name = "channels-zcode", version = "1.0.0" } -- ★ 官方 CLI 前缀(准入必需)。逐字复刻,勿改。 -local CLI_PREFIX = "You are ZCode, an interactive coding agent" -- ★ 官方 stable 段(准入必需;三段以 "\n\n" 连接)。逐字复刻,勿改。 -- 官方完整身份块还含 ~5KB 的 dynamic 段(给 coding agent 的行为指令),与准入 @@ -11361,7 +11572,7 @@ return adapter -- ⚠️ 时长 token 必须**累加**且**不吃重复匹配**:`minutes?` 与 `m` 都能匹配 -- 同一段会让时长翻倍(参考注释点名)。我们用一次扫描 + 贪婪单位即可。 -local CLINE_ADAPTER_SRC = [==[ +plugin.K.CLINE_ADAPTER_SRC = [==[ local adapter = { name = "channels-cline", version = "1.0.0" } function adapter.transform_request(raw_body) return raw_body end @@ -11456,11 +11667,11 @@ return adapter -- installClineAdapter():装/更新本插件自己的 Cline 适配器。 local function installClineAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end - local f, lerr = load(CLINE_ADAPTER_SRC) + local f, lerr = load(plugin.K.CLINE_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-cline", CLINE_ADAPTER_SRC) + local _, uerr = __llmsproxy_adapters.update("channels-cline", plugin.K.CLINE_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-cline", CLINE_ADAPTER_SRC) + local _, ierr = __llmsproxy_adapters.install("channels-cline", plugin.K.CLINE_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11468,11 +11679,11 @@ end local function installQoderAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end - local f, lerr = load(QODER_ADAPTER_SRC) + local f, lerr = load(plugin.K.QODER_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-qoder", QODER_ADAPTER_SRC) + local _, uerr = __llmsproxy_adapters.update("channels-qoder", plugin.K.QODER_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-qoder", QODER_ADAPTER_SRC) + local _, ierr = __llmsproxy_adapters.install("channels-qoder", plugin.K.QODER_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11483,11 +11694,11 @@ local function installGeminiAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end -- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息 -- (load 失败只给"源码无法编译",不给行号) - local f, lerr = load(GEMINI_PRIVATE_ADAPTER_SRC) + local f, lerr = load(plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC) + local _, uerr = __llmsproxy_adapters.update("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC) + local _, ierr = __llmsproxy_adapters.install("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11542,11 +11753,11 @@ end local function installCodeartsAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 - local f, lerr = load(CODEARTS_ADAPTER_SRC) + local f, lerr = load(plugin.K.CODEARTS_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC) + local _, uerr = __llmsproxy_adapters.update("codearts", plugin.K.CODEARTS_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC) + local _, ierr = __llmsproxy_adapters.install("codearts", plugin.K.CODEARTS_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11560,8 +11771,8 @@ function plugin.__codearts_adapter_sign_probe(args) args = args or {} -- 从适配器池里取 build_headers 的结果不易(插件 VM 与适配器 VM 分离), -- 故用**同一份源码**在本 VM 里求值后调用 —— 源码是单一定义(上面那个 - -- CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。 - local f = load(CODEARTS_ADAPTER_SRC) + -- plugin.K.CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。 + local f = load(plugin.K.CODEARTS_ADAPTER_SRC) if not f then return { json = { error = "适配器源码无法编译" } } end local adapter = f() local meta = { @@ -11917,7 +12128,10 @@ plugin.ui = { const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序 // 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关 const acts = []; - acts.push(''); + acts.push('' + + (["buddy","qoder"].includes(id) + ? ' ' + : '')); if ((s.accounts.total||0) > 0) acts.push(''); acts.push(''); acts.push(''); @@ -12092,13 +12306,16 @@ plugin.ui = { function notice(msg) { q("#ch-notice").textContent = msg; } // ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)---- - window.chStartLogin = async () => { + window.chStartLogin = async (region) => { const id = state.selected; if (!id) return; + // ★ 双区:按钮按住 Shift 点 = 登录国际区(buddy→WorkBuddy / qoder→qoder.com)。 + // 简单可靠:不需要为双区改版式,一行 title 提示即可。默认国内/国际为主区。 + const r = (region === "intl") ? "intl" : "cn"; // 池路径:先建占位账号,登录完成后回填凭据到这个账号 const aj = await call("channels/account/add", "POST", { platform: id }); if (aj.error) { alert(aj.error); return; } const aid = aj.account_id; - const j = await call("channels/login", "POST", { platform: id, account_id: aid }); + const j = await call("channels/login", "POST", { platform: id, account_id: aid, region: r }); if (j.error) { alert(j.error); return; } const box = q("#ch-login-box"); box.style.display = "block"; const vnc = q("#ch-vnc-wrap"); @@ -12121,8 +12338,13 @@ plugin.ui = { "

"; } else { vnc.style.display = "none"; + // 扫码类流程(qr-poll)的地址是「用微信扫」的落地页,直接点开也能拿到 + // 页面里的二维码,故文案要说清楚,别让用户以为点开就自动登录了。 + const isQr = !!(j.session && j.session.flow === "qr-poll"); q("#ch-login-body").innerHTML = - "

请在你自己的浏览器打开下面的地址完成登录:

" + + "

" + (isQr + ? "用微信扫码登录(本机点开下面地址,页面里也有二维码):" + : "请在你自己的浏览器打开下面的地址完成登录:") + "

" + "

" + esc(j.url) + "

" + (j.session && j.session.user_code ? "

设备码:" + esc(j.session.user_code) + "

" : "") + "

等待完成…

"; @@ -12499,10 +12721,17 @@ function plugin.__inject_refresh_platform_probe(args) hosts = { "127.0.0.1" }, } if repl then lst[repl] = entry else lst[#lst + 1] = entry end - -- 建账号(凭据走保险库) + -- 建账号(凭据走保险库)。expiresAt 从凭据的 expires_at 派生(毫秒→秒), + -- 供"账号已过期"类判据与 account_list 展示;解析失败保持 0。 local ref = "channels:" .. id __llmsproxy_secrets.set(ref, tostring(args.credential or "{}")) - poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = 0 }) + local expAt = 0 + local dok, dcred = pcall(json.decode, tostring(args.credential or "{}")) + if dok and type(dcred) == "table" then + local ms = credentialExpiresAtMs(dcred) + if ms then expAt = math.floor(ms / 1000) end + end + poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = expAt }) return { json = { ok = true } } end @@ -12609,6 +12838,15 @@ function plugin.__rank_accounts_probe(args) usable_n = #(r.usable or {}) } } end +-- plugin.__chosen_account_probe:只给测试用 —— 当前为该通道服务的账号 id。 +-- 一通道一源之后,account_reorder 的效果不再是"源列表顺序",而是 +-- **选中的账号变了**(源同名覆盖,api_key 换成新账号的 token)→ 内核先试它。 +function plugin.__chosen_account_probe(args) + args = args or {} + local provider = tostring(args.platform or "") + return { json = { account_id = (plugin.state.chosen or {})[provider] or "" } } +end + -- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。 function plugin.__expiring_window_probe() return { json = { days = expiringWindowDays() } }