mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
chore(adapters): 移除非官方调用方式的适配器,保留官方适配器与模板
移除 4 个靠伪造客户端身份/绕过 WAF 才能工作的适配器,内核不再发行任何 非官方调用方式: - opencodezen / opencode / opencodego:靠 x-opencode-* 客户端指纹冒充官方 客户端;zen 免费池明确要求「free tier can only be used in OpenCode」 - agentrouter:伪造 Origin: qwen.ai + Referer + X-Requested-With 绕过阿里云 WAF 保留 12 个官方适配器(openai/deepseek/anthropic/gemini/github/groq/mistral/ ollama/kimicode/qwen38/sensenova/trae),其中 sensenova/qwen38 实测零伪装特征。 本地保留两处:adapters/_local_archive/(gitignored 非递归子目录,不加载) 与线上 /etc/llmsproxy/adapters/(adapter_dir 权威副本,cp -f 不删文件)。 测试同步:删除仅服务于被移除适配器的 11 个测试函数与 usage_cost_test.go; 改写 2 处借 opencode 之名的 provider 测试为用 openai 的 HTML 错误页压缩能力 验证同一契约(适配器钩子拥有错误格式、原文不外泄)。 验证:go test -tags luajit ./... 全绿;二进制内 x-opencode-client / QwenCode/0.2.0 / qwen.ai 等指纹字符串计数归零。
This commit is contained in:
@ -32,7 +32,7 @@ func TestLoadBundledAdapters(t *testing.T) {
|
||||
for _, a := range adapters {
|
||||
names[a.Name] = true
|
||||
}
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode", "opencode"} {
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing adapter %s (got %v)", want, names)
|
||||
}
|
||||
@ -112,179 +112,6 @@ func TestBuildHeadersFallbackStatic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeAdapterFingerprint(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
hdrs, err := vm.BuildHeaders("opencode", map[string]interface{}{
|
||||
"api_key": "public",
|
||||
"timestamp": 1700000000,
|
||||
"body": `{"model":"x"}`,
|
||||
"url": "https://opencode.ai/zen/v1/chat/completions",
|
||||
"method": "POST",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build headers: %v", err)
|
||||
}
|
||||
// zen 按客户端指纹路由请求池:UA 必须是 opencode 真实格式,
|
||||
// 且必须携带 x-opencode-* 身份头,否则带 tools 的请求会被上游
|
||||
// 判为匿名客户端并返回 network_error 空流(表现为空回复)。
|
||||
if ua := hdrs["User-Agent"]; !strings.HasPrefix(ua, "opencode/") ||
|
||||
!strings.Contains(ua, "ai-sdk/provider-utils/") {
|
||||
t.Fatalf("opencode adapter must send the real opencode client UA, got %q", ua)
|
||||
}
|
||||
for _, h := range []string{"x-opencode-client", "x-opencode-project", "x-opencode-session", "x-opencode-request"} {
|
||||
if hdrs[h] == "" {
|
||||
t.Fatalf("opencode adapter must send %q (zen client fingerprint), got %v", h, hdrs)
|
||||
}
|
||||
}
|
||||
hdrs2, _ := vm.BuildHeaders("opencode", map[string]interface{}{"api_key": "public", "timestamp": 1700000001, "body": `{"model":"y"}`, "method": "POST"})
|
||||
if hdrs["x-opencode-request"] == hdrs2["x-opencode-request"] {
|
||||
t.Fatalf("x-opencode-request must vary per request, got %q twice", hdrs["x-opencode-request"])
|
||||
}
|
||||
if hdrs["Authorization"] != "" {
|
||||
t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"])
|
||||
}
|
||||
// passthrough behaves like the openai adapter
|
||||
out, err := vm.Transform("opencode", "transform_request", `{"model":"x","disable_thinking":true,"extra_body":{},"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "disable_thinking") || strings.Contains(out, "extra_body") {
|
||||
t.Fatalf("opencode transform_request must strip provider fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeNormalizesRoles(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
// zen 上游只接受 system/user/assistant/tool/latest_reminder;
|
||||
// OpenAI 系客户端发 `developer`(Claude Code 还会用 `function`)必须归一化成 system。
|
||||
body := `{"model":"x","messages":[
|
||||
{"role":"developer","content":"you are helpful"},
|
||||
{"role":"system","content":"sys"},
|
||||
{"role":"user","content":"hi"},
|
||||
{"role":"assistant","content":"ok"},
|
||||
{"role":"tool","tool_call_id":"t1","content":"{\"ok\":true}"}
|
||||
]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, `"developer"`) {
|
||||
t.Fatalf("developer role must be normalized: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"system"`) || !strings.Contains(out, `"user"`) {
|
||||
t.Fatalf("allowed roles must survive: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"assistant"`) {
|
||||
t.Fatalf("assistant role must survive: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"tool"`) {
|
||||
t.Fatalf("tool role must survive: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeStripsMultiModalParts(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
body := `{"model":"x","messages":[
|
||||
{"role":"user","content":[
|
||||
{"type":"text","text":"describe"},
|
||||
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}},
|
||||
{"type":"input_audio","input_audio":{"data":"QQ","format":"wav"}}
|
||||
]},
|
||||
{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]},
|
||||
{"role":"assistant","content":"plain"}
|
||||
]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "image_url") || strings.Contains(out, "input_audio") {
|
||||
t.Fatalf("opencode must strip multimodal parts: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "describe") {
|
||||
t.Fatalf("text parts must survive: %s", out)
|
||||
}
|
||||
// image-only message dropped: only the text message and plain assistant message remain
|
||||
if got := strings.Count(out, `"role"`); got != 2 {
|
||||
t.Fatalf("image-only message must be dropped, got %d messages: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenCodeKeepsToolCallWithEmptyContent pins the fix for an infinite
|
||||
// "repeated tool call" loop: an assistant turn whose content serialises to an
|
||||
// empty ARRAY while carrying tool_calls must survive the multimodal strip.
|
||||
// Dropping it orphans the following tool result, so the model never sees the
|
||||
// call it already made and re-issues it on every turn.
|
||||
func TestOpenCodeKeepsToolCallWithEmptyContent(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
body := `{"model":"x","messages":[
|
||||
{"role":"user","content":"read /tmp/x"},
|
||||
{"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]},
|
||||
{"role":"tool","tool_call_id":"call_abc","content":"hello"},
|
||||
{"role":"user","content":"now summarize"}
|
||||
]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"tool_calls"`
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out)
|
||||
}
|
||||
if len(req.Messages) != 4 {
|
||||
t.Fatalf("expected 4 messages (tool call must not be dropped), got %d: %s", len(req.Messages), out)
|
||||
}
|
||||
asst := req.Messages[1]
|
||||
if asst.Role != "assistant" || len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" {
|
||||
t.Fatalf("assistant tool_calls lost: %s", out)
|
||||
}
|
||||
if string(asst.Content) != `""` {
|
||||
t.Fatalf("empty content must be rewritten to \"\", got %s", asst.Content)
|
||||
}
|
||||
if req.Messages[2].Role != "tool" || req.Messages[2].ToolCallID != "call_abc" {
|
||||
t.Fatalf("tool result must stay paired with its call: %s", out)
|
||||
}
|
||||
|
||||
// Negative control: a message with neither text nor tool calls is still dropped.
|
||||
only := `{"model":"x","messages":[
|
||||
{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]},
|
||||
{"role":"user","content":"keep me"}
|
||||
]}`
|
||||
out2, err := vm.Transform("opencode", "transform_request", only)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(out2, `"role"`); got != 1 {
|
||||
t.Fatalf("image-only message without tool calls must still be dropped, got %d: %s", got, out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
@ -432,74 +259,6 @@ func TestMultimodalTransform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeAdapterNormalizesDeveloperRole(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
raw := `{"model":"zen","messages":[` +
|
||||
`{"role":"developer","content":"be concise"},` +
|
||||
`{"role":"user","content":"hi"},` +
|
||||
`{"role":"assistant","content":"hello"},` +
|
||||
`{"role":"function","content":"{\"a\":1}"},` +
|
||||
`{"role":"latest_reminder","content":"remind"}]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("output not JSON: %v\n%s", err, out)
|
||||
}
|
||||
want := []string{"system", "user", "assistant", "system", "latest_reminder"}
|
||||
if len(req.Messages) != len(want) {
|
||||
t.Fatalf("got %d messages, want %d: %s", len(req.Messages), len(want), out)
|
||||
}
|
||||
for i, w := range want {
|
||||
if req.Messages[i].Role != w {
|
||||
t.Errorf("message %d role = %q, want %q", i, req.Messages[i].Role, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeAdapterKeepsWhitelistedRolesAndDropsMultimodal(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
raw := `{"messages":[` +
|
||||
`{"role":"system","content":"sys"},` +
|
||||
`{"role":"user","content":[{"type":"text","text":"keep"},{"type":"image_url","url":"x"}]},` +
|
||||
`{"role":"user","content":[{"type":"image_url","url":"x"}]}]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("output not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if len(req.Messages) != 2 {
|
||||
t.Fatalf("got %d messages, want 2 (multimodal-only dropped): %s", len(req.Messages), out)
|
||||
}
|
||||
if req.Messages[0].Role != "system" || req.Messages[1].Role != "user" {
|
||||
t.Fatalf("unexpected roles: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptersPassFinishReason(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
@ -511,7 +270,7 @@ func TestAdaptersPassFinishReason(t *testing.T) {
|
||||
// every chunk) must NOT terminate the stream.
|
||||
chunk := `{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"content":""}}]}`
|
||||
empty := `{"choices":[{"index":0,"finish_reason":"","delta":{"content":"x"}}]}`
|
||||
for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", "opencode"} {
|
||||
for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral"} {
|
||||
out, err := vm.Transform(name, "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
@ -736,8 +495,7 @@ func TestAdaptersReportZeroCacheHit(t *testing.T) {
|
||||
defer vm.Stop()
|
||||
|
||||
// OpenAI-shaped upstreams: usage.prompt_tokens_details.cached_tokens = 0
|
||||
openaiLike := []string{"openai", "deepseek", "sensenova", "opencode",
|
||||
"agentrouter", "github", "groq", "kimicode", "mistral"}
|
||||
openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "kimicode", "mistral"}
|
||||
respBody := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12,
|
||||
"prompt_tokens_details":{"cached_tokens":0}}}`
|
||||
@ -1465,7 +1223,7 @@ func TestNonStreamToolCallsPreserved(t *testing.T) {
|
||||
|
||||
for _, name := range []string{
|
||||
"openai", "sensenova", "trae", "deepseek", "github",
|
||||
"groq", "kimicode", "mistral", "opencode", "agentrouter",
|
||||
"groq", "kimicode", "mistral",
|
||||
} {
|
||||
out, err := vm.Transform(name, "transform_response", body)
|
||||
if err != nil {
|
||||
@ -1741,9 +1499,8 @@ func TestToolIDSanitize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- openai + agentrouter: same rewrite, and both sites must stay in sync ----
|
||||
// agentrouter fronts claude-opus-4-8, so it inherits the same id rule.
|
||||
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||
// ---- openai: tool-id rewrite ----
|
||||
for _, adapterName := range []string{"openai"} {
|
||||
for _, id := range []string{"bash:0", "call_ok_123", long} {
|
||||
out, err := vm.Transform(adapterName, "transform_request", body(id))
|
||||
if err != nil {
|
||||
@ -1838,12 +1595,12 @@ func TestToolIDSanitizeResponse(t *testing.T) {
|
||||
|
||||
legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
// ---- openai + agentrouter non-streaming ----
|
||||
// ---- openai non-streaming ----
|
||||
resp := `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[
|
||||
{"id":"bash:0","type":"function","function":{"name":"bash","arguments":"{\"command\":\"whoami\"}"}}]},
|
||||
"finish_reason":"tool_calls"}]}`
|
||||
var nonStreamID string
|
||||
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||
for _, adapterName := range []string{"openai"} {
|
||||
out, err := vm.Transform(adapterName, "transform_response", resp)
|
||||
if err != nil {
|
||||
t.Fatalf("%s transform_response: %v", adapterName, err)
|
||||
@ -1878,7 +1635,7 @@ func TestToolIDSanitizeResponse(t *testing.T) {
|
||||
ID string `json:"id"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||
for _, adapterName := range []string{"openai"} {
|
||||
out, err := vm.Transform(adapterName, "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("%s transform_stream_chunk: %v", adapterName, err)
|
||||
@ -1902,7 +1659,7 @@ func TestToolIDSanitizeResponse(t *testing.T) {
|
||||
// ---- a later argument fragment has no id and must stay id-less ----
|
||||
argChunk := `{"choices":[{"delta":{"tool_calls":[
|
||||
{"index":0,"function":{"arguments":"{\"command\":\"whoami\"}"}}]},"finish_reason":null}]}`
|
||||
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||
for _, adapterName := range []string{"openai"} {
|
||||
out, err := vm.Transform(adapterName, "transform_stream_chunk", argChunk)
|
||||
if err != nil {
|
||||
t.Fatalf("%s arg fragment: %v", adapterName, err)
|
||||
|
||||
Reference in New Issue
Block a user