mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。
- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
(content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
system 支持字符串与 [{type:text,text}] 两种写法)。
调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
serialize_open_preamble / serialize_chunk / serialize_close,
把统一格式译回 Anthropic Messages 形状(含 content 分块、
stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。
内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。
测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。
golden 字节级未漂移;go test -tags luajit ./... 全绿。
151 lines
4.5 KiB
Go
151 lines
4.5 KiB
Go
package gateway
|
|
|
|
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
|
|
// surgery, and the split point is easy to get wrong in a way that still
|
|
// "works": keeping the "call/" segment in the function name produced
|
|
// `does not export "call/collect_account"` — the plugin was correct, the router
|
|
// was not, and every call 502'd while the exports list looked perfectly fine.
|
|
//
|
|
// These tests drive the real router, so a refactor that reintroduces the
|
|
// off-by-one is caught here rather than in production.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/core"
|
|
)
|
|
|
|
// callPlugin exposes exactly one export plus a function that is NOT exported.
|
|
// If the exports gate is not honoured, secretFn is reachable and with it the
|
|
// plugin's private state — for a plugin holding an API key that means handing
|
|
// the key to anyone who can POST to the gateway.
|
|
const callPlugin = `
|
|
local P = {}
|
|
P.name = "calltest"
|
|
P.version = "1.0.0"
|
|
P.exports = { "add" }
|
|
P.secret = "do-not-leak"
|
|
function P.add(a)
|
|
return { result = (tonumber(a and a.x) or 0) + 1 }
|
|
end
|
|
function P.secretFn()
|
|
return { leaked = P.secret }
|
|
end
|
|
return P
|
|
`
|
|
|
|
func gatewayWithCallPlugin(t *testing.T) *Gateway {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.yaml")
|
|
body := "listen: :0\n" +
|
|
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
|
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
|
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
|
"gateway_keys:\n - sk-test\n"
|
|
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := core.NewFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("core: %v", err)
|
|
}
|
|
t.Cleanup(c.Close)
|
|
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
|
|
t.Fatalf("load calltest: %v", err)
|
|
}
|
|
g, err := New(c)
|
|
if err != nil {
|
|
t.Fatalf("gateway: %v", err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodPost,
|
|
"/api/plugins/calltest/call/add", `{"x":41}`)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var out struct {
|
|
Result struct {
|
|
Result int `json:"result"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
|
}
|
|
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
|
|
// segment did not leak into the function name, and the payload arrived.
|
|
if out.Result.Result != 42 {
|
|
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
|
|
"function name or the argument table never reached Lua", out.Result.Result)
|
|
}
|
|
}
|
|
|
|
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodPost,
|
|
"/api/plugins/calltest/call/secretFn", `{}`)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("an unexported function must not be callable, got 200: %s",
|
|
rr.Body.String())
|
|
}
|
|
if strings.Contains(rr.Body.String(), "do-not-leak") {
|
|
t.Fatal("the refusal leaked the plugin's private state")
|
|
}
|
|
}
|
|
|
|
// A plugin that declares no exports at all has no callable surface. Reading the
|
|
// missing list as "allow everything" would turn every plugin into a remote
|
|
// evaluator the moment this endpoint ships.
|
|
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
const bare = `
|
|
local P = {}
|
|
P.name = "bare"
|
|
P.version = "1.0.0"
|
|
function P.anything()
|
|
return { ok = true }
|
|
end
|
|
return P
|
|
`
|
|
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
|
|
t.Fatalf("load bare: %v", err)
|
|
}
|
|
rr := doReq(t, g, http.MethodPost,
|
|
"/api/plugins/bare/call/anything", `{}`)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
|
|
rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginCallIsAdminOnly(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
// A real user-role key, not a bare one from config: sk-test/sk-user in the
|
|
// config both default to admin, so a test using them would pass the gate for
|
|
// the wrong reason and prove nothing.
|
|
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rr := doReqAs(t, g, rec.Key, http.MethodPost,
|
|
"/api/plugins/calltest/call/add", `{"x":1}`)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
|
|
rr.Code, rr.Body.String())
|
|
}
|
|
}
|