mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 渠道鉴权头三修 + 源名契约 + 夹具形状判据
P1-1 anthropic 基座补 build_headers(x-api-key + anthropic-version) 根因:基座只有静态 anthropic-version、没有 build_headers ⇒ 内核兜底发 Authorization: Bearer,而 Anthropic 协议要 x-api-key。 ★ 证据冲突(计划 P1-1 原文一半有误):zcode 的 /zcode-plan/anthropic/v1/messages 虽然**说 Anthropic 协议**,鉴权却是 Bearer <zcode_jwt>(参考 zcode-transport.ts:121-125;全仓 grep x-api-key = 0 次命中)。故 zcode 必须**显式覆盖** build_headers 回到 Bearer, 否则基座的正向修复会把它从"对"改成"错"。 内核改动:internal/provider/provider.go —— 兜底 Bearer 让位给 x-api-key, 避免同一请求带两套鉴权(部分 Anthropic 兼容上游对歧义鉴权直接 401)。 P1-2 channels-cline 的 build_headers 补 workos: 前缀 根因:参考 cline.ts 发 `Bearer workos:<jwt>`,而池里存的是第二步 register 换回的裸 token ⇒ 之前无 build_headers,内核兜底发裸 Bearer。 幂等:已带前缀(含大小写变体)不重复拼。 P1-3 channels-gemini 只确认不改 参考 gemini.ts:353-366 同样是 OAuth Bearer + 身份五头、刻意不发 x-goog-api-key ⇒ 我们现状本来就对。加判据把这个结论钉住,防下轮照 P1-1 的模子把它改坏。 P2 源名契约:仅 [A-Za-z0-9_-],禁 : 与 / 根因:: 与 / 是限定名分隔符,出现在源名里会让 `源名:模型名` 有两种都 自圆其说的切法,同一请求串在不同部署下可能路由到不同源,且**不报错**。 校验加在写入口 core.normalizeSource(硬拒,API 400);启动时只告警, 避免存量带 : 的源名让升级 brick 掉整个网关。 解析入口 splitPinned 不做校验(解析不该拒绝已有数据)。 P3 夹具形状判据(本轮最重要的一条) 根因:trae 的 Result 信封缺陷在扁平夹具下所有判据全绿,真实登录却报 「缺少 Token」—— 夹具形状错 ⇒ 判据必然虚假通过。 通用形状层:shapeFamilies 登记表,每族给 real/naive/fact/eval/assert, 断言 fact(real) 且 !fact(naive) 且 assert(eval(real))。 刻意**不比较 eval 输出形状**:trae 余额实现合法保留顶层兼容路径, 拿输出比对会把合法兼容判成错误;能区分真假的是 fact(额度在哪一层)。 另有仓库扫描:对特征明确的退化形状静态反查,需退化夹具时写 SHAPE-OK 豁免。 判据:11 个文件 / 30 用例。全部变异已验证会报红(9 处)。
This commit is contained in:
122
internal/lua/adapter_anthropic_headers_test.go
Normal file
122
internal/lua/adapter_anthropic_headers_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-1:anthropic 基座必须发 `x-api-key`,不是 `Authorization: Bearer`。
|
||||
//
|
||||
// 真实缺陷:anthropic.lua 只有一个静态 `anthropic-version`、**没有**
|
||||
// build_headers ⇒ 内核走兜底 `Authorization: Bearer <api_key>` ⇒ Anthropic
|
||||
// 协议一律回 401 `invalid x-api-key`。继承该基座的渠道源(channels-zcode)
|
||||
// 一有账号就会立刻暴露,与 trae 修复前完全同型(静态头 + 内核兜底 Bearer,
|
||||
// 缺的正是协议要的那一个头)。
|
||||
//
|
||||
// 判据咬的是**真实产出表**(vm.BuildHeaders 就是内核发请求前走的那条路径),
|
||||
// 不造 HTTP 假上游 —— 假上游需要 URL 覆盖/凭据 ref/路径匹配全部对齐,任一环
|
||||
// 错都会让「没发请求」伪装成「头不对」(plan §3 第 4 条踩过三轮)。
|
||||
func TestAnthropicBuildHeadersSendsXAPIKey(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
if !vm.HasFn("anthropic", "build_headers") {
|
||||
t.Fatal("★ anthropic 基座没有 build_headers —— 内核会兜底发 " +
|
||||
"Authorization: Bearer,而 Anthropic 协议要 x-api-key ⇒ 404/401")
|
||||
}
|
||||
|
||||
hdrs, err := vm.BuildHeaders("anthropic", map[string]interface{}{
|
||||
"api_key": "sk-ant-KEY-123",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"method": "POST",
|
||||
"body": `{"model":"claude"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
|
||||
// ① 键集合:必须**恰好**是这两个。多出来的 Authorization 会让上游面对
|
||||
// 两套鉴权方案(部分 Anthropic 兼容上游对歧义鉴权直接拒)。
|
||||
if len(hdrs) != 2 {
|
||||
t.Fatalf("★ 头集合应恰好 2 个(x-api-key + anthropic-version),实得 %d: %v",
|
||||
len(hdrs), keysOf(hdrs))
|
||||
}
|
||||
if got := hdrs["x-api-key"]; got != "sk-ant-KEY-123" {
|
||||
t.Fatalf("★ x-api-key 必须取凭据原值,实得 %q(头: %v)", got, keysOf(hdrs))
|
||||
}
|
||||
if got := hdrs["anthropic-version"]; got != "2023-06-01" {
|
||||
t.Fatalf("anthropic-version 应为 2023-06-01,实得 %q", got)
|
||||
}
|
||||
if _, has := hdrs["Authorization"]; has {
|
||||
t.Fatalf("★ 不得同时发 Authorization(与 x-api-key 冲突,上游视为歧义): %v", hdrs)
|
||||
}
|
||||
// ② 值形状:不能带 Bearer 前缀(x-api-key 的值是裸 key)
|
||||
if strings.Contains(hdrs["x-api-key"], "Bearer") {
|
||||
t.Fatalf("★ x-api-key 的值是**裸 key**,不能带 Bearer 前缀,实得 %q", hdrs["x-api-key"])
|
||||
}
|
||||
// ③ key 绝不外泄到别的头里(防止某次改动把 key 顺手塞进 UA 之类)
|
||||
for k, v := range hdrs {
|
||||
if k != "x-api-key" && strings.Contains(v, "sk-ant-KEY-123") {
|
||||
t.Fatalf("★ 凭据只能出现在 x-api-key,泄露到了 %s=%q", k, v)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ anthropic 头: %v", keysOf(hdrs))
|
||||
}
|
||||
|
||||
// 配对判据:没有凭据时仍要发 anthropic-version(协议头),但**不得**发空
|
||||
// x-api-key —— 空值头会让上游回 `invalid x-api-key` 而不是清晰的"未配置凭据"。
|
||||
func TestAnthropicBuildHeadersWithoutCredential(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
for _, meta := range []map[string]interface{}{nil, {}, {"api_key": ""}} {
|
||||
hdrs, err := vm.BuildHeaders("anthropic", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders(%v): %v", meta, err)
|
||||
}
|
||||
if hdrs["anthropic-version"] != "2023-06-01" {
|
||||
t.Fatalf("mate=%v 时仍须有 anthropic-version,实得 %v", meta, hdrs)
|
||||
}
|
||||
if _, has := hdrs["x-api-key"]; has {
|
||||
t.Fatalf("meta=%v 时不得发空 x-api-key,实得 %v", meta, hdrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底路径:有些调用方只把 key 放在 source.meta.api_key(kimicode 同约定)。
|
||||
// 基座必须与 kimicode 一致地认这第二种位置,否则换调用方就静默少头。
|
||||
func TestAnthropicBuildHeadersReadsSourceMetaKey(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
hdrs, err := vm.BuildHeaders("anthropic", map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"name": "claude-direct",
|
||||
"meta": map[string]interface{}{"api_key": "KEY-FROM-META"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
if hdrs["x-api-key"] != "KEY-FROM-META" {
|
||||
t.Fatalf("★ 应读 source.meta.api_key 兜底,实得 %q(头: %v)",
|
||||
hdrs["x-api-key"], keysOf(hdrs))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
143
internal/lua/adapter_zcode_build_headers_test.go
Normal file
143
internal/lua/adapter_zcode_build_headers_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ zcode 的 Messages 端点鉴权是 **Bearer**,不是 x-api-key。
|
||||
//
|
||||
// 背景(本轮**最重要的一处证据冲突**,必须写清):
|
||||
//
|
||||
// 任务前提是「anthropic 协议 ⇒ x-api-key,所以 channels-zcode 继承基座就 401」。
|
||||
// 但逆向参考(vendor/channel-pack,全部结论来自真实流量)明确写着相反的事:
|
||||
//
|
||||
// src/zcode-transport.ts:121-125 buildChannelRequest()
|
||||
// headers = buildZcodeHeaders(credential, { json: true })
|
||||
// headers.Authorization = `Bearer ${token}` // token = zcode_jwt
|
||||
// src/zcode-upstream.ts:83-108 buildZcodeHeaders()
|
||||
// 只发 User-Agent / HTTP-Referer / X-Device-Mid / anthropic-version …
|
||||
// Authorization 是**可选注入项**,且全仓 `grep -rn "x-api-key" src/` = **0 次**
|
||||
//
|
||||
// 也就是说 zcode 的这条路径是「**Anthropic 请求体形状 + Bearer 鉴权**」——
|
||||
// 协议与鉴权是两个独立维度,被同一家上游拆开了。
|
||||
//
|
||||
// 为什么修复前它其实是**对的**:zcode 未声明 credential_shape ⇒ api_key = 凭据里的
|
||||
// access_token = zcode_jwt;而基座当时没有 build_headers ⇒ 内核兜底发了
|
||||
// `Authorization: Bearer <zcode_jwt>`,与参考逐字一致。
|
||||
//
|
||||
// ⇒ 真正的风险来自**本轮的正向修复**:给 anthropic 基座补 x-api-key 之后,
|
||||
// 若 zcode 只是「继承」而不覆盖,它就会从 Bearer 变成 x-api-key ⇒ 立刻 401。
|
||||
// 所以 zcode 必须**显式覆盖** build_headers,把 Bearer 钉住。
|
||||
//
|
||||
// 判据咬真实产出(vm.BuildHeaders 是内核发请求前的那条路径),不造 HTTP 假上游。
|
||||
func TestZcodeBuildHeadersUsesBearerNotXAPIKey(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
if !vm.HasFn("channels-zcode", "build_headers") {
|
||||
t.Fatal("★ channels-zcode 必须有**自己的** build_headers(覆盖基座)," +
|
||||
"否则会继承基座的 x-api-key 而丢掉参考要求的 Bearer")
|
||||
}
|
||||
|
||||
hdrs, err := vm.BuildHeaders("channels-zcode", map[string]interface{}{
|
||||
"api_key": "ZCODE-JWT-ABC",
|
||||
"url": "https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages",
|
||||
"method": "POST",
|
||||
"body": `{"model":"GLM-5.3"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
|
||||
// ① 鉴权必须是 Bearer <zcode_jwt>(参考 zcode-transport.ts:125)
|
||||
if got := hdrs["Authorization"]; got != "Bearer ZCODE-JWT-ABC" {
|
||||
t.Fatalf("★ zcode 的 Authorization 必须是 `Bearer <zcode_jwt>`,实得 %q(头: %v)\n"+
|
||||
" 参考 zcode-transport.ts:125 逐字如此;全仓没有一处发 x-api-key",
|
||||
got, keysOf(hdrs))
|
||||
}
|
||||
// ② 不得同时发 x-api-key(那就是双鉴权;且参考从不发它)
|
||||
if v, has := hdrs["x-api-key"]; has {
|
||||
t.Fatalf("★ zcode 不得发 x-api-key(实得 %q)—— 参考全程没有这个头,"+
|
||||
"发了就是两套鉴权方案", v)
|
||||
}
|
||||
// ③ 协议头 anthropic-version 必须保留(它与鉴权无关,参考也发)
|
||||
if got := hdrs["anthropic-version"]; got != "2023-06-01" {
|
||||
t.Fatalf("anthropic-version 应保留为 2023-06-01,实得 %q", got)
|
||||
}
|
||||
// ④ 键集合恰好 2 个,不多不少(防止顺手塞进别的身份头)
|
||||
if len(hdrs) != 2 {
|
||||
t.Fatalf("头集合应恰好 2 个(Authorization + anthropic-version),实得 %d: %v",
|
||||
len(hdrs), keysOf(hdrs))
|
||||
}
|
||||
t.Logf("✓ zcode 头: %v", keysOf(hdrs))
|
||||
}
|
||||
|
||||
// 配对判据:**基座**必须发 x-api-key(这是 P1-1 的正向要求,也是与 zcode 的差异点)。
|
||||
//
|
||||
// 两条判据放在一起才有意义:它们证明「同一份基座 + 显式覆盖」能同时满足
|
||||
// 两种鉴权,而不是把整个 anthropic 生态压成一种。
|
||||
func TestAnthropicBaseAndZcodeDifferOnAuthHeader(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
|
||||
base, err := vm.BuildHeaders("anthropic", map[string]interface{}{"api_key": "SK-ANT"})
|
||||
if err != nil {
|
||||
t.Fatalf("anthropic BuildHeaders: %v", err)
|
||||
}
|
||||
zc, err := vm.BuildHeaders("channels-zcode", map[string]interface{}{"api_key": "JWT-Z"})
|
||||
if err != nil {
|
||||
t.Fatalf("zcode BuildHeaders: %v", err)
|
||||
}
|
||||
|
||||
// 基座:x-api-key,无 Authorization
|
||||
if base["x-api-key"] != "SK-ANT" {
|
||||
t.Fatalf("★ anthropic 基座必须发 x-api-key,实得 %v", base)
|
||||
}
|
||||
if _, has := base["Authorization"]; has {
|
||||
t.Fatalf("★ anthropic 基座不得发 Authorization(x-api-key 才是它的鉴权),实得 %v", base)
|
||||
}
|
||||
// zcode:Authorization,无 x-api-key
|
||||
if zc["Authorization"] == "" {
|
||||
t.Fatalf("★ zcode 必须发 Authorization: Bearer,实得 %v", zc)
|
||||
}
|
||||
if _, has := zc["x-api-key"]; has {
|
||||
t.Fatalf("★ zcode 不得发 x-api-key,实得 %v", zc)
|
||||
}
|
||||
// 两者必须**真的不同**——若某天有人删掉 zcode 的覆盖,这条会立刻红
|
||||
if base["x-api-key"] == "" || zc["Authorization"] == "" {
|
||||
t.Fatal("两者鉴权头方案必须各自成立")
|
||||
}
|
||||
b, _ := json.Marshal(base)
|
||||
z, _ := json.Marshal(zc)
|
||||
if string(b) == string(z) {
|
||||
t.Fatalf("★ 基座与 zcode 的鉴权方案不能相同:%s", b)
|
||||
}
|
||||
// 协议头必须相同(anthropic-version 与鉴权无关)
|
||||
if base["anthropic-version"] != zc["anthropic-version"] {
|
||||
t.Fatalf("两者都必须发 anthropic-version:base=%v zcode=%v", base, zc)
|
||||
}
|
||||
if strings.Contains(string(b), "Bearer") {
|
||||
t.Fatalf("基座不该出现 Bearer:%s", b)
|
||||
}
|
||||
t.Logf("✓ 基座=%s | zcode=%s", b, z)
|
||||
}
|
||||
|
||||
// 无凭据时:两边都只发协议头,都不发空鉴权(空值会让上游回认证失败,
|
||||
// 而真因是「没配置凭据」,会把人带偏)。
|
||||
func TestAuthHeadersOmitEmptyCredential(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
for _, name := range []string{"anthropic", "channels-zcode"} {
|
||||
h, err := vm.BuildHeaders(name, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if h["anthropic-version"] != "2023-06-01" {
|
||||
t.Errorf("%s 仍须发 anthropic-version,实得 %v", name, h)
|
||||
}
|
||||
if _, has := h["x-api-key"]; has {
|
||||
t.Errorf("%s 无凭据时不得发空 x-api-key,实得 %v", name, h)
|
||||
}
|
||||
if _, has := h["Authorization"]; has {
|
||||
t.Errorf("%s 无凭据时不得发空 Authorization,实得 %v", name, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/lua/adapter_zcode_harness_test.go
Normal file
68
internal/lua/adapter_zcode_harness_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// zcodeHarness 是与 runtimePlugins 等价的测试台,唯一差别是 **adapters 与
|
||||
// plugins 目录互为兄弟**(`<tmp>/adapters` + `<tmp>/plugins`)。
|
||||
//
|
||||
// 为什么必须这样:installZcodeAdapter 要从「插件目录的父目录/adapters」读
|
||||
// anthropic.lua 的源码,再把基座**内联**进适配器脚本(适配器沙箱没有 fs)。
|
||||
// runtimePlugins 给 adapters/plugins 各用一个独立的 t.TempDir(),两个目录
|
||||
// 不是兄弟 ⇒ zcode 安装期就报「读不到 anthropic.lua」而被跳过,适配器根本
|
||||
// 不会出现(探针会如实报 ok:false,这本身是好事 —— 说明失败可见)。
|
||||
//
|
||||
// 生产里这两个目录是兄弟(config.PluginDir 与 config.AdapterDir 同级),
|
||||
// 所以这个测试台更贴近真实布局。
|
||||
func zcodeHarness(t *testing.T) (*VM, *Plugins) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
adir := filepath.Join(root, "adapters")
|
||||
vm := NewVM(adir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(root, "plugins")
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir)
|
||||
}
|
||||
ps := NewPlugins(vm, pdir)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
// 同 runtimePlugins:关掉状态持久化,避免后台调度器写进生产 store。
|
||||
ps.DisableStatePersistence()
|
||||
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// zcode 装不上时必须**响亮失败**,不能静默跳过 —— 否则判据会在
|
||||
// 「适配器根本不存在」的情况下误判为通过(那正是 plan §3 第 1 条
|
||||
// 说的假绿形态)。
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__adapter_install_probe: %v", err)
|
||||
}
|
||||
j := probeJSON(t, res)
|
||||
zc, ok := j["zcode"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针未报告 zcode 装载结果: %v", j)
|
||||
}
|
||||
if zc["ok"] != true {
|
||||
t.Fatalf("★ zcode 适配器装载失败: %v\n"+
|
||||
" 注意 luac -p 对长字符串内的适配器代码无效(plan §3 第 1 条),"+
|
||||
"必须以本探针为准", zc["err"])
|
||||
}
|
||||
if !vm.HasFn("channels-zcode", "build_headers") {
|
||||
t.Fatal("zcode 装载后仍然没有 build_headers")
|
||||
}
|
||||
_ = fmt.Sprint()
|
||||
return vm, ps
|
||||
}
|
||||
@ -5,6 +5,45 @@ adapter.endpoint = "/v1/messages"
|
||||
adapter.headers = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
|
||||
-- ============================================================
|
||||
-- build_headers (鉴权头)
|
||||
-- ============================================================
|
||||
-- Anthropic 协议要 **x-api-key**,不是 `Authorization: Bearer`。
|
||||
-- 此前本基座只有一个静态 `anthropic-version`、**没有** build_headers ⇒ 内核
|
||||
-- 走兜底 `Authorization: Bearer <api_key>` ⇒ 上游一律 401/403:
|
||||
-- `{"type":"error","error":{"type":"authentication_error",
|
||||
-- "message":"invalid x-api-key"}}`
|
||||
-- 继承本基座的渠道源(channels-zcode 等)一有账号就会立刻暴露 —— 与 trae
|
||||
-- 修复前完全同型(静态头 + 内核 Bearer,缺协议要的那一个头)。
|
||||
--
|
||||
-- 三条纪律:
|
||||
-- ① 值取自内核注入的 `meta.api_key`(源的凭据),**绝不**写日志、不额外
|
||||
-- 放进返回值以外的任何地方;本函数只把它放进请求头。
|
||||
-- ② **不自己发 Authorization** —— 源级 headers 若已显式配置 Authorization,
|
||||
-- 重复发两种鉴权方案会让部分 Anthropic 兼容上游直接拒(歧义)。
|
||||
-- ③ `anthropic-version` 与静态头同值,保证「装了 hook」与「没装 hook」两种
|
||||
-- 路径产出同一份协议头,不会因为加了 hook 而少一个版本头。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
local key = ""
|
||||
if type(meta) == "table" then
|
||||
if type(meta.api_key) == "string" then key = meta.api_key end
|
||||
-- 兜底:个别调用方只给 source.meta.api_key(与 kimicode 同一约定)
|
||||
if key == "" and type(meta.source) == "table"
|
||||
and type(meta.source.meta) == "table"
|
||||
and type(meta.source.meta.api_key) == "string" then
|
||||
key = meta.source.meta.api_key
|
||||
end
|
||||
end
|
||||
if key ~= "" then
|
||||
h["x-api-key"] = key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the
|
||||
-- client-facing POST /v1/messages request body. The kernel keeps a built-in
|
||||
-- Anthropic parser as its fallback (R5) — that is what an unconfigured
|
||||
|
||||
129
internal/lua/channels_cline_workos_test.go
Normal file
129
internal/lua/channels_cline_workos_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-2:Cline 的推理端点要 `Authorization: Bearer workos:<jwt>`。
|
||||
//
|
||||
// 真实缺陷:参考实现(cline.ts)发头时直接拼 `workos:${token}`,而我们池里
|
||||
// 存的是**第二步 register 换回来的 Cline token**(裸 token,不带前缀)
|
||||
// ⇒ 之前 channels-cline 干脆没有 build_headers,内核兜底发
|
||||
// `Authorization: Bearer <裸token>` ⇒ 少了协议要求的前缀 ⇒ 上游只回泛化的
|
||||
// 认证失败(与 trae 修前同型:头发了,但形式不对)。
|
||||
//
|
||||
// 判据咬**真实产出**(vm.BuildHeaders 就是内核发请求前走的那条路径),
|
||||
// 不造 HTTP 假上游(那需要 URL 覆盖/凭据 ref/路径匹配全部对齐,任一环错
|
||||
// 都会让「没发请求」伪装成「头不对」,plan §3 第 4 条)。
|
||||
func TestClineBuildHeadersAddsWorkOSPrefix(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !vm.HasFn("channels-cline", "build_headers") {
|
||||
t.Fatal("★ channels-cline 没有 build_headers —— 内核会兜底发裸 Bearer," +
|
||||
"而 Cline 推理端点要 `Bearer workos:<jwt>`")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tok string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "无前缀 → 补上 workos:",
|
||||
tok: "eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
want: "Bearer workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
},
|
||||
{
|
||||
name: "已有 workos: 前缀 → 不重复拼",
|
||||
tok: "workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
want: "Bearer workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
},
|
||||
{
|
||||
name: "前缀大小写不同(WorkOS:)→ 视为已带,不重复拼",
|
||||
tok: "WorkOS:abc123",
|
||||
want: "Bearer WorkOS:abc123",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{
|
||||
"api_key": tc.tok,
|
||||
"url": "https://api.cline.bot/api/v1/chat/completions",
|
||||
"method": "POST",
|
||||
"body": `{"model":"x"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
auth := hdrs["Authorization"]
|
||||
if auth != tc.want {
|
||||
t.Fatalf("★ Authorization 应为 %q,实得 %q\n"+
|
||||
" Cline 要 `Bearer workos:<jwt>`;缺前缀或重复拼都会认证失败",
|
||||
tc.want, auth)
|
||||
}
|
||||
// 不得出现 workos:workos: 这种重复前缀(旧数据可能已带前缀)
|
||||
if strings.Count(strings.ToLower(auth), "workos:") != 1 {
|
||||
t.Fatalf("★ workos: 前缀出现 %d 次(必须恰好 1 次): %q",
|
||||
strings.Count(strings.ToLower(auth), "workos:"), auth)
|
||||
}
|
||||
t.Logf("✓ %s → %s", tc.name, auth)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 配对判据:没有凭据时不得发出 `Bearer workos:` 这种空凭据头
|
||||
// (空凭据头会让上游回认证失败,而真因是「没配置 token」,会把人带偏)。
|
||||
func TestClineBuildHeadersWithoutToken(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders 不该因缺凭据报错: %v", err)
|
||||
}
|
||||
if auth, has := hdrs["Authorization"]; has {
|
||||
t.Fatalf("★ 无凭据时不得发 Authorization(会变成 `Bearer workos:`),实得 %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 单一实现:前缀规则只在 with_workos_prefix 里写一次。
|
||||
// 判据直接咬那个函数(经适配器导出),确保 build_headers 用的是同一份逻辑 ——
|
||||
// 若哪天有人把规则复制到 build_headers 里内联、而导出版留着旧的,这条会红。
|
||||
func TestClineWorkOSPrefixHelperIsSingleSource(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !vm.HasFn("channels-cline", "with_workos_prefix") {
|
||||
t.Fatal("★ channels-cline 应导出 with_workos_prefix 供判据咬同一份实现" +
|
||||
"(否则判据只能自算前缀规则,真实函数被改坏时会继续绿)")
|
||||
}
|
||||
// 用适配器池里真实的那份脚本再算一遍,与 build_headers 的产出对照。
|
||||
// 这里直接调 Transform 不方便(不是 transform 钩子),故用 build_headers
|
||||
// 的两种输入反推:带前缀的 token 必须**原样**出现在 Authorization 里。
|
||||
hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{
|
||||
"api_key": "workos:XYZ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hdrs["Authorization"]; got != "Bearer workos:XYZ" {
|
||||
t.Fatalf("已带前缀的 token 必须原样透传,实得 %q", got)
|
||||
}
|
||||
}
|
||||
506
internal/lua/channels_fixture_shapes_general_test.go
Normal file
506
internal/lua/channels_fixture_shapes_general_test.go
Normal file
@ -0,0 +1,506 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// P3(通用版):把「夹具形状」本身变成判据,而不是逐个渠道写三条用例。
|
||||
// ============================================================================
|
||||
//
|
||||
// 为什么这条比「三个具体用例」重要(plan §3 第 1 条 / §1.4 ④):
|
||||
//
|
||||
// 本轮最大的教训**不是某个 bug**,而是「夹具形状错 ⇒ 判据必然虚假通过」。
|
||||
// trae ExchangeToken 的「只读顶层、不认 Result 信封」在两个版本的扁平夹具
|
||||
// `{"Token":...}` 下**全部判据都是绿的**,真实登录却报「缺少 Token」。
|
||||
//
|
||||
// ⇒ 判据写得再严,只要喂进去的形状不是生产形状,它就毫无意义。
|
||||
// 所以必须钉住的不是「行为」,而是「**喂进去的形状**」。
|
||||
//
|
||||
// 本文件做两层(`channels_fixture_shapes_test.go` 的三条具体用例仍在,负责
|
||||
// 行为断言;这里负责**形状本身**的守门):
|
||||
//
|
||||
// ① shapeFamilies 登记表:每族给出
|
||||
// real —— 生产原文(引自实测抓包/参考实现注释)
|
||||
// naive —— 错误的「方便测试版」
|
||||
// fact —— 对原始 JSON 的**结构事实**(real 必须满足,naive 必须不满足)
|
||||
// eval —— 用**真实实现**处理它
|
||||
// 断言:fact(real) && !fact(naive) && eval(real) 符合该族契约。
|
||||
// 注意**不比较 eval 的输出形状**:多族实现刻意保留了兼容兜底路径
|
||||
// (例如 trae 余额同时认顶层与嵌套),拿输出比对会把「合法兼容」判成错误。
|
||||
// 真正能区分真假的判据是 fact —— 它说的是「生产响应长什么样」。
|
||||
//
|
||||
// ② 仓库扫描:对**特征明确**的退化形状做静态反查(只用于不会误报的那一族:
|
||||
// 把额度挪到 pack 顶层的 `{"credits_limit":`)。其余族的精确钉法是 ①,
|
||||
// 不靠文本匹配 —— 文本匹配对「Fatalf 文案里提到 choices」这类必然误报。
|
||||
|
||||
// shapeFamily 一类真实上游形状。
|
||||
type shapeFamily struct {
|
||||
// name 族名(判据名里会带上它,便于定位)。
|
||||
name string
|
||||
// why 为什么这族形状会被「方便的夹具」写坏(写进失败信息)。
|
||||
why string
|
||||
// real 生产原文。
|
||||
real string
|
||||
// naive 错误的「方便版」。必须**不满足** fact。
|
||||
naive string
|
||||
// fact 结构事实:true 表示「这段 body 是生产形状」。
|
||||
// 只用它做形状判定,不用它替代 eval。
|
||||
fact func(raw map[string]interface{}) bool
|
||||
// eval 用真实实现处理一段 body。
|
||||
eval func(body string) (map[string]interface{}, error)
|
||||
// assert 断言 eval(real) 的结果符合该族契约(行为层)。
|
||||
assert func(t *testing.T, obs map[string]interface{})
|
||||
}
|
||||
|
||||
// shapeFamilies 是形状契约的**唯一登记处**。
|
||||
//
|
||||
// 新增渠道若出现「信封 / 自定义 SSE / 嵌套字段」这类容易被夹具写坏的形状,
|
||||
// 必须在这里加一行 —— 元判据会检查三类基线族都在,且每行都真的调了被测实现。
|
||||
func shapeFamilies(t *testing.T) []shapeFamily {
|
||||
t.Helper()
|
||||
|
||||
return []shapeFamily{
|
||||
{
|
||||
// ① Result 信封:凭据在 **Result 里**,顶层只有 ResponseMetadata。
|
||||
name: "Result 信封",
|
||||
why: "顶层看起来「也」该有 Token,于是夹具顺手写成扁平 {\"Token\":...};" +
|
||||
"实现只读顶层时判据全绿 —— 真实缺陷就是这么藏了两轮",
|
||||
real: `{"ResponseMetadata":{"RequestId":"req-1","Action":"ExchangeToken"},` +
|
||||
`"Result":{"ClientID":"cid","Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`,
|
||||
naive: `{"ResponseMetadata":{"RequestId":"req-1"},` +
|
||||
`"Token":"ACCESS-FROM-RESULT"}`,
|
||||
fact: func(raw map[string]interface{}) bool {
|
||||
// 生产形状的判据:Result 是对象且其中有 Token,**顶层没有** Token。
|
||||
r, ok := raw["Result"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, has := r["Token"]; !has {
|
||||
return false
|
||||
}
|
||||
_, topHas := raw["Token"]
|
||||
return !topHas
|
||||
},
|
||||
eval: func(body string) (map[string]interface{}, error) {
|
||||
_, ps := shapesProbeVM(t)
|
||||
out, err := ps.Call("channels", "__trae_exchange_parse_probe",
|
||||
map[string]interface{}{"body": body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return probeJSON(t, out), nil
|
||||
},
|
||||
assert: func(t *testing.T, obs map[string]interface{}) {
|
||||
if obs["error"] != nil {
|
||||
t.Fatalf("★ 生产形状解析失败: %v —— 是否只读顶层、没认 Result 信封?", obs["error"])
|
||||
}
|
||||
if got, _ := obs["access"].(string); got != "ACCESS-FROM-RESULT" {
|
||||
t.Fatalf("★ access 必须来自 Result.Token,实得 %q", got)
|
||||
}
|
||||
if got, _ := obs["refresh"].(string); got != "RT-ROTATED" {
|
||||
t.Fatalf("★ 轮换后的 refresh 必须来自 Result.RefreshToken,实得 %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// ② SOLO 自定义 SSE:扁平帧,正文在 **response**,**没有 choices**。
|
||||
name: "SOLO 自定义 SSE",
|
||||
why: "内核按 UnifiedChunk 解**扁平**结构;夹具若照 OpenAI 写成 " +
|
||||
"{choices:[{delta}]},适配器返回外壳也会「看起来对」,而真机是 91 帧全空",
|
||||
real: `{"response":"### 分析","reasoning_content":null,"tool_calls":null}`,
|
||||
naive: `{"choices":[{"delta":{"content":"### 分析"}}]}`,
|
||||
fact: func(raw map[string]interface{}) bool {
|
||||
// 生产形状:正文在顶层 response,且**没有** choices。
|
||||
if _, has := raw["response"]; !has {
|
||||
return false
|
||||
}
|
||||
_, hasChoices := raw["choices"]
|
||||
return !hasChoices
|
||||
},
|
||||
eval: func(body string) (map[string]interface{}, error) {
|
||||
vm, _ := shapesProbeVM(t)
|
||||
out, err := vm.Transform("channels-trae", "transform_stream_chunk", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
// 适配器对非 SOLO 帧返回 "" 是合法行为,不是夹具问题。
|
||||
return map[string]interface{}{"raw": out}, nil
|
||||
}
|
||||
return ck, nil
|
||||
},
|
||||
assert: func(t *testing.T, obs map[string]interface{}) {
|
||||
if _, has := obs["choices"]; has {
|
||||
t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析: %v", obs)
|
||||
}
|
||||
if got, _ := obs["content"].(string); got != "### 分析" {
|
||||
t.Fatalf("★ SOLO 正文必须摊平到顶层 content,实得 %q(%v)", got, obs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// ③ 嵌套 quota:额度在 **entitlement_base_info.quota.credits_limit**。
|
||||
name: "嵌套 quota",
|
||||
why: "pack 顶层**没有** credits_limit(该字段根本不存在);" +
|
||||
"夹具若照想象的形状写成 pack.credits_limit,实现读错字段也全绿。" +
|
||||
"注意实现**刻意**保留了顶层兼容路径,所以「输出形状」不能当判据 ——" +
|
||||
"能区分真假的是「生产响应里额度在哪一层」",
|
||||
real: `{"user_entitlement_pack_list":[` +
|
||||
`{"pack_type":"free","entitlement_base_info":{"quota":{"enable_free":true}}},` +
|
||||
`{"pack_type":"monthly","entitlement_base_info":{"quota":{"credits_limit":500}},"usage":{}},` +
|
||||
`{"pack_type":"checkin","entitlement_base_info":{"quota":{"credits_limit":100}},` +
|
||||
` "usage":{"credits_amount":0}}],` +
|
||||
`"usage_summary":{"total_amount":600,"consumed_amount":0}}`,
|
||||
// 退化版:额度直接挂 pack 顶层(= 旧实现假设的形状,真实上游不发)。
|
||||
naive: `{"user_entitlement_pack_list":[{"credits_limit":500},{"credits_limit":100}]}`,
|
||||
fact: func(raw map[string]interface{}) bool {
|
||||
list, ok := raw["user_entitlement_pack_list"].([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
nested := 0
|
||||
for _, it := range list {
|
||||
e, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, top := e["credits_limit"]; top {
|
||||
return false // 顶层有 credits_limit ⇒ 不是生产形状
|
||||
}
|
||||
base, ok := e["entitlement_base_info"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
q, ok := base["quota"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := q["credits_limit"]; ok {
|
||||
nested++
|
||||
}
|
||||
}
|
||||
return nested > 0
|
||||
},
|
||||
eval: func(body string) (map[string]interface{}, error) {
|
||||
_, ps := shapesProbeVM(t)
|
||||
out, err := ps.Call("channels", "__balance_total_probe",
|
||||
map[string]interface{}{"platform": "trae", "body": body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return probeJSON(t, out), nil
|
||||
},
|
||||
assert: func(t *testing.T, obs map[string]interface{}) {
|
||||
if obs["error"] != nil {
|
||||
t.Fatalf("★ 生产形状解析失败: %v", obs["error"])
|
||||
}
|
||||
if got, _ := obs["total"].(float64); got != 600 {
|
||||
t.Fatalf("★ 余额应为 500+100=600(嵌套 entitlement_base_info.quota.credits_limit),"+
|
||||
"实得 %v —— 若为 0,检查是否又去读 pack 顶层的 credits_limit(该字段不存在)", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealUpstreamShapesArePinned 是 P3 的**通用**判据。
|
||||
//
|
||||
// 每族三重断言:
|
||||
// 1. fact(real) —— 生产原文**确实是**该族形状(防止有人把「生产原文」写错)
|
||||
// 2. !fact(naive) —— 退化形状**确实不是**该族形状(证明两者可区分)
|
||||
// 3. assert(eval(real)) —— 真实实现能吃下生产形状
|
||||
//
|
||||
// 第 1、2 条是关键:它们证明「判据在解结构」,而不是「碰巧对任何输入都成功」。
|
||||
func TestRealUpstreamShapesArePinned(t *testing.T) {
|
||||
fams := shapeFamilies(t)
|
||||
if len(fams) < 3 {
|
||||
t.Fatalf("★ 形状契约至少要覆盖 3 族(Result 信封 / SOLO SSE / 嵌套 quota),实得 %d", len(fams))
|
||||
}
|
||||
for _, f := range fams {
|
||||
t.Run(f.name, func(t *testing.T) {
|
||||
if f.real == "" || f.naive == "" || f.fact == nil || f.eval == nil || f.assert == nil {
|
||||
t.Fatalf("族 %q 登记不完整(real/naive/fact/eval/assert 全必填)—— "+
|
||||
"只钉生产形状无法证明判据真的在解结构", f.name)
|
||||
}
|
||||
|
||||
// ① 生产原文必须真的满足该族的结构事实
|
||||
realRaw := mustJSONObject(t, f.real, f.name, "real")
|
||||
if !f.fact(realRaw) {
|
||||
t.Fatalf("★ 族 %q:登记的「生产原文」不满足结构事实 —— "+
|
||||
"要么原文抄错了,要么 fact 写错了(两者都会让判据失去意义):\n %s", f.name, f.real)
|
||||
}
|
||||
// ② 退化形状必须不满足(证明 fact 能区分,不是恒真)
|
||||
naiveRaw := mustJSONObject(t, f.naive, f.name, "naive")
|
||||
if f.fact(naiveRaw) {
|
||||
t.Fatalf("★ 族 %q:退化形状也满足结构事实 ⇒ fact 是恒真式,"+
|
||||
"它区分不了真假夹具(这正是「夹具形状错⇒判据虚假通过」的机制):\n %s",
|
||||
f.name, f.naive)
|
||||
}
|
||||
|
||||
// ③ 真实实现必须能吃下生产形状,且行为符合契约
|
||||
obs, err := f.eval(f.real)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 生产形状求值失败: %v", f.name, err)
|
||||
}
|
||||
f.assert(t, obs)
|
||||
|
||||
// ④ 退化形状**不得**被当成同一族的成功解析:要么报错,要么明确
|
||||
// 走了兼容路径(此时 fact 已经证明它不是生产形状)。
|
||||
// 这里只记录,不做断言 —— 兼容路径是刻意的(见 why)。
|
||||
naiveObs, naiveErr := f.eval(f.naive)
|
||||
t.Logf("✓ %s: real→%v | naive→(err=%v, obs=%v)", f.name, obs, naiveErr, naiveObs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSONObject(t *testing.T, s, fam, which string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var v map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("族 %q 的 %s 形状不是合法 JSON 对象: %v\n %s", fam, which, err, s)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// TestShapeFamilyTableIsHonest 元判据:登记表本身不许糊弄。
|
||||
//
|
||||
// ① 三类基线族必须在(Result 信封 / SOLO SSE / 嵌套 quota);
|
||||
// ② 每族的 eval 必须真的打到被测实现 —— 给一段垃圾 body 时它必须有反应
|
||||
// (报错,或产出与生产形状不同的观测)。恒等函数会在这里被抓住。
|
||||
func TestShapeFamilyTableIsHonest(t *testing.T) {
|
||||
fams := shapeFamilies(t)
|
||||
want := []string{"Result 信封", "SOLO 自定义 SSE", "嵌套 quota"}
|
||||
got := map[string]bool{}
|
||||
for _, f := range fams {
|
||||
got[f.name] = true
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Fatalf("★ 形状表缺基线族 %q —— 三类真实形状必须持续被钉住(plan §1.4 ④)", w)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range fams {
|
||||
realObs, err := f.eval(f.real)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 生产形状求值失败: %v", f.name, err)
|
||||
}
|
||||
junkObs, junkErr := f.eval(`<<<not json at all>>>`)
|
||||
if junkErr == nil && samePrimitives(realObs, junkObs) {
|
||||
t.Fatalf("★ 族 %q 的 eval 对垃圾输入与生产输入返回相同观测 ⇒ "+
|
||||
"它不是真的在调被测实现(恒等判据毫无意义)", f.name)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 形状表 %d 族,基线齐全且 eval 真的打到被测实现", len(fams))
|
||||
}
|
||||
|
||||
// samePrimitives 只比较"标量键值都相同",用于检测恒等实现。
|
||||
func samePrimitives(a, b map[string]interface{}) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, av := range a {
|
||||
bv, ok := b[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch x := av.(type) {
|
||||
case string:
|
||||
if y, ok := bv.(string); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case float64:
|
||||
if y, ok := bv.(float64); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case bool:
|
||||
if y, ok := bv.(bool); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case nil:
|
||||
if bv != nil {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ② 仓库扫描:只对**特征明确**的退化形状做静态反查
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 为什么只留一条规则:文本匹配对另外两族**必然误报** ——
|
||||
// · SOLO:判据自己会写 `t.Fatalf("不得返回 choices …")`,天然含 choices;
|
||||
// · Result:真夹具里 `"Result":{…,"Token":…}` 与顶层 `"Token":` 是同一行。
|
||||
// 那两族的精确钉法是上面的 fact 判据(对原始 JSON 结构判定),不是文本。
|
||||
//
|
||||
// 剩下这一条规则的特征是**不会误报**的:把额度写到 pack 顶层必然写成
|
||||
// `{"credits_limit":`(花括号紧跟键名),而生产形状一定写成
|
||||
// `"quota":{"credits_limit":`(键名前是 `"quota":`)。
|
||||
|
||||
// degenerateFixturePattern 一条"退化夹具"特征。
|
||||
type degenerateFixturePattern struct {
|
||||
name string
|
||||
trigger string // 文件里出现它 ⇒ 该文件在测这个渠道
|
||||
forbidden string // 出现它 ⇒ 疑似把生产形状改成了方便版
|
||||
why string
|
||||
}
|
||||
|
||||
var degenerateFixturePatterns = []degenerateFixturePattern{
|
||||
{
|
||||
name: "trae 余额(嵌套 quota)",
|
||||
trigger: "user_entitlement_pack_list",
|
||||
forbidden: `{"credits_limit":`,
|
||||
why: "生产把额度放在 entitlement_base_info.quota.credits_limit(pack 顶层" +
|
||||
"**没有**该字段,实测上游从不发);`{\"credits_limit\":` 是把额度挪到顶层的" +
|
||||
"退化形状,会让「读错层级」这个缺陷继续绿(余额恒 0 就是这么来的)",
|
||||
},
|
||||
}
|
||||
|
||||
// TestNoDegenerateFixturesInRepo 扫 internal/**/*_test.go,任何"可疑退化夹具"
|
||||
// 都必须带 `// SHAPE-OK: <理由>` 显式豁免。
|
||||
//
|
||||
// 为什么扫描而不是只钉三条用例:三条用例只能钉住**它们自己**用的夹具;
|
||||
// 下一次有人在**别的**测试里写一个退化夹具,那三条照样绿。这条扫描对着
|
||||
// 整个仓库,是「防再犯」的那一半。
|
||||
func TestNoDegenerateFixturesInRepo(t *testing.T) {
|
||||
root, err := repoInternalDir()
|
||||
if err != nil {
|
||||
t.Fatalf("定位 internal/ 失败: %v", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(p, "_test.go") {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历 internal/: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("没扫到任何 _test.go —— 扫描失效(这本身比告警更糟)")
|
||||
}
|
||||
|
||||
var hits []string
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, f)
|
||||
// 本文件自身在描述规则(含特征字面量),跳过。
|
||||
if strings.HasSuffix(f, "channels_fixture_shapes_general_test.go") {
|
||||
continue
|
||||
}
|
||||
code := stripGoComments(string(b))
|
||||
for _, pat := range degenerateFixturePatterns {
|
||||
if !strings.Contains(code, pat.trigger) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(code, pat.forbidden) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(b), "SHAPE-OK:") {
|
||||
continue // 显式豁免,理由写在文件里
|
||||
}
|
||||
hits = append(hits, rel+" ["+pat.name+"] 含 "+pat.forbidden)
|
||||
}
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("★ 疑似退化夹具(形状与生产不一致 ⇒ 判据必然虚假通过):\n %s\n\n"+
|
||||
"若这**确实**是在测兼容/兜底路径,请在文件里加一行\n"+
|
||||
" // SHAPE-OK: <为什么这个退化形状是刻意的>\n"+
|
||||
"历史代价:trae 的 Result 信封缺陷就是这样藏了两轮(plan §1.4 ④)。\n"+
|
||||
"规则理由:\n%s", strings.Join(hits, "\n "), whyList())
|
||||
}
|
||||
t.Logf("✓ 扫描 %d 个 _test.go,无未豁免的退化夹具(%d 条特征规则)",
|
||||
len(files), len(degenerateFixturePatterns))
|
||||
}
|
||||
|
||||
func whyList() string {
|
||||
var b strings.Builder
|
||||
for _, p := range degenerateFixturePatterns {
|
||||
b.WriteString(" · " + p.name + ": " + p.why + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stripGoComments 去掉 `//` 行注释与 `/* */` 块注释。
|
||||
//
|
||||
// 只做够用的一层:不解析字符串字面量里的 `//`(例如 URL "https://…")。
|
||||
// 略微保守(可能少剥一点)比误剥安全 —— 误剥会让扫描漏报,少剥只会多报,
|
||||
// 而多报可以由 SHAPE-OK 显式豁免。
|
||||
func stripGoComments(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
lines := strings.Split(s, "\n")
|
||||
inBlock := false
|
||||
for _, line := range lines {
|
||||
if inBlock {
|
||||
if i := strings.Index(line, "*/"); i >= 0 {
|
||||
inBlock = false
|
||||
line = line[i+2:]
|
||||
} else {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, "/*"); i >= 0 && !strings.Contains(line[:i], `"`) {
|
||||
if j := strings.Index(line[i:], "*/"); j >= 0 {
|
||||
line = line[:i] + line[i+j+2:]
|
||||
} else {
|
||||
line = line[:i]
|
||||
inBlock = true
|
||||
}
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// repoInternalDir 从测试工作目录(internal/lua)向上找 internal/。
|
||||
func repoInternalDir() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for d := wd; d != "/" && d != ""; d = filepath.Dir(d) {
|
||||
if filepath.Base(d) == "internal" {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return filepath.Dir(wd), nil
|
||||
}
|
||||
|
||||
// shapesProbeVM 提供形状判据共用的插件 VM(插件自装适配器需要它)。
|
||||
//
|
||||
// 用 zcodeHarness 而不是 runtimePlugins:后者给 adapters/plugins 各一个独立
|
||||
// 临时目录,而 installZcodeAdapter 要求两者是兄弟目录(见 zcodeHarness 注释)。
|
||||
func shapesProbeVM(t *testing.T) (*VM, *Plugins) {
|
||||
t.Helper()
|
||||
return zcodeHarness(t)
|
||||
}
|
||||
318
internal/lua/channels_fixture_shapes_test.go
Normal file
318
internal/lua/channels_fixture_shapes_test.go
Normal file
@ -0,0 +1,318 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ P3:夹具形状防回归 —— 钉住三类**真实上游形状**。
|
||||
//
|
||||
// 本轮最大的教训不是某个 bug,而是「**夹具形状错 ⇒ 判据必然虚假通过**」:
|
||||
// trae 的 ExchangeToken 缺陷(只读顶层、不认 `Result` 信封)在两个版本的
|
||||
// 扁平夹具 `{"Token":...}` 下**全部判据都是绿的**,真实登录却报「缺少 Token」。
|
||||
// 也就是说:判据写得再严,只要它喂进去的形状不是生产形状,它就毫无意义。
|
||||
//
|
||||
// 因此这里不只钉行为,还要钉**形状**本身,且钉在**真实的解析器/转换器**上
|
||||
// (经插件探针调用同一份实现),而不是在 Go 侧重写一遍解包逻辑 ——
|
||||
// 自算版会在真实函数被改坏时继续绿(plan §3 第 4 条踩过三轮)。
|
||||
//
|
||||
// 三类形状(依据 docs/channel-repair-plan.md §1.4 ④ 与 §3 第 1 条):
|
||||
// ① Result 信封 —— trae ExchangeToken:凭据在 `Result` 里,顶层没有
|
||||
// ② SOLO 自定义 SSE —— trae 流式:`{"response":...}` 扁平帧,无 choices
|
||||
// ③ 嵌套 quota —— trae 余额:`pack.entitlement_base_info.quota.credits_limit`
|
||||
//
|
||||
// 任一形状被"改回方便测试的样子"(扁平/OpenAI 外壳/顶层字段),本判据立刻报红。
|
||||
|
||||
// TestFixtureShapeResultEnvelope ①:Result 信封必须被真实解析器认出来。
|
||||
//
|
||||
// 真实上游(2026-10-17 实测,HTTP 200):
|
||||
//
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
//
|
||||
// 顶层**只有** ResponseMetadata + Result、没有 Token。若夹具写成扁平
|
||||
// `{"Token":...}`,实现里「只读顶层」的错误会被完美掩盖 —— 这正是它藏了两轮的原因。
|
||||
func TestFixtureShapeResultEnvelope(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 真实形状:凭据只在 Result 里
|
||||
real := `{"ResponseMetadata":{"RequestId":"req-1","Action":"ExchangeToken"},` +
|
||||
`"Result":{"ClientID":"cid","Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000,` +
|
||||
`"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`
|
||||
out, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
|
||||
"body": real,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j := probeJSON(t, out)
|
||||
if j["error"] != nil {
|
||||
t.Fatalf("★ 真实 Result 信封解析失败: %v —— 检查是否只读顶层", j["error"])
|
||||
}
|
||||
if got, _ := j["access"].(string); got != "ACCESS-FROM-RESULT" {
|
||||
t.Fatalf("★ access 必须来自 Result.Token,实得 %q", got)
|
||||
}
|
||||
if got, _ := j["refresh"].(string); got != "RT-ROTATED" {
|
||||
t.Fatalf("★ 轮换后的 refresh 必须来自 Result.RefreshToken,实得 %q", got)
|
||||
}
|
||||
|
||||
// 反例:把这个形状的 Result 剥掉(= 旧的扁平夹具),解析器必须**报错**而不是
|
||||
// 悄悄返回空 —— 否则"缺 Token"永远被误报成成功。
|
||||
flat := `{"ResponseMetadata":{"RequestId":"req-1"},"Token":"SHOULD-NOT-RESOLVE"}`
|
||||
out2, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
|
||||
"body": flat,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j2 := probeJSON(t, out2)
|
||||
// 注意:顶层 Token 是**兼容兜底**(上游可能改版),所以这里允许成功;
|
||||
// 但要钉住「Result 缺失时不会崩、也不会把 ResponseMetadata 当 Result」。
|
||||
if j2["error"] == nil {
|
||||
if got, _ := j2["access"].(string); got != "SHOULD-NOT-RESOLVE" {
|
||||
t.Fatalf("顶层兜底路径应取顶层 Token,实得 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 真正必须报错的形状:既无 Result.Token 也无顶层 Token
|
||||
bad := `{"ResponseMetadata":{"RequestId":"req-1"},"Result":{"ClientID":"cid"}}`
|
||||
out3, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
|
||||
"body": bad,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j3 := probeJSON(t, out3)
|
||||
if j3["error"] == nil {
|
||||
t.Fatalf("★ 无 Token 的响应必须报错(否则「缺少 Token」会被误判成成功): %v", j3)
|
||||
}
|
||||
t.Logf("✓ Result 信封: access=%v refresh=%v;缺 Token 报错=%v",
|
||||
j["access"], j["refresh"], j3["error"])
|
||||
}
|
||||
|
||||
// TestFixtureShapeSoloSSE ②:trae 的 SOLO 自定义 SSE 必须被摊平成扁平 UnifiedChunk。
|
||||
//
|
||||
// 真实上游帧(参考 trae.ts SOLO):
|
||||
//
|
||||
// event: output
|
||||
// data: {"response":"###","reasoning_content":null,"tool_calls":null}
|
||||
//
|
||||
// 内核只把 `data:` 行交给适配器(event 行在 provider.go 丢弃),且
|
||||
// `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构** ——
|
||||
// 返回 OpenAI 外壳 `{choices:[{delta}]}` 会被整体丢弃(91 帧全空)。
|
||||
func TestFixtureShapeSoloSSE(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 真实正文帧(注意:`reasoning_content` / `tool_calls` 显式 null,不是缺失)
|
||||
frames := []struct {
|
||||
name string
|
||||
frame string
|
||||
wantKey string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "正文帧",
|
||||
frame: `{"response":"### 分析","reasoning_content":null,"tool_calls":null}`,
|
||||
wantKey: "content",
|
||||
wantText: "### 分析",
|
||||
},
|
||||
{
|
||||
name: "推理帧",
|
||||
frame: `{"response":"","reasoning_content":"We need","tool_calls":null}`,
|
||||
wantKey: "reasoning_content",
|
||||
wantText: "We need",
|
||||
},
|
||||
}
|
||||
for _, f := range frames {
|
||||
out, err := vm.Transform("channels-trae", "transform_stream_chunk", f.frame)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: transform 失败: %v", f.name, err)
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("%s: 产出不是 JSON: %q", f.name, out)
|
||||
}
|
||||
// ★ 形状一:必须是**扁平**(顶层 content),不能是 OpenAI 外壳
|
||||
if _, has := ck["choices"]; has {
|
||||
t.Fatalf("★ %s 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,"+
|
||||
"外壳字段会被全部丢弃: %s", f.name, out)
|
||||
}
|
||||
got, _ := ck[f.wantKey].(string)
|
||||
if got != f.wantText {
|
||||
t.Fatalf("★ %s 应摊平到顶层 %s=%q,实得 %q(原始产出 %s)",
|
||||
f.name, f.wantKey, f.wantText, got, out)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ SOLO SSE 扁平形状正确(content / reasoning_content 均在顶层)")
|
||||
}
|
||||
|
||||
// TestFixtureShapeNestedQuota ③:trae 余额在**两层嵌套**里,不在 pack 顶层。
|
||||
//
|
||||
// 真实上游(2026-10-17 实测):
|
||||
//
|
||||
// pack[0] 免费 → entitlement_base_info.quota 无 credits_limit
|
||||
// pack[1] 每月登录赠送 → entitlement_base_info.quota.credits_limit = 500
|
||||
// pack[2] 签到奖励 → entitlement_base_info.quota.credits_limit = 100
|
||||
// usage_summary: {total_amount:600, consumed_amount:0}
|
||||
//
|
||||
// 旧实现读 `pack.credits_limit`(**根本不存在的字段**)⇒ 余额恒 0,
|
||||
// 而账号里其实有 600 —— 用户看到「额度显示异常」。
|
||||
func TestFixtureShapeNestedQuota(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 真实形状:额度在 entitlement_base_info.quota 里(含一个无额度的免费包)
|
||||
real := `{"user_entitlement_pack_list":[` +
|
||||
`{"pack_type":"free","entitlement_base_info":{"quota":{"enable_free":true}}},` +
|
||||
`{"pack_type":"monthly","entitlement_base_info":{"quota":{"credits_limit":500}},` +
|
||||
` "usage":{}},` +
|
||||
`{"pack_type":"checkin","entitlement_base_info":{"quota":{"credits_limit":100}},` +
|
||||
` "usage":{"credits_amount":0}}` +
|
||||
`],"usage_summary":{"total_amount":600,"consumed_amount":0}}`
|
||||
out, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"body": real,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j := probeJSON(t, out)
|
||||
if j["error"] != nil {
|
||||
t.Fatalf("★ 嵌套 quota 解析失败: %v", j["error"])
|
||||
}
|
||||
if got, _ := j["total"].(float64); got != 600 {
|
||||
t.Fatalf("★ 余额应为 500+100=600(嵌套 entitlement_base_info.quota.credits_limit),"+
|
||||
"实得 %v —— 若为 0,检查是否又去读了 pack 顶层的 credits_limit(该字段不存在)",
|
||||
j["total"])
|
||||
}
|
||||
|
||||
// 反例:把额度放到**pack 顶层**(= 旧实现假设的形状)。真实上游不这么发,
|
||||
// 但实现保留了兼容路径 —— 这里钉住的是「嵌套路径是权威」,同时确认
|
||||
// 顶层兼容不会把数字算错(不会重复累加)。
|
||||
//
|
||||
// SHAPE-OK: 这段**刻意**用退化形状(额度挂 pack 顶层),因为它要测的正是
|
||||
// 兼容兵底路径。生产形状由上面的嵌套夹具铉住;本文件里“生产/退化”两种
|
||||
// 形状成对出现,才是“实现能区分两者”的证据。
|
||||
// (若删掉这句,TestNoDegenerateFixturesInRepo 会报红 —— 这是故意的:
|
||||
// 它强迫每一个退化夹具显式声明自己是刻意的。)
|
||||
topLevel := `{"user_entitlement_pack_list":[{"credits_limit":50}]}`
|
||||
out2, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"body": topLevel,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j2 := probeJSON(t, out2)
|
||||
if j2["error"] != nil {
|
||||
t.Fatalf("顶层兼容路径不该报错: %v", j2["error"])
|
||||
}
|
||||
if got, _ := j2["total"].(float64); got != 50 {
|
||||
t.Fatalf("顶层兼容路径应算 50,实得 %v", j2["total"])
|
||||
}
|
||||
|
||||
// 已用额度必须扣减(usage.credits_amount 也是嵌套的)
|
||||
consumed := `{"user_entitlement_pack_list":[` +
|
||||
`{"entitlement_base_info":{"quota":{"credits_limit":500}},` +
|
||||
` "usage":{"credits_amount":120}}]}`
|
||||
out3, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"body": consumed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j3 := probeJSON(t, out3)
|
||||
if got, _ := j3["total"].(float64); got != 380 {
|
||||
t.Fatalf("★ 余额应为 500-120=380(usage.credits_amount 是嵌套的),实得 %v", j3["total"])
|
||||
}
|
||||
t.Logf("✓ 嵌套 quota: 600 / 扣减后 380 / 顶层兼容 50")
|
||||
}
|
||||
|
||||
// probeJSON 取探针返回的 {json:{...}} 那层。
|
||||
func probeJSON(t *testing.T, got interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值不是对象: %#v", got)
|
||||
}
|
||||
j, ok := m["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值缺 json 层: %#v", m)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// TestFixtureShapeGuardCoversAllThree 元判据:上三条**真的存在且真的咬到了
|
||||
// 被测对象**。这是防「有人删掉形状判据」或「把判据改成不调真实解析器」的兜底。
|
||||
//
|
||||
// 它读自己的源码文本(同目录下),断言:
|
||||
// ① 三个形状判据函数都在;
|
||||
// ② 它们**都调用了**插件探针(而不是自己另写一份解包逻辑 ——
|
||||
// 那样真实函数被改坏时判据会继续绿,这正是 trae 藏了两轮的原因)。
|
||||
func TestFixtureShapeGuardCoversAllThree(t *testing.T) {
|
||||
self, err := os.ReadFile("channels_fixture_shapes_test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读不到自身源码(元判据靠它验证三条形状判据是否还在): %v", err)
|
||||
}
|
||||
s := string(self)
|
||||
|
||||
// ① 三个形状判据必须在
|
||||
for _, fn := range []string{
|
||||
"TestFixtureShapeResultEnvelope",
|
||||
"TestFixtureShapeSoloSSE",
|
||||
"TestFixtureShapeNestedQuota",
|
||||
} {
|
||||
if !strings.Contains(s, "func "+fn+"(") {
|
||||
t.Fatalf("★ 形状判据 %s 不见了 —— 三类真实形状必须持续被钉住", fn)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 三条判据必须真的调用探针/真实适配器(不能退化成读源码字符串断言)
|
||||
// Result 信封 / 嵌套 quota → 探针(同一个真实解析器)
|
||||
// SOLO SSE → 已装载的 channels-trae 适配器
|
||||
for _, anchor := range []string{
|
||||
`__trae_exchange_parse_probe`, // ① Result 信封
|
||||
`__balance_total_probe`, // ③ 嵌套 quota
|
||||
`vm.Transform("channels-trae", "transform_stream_chunk"`, // ② SOLO SSE
|
||||
} {
|
||||
if !strings.Contains(s, anchor) {
|
||||
t.Fatalf("★ 形状判据不再直接咬真实实现(缺 %s)—— "+
|
||||
"自算版会在真实函数被改坏时继续绿", anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 探针必须真的在插件里导出(否则上面两条会在运行期变成"探针不存在")
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, probe := range []string{"__trae_exchange_parse_probe", "__balance_total_probe"} {
|
||||
if !strings.Contains(string(src), probe) {
|
||||
t.Fatalf("★ 插件缺探针 %s —— 形状判据将无从咬真实解析器", probe)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 三类形状判据齐全,且均直接咬真实实现")
|
||||
}
|
||||
91
internal/lua/channels_gemini_auth_check_test.go
Normal file
91
internal/lua/channels_gemini_auth_check_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-3(**只确认不改**):channels-gemini 的「静态身份五头 + 内核兜底 Bearer」
|
||||
// 本来就是**对的**,不需要像 zcode 那样补 x-api-key。
|
||||
//
|
||||
// 这是本轮唯一一个"看起来同型、实际不是缺陷"的渠道,所以把它钉成判据,
|
||||
// 免得下一轮有人照 P1-1 的模子给它也改一遍(那会把它弄坏)。
|
||||
//
|
||||
// 证据链(两条独立来源一致):
|
||||
//
|
||||
// ① 参考实现 vendor/channel-pack/src/gemini.ts:353-366 geminiHeaders()
|
||||
// headers.set('Authorization', `Bearer ${credential.access_token}`)
|
||||
// 再逐字注入 GEMINI_IDENTITY_HEADERS 五头
|
||||
// 并**刻意不注入** x-goog-api-key(源注释:原版不带)
|
||||
// ② 参考 src/gemini.ts:110-117 的 GEMINI_IDENTITY_HEADERS 与我们
|
||||
// adapter.headers 的五头逐字相同(UA / x-client-name / x-client-version /
|
||||
// x-machine-id / x-vscode-sessionid)
|
||||
//
|
||||
// 也就是说:gemini 走的是 **OAuth Bearer**(Cloud Code 凭据),不是
|
||||
// Anthropic 那种 x-api-key,也不是 Cline 那种 workos: 前缀。它的鉴权
|
||||
// 由内核兜底 `Authorization: Bearer <api_key>` 提供 —— 而 gemini 未声明
|
||||
// credential_shape ⇒ api_key = 凭据里的 access_token ⇒ 兜底值正确。
|
||||
//
|
||||
// ⇒ **结论:不改。** 本判据把这个结论钉住:一旦有人给 channels-gemini 加上
|
||||
//
|
||||
// build_headers 发别的鉴权头(例如照 zcode 改成 x-api-key),这里会红。
|
||||
func TestChannelsGeminiAuthIsBearerByDesign(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
|
||||
// ① 适配器不该自己发鉴权头:静态头里**不得**出现 Authorization /
|
||||
// x-api-key / x-goog-api-key。鉴权交给内核兜底 Bearer。
|
||||
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{
|
||||
"api_key": "GA-OAUTH-TOKEN",
|
||||
"url": "https://cloudcode-pa.googleapis.com/v1internal:generateContent",
|
||||
"method": "POST",
|
||||
"body": `{}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"Authorization", "x-api-key", "x-goog-api-key"} {
|
||||
if v, has := hdrs[forbidden]; has {
|
||||
t.Fatalf("★ channels-gemini 不该自己发 %s(实得 %q)—— "+
|
||||
"Cloud Code 走 OAuth Bearer,由内核兜底发;"+
|
||||
"自己发会让它与兜底冲突或改变鉴权方案(P1-3 结论是「不改」)",
|
||||
forbidden, v)
|
||||
}
|
||||
}
|
||||
// ② 身份五头必须还在(它们是准入条件,与鉴权无关)
|
||||
for k, want := range map[string]string{
|
||||
"User-Agent": "antigravity/4.3.0 (cmdc-pak)",
|
||||
"x-client-name": "antigravity",
|
||||
"x-client-version": "4.3.0",
|
||||
"x-machine-id": "cmdc-pak",
|
||||
"x-vscode-sessionid": "proxy",
|
||||
} {
|
||||
if got := hdrs[k]; got != want {
|
||||
t.Fatalf("身份头 %s 应为 %q,实得 %q(参考 GEMINI_IDENTITY_HEADERS 逐字)", k, want, got)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ channels-gemini 只发身份五头、不自带鉴权(兜底 Bearer 即正确): %v", keysOf(hdrs))
|
||||
}
|
||||
|
||||
// 配对判据:经**内核**装出来的头必须真的是 `Authorization: Bearer <token>`。
|
||||
//
|
||||
// 这条在 internal/provider 侧另有一条同型判据(build_headers_fallback_test.go),
|
||||
// 这里从「适配器没抢鉴权」推到「兜底确实补上了」,把 P1-3 的结论补成闭环。
|
||||
func TestChannelsGeminiKernelFallbackSendsBearer(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
|
||||
// 适配器产出:不含任何鉴权头(见上一条)
|
||||
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{
|
||||
"api_key": "GA-OAUTH-TOKEN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := hdrs["Authorization"]; has {
|
||||
t.Fatalf("适配器不该发 Authorization,实得 %v", hdrs)
|
||||
}
|
||||
// 于是内核兜底条件成立(Authorization 与 x-api-key 都为空)⇒ 会补 Bearer。
|
||||
// 这里断言**条件成立**,而不是重复实现兜底逻辑(兜底本身由 provider 判据咬)。
|
||||
if hdrs["x-api-key"] != "" {
|
||||
t.Fatalf("x-api-key 必须为空(否则内核兜底不会补 Bearer),实得 %v", hdrs)
|
||||
}
|
||||
t.Log("✓ 适配器不装鉴权 ⇒ 内核兜底条件成立 ⇒ 发 Authorization: Bearer <access_token>")
|
||||
}
|
||||
@ -116,7 +116,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", "__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", "__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", "__balance_total_probe", "__trae_exchange_parse_probe" }
|
||||
|
||||
|
||||
|
||||
@ -8813,6 +8813,36 @@ local function jsonField(raw, key)
|
||||
return v[key] or ""
|
||||
end
|
||||
|
||||
-- parseTraeExchangeBody(body):ExchangeToken 响应体 → (access, newRefresh, expireAt, err)。
|
||||
--
|
||||
-- ★ 为什么抽成纯函数:判据要能**直接咬真实解析器**。此前 trae 的判据是
|
||||
-- 「造 HTTP 假上游 → 走完整回调」,结果一次形状不符的**夹具**(扁平
|
||||
-- `{"Token":...}`)把这个真缺陷(只读顶层)完美掩盖了两轮。夹具形状错
|
||||
-- ⇒ 判据必然虚假通过。抽出来之后,判据拿真实上游响应原文直接打它。
|
||||
--
|
||||
-- 真实上游形状(2026-10-17 实测,HTTP 200):
|
||||
-- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...",
|
||||
-- "RefreshToken":"...","TokenExpireAt":1800000000000}}
|
||||
-- 顶层只有 ResponseMetadata + Result、**没有** Token。
|
||||
-- 参考 parseTraeExchangeResponse 同样先取 `data.Result ?? data.result`。
|
||||
-- 顶层也兜(上游可能改版),但**优先 Result**。
|
||||
local function parseTraeExchangeBody(body)
|
||||
local ok, rec = pcall(json.decode, body or "")
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return nil, nil, nil, "TRAE ExchangeToken 响应无法解析"
|
||||
end
|
||||
local r2 = rec.Result or rec.result
|
||||
if type(r2) ~= "table" then r2 = rec end
|
||||
local access = r2.Token or r2.token or r2.AccessToken or r2.access_token
|
||||
or rec.Token or rec.token
|
||||
if not access or access == "" then
|
||||
return nil, nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)"
|
||||
end
|
||||
local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken
|
||||
local expireAt = r2.TokenExpireAt or rec.TokenExpireAt or r2.expireAt
|
||||
return access, newRefresh, expireAt, nil
|
||||
end
|
||||
|
||||
traeCallback = function(plat, q, s)
|
||||
q = q or {}
|
||||
local userJwt = q.userJwt or ""
|
||||
@ -8882,29 +8912,21 @@ traeCallback = function(plat, q, s)
|
||||
if resp.status ~= 200 then
|
||||
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return nil, nil, "TRAE ExchangeToken 响应无法解析"
|
||||
end
|
||||
-- ★★ 上游把凭据放在 **Result 里**,不是顶层:
|
||||
-- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
-- 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):HTTP 200,
|
||||
-- 顶层只有 ResponseMetadata + Result、**没有** Token。
|
||||
-- 我原先读 `rec.Token` ⇒ 恒 nil ⇒ 报「响应缺少 Token」;用户只看到「登录失败」,
|
||||
-- 完全看不出是解包层级错了。参考 parseTraeExchangeResponse 同样先取
|
||||
-- `data.Result ?? data.result`。
|
||||
-- ⚠️ 顶层也兜(上游可能改版),但**优先 Result**。
|
||||
local r2 = rec.Result or rec.result
|
||||
if type(r2) ~= "table" then r2 = rec end
|
||||
local access = r2.Token or r2.token or r2.AccessToken or r2.access_token
|
||||
or rec.Token or rec.token
|
||||
if not access or access == "" then
|
||||
return nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)"
|
||||
end
|
||||
-- ★ 解包交给 parseTraeExchangeBody(上面那个纯函数)。
|
||||
-- **单一定义**:判据拿真实上游响应原文直接打同一个函数,不另写副本 ——
|
||||
-- 否则夹具/副本与生产漂移时判据会继续绿(trae 就是这么藏了两轮)。
|
||||
--
|
||||
-- 上游把凭据放在 **Result 里**,不是顶层:
|
||||
-- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
-- 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):HTTP 200,
|
||||
-- 顶层只有 ResponseMetadata + Result、**没有** Token。
|
||||
-- 原先读 `rec.Token` ⇒ 恒 nil ⇒ 误报「响应缺少 Token」;用户只看到
|
||||
-- 「登录失败」,完全看不出是解包层级错了。
|
||||
-- 参考 parseTraeExchangeResponse 同样先取 `data.Result ?? data.result`。
|
||||
local access, newRefresh, expireAt, perr = parseTraeExchangeBody(resp.body)
|
||||
if perr then return nil, nil, perr end
|
||||
-- ★ 轮换后的 refreshToken 必须留下:旧 refresh 已被上游作废
|
||||
-- (参考注释明说「换新 access 并**轮换** refreshToken」)。不存新值 ⇒ 下次续期必失败。
|
||||
local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken
|
||||
local expireAt = r2.TokenExpireAt or rec.TokenExpireAt
|
||||
local packed = packTrae(access)
|
||||
if (newRefresh and newRefresh ~= "") or expireAt then
|
||||
local okc, cred = pcall(json.decode, packed)
|
||||
@ -10618,6 +10640,51 @@ end
|
||||
adapter.name = "channels-zcode"
|
||||
adapter.version = "1.0.0"
|
||||
|
||||
-- ============================================================================
|
||||
-- build_headers:zcode 的 Messages 端点是 **Bearer**,不是 x-api-key
|
||||
-- ============================================================================
|
||||
-- ★★ 这一层**必须显式覆盖**基座的 build_headers,不能靠继承。
|
||||
--
|
||||
-- 基座(anthropic.lua)现在按 Anthropic 协议发 `x-api-key`,而 zcode 的
|
||||
-- `/api/v1/zcode-plan/anthropic/v1/messages` 虽然**说着 Anthropic 协议**,
|
||||
-- 鉴权却是 **Authorization: Bearer <zcode_jwt>**。
|
||||
--
|
||||
-- 参考实现是唯一权威(vendor/channel-pack 实测逆向):
|
||||
-- src/zcode-transport.ts:121-125 buildChannelRequest()
|
||||
-- headers = buildZcodeHeaders(credential, { json: true })
|
||||
-- headers.Authorization = `Bearer ${token}` ← token = zcode_jwt
|
||||
-- src/zcode-upstream.ts:83-108 buildZcodeHeaders()
|
||||
-- 只发 User-Agent / HTTP-Referer / X-Device-Mid / anthropic-version 等,
|
||||
-- Authorization 是**可选**注入项,**全程没有 x-api-key**
|
||||
-- 全仓 `grep -rn "x-api-key" src/` ⇒ **0 次命中**
|
||||
--
|
||||
-- ⇒ 协议(Anthropic Messages 形状)与鉴权(Bearer)在这里是**两个独立维度**:
|
||||
-- 基座对的是「任何说 Anthropic 协议的上游」,zcode 对的是「它自己那家」。
|
||||
-- 若这里不覆盖:基座会发 x-api-key,而内核 buildHeaders 不会再补 Bearer
|
||||
-- ⇒ zcode 一有账号就 401,与「什么都没发」等价。
|
||||
--
|
||||
-- 同时保留基座给的 anthropic-version(协议头,跟鉴权无关),
|
||||
-- 并用基座同一套键名取凭据,避免两处各写一份取值规则。
|
||||
local function zcode_api_key(meta)
|
||||
if type(meta) ~= "table" then return "" end
|
||||
if type(meta.api_key) == "string" and meta.api_key ~= "" then return meta.api_key end
|
||||
-- 兜底:个别调用方只给 source.meta.api_key(与基座/kimicode 同一约定)
|
||||
local sm = type(meta.source) == "table" and meta.source.meta or nil
|
||||
if type(sm) == "table" and type(sm.api_key) == "string" then return sm.api_key end
|
||||
return ""
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
local key = zcode_api_key(meta)
|
||||
if key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 身份块构造
|
||||
-- ============================================================================
|
||||
@ -10863,6 +10930,50 @@ function adapter.transform_request(raw_body) return raw_body end
|
||||
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
|
||||
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
|
||||
|
||||
-- build_headers:Cline 的**推理**端点要 `Authorization: Bearer workos:<jwt>`。
|
||||
--
|
||||
-- 为什么必须在这里补前缀(而不是登录处):参考实现 cline.ts 的发头函数直接
|
||||
-- 拼 `workos:${token}`,而 Cline **第二步 register** 把 WorkOS token 换成了
|
||||
-- Cline 自己的 token(我们的池里存的就是**后者**,不带前缀)。实测/参考都
|
||||
-- 要求网关侧把 `workos:` 当**协议前缀**重新拼上,否则上游只回泛化的认证失败
|
||||
-- (与 trae 修前同型:头发了,但形式不对)。
|
||||
--
|
||||
-- ★ 幂等:已经带 `workos:` 的 token **不再重复拼**(`workos:workos:xxx` 必错,
|
||||
-- 而两种凭据在池里都可能出现:旧数据是完整的带前缀串,新登录的是裸 token)。
|
||||
-- ★ 前缀必须**大小写不敏感**地判(上游文档写 `workos:`,实际发过 `WorkOS:`
|
||||
-- 的客户端也能用),但补出来的一律是小写形式。
|
||||
local WORKOS_PREFIX = "workos:"
|
||||
|
||||
local function with_workos_prefix(tok)
|
||||
if type(tok) ~= "string" or tok == "" then return "" end
|
||||
if string.lower(string.sub(tok, 1, #WORKOS_PREFIX)) == WORKOS_PREFIX then
|
||||
return tok
|
||||
end
|
||||
return WORKOS_PREFIX .. tok
|
||||
end
|
||||
|
||||
-- 导出给判据用(不是内核契约的一部分):判据要直接咬**同一个**拼装函数,
|
||||
-- 不能在 Go 侧自算一遍前缀规则(那样真实函数被改坏时判据会继续绿)。
|
||||
adapter.with_workos_prefix = with_workos_prefix
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local key = ""
|
||||
if type(meta) == "table" then
|
||||
if type(meta.api_key) == "string" then key = meta.api_key end
|
||||
if key == "" and type(meta.source) == "table"
|
||||
and type(meta.source.meta) == "table"
|
||||
and type(meta.source.meta.api_key) == "string" then
|
||||
key = meta.source.meta.api_key
|
||||
end
|
||||
end
|
||||
local h = { ["Content-Type"] = "application/json" }
|
||||
local tok = with_workos_prefix(key)
|
||||
if tok ~= "" then
|
||||
h["Authorization"] = "Bearer " .. tok
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
|
||||
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
|
||||
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
|
||||
@ -11694,6 +11805,43 @@ function plugin.credits_tier_probe(args)
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__trae_exchange_parse_probe:只给测试/诊断用 —— 把一段**上游响应原文**
|
||||
-- 交给真实的 parseTraeExchangeBody,返回解出的字段。
|
||||
--
|
||||
-- 为何必须有这个缝隙:P3 的「Result 信封」判据要咬**真实解析器**。
|
||||
-- 若判据自己在 Go 侧再写一份解包逻辑,真实函数被改坏时它会继续绿
|
||||
-- (这正是 trae 那个缺陷藏了两轮的原因:夹具形状错与实现错恰好互相兼容)。
|
||||
function plugin.__trae_exchange_parse_probe(args)
|
||||
args = args or {}
|
||||
local access, newRefresh, expireAt, err = parseTraeExchangeBody(tostring(args.body or ""))
|
||||
if err then return { json = { error = err } } end
|
||||
return { json = { access = access, refresh = newRefresh, expire_at = expireAt } }
|
||||
end
|
||||
|
||||
-- plugin.__balance_total_probe:只给测试/诊断用 —— 把一段**上游响应原文**
|
||||
-- 交给该平台真实的 `balance.total_fn`,返回它算出的数字。
|
||||
--
|
||||
-- 为何需要:余额口径(嵌套 quota vs pack 顶层)曾经算错过一次 —— 读了一个
|
||||
-- **根本不存在的字段** ⇒ 余额恒 0,而判据当时是「造 HTTP 假上游 + 走完整
|
||||
-- credits_balances」,URL 覆盖/凭据 ref/路径匹配任一环错就会让「没发请求」
|
||||
-- 伪装成「余额算错」(耗了三轮)。本探针直接咬**被测对象**:同一个 total_fn。
|
||||
function plugin.__balance_total_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local spec = plat[args.endpoint or "balance"]
|
||||
if type(spec) ~= "table" or type(spec.total_fn) ~= "function" then
|
||||
return { json = { error = "该端点未声明 total_fn" } }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, tostring(args.body or ""))
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return { json = { error = "夹具不是合法 JSON 对象" } }
|
||||
end
|
||||
local okf, total = pcall(spec.total_fn, rec)
|
||||
if not okf then return { json = { error = "total_fn 抛错: " .. tostring(total) } } end
|
||||
return { json = { total = tonumber(total) } }
|
||||
end
|
||||
|
||||
-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。
|
||||
function plugin.__set_lock_permanent_probe(args)
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
@ -12188,6 +12336,11 @@ function plugin.__adapter_install_probe(args)
|
||||
trae = plugin._trae_adapter,
|
||||
gemini = plugin._gemini_adapter,
|
||||
zcode = plugin._zcode_adapter,
|
||||
-- ★ cline / qoder 此前**不在探针输出里** —— 于是「装了但没生效」这个
|
||||
-- 最坑的故障态在这两个适配器上完全不可观测(本轮修 cline 的 workos:
|
||||
-- 前缀时才发现)。装上就该看得见。
|
||||
cline = plugin._cline_adapter,
|
||||
qoder = plugin._qoder_adapter,
|
||||
} }
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user