From 192e545d40405974c6e018b4b09f891bc6869985 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 13 Oct 2026 07:57:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20buddy=20=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=BC=BA=E8=BA=AB=E4=BB=BD=E5=A4=B4=20?= =?UTF-8?q?=E2=86=92=20=E4=B8=8A=E6=B8=B8=20401=EF=BC=88=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E7=A7=BB=E6=A4=8D=E5=8F=82=E8=80=83=20checkinHeaders=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修好凭据读取后,余额端点仍报 HTTP 401。逐行核对参考 checkinHeaders (credits.ts) 后发现我们只发了 Authorization,而 buddy 要求身份标识头齐全: X-Domain: copilot.tencent.com (以产品配置为准,不用凭据里的 domain 快照) X-Product: SaaS (部署类型) X-Product-Code: codebuddy (产品码) User-Agent: CodeBuddyIDE/1.106.1 X-User-Id / X-Enterprise-Id / X-Tenant-Id(凭据携带时) 参考的注释特别警告 X-Domain 的方向:凭据里的 domain 是「登录时那个域名」的 快照,跨产品迁移后会指错区域,所以必须以产品端点为准。 ## 实现 - 平台级声明 headers_fn(accessToken, cred) → 返回请求头表;buddy 据此逐行移植。 - fetchAccountJSON 增加头钩子位:spec 自己的 headers_fn 优先,否则回退平台级。 未声明的平台仍走通用默认头(Bearer + JSON),不改变既有行为。 - set_endpoints 补齐 checkin_status / balance 端点覆盖(此前只覆盖旧的 plat.checkin,buddy 这类平台无从改指,自动化测试也指不到假上游)。 端点覆盖是参考明确的产品配置能力,也是测试缝。 ## 判据 TestBuddyBillingHeadersMatchReference:把 buddy 余额端点指到本地假上游, 断言四个必备身份头逐字段正确。变异:改名 headers_fn → 立即 FAIL(已实测)。 go test ./internal/core/ 与 ./internal/lua/ 全绿。 --- internal/core/channels_pool_test.go | 48 ++++++++++++++++++++++ internal/lua/plugins/channels.lua | 62 +++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go index 3692b1a..c4b8e84 100644 --- a/internal/core/channels_pool_test.go +++ b/internal/core/channels_pool_test.go @@ -5,6 +5,10 @@ import ( "testing" "llmsproxy/internal/lua" + + "fmt" + "net/http" + "net/http/httptest" ) // channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。 @@ -320,3 +324,47 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) { } }) } + +// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考 +// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后 +// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。 +func TestBuddyBillingHeadersMatchReference(t *testing.T) { + _, ps := poolCore(t) + + var got map[string]string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = map[string]string{} + for k := range r.Header { + got[k] = r.Header.Get(k) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":{"totalCredit":42}}`) + })) + defer ts.Close() + + // 把 buddy 的余额/签到端点指到本地假上游 + invoke(t, ps, "set_endpoints", fmt.Sprintf( + `{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL)) + m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`) + if m["ok"] != true { + t.Fatalf("account_add: %v", m) + } + invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) + + if got == nil { + t.Fatal("余额请求未发出") + } + // 参考 checkinHeaders 的必备身份头(缺一个上游就 401) + want := map[string]string{ + "Authorization": "Bearer BD-AT", + "X-Domain": "copilot.tencent.com", + "X-Product": "SaaS", + "X-Product-Code": "codebuddy", + "User-Agent": "CodeBuddyIDE/1.106.1", + } + for k, v := range want { + if got[k] != v { + t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v) + } + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 9def959..1da1a7a 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -287,6 +287,30 @@ local PLATFORMS = { hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, base_url = "https://copilot.tencent.com", adapter = "openai", + -- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。 + -- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。 + -- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域, + -- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS); + -- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。 + headers_fn = function(accessToken, cred) + local h = { + ["Authorization"] = "Bearer " .. accessToken, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["X-Domain"] = "copilot.tencent.com", + ["X-Product"] = "SaaS", + ["X-Product-Code"] = "codebuddy", + ["User-Agent"] = "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 + local eid = cred and cred.enterprise_id + if type(eid) == "string" and eid ~= "" then + h["X-Enterprise-Id"] = eid + h["X-Tenant-Id"] = eid + end + return h + end, models = { "deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x", "glm-5.3", "glm-5.3-flash", "glm-5.2", @@ -2027,8 +2051,18 @@ end -- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances) -- ============================================================================ -- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。 -local function fetchAccountJSON(spec, tok) - local headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" } +local function fetchAccountJSON(spec, tok, credTable, plat) + local headers + -- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。 + -- buddy 在平台层声明一次,签到与余额复用同一套身份头。 + local hfn = spec.headers_fn or (plat and plat.headers_fn) + if hfn then + local okf, h = pcall(hfn, tok, credTable or {}) + if okf and type(h) == "table" then headers = h end + end + if not headers then + headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" } + end local resp, err = __llmsproxy_http({ url = spec.url, method = spec.method or "GET", headers = headers, body = spec.body, @@ -2054,7 +2088,7 @@ function plugin.credits_status(req) if cs then local tok = credentialOf(a.credentialRef) if tok then - local rec, err = fetchAccountJSON(cs, tok) + local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat) if rec and plat.checkin_status.status_fn then local okf, st = pcall(plat.checkin_status.status_fn, rec) if okf then status = st end @@ -2088,7 +2122,7 @@ function plugin.credits_balances(req) if not tok then entry.error = "无凭据" else - local rec, err = fetchAccountJSON(b, tok) + local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat) if err then entry.error = err elseif b.total_fn then @@ -2399,6 +2433,26 @@ function plugin.set_endpoints(req) if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end end end + -- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当 + -- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了 + -- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 —— + -- 自动化测试也就无法把它们的端点指到假上游。 + if type(args.checkin_status) == "table" and plat.checkin_status then + for _, k in ipairs({ "url", "method", "body" }) do + if args.checkin_status[k] then + plat.checkin_status[k] = tostring(args.checkin_status[k]) + changed[#changed+1] = "checkin_status." .. k + end + end + end + if type(args.balance) == "table" and plat.balance then + for _, k in ipairs({ "url", "method", "body" }) do + if args.balance[k] then + plat.balance[k] = tostring(args.balance[k]) + changed[#changed+1] = "balance." .. k + end + end + end if type(args.callback) == "table" and plat.callback then for _, k in ipairs({ "exchange_url" }) do if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end