Compare commits

...

2 Commits

Author SHA1 Message Date
ed954b77bd fix(channels): buddy 计费请求缺身份头 → 上游 401(逐行移植参考 checkinHeaders)
修好凭据读取后,余额端点仍报 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/ 全绿。
2026-10-13 07:57:46 +08:00
cb3ea25586 fix(lua): InvokeHandler 从不 markDirty —— 无 hooks 的插件状态永不落盘
## 现象
channels 插件重启后账号池静默清空:status 返回 accounts={}、pool=null、log=0 条,
磁盘上 `.channels.state.json` 从未存在过(billing/quota 的 .state.json 都在)。
用户侧表现为「登录了,但重启一次账号就没了」。

## 根因
插件状态持久化有三条分发路径,其中两条会 markDirty:
  - hook 路径(invoke)        → markDirtyLocked ✓
  - 导出函数路径(Call)       → markDirtyLocked ✓
  - HTTP handler 路径(InvokeHandler) → **从不 markDirty** ✗

channels 声明 `hooks = {}`,它的一切状态变更(账号入池、凭据引用、模型黑名单、
事件日志)都只经过自己的 HTTP 路由。于是状态从未被标记为脏,
`.channels.state.json` 从未写出,重启即全部丢失。

billing/quota 侥幸没踩到:billing 有 hooks,quota 走 Call。

## 修复
InvokeHandler 在成功返回前 markDirtyLocked(p),并在 handler 抛错的早退分支
也标记(抛错前可能已改过状态,那次改动仍然是真实的)。与 Call / hook 路径同模式,
只碰 stateSaver 自己的锁,不重入 p.mu(调用方已持锁)。

## 判据
internal/lua/persist_test.go::TestInvokeHandlerIsPersisted
用「无 hooks、只走 HTTP handler」的插件复现 channels 形态:两次 handler 调用后
Close()(等最后一次 flush),断言 .state.json 存在且含 2 个账号。
变异:撤掉 markDirtyLocked → 测试立即 FAIL(已实测)。

go test ./internal/lua/ 与 ./internal/core/ 全绿。
2026-10-13 07:48:15 +08:00
4 changed files with 186 additions and 4 deletions

View File

@ -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)
}
}
}

View File

@ -338,3 +338,67 @@ return plugin
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
}
}
// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
// 有、Call 路径有,只有它漏了。后果:
//
// .channels.state.json 从未被写出 → 重启后账号池静默清空。
// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
//
// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
func TestInvokeHandlerIsPersisted(t *testing.T) {
ps, _ := newPersistVM(t)
if err := ps.LoadSource("handlersonly", `
local plugin = { name = "handlersonly", version = "1.0" }
plugin.state = { accounts = {} }
-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
plugin.hooks = {}
plugin.http = {
{ path = "add", method = "POST", handler = "add" },
}
function plugin.add(req)
local body = req.body or ""
local id = body:match('"id":"([^"]+)"') or "unknown"
plugin.state.accounts[id] = { id = id, enabled = true }
return { status = 200, json = { ok = true } }
end
return plugin
`); err != nil {
t.Fatal(err)
}
for _, id := range []string{"acct-1", "acct-2"} {
resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
Method: "POST", Body: `{"id":"` + id + `"}`,
})
if err != nil {
t.Fatalf("handler: %v", err)
}
if resp == nil {
t.Fatal("handler returned nil response")
}
}
// Close() waits for the final flush, so no timing is involved: anything the
// handlers did NOT mark dirty cannot appear here.
ps.Close()
b, err := os.ReadFile(ps.stateFile("handlersonly"))
if err != nil {
t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
" —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
" 否则 channels 的账号池会在每次重启后静默清空", err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatal(err)
}
accts, _ := saved.State["accounts"].(map[string]interface{})
if len(accts) != 2 {
t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
}
}

View File

@ -264,6 +264,10 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
L.Pop(1)
}
if errMsg != "" {
// A handler that raised partway may already have mutated plugin.state
// (an account added before a later step failed), so mark it dirty before
// bailing: the mutation it managed to make is still real.
ps.markDirtyLocked(p)
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
@ -273,6 +277,18 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
if err := luaToJSON(L, -1, &raw); err != nil {
return nil, err
}
// A handler mutates plugin.state just as a hook or an exported Call does, and
// this was the one dispatch path that never marked the plugin dirty.
//
// Consequence (real defect): a plugin with NO hooks — channels is exactly
// that, it declares hooks=[] and drives everything through its own HTTP
// routes — accumulated accounts, credential refs, model blacklists and its
// event log entirely in memory. Nothing ever marked it dirty, so
// .channels.state.json was never written and a restart silently wiped the
// account pool: the operator saw "logged in", restarted for an upgrade, and
// the account was gone. billing/quota escape this only because they declare
// hooks and/or use Call, both of which already mark dirty.
ps.markDirtyLocked(p)
return parseHandlerResponse(raw)
}

View File

@ -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