mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。
改掉的具体缺陷:
1. 私有字段泄漏归零(v1 的核心问题)
v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
适配器都认这个词:
- gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
- deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
- anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
(enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
- 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"
2. Tools/ToolChoice 改 json.RawMessage
原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。
3. 删除 MarshalJSON 后门
v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
UnifiedRequest 上一个正正经经声明的字段。
渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。
测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
219 lines
8.5 KiB
Go
219 lines
8.5 KiB
Go
package lua
|
|
|
|
// The plugin HTTP bridge is the one capability that lets a .lua file — installed
|
|
// from a textarea in the WebUI — reach out on the gateway's own network. That
|
|
// makes its DENY rules the security boundary, and these tests exist to pin the
|
|
// refusals rather than the happy path.
|
|
//
|
|
// The failure this guards against is concrete: an operator installs a quota
|
|
// plugin that reads a vendor's balance, and a later plugin (or a compromised
|
|
// one) posts that gateway key material to an attacker, or reads the cloud
|
|
// metadata endpoint, because __llmsproxy_http happened to be a general HTTP
|
|
// client.
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
golua "github.com/aarzilli/golua/lua"
|
|
)
|
|
|
|
// newTestState returns a bare Lua state for the argument-decoding tests.
|
|
func newTestState(t *testing.T) *golua.State {
|
|
t.Helper()
|
|
L := golua.NewState()
|
|
t.Cleanup(L.Close)
|
|
L.OpenLibs()
|
|
return L
|
|
}
|
|
|
|
func TestPluginHTTPAllowlistRejectsUnlistedHost(t *testing.T) {
|
|
allow := newHTTPAllowlist([]string{"api.example.com"})
|
|
for _, raw := range []string{
|
|
"https://evil.test/steal",
|
|
"https://169.254.169.254/latest/meta-data/",
|
|
"https://api.example.com.evil.test/x", // suffix trick
|
|
"https://user@evil.test/",
|
|
} {
|
|
if _, err := allow.checkURL(raw); err == nil {
|
|
t.Fatalf("allowlist permitted %q — a plugin must not reach hosts "+
|
|
"the operator never configured as a source", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPluginHTTPAllowlistAcceptsConfiguredHost(t *testing.T) {
|
|
allow := newHTTPAllowlist([]string{"api.example.com"})
|
|
for _, raw := range []string{
|
|
"https://api.example.com/alpha/credits",
|
|
"https://API.EXAMPLE.COM/x", // host compare must be case-insensitive
|
|
"https://api.example.com:443/x",
|
|
} {
|
|
if _, err := allow.checkURL(raw); err != nil {
|
|
t.Fatalf("allowlist refused configured host %q: %v", raw, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPluginHTTPRefusesPlainHTTPOffLoopback(t *testing.T) {
|
|
allow := newHTTPAllowlist([]string{"api.example.com"})
|
|
// Allowed host, but plain HTTP would put the API key on the wire in clear.
|
|
if _, err := allow.checkURL("http://api.example.com/alpha"); err == nil {
|
|
t.Fatal("plain http to an allowed non-loopback host must be refused")
|
|
}
|
|
// Loopback stays allowed: it cannot leave the machine, and the billing rules
|
|
// UI reads the gateway's own API over plain HTTP in dev setups.
|
|
for _, raw := range []string{"http://127.0.0.1:8081/api/x", "http://localhost:8081/x"} {
|
|
if _, err := allow.checkURL(raw); err != nil {
|
|
t.Fatalf("loopback http must stay usable, got %v for %q", err, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPluginHTTPRejectsBadSchemesAndMissingURL(t *testing.T) {
|
|
allow := newHTTPAllowlist([]string{"api.example.com"})
|
|
for _, raw := range []string{"file:///etc/passwd", "ftp://api.example.com/x", "gopher://x"} {
|
|
if _, err := allow.checkURL(raw); err == nil {
|
|
t.Fatalf("scheme in %q must be refused", raw)
|
|
}
|
|
}
|
|
// An empty allowlist must deny, not fall open.
|
|
if _, err := newHTTPAllowlist(nil).checkURL("https://api.example.com/x"); err == nil {
|
|
t.Fatal("an unconfigured gateway must deny outbound HTTP entirely")
|
|
}
|
|
}
|
|
|
|
// A redirect is a hop the allowlist never saw. Following it would let an
|
|
// allowed host bounce a plugin's request (and its headers) anywhere.
|
|
func TestPluginHTTPDoesNotFollowRedirects(t *testing.T) {
|
|
if httpClient.CheckRedirect == nil {
|
|
t.Fatal("CheckRedirect must be set: following a redirect escapes the allowlist")
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil)
|
|
err := httpClient.CheckRedirect(req, []*http.Request{req})
|
|
if err != http.ErrUseLastResponse {
|
|
t.Fatalf("CheckRedirect must return ErrUseLastResponse so the 3xx is "+
|
|
"handed back unfollowed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPluginHTTPTimeoutAndBodyAreClamped(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
allow := newHTTPAllowlist([]string{"127.0.0.1"})
|
|
u, err := allow.checkURL(srv.URL)
|
|
if err != nil {
|
|
t.Fatalf("loopback test server must be allowed: %v", err)
|
|
}
|
|
// An absurd timeout is clamped instead of honoured.
|
|
res, err := performHTTP(u, httpRequestSpec{Timeout: 24 * time.Hour})
|
|
if err != nil {
|
|
t.Fatalf("clamped request failed: %v", err)
|
|
}
|
|
if res.Status != 200 || !strings.Contains(res.Body, `"ok"`) {
|
|
t.Fatalf("unexpected result: status=%d body=%q", res.Status, res.Body)
|
|
}
|
|
}
|
|
|
|
func TestPluginHTTPOversizedBodyIsRefused(t *testing.T) {
|
|
big := strings.Repeat("x", httpMaxResponse+64)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(big))
|
|
}))
|
|
defer srv.Close()
|
|
allow := newHTTPAllowlist([]string{"127.0.0.1"})
|
|
u, _ := allow.checkURL(srv.URL)
|
|
// Refusing beats truncating: a truncated body parses as corrupt JSON and the
|
|
// plugin would report a parse error instead of the real cause.
|
|
if _, err := performHTTP(u, httpRequestSpec{}); err == nil {
|
|
t.Fatal("an oversized response must be refused, not silently truncated")
|
|
}
|
|
}
|
|
|
|
func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) {
|
|
// Lua-side contract: the bridge returns (nil, message) on every failure so a
|
|
// plugin mistake never raises through golua (see the SIGSEGV note on
|
|
// hookGuardSrc — an escaping error can take the gateway down).
|
|
allow := newHTTPAllowlist([]string{"api.example.com"})
|
|
if _, err := allow.checkURL("https://evil.test/"); err == nil {
|
|
t.Fatal("expected refusal")
|
|
}
|
|
// readHTTPRequestSpec must reject a non-table without touching the stack.
|
|
L := newTestState(t)
|
|
if _, err := readHTTPRequestSpec(L); err == nil {
|
|
t.Fatal("a missing argument table must be an error, not a panic")
|
|
}
|
|
}
|
|
|
|
// The bridge must be reachable under the name plugins actually call.
|
|
//
|
|
// This exists because the registration once used the CONSTANT'S IDENTIFIER as
|
|
// the Lua global name (`registerFn(L, "httpGlobal", …)`) rather than its VALUE,
|
|
// so the bridge was published as `httpGlobal` while every plugin called
|
|
// `__llmsproxy_http`. The failure only appeared at runtime, as
|
|
// "attempt to call global '__llmsproxy_http' (a nil value)" on the first real
|
|
// request — after the unit tests were green, because they drove the Go-side
|
|
// helpers directly and never went through Lua name resolution.
|
|
//
|
|
// So this test boots a real plugin and calls the bridge the way a plugin does.
|
|
func TestPluginHTTPBridgeIsReachableUnderItsLuaName(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ps := NewPlugins(NewVM(dir), dir)
|
|
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":1.5}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// The plugin asks for a loopback URL, which is allowed without being
|
|
// configured as a source. It type-checks both the table and arg.url, so a
|
|
// malformed call is reported by the plugin rather than as a bewildering URL
|
|
// parse error from the bridge.
|
|
src := `
|
|
local P = {}
|
|
P.name = "httpprobe"
|
|
P.version = "1.0.0"
|
|
P.exports = { "probe" }
|
|
-- Call passes the WHOLE argument table as a single Lua argument, which is the
|
|
-- same convention the hooks use and what a plugin page's pluginAPI.call ends up
|
|
-- producing. Taking the table (not a bare string) is the correct shape.
|
|
function P.probe(arg)
|
|
if type(arg) ~= "table" then return { error = "probe takes the args table, got " .. type(arg) } end
|
|
if type(arg.url) ~= "string" then return { error = "arg.url must be a string, got " .. type(arg.url) } end
|
|
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
|
if res == nil then return { error = tostring(err) } end
|
|
return { status = res.status, body = res.body }
|
|
end
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("httpprobe", src); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
got, err := ps.Call("httpprobe", "probe", map[string]interface{}{"url": srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
m, _ := got.(map[string]interface{})
|
|
if m == nil {
|
|
t.Fatalf("probe returned %T, want a table", got)
|
|
}
|
|
// A nil global surfaces as {error = "attempt to call global ... (a nil value)"} —
|
|
// which is a table too, so the error field is what distinguishes it.
|
|
if e, ok := m["error"]; ok {
|
|
t.Fatalf("the bridge is not reachable as %s: %v", httpGlobal, e)
|
|
}
|
|
if s, _ := m["status"].(float64); int(s) != 200 {
|
|
t.Fatalf("status=%v body=%v, want 200 from the loopback test server", m["status"], m["body"])
|
|
}
|
|
if body, _ := m["body"].(string); !strings.Contains(body, "monthlyCredits") {
|
|
t.Fatalf("body=%q, want the server's JSON", body)
|
|
}
|
|
}
|