mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类 能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id, 属于绕过客户端校验获取额度,风险高于普通协议适配。 移除(93 个文件): - 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode - 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针) - 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档 同时修掉的既有问题: - **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处) 随 channels.lua 一起消失。 - `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提 把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约 冲突。现在部署脚本只同步适配器与二进制/配置。 - 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已 移除,该测试此前已失效)。 保留(公开协议):openai / anthropic / gemini / deepseek / github / groq / mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。 验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、 gateway 122.8s、e2e 9.0s 全绿。
121 lines
4.1 KiB
Go
121 lines
4.1 KiB
Go
package lua
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestAdaptersPreserveToolCalls is the fleet-wide guard for the
|
|
// "orphaned tool result" class of bug: when an agent client replays a turn
|
|
// whose assistant message carries tool_calls, EVERY adapter must forward both
|
|
// the call and the attribution of its result. Dropping the call (or the
|
|
// tool_call_id / tool_name that ties the result to it) makes the model see a
|
|
// result for a call it never made and re-issue the same call forever.
|
|
//
|
|
// The input deliberately uses content:[] — the shape most agent clients emit
|
|
// for a tool-calling assistant turn with no text.
|
|
func TestAdaptersPreserveToolCalls(t *testing.T) {
|
|
vm := NewVM(freshAdapterDir(t))
|
|
if err := vm.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer vm.Stop()
|
|
|
|
body := `{"model":"m","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 world"},
|
|
{"role":"user","content":"summarize"}
|
|
]}`
|
|
const (
|
|
callID = "call_abc"
|
|
fnName = "read"
|
|
toolOut = "hello world"
|
|
lastUser = "summarize"
|
|
)
|
|
|
|
// Adapters that identify a tool result by the call id.
|
|
byID := []string{"openai", "deepseek", "github", "groq", "mistral",
|
|
"sensenova", "anthropic"}
|
|
|
|
for _, a := range byID {
|
|
out, err := vm.Transform(a, "transform_request", body)
|
|
if err != nil {
|
|
t.Errorf("%s: transform_request: %v", a, err)
|
|
continue
|
|
}
|
|
if !strings.Contains(out, callID) {
|
|
t.Errorf("%s dropped the tool call id %q: %s", a, callID, out)
|
|
}
|
|
if !strings.Contains(out, fnName) {
|
|
t.Errorf("%s dropped the function name %q: %s", a, fnName, out)
|
|
}
|
|
if !strings.Contains(out, toolOut) {
|
|
t.Errorf("%s dropped the tool result: %s", a, out)
|
|
}
|
|
if !strings.Contains(out, lastUser) {
|
|
t.Errorf("%s dropped the final user turn: %s", a, out)
|
|
}
|
|
}
|
|
|
|
// Ollama identifies the result with tool_name (its API has no tool_call_id
|
|
// on assistant turns) and takes arguments as an object, not a JSON string.
|
|
out, err := vm.Transform("ollama", "transform_request", body)
|
|
if err != nil {
|
|
t.Fatalf("ollama: %v", err)
|
|
}
|
|
for _, want := range []string{`"tool_calls"`, fnName, `"tool_name"`, toolOut} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("ollama missing %s: %s", want, out)
|
|
}
|
|
}
|
|
|
|
// Gemini identifies the result by function NAME and has no call id in its
|
|
// wire format; both directions must be present as functionCall /
|
|
// functionResponse parts, and the system role must move to
|
|
// systemInstruction rather than sitting in contents.
|
|
gBody := `{"model":"m","messages":[
|
|
{"role":"system","content":"be brief"},
|
|
{"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 world"}
|
|
]}`
|
|
gout, err := vm.Transform("gemini", "transform_request", gBody)
|
|
if err != nil {
|
|
t.Fatalf("gemini: %v", err)
|
|
}
|
|
for _, want := range []string{`"functionCall"`, `"functionResponse"`, fnName, toolOut, `"systemInstruction"`} {
|
|
if !strings.Contains(gout, want) {
|
|
t.Errorf("gemini missing %s: %s", want, gout)
|
|
}
|
|
}
|
|
// No message may keep the OpenAI-only roles inside contents.
|
|
var greq struct {
|
|
Contents []struct {
|
|
Role string `json:"role"`
|
|
} `json:"contents"`
|
|
}
|
|
if err := json.Unmarshal([]byte(gout), &greq); err != nil {
|
|
t.Fatalf("gemini unmarshal: %v (%s)", err, gout)
|
|
}
|
|
for _, c := range greq.Contents {
|
|
if c.Role != "user" && c.Role != "model" {
|
|
t.Errorf("gemini contents carry an invalid role %q: %s", c.Role, gout)
|
|
}
|
|
}
|
|
|
|
// Negative control: a text-only turn must still be forwarded verbatim.
|
|
plain := `{"model":"m","messages":[{"role":"user","content":"just text"}]}`
|
|
for _, a := range append(byID, "ollama", "gemini") {
|
|
pout, err := vm.Transform(a, "transform_request", plain)
|
|
if err != nil {
|
|
t.Errorf("%s plain: %v", a, err)
|
|
continue
|
|
}
|
|
if !strings.Contains(pout, "just text") {
|
|
t.Errorf("%s dropped plain text: %s", a, pout)
|
|
}
|
|
}
|
|
}
|