Compare commits

..

2 Commits

Author SHA1 Message Date
6bec260513 fix(deploy): 内置插件的修复无法下发 + buddy 余额解析口径
## deploy.sh:内置插件修复本该覆盖
sync_plugins 原为「只补缺,已存在一律不动」。看着安全,实际造成功能性缺口:
**内置插件自身的修复永远下发不了**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版
(实测 sha256 不同、headers_fn 计数 0),生产行为毫无变化。我自己的验证还被
这个"静默不生效"骗过一轮:测试全绿、部署成功,线上仍是旧逻辑。

修法:与 sync_adapters 同纪律 —— **内置同名文件覆盖(带备份)**,
运行时新增/运维自加的非内置名字一律不动。内置源码是发行物的一部分,
部署必须与二进制内容对齐;"只补缺"想保护的是非内置文件,两者不是一回事。

判据:部署日志出现 `~ 更新内置插件: channels.lua`;部署后生产文件 sha256
与仓库一致。

## buddy 余额:双层嵌套 + 只累加有效包
余额端点响应是 **data.Response.Data.Accounts[]**(与签到端点的单层 data
不同),原实现按顶层找 totalCredit → 永远解析不出、回落 0 或报错。
且刻意**不用**服务端的 TotalDosage:那是终身口径且取整(参考实测同一响应
TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts 现算:
只累加有效包的 CycleCapacityRemain(失效包服务端仍返回,并入会虚高),
按两位小数规整;双层缺失时如实报错,不把「查不到」当「余额为 0」。

判据 TestBuddyBalanceParseDoubleNested:100.5+55.17=155.67(失效的 500
不计入)+ 单层 data 判为失败。变异:改用 TotalDosage → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 08:05:39 +08:00
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
3 changed files with 232 additions and 22 deletions

View File

@ -393,13 +393,17 @@ sync_adapters() {
# 同步内置插件到 plugin_dir。
#
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
# 保留目录里运行时新增/运维自加的 .lua。
#
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
#
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
sync_plugins() {
log "同步内置插件"
@ -408,24 +412,35 @@ sync_plugins() {
mkdir -p "$TARGET_PLUGINS"
# 备份将被触及的文件(只会是本次新增的那些)
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
local added=0
mkdir -p "$BACKUP_DIR"
local added=0 updated=0 same=0
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
continue # 已存在:运维版本优先,不覆盖
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
((same++)) || true
continue
fi
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " ~ 更新内置插件: $name"
((updated++)) || true
else
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
fi
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
done
if (( added == 0 )); then
log " 无新增内置插件(均已存在)"
# 没有任何改动时不必留空备份目录
if (( updated == 0 && added == 0 )); then
rmdir "$BACKUP_DIR" 2>/dev/null || true
else
log " 旧插件已备份到 $BACKUP_DIR"
fi
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
@ -434,7 +449,7 @@ sync_plugins() {
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
}
# ---------- 重启服务 ----------

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,95 @@ 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)
}
}
}
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
_, ps := poolCore(t)
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
]}}}}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer ts.Close()
invoke(t, ps, "set_endpoints", fmt.Sprintf(
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
var row map[string]interface{}
for _, a := range asArr(j["accounts"]) {
row = a.(map[string]interface{})
}
bal, _ := row["balance"].(map[string]interface{})
if bal == nil {
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
}
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
if got, _ := bal["total"].(float64); got != 155.67 {
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
}
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
body = `{"code":0,"data":{"TotalDosage":609}}`
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
for _, a := range asArr(j["accounts"]) {
row = a.(map[string]interface{})
}
if row["balance"] != nil {
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
}
}

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",
@ -307,12 +331,57 @@ local PLATFORMS = {
end,
},
-- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource)
--
-- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层
-- data 不同),这是本接口最容易解析错的地方。
--
-- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一
-- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[]
-- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回,
-- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。
balance = {
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
method = "POST", body = "{}",
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.totalCredit or d.total_credit or d.balance or d.amount) or 0
local data = rec.data
if type(data) ~= "table" then return nil end
local resp = data.Response
if type(resp) ~= "table" then return nil end
local inner = resp.Data
if type(inner) ~= "table" then return nil end
local accounts = inner.Accounts
if type(accounts) ~= "table" then return nil end
local now = os.time and os.time() or 0
local sum = 0
for _, pkg in ipairs(accounts) do
if type(pkg) == "table" then
-- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。
local active = true
local st = pkg.Status
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
local function past(t)
if type(t) ~= "string" or t == "" then return false end
local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)")
if Y then
local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
return ts and now >= ts
end
return false
end
if past(pkg.ExpiredTime) then active = false end
local ded = tonumber(pkg.DeductionEndTime)
if ded and ded > 0 then
-- 毫秒时间戳
if now >= math.floor(ded / 1000) then active = false end
end
if active then
sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0)
end
end
end
-- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031)
return math.floor(sum * 100 + 0.5) / 100
end,
},
},
@ -2027,8 +2096,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 +2133,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 +2167,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 +2478,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