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。凡"某内容触发"的规律
必须交叉验证。
157 lines
5.8 KiB
Go
157 lines
5.8 KiB
Go
package lua
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式;
|
||
// (3) 无 tools 的请求被注入 read 桩且 properties 非空。
|
||
func TestOpencodeZenAdapterE2E(t *testing.T) {
|
||
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.Fatalf("加载失败: %v", err)
|
||
}
|
||
|
||
t.Run("头:两个 session 头同值", func(t *testing.T) {
|
||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"]
|
||
if s1 == "" || s2 == "" {
|
||
t.Fatalf("两个 session 头都必须存在: %#v", h)
|
||
}
|
||
if s1 != s2 {
|
||
t.Fatalf("必须同值: %q vs %q", s1, s2)
|
||
}
|
||
if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 {
|
||
t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1))
|
||
}
|
||
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"])
|
||
}
|
||
t.Logf("session=%s request=%s", s1, h["x-opencode-request"])
|
||
// 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟
|
||
// (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。
|
||
// ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。
|
||
seg := s1[4:16]
|
||
ms := int64(1792065049) * 1000
|
||
bucket := ms - (ms % 60000)
|
||
want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF
|
||
if got := seg; got != fmtHex12(want) {
|
||
t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket)
|
||
}
|
||
})
|
||
|
||
t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) {
|
||
out, err := vm.Transform("opencodezen", "transform_request",
|
||
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var req map[string]interface{}
|
||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ts, _ := req["tools"].([]interface{})
|
||
if len(ts) == 0 {
|
||
t.Fatalf("必须注入 tools: %s", out)
|
||
}
|
||
found := false
|
||
for _, x := range ts {
|
||
fn := x.(map[string]interface{})["function"].(map[string]interface{})
|
||
if fn["name"] == "read" {
|
||
p, _ := fn["parameters"].(map[string]interface{})
|
||
props, _ := p["properties"].(map[string]interface{})
|
||
if len(props) == 0 {
|
||
t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out)
|
||
}
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("必须含名为 read 的工具: %s", out)
|
||
}
|
||
})
|
||
|
||
t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) {
|
||
in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}],
|
||
"tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}`
|
||
out, err := vm.Transform("opencodezen", "transform_request", in)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var req map[string]interface{}
|
||
json.Unmarshal([]byte(out), &req)
|
||
ts, _ := req["tools"].([]interface{})
|
||
if len(ts) != 1 {
|
||
t.Fatalf("不应追加(已有 read): %s", out)
|
||
}
|
||
fn := ts[0].(map[string]interface{})["function"].(map[string]interface{})
|
||
if fn["description"] != "MY" {
|
||
t.Fatalf("用户定义被覆盖: %s", out)
|
||
}
|
||
})
|
||
}
|
||
|
||
func fmtHex12(v int64) string {
|
||
const hex = "0123456789abcdef"
|
||
out := make([]byte, 12)
|
||
for i := 11; i >= 0; i-- {
|
||
out[i] = hex[v&0xF]
|
||
v >>= 4
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时
|
||
// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。
|
||
// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。
|
||
func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) {
|
||
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)
|
||
}
|
||
// 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧)
|
||
sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]}
|
||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]}
|
||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]}
|
||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}
|
||
data: [DONE]
|
||
`
|
||
out, err := vm.Transform("opencodezen", "transform_response", sse)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var u map[string]interface{}
|
||
if err := json.Unmarshal([]byte(out), &u); err != nil {
|
||
t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out)
|
||
}
|
||
if u["content"] != "HELLO WORLD" {
|
||
t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"])
|
||
}
|
||
tu, _ := u["token_usage"].(map[string]interface{})
|
||
if tu == nil || tu["prompt"] != float64(7) {
|
||
t.Fatalf("usage 应透传: %v", u["token_usage"])
|
||
}
|
||
if u["finish_reason"] != "stop" {
|
||
t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"])
|
||
}
|
||
t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"])
|
||
}
|