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 字节级未漂移。
194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
package lua
|
|
|
|
// Plugin visibility: a plugin page is not automatically public data.
|
|
//
|
|
// The billing page renders GLOBAL cost across every source and the quota page
|
|
// renders upstream balances. Both were served to any authenticated key because
|
|
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
|
|
// key pulled the 18KB billing state and read the source names and the $8.86
|
|
// total straight out of it.
|
|
//
|
|
// These tests pin the DENIALS. A visibility feature whose tests only assert that
|
|
// an admin still sees everything passes while the leak is wide open.
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestAudienceAllows(t *testing.T) {
|
|
cases := []struct {
|
|
want, role string
|
|
ok bool
|
|
why string
|
|
}{
|
|
{"", "user", true, "unset means everyone"},
|
|
{"all", "user", true, "explicit all"},
|
|
{"admin", "admin", true, "exact match"},
|
|
{"admin", "user", false, "admin-only excludes user"},
|
|
{"user", "user", true, "user-only admits user"},
|
|
{"user", "admin", false, "user-only excludes admin"},
|
|
{"admin,user", "user", true, "list admits a listed role"},
|
|
{" admin , user ", "user", true, "whitespace is not significant"},
|
|
{"ADMIN", "user", false, "case is normalised"},
|
|
{"*", "user", false, `"*" must NOT be a synonym for all`},
|
|
}
|
|
for _, c := range cases {
|
|
if got := audienceAllows(c.want, c.role); got != c.ok {
|
|
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
|
|
c.want, c.role, got, c.ok, c.why)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ps := NewPlugins(NewVM(dir), dir)
|
|
|
|
// One admin-only plugin, one open plugin: the filter must drop exactly one
|
|
// and leave the other intact, or a plugin author has no way to ship a mixed
|
|
// set.
|
|
const adminOnly = `
|
|
local P = {}
|
|
P.name = "adminonly"
|
|
P.version = "1.0.0"
|
|
P.exports = {}
|
|
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
|
|
return P
|
|
`
|
|
const open = `
|
|
local P = {}
|
|
P.name = "openpage"
|
|
P.version = "1.0.0"
|
|
P.exports = {}
|
|
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
|
return P
|
|
`
|
|
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
|
|
if err := ps.LoadSource(name, src); err != nil {
|
|
t.Fatalf("load %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
admin := ps.UIForRole("admin")
|
|
if len(admin.Pages) != 2 {
|
|
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
|
|
}
|
|
|
|
user := ps.UIForRole("user")
|
|
for _, pg := range user.Pages {
|
|
if pg.PageID == "secret" {
|
|
t.Fatal("a user key can see the admin-only page — this is the leak")
|
|
}
|
|
}
|
|
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
|
|
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
|
|
}
|
|
}
|
|
|
|
// The operator must be able to impose visibility the plugin did not choose —
|
|
// that is the whole point of the override, since a bundled .lua cannot be edited
|
|
// without being overwritten on the next upgrade.
|
|
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ps := NewPlugins(NewVM(dir), dir)
|
|
const open = `
|
|
local P = {}
|
|
P.name = "openpage"
|
|
P.version = "1.0.0"
|
|
P.exports = {}
|
|
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("openpage", open); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ps.UIForRole("user").Pages) != 1 {
|
|
t.Fatal("precondition: the page should start visible to user")
|
|
}
|
|
|
|
ps.SetPluginVisibility("openpage", "admin")
|
|
if got := ps.UIForRole("user").Pages; len(got) != 0 {
|
|
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
|
|
}
|
|
if ps.VisibilityOverride("openpage") != "admin" {
|
|
t.Error("the override should be reportable so /api/plugins can show it")
|
|
}
|
|
|
|
// Clearing restores the plugin's own declaration.
|
|
ps.SetPluginVisibility("openpage", "")
|
|
if got := ps.UIForRole("user").Pages; len(got) != 1 {
|
|
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
|
|
}
|
|
}
|
|
|
|
// A page may narrow visibility below the plugin default, so one plugin can ship
|
|
// a public summary beside an admin-only detail page.
|
|
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ps := NewPlugins(NewVM(dir), dir)
|
|
const mixed = `
|
|
local P = {}
|
|
P.name = "mixed"
|
|
P.version = "1.0.0"
|
|
P.exports = {}
|
|
P.ui = {
|
|
pages = {
|
|
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
|
|
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
|
|
},
|
|
}
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("mixed", mixed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := ps.UIForRole("user")
|
|
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
|
|
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
|
|
}
|
|
if len(ps.UIForRole("admin").Pages) != 2 {
|
|
t.Error("admin should see both pages")
|
|
}
|
|
}
|
|
|
|
func TestPluginStateVisibleTo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ps := NewPlugins(NewVM(dir), dir)
|
|
const adminOnly = `
|
|
local P = {}
|
|
P.name = "adminonly"
|
|
P.version = "1.0.0"
|
|
P.exports = {}
|
|
P.state = { secret = "x" }
|
|
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
|
|
return P
|
|
`
|
|
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The data has to follow the page: a hidden UI with an open state endpoint is
|
|
// the same leak with one more step.
|
|
if ps.StateVisibleTo("adminonly", "user") {
|
|
t.Error("user must not read an admin-only plugin's state")
|
|
}
|
|
if !ps.StateVisibleTo("adminonly", "admin") {
|
|
t.Error("admin must still read it")
|
|
}
|
|
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
|
|
// not start answering 403.
|
|
if !ps.StateVisibleTo("nosuchplugin", "user") {
|
|
t.Error("an unknown plugin name must not become forbidden; that would change " +
|
|
"behaviour for plugins that simply are not installed")
|
|
}
|
|
}
|
|
|
|
func pageIDs(pages []*UIPage) []string {
|
|
out := make([]string, 0, len(pages))
|
|
for _, p := range pages {
|
|
if p != nil {
|
|
out = append(out, p.PageID)
|
|
}
|
|
}
|
|
return out
|
|
}
|