mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
问题:localzen 源(opencode Zen)整个免费池恒 403 FreeTierError
("free tier can only be used from within OpenCode"),space-bunny-free
等免费模型全部失效。
排查(装官方 CLI 抓包 + 逆其本地 opencode.db,见下):门禁是**四条请求形状
条件**,与 TLS 指纹 / UA / 凭据来源 / system 内容 / 消息条数全部无关:
1. 端点必须是 /inference/v1/chat/completions(原来打的 /zen/v1 恒 403)
2. 必须同时发 x-opencode-session 与 x-opencode-session-id(值相同)
3. tools 里必须有一个名为 read 的工具,且其 parameters.properties **非空**
(实测:抽掉官方 11 个工具中任意一个仍 200,**只有抽掉 read 变 403**;
properties 里放任意属性名即可,描述/$schema/required 都不需要;
改成 properties:{} 或只有 type:object 则 403)
4. stream 必须为 true(同 body 改 false 恒 403)
★ 两个 id 的 12 位 hex 段由**确定公式**给出(逆自 opencode.db 的 session 表,
用 11/12 条真实 id 逐条比对完全一致):
MASK = 2^48-1 ; K = 0x1afffffffffffe (注意不是 27<<48,两者差 2)
session 段 = (K - now_ms*4096) & MASK ← 倒计时式,Δseg/Δms ≡ -2^12
request 段 = (now_ms*16) & MASK
时间容差实测 ≥1 小时(-3600s 仍 200),故 os.time() 的秒精度足够;
now_ms*4096 ≈ 7.3e15 < 2^53,在 double 安全整数范围内。
实现:
- id 生成换成上述公式(原用 sha256 派生,完全不符)
- 补 x-opencode-session-id;两个头同值
- 会话号按**整分钟桶**取:既满足门禁的倒计时公式,又保留上游前缀缓存命中
(实测 cached_tokens: 256 生效)
- tools 兜底:客户端没带 tools 时注入带非空 properties 的 read 桩;
用户自带 read 则原样保留,不覆盖其定义
- 强制 stream=true
- ★ transform_response 支持 SSE 聚合:免费层只能流式,而客户端可能发非流式,
此时内核按客户端意图走 Chat/transform_response(期望 JSON)⇒ 必须自行把
SSE 聚合成 chat.completion,否则报 "invalid character 'd'"
配置(已在生产落地):
- localzen base_url: /zen/v1 → /inference/v1(billing 规则同步)
- 免费模型 1 个 → 8 个(含 deepseek-v4.1-flash-free)
- 适配器移入 internal/lua/adapters/,随 deploy.sh 自动投放
判据(4 条):
- TestOpencodeZenAdapterE2E 头公式/两头部同值/tools 兜底/不覆盖
- TestOpencodeZenAggregatesSSEForNonStreamClient SSE → chat.completion 聚合
- TestOpencodeZenLiveE2E ★真上游:纯对话/自定义system/多轮/算术
- TestOpencodeZenLiveStreamThroughAdapter ★真上游:完整流解析链(25 chunk)
(后两条需 LLMSPROXY_LIVE_ZEN=1 + 真实凭据,默认 skip)
生产 e2e 实测:非流式返回 GW-NONSTREAM-OK(usage 含 cached_tokens 256);
流式原生 SSE 透传;多轮+工具返回正确结果(12*12=144 → 144+1=145)。
回归:go test -tags luajit ./... 10 包全绿。
★ 方法论教训(本轮绕了 4 轮、自我推翻 3 次):
- 一次只改一个变量。我多次同时改 system+条数+tools,拟合出"消息条数上限 3"
这种假规律,后续每次反转都源于此 —— 真相是那些测试**都缺 tools**。
- 重放成功 ≠ 解释成功。第一次逐字节重放就 200,我却当"重放了官方刚发的请求",
继续追 TLS 指纹/端点/形状。正确做法:一旦重放成功立刻做**单变量消去**。
- "毒药短语"陷阱:我二分到 963/964 字符精度锁定 Never use tools 是触发点,
但它单独出现全 200 —— 真相是缺 read 的 properties。凡"某内容触发"的规律
必须交叉验证。
214 lines
6.8 KiB
Go
214 lines
6.8 KiB
Go
package lua
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。
|
||
// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。
|
||
func TestOpencodeZenLiveE2E(t *testing.T) {
|
||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e")
|
||
}
|
||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
vm := NewVM(t.TempDir())
|
||
defer vm.Stop()
|
||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 取生产凭据(解密)
|
||
out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
apiKey := ""
|
||
for _, l := range strings.Split(string(out), "\n") {
|
||
if strings.HasPrefix(l, "source localzen") {
|
||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||
}
|
||
}
|
||
}
|
||
if apiKey == "" {
|
||
t.Fatal("未取到 localzen 凭据")
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
body string
|
||
want string
|
||
}{
|
||
{"纯对话(无 tools,靠适配器兜底注入)",
|
||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||
"messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"},
|
||
{"带自定义 system",
|
||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||
"messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"},
|
||
{"多轮",
|
||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||
"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"},
|
||
{"算术(验证真推理)",
|
||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||
"messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
body, err := vm.Transform("opencodezen", "transform_request", tc.body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||
"timestamp": time.Now().Unix(), "body": body,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req, _ := http.NewRequest("POST",
|
||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||
for k, v := range h {
|
||
req.Header.Set(k, v)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
cl := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := cl.Do(req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||
}
|
||
// 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。
|
||
got := ""
|
||
for _, line := range strings.Split(string(raw), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if !strings.HasPrefix(line, "data: ") {
|
||
continue
|
||
}
|
||
p := strings.TrimPrefix(line, "data: ")
|
||
if p == "[DONE]" {
|
||
continue
|
||
}
|
||
var d struct {
|
||
Choices []struct {
|
||
Delta struct{ Content string } `json:"delta"`
|
||
} `json:"choices"`
|
||
}
|
||
if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 {
|
||
continue
|
||
}
|
||
got += d.Choices[0].Delta.Content
|
||
}
|
||
if got == "" {
|
||
t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))])
|
||
}
|
||
if !strings.Contains(got, tc.want) {
|
||
t.Fatalf("回复 %q 不含 %q", got, tc.want)
|
||
}
|
||
t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got))
|
||
})
|
||
}
|
||
fmt.Println("真上游 e2e 全部通过")
|
||
}
|
||
|
||
// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。
|
||
// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。
|
||
func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) {
|
||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启")
|
||
}
|
||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
vm := NewVM(t.TempDir())
|
||
defer vm.Stop()
|
||
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()
|
||
apiKey := ""
|
||
for _, l := range strings.Split(string(out), "\n") {
|
||
if strings.HasPrefix(l, "source localzen") {
|
||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||
}
|
||
}
|
||
}
|
||
|
||
body, _ := vm.Transform("opencodezen", "transform_request",
|
||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||
"messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`)
|
||
h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||
"timestamp": time.Now().Unix(), "body": body})
|
||
|
||
req, _ := http.NewRequest("POST",
|
||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||
for k, v := range h {
|
||
req.Header.Set(k, v)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||
}
|
||
|
||
// 逐行喂给适配器的流解析器,累积统一格式的 content
|
||
joined := ""
|
||
n := 0
|
||
for _, line := range strings.Split(string(raw), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
||
continue
|
||
}
|
||
// 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go:
|
||
// strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。
|
||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||
unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload)
|
||
if err != nil {
|
||
t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))])
|
||
}
|
||
if unified == "" || unified == "{}" {
|
||
continue
|
||
}
|
||
var u struct {
|
||
Content string `json:"content"`
|
||
}
|
||
if json.Unmarshal([]byte(unified), &u) == nil {
|
||
joined += u.Content
|
||
n++
|
||
}
|
||
}
|
||
if n == 0 {
|
||
t.Fatal("适配器未解出任何 chunk(流解析链断了)")
|
||
}
|
||
if !strings.Contains(joined, "CHAIN-OK") {
|
||
t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n)
|
||
}
|
||
t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined))
|
||
}
|