diff --git a/internal/lua/adapter_opencodezen_live_test.go b/internal/lua/adapter_opencodezen_live_test.go index a3184f5..84d93a6 100644 --- a/internal/lua/adapter_opencodezen_live_test.go +++ b/internal/lua/adapter_opencodezen_live_test.go @@ -14,10 +14,10 @@ import ( ) // 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。 -// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。 +// 需要真实凭据(从生产配置解密),故用 MODELROUTER_LIVE_ZEN=1 显式开启。 func TestOpencodeZenLiveE2E(t *testing.T) { - if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" { - t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e") + if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" { + t.Skip("设 MODELROUTER_LIVE_ZEN=1 开启真上游 e2e") } src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") if err != nil { @@ -30,8 +30,8 @@ func TestOpencodeZenLiveE2E(t *testing.T) { } // 取生产凭据(解密) - out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets", - "-config", "/etc/llmsproxy/config.yaml").Output() + out, err := exec.Command(gatewayBinary(), "-show-secrets", + "-config", "/etc/modelrouter/config.yaml").Output() if err != nil { t.Fatal(err) } @@ -131,8 +131,8 @@ func TestOpencodeZenLiveE2E(t *testing.T) { // 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。 // 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。 func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) { - if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" { - t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启") + if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" { + t.Skip("设 MODELROUTER_LIVE_ZEN=1 开启") } src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") if err != nil { @@ -143,8 +143,8 @@ func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) { if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { t.Fatal(err) } - out, _ := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets", - "-config", "/etc/llmsproxy/config.yaml").Output() + out, _ := exec.Command(gatewayBinary(), "-show-secrets", + "-config", "/etc/modelrouter/config.yaml").Output() apiKey := "" for _, l := range strings.Split(string(out), "\n") { if strings.HasPrefix(l, "source localzen") { @@ -211,3 +211,17 @@ func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) { } t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined)) } + +// gatewayBinary 定位本机网关二进制。改名后默认是 /usr/local/bin/modelrouter; +// 仍兼容改名前的 /usr/local/bin/llmsproxy,避免测试脚本要跟着运维机器改。 +func gatewayBinary() string { + if v := os.Getenv("MODELROUTER_BIN"); v != "" { + return v + } + for _, p := range []string{"/usr/local/bin/modelrouter", "/usr/local/bin/llmsproxy"} { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "/usr/local/bin/modelrouter" +} diff --git a/internal/lua/adapter_opencodezen_test.go b/internal/lua/adapter_opencodezen_test.go index 25eb38d..f1d979f 100644 --- a/internal/lua/adapter_opencodezen_test.go +++ b/internal/lua/adapter_opencodezen_test.go @@ -37,8 +37,13 @@ func TestOpencodeZenAdapterE2E(t *testing.T) { if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") { t.Fatalf("request 形状: %q", h["x-opencode-request"]) } - if !strings.Contains(h["User-Agent"], "opencode/1.19.0") { - t.Fatalf("UA 应为 1.19.0: %q", h["User-Agent"]) + // ★ UA 必须与**抓包的官方版本**一致:上游按客户端指纹判定免费层资格。 + // 实测(2026-10-24,同一凭据同一模型): + // CLI 1.18.29(发 4 个头) → 429 FreeUsageLimitError + // CLI 1.19.4 (发 5 个头) → 200,任务真实完成 + // ⇒ UA 里声称的版本与发头集合必须同步升级,不能只改其一。 + if !strings.Contains(h["User-Agent"], "opencode/1.19.4") { + t.Fatalf("UA 应为 1.19.4: %q", h["User-Agent"]) } t.Logf("session=%s request=%s", s1, h["x-opencode-request"]) // 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟 diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua index bb74c8a..c138600 100644 --- a/internal/lua/adapters/opencodezen.lua +++ b/internal/lua/adapters/opencodezen.lua @@ -59,7 +59,7 @@ adapter.hidden = true -- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties) -- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。 adapter.headers = { - ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", + ["User-Agent"] = "opencode/1.19.4 ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4", } -- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。 @@ -176,17 +176,39 @@ end function adapter.build_headers(meta) local ms = now_ms(meta) local bucket = session_bucket_ms(meta) - -- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。 - -- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号, - -- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。 + -- ★ 本函数的头集合以**官方 CLI 抓包**为准(opencode 1.19.4,2026-10-24 + -- 通过本地抓包代理取得明文请求)。官方实际发这五个 x-opencode-* 头: + -- + -- x-opencode-client: cli + -- x-opencode-project: global + -- x-opencode-request: msg_<12hex><14base62> + -- x-opencode-session: ses_<12hex><14base62> + -- x-opencode-session-id: ses_<12hex><14base62> ← 与 session 同值 + -- + -- ⚠⚠ 这里有一个**版本相关的陷阱**,用实测记录,因为它反反复复: + -- 同一台机器、同一个 `Bearer public` 凭据、同一个模型: + -- + -- CLI 1.18.29(只发 4 个头,无 session-id) → "Free usage limit reached" + -- CLI 1.19.4 (发 5 个头,含 session-id) → 200,任务真实完成 + -- + -- 我一度根据 1.18.29 的抓包删掉了 `x-opencode-session-id`,并把注释 + -- 改写成「官方根本不发这个头」。**那是错的** —— 旧版客户端当时的报错 + -- 本来就是额度不足,不是头缺失;而新版上游确实要这个头。删掉它会让 + -- 1.19.x 的指纹不匹配,重新落回 429。 + -- + -- 结论:头集合必须与 **UA 里声称的版本**一致。声称 1.19.4 就必须发 5 个头。 + -- 将来升级 UA 时,必须**重新抓包**该版本的发头集合,不能沿用旧结论。 + -- + -- 会话亲和键(可空)混入 tail:同一分钟桶内不同会话得到不同会号, + -- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。 local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta)) return { ["User-Agent"] = adapter.headers["User-Agent"], ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份(非门禁必需,但官方也发) + -- project 固定:同网关实例共享一个工作区身份(官方也发同样值) ["x-opencode-project"] = "global", ["x-opencode-session"] = sid, - -- ★ 两个都要:上游对免费池同时校验 x-opencode-session 与这个头。 + -- ★ 1.19.4 实测**必须**并发这一份(与 session 同值)。见上面的版本陷阱。 ["x-opencode-session-id"] = sid, -- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复 ["x-opencode-request"] = rand_id("msg_", ms, "request"),