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 ./... 全绿。
264 lines
9.6 KiB
Go
264 lines
9.6 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/core"
|
|
"llmsproxy/internal/lua"
|
|
)
|
|
|
|
// gatewayWithBilling boots a gateway with the bundled billing plugin loaded, so
|
|
// the UI-injection endpoint is exercised against a real plugin rather than a
|
|
// hand-written stub. The other plugin tests in this package assert on the
|
|
// WebUI source; this one asserts on the HTTP contract the browser consumes.
|
|
func gatewayWithBilling(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)
|
|
// Load the shipped plugin explicitly: seeding only runs for a directory that
|
|
// does not exist yet, and this test wants a known plugin regardless.
|
|
src, err := lua.ReadBundledPlugin("billing")
|
|
if err != nil {
|
|
t.Fatalf("read bundled billing: %v", err)
|
|
}
|
|
if err := c.Plugins().LoadSource("billing", src); err != nil {
|
|
t.Fatalf("load billing: %v", err)
|
|
}
|
|
g, err := New(c)
|
|
if err != nil {
|
|
t.Fatalf("gateway: %v", err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
// TestUIInjectServesPluginUI: GET /api/ui-inject is the single call the WebUI
|
|
// makes at boot, and it must carry BOTH a contributed page and contributed
|
|
// elements — the browser builds the sidebar from the page and mounts the
|
|
// elements into existing panes from the same payload, so a partial response
|
|
// would produce a page with no body or a missing tile.
|
|
func TestUIInjectServesPluginUI(t *testing.T) {
|
|
g := gatewayWithBilling(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var view struct {
|
|
// Decoded into the real types so the test cannot drift from the wire
|
|
// contract. An inline copy missed the `pages` field when the payload
|
|
// shape changed and failed to compile, which is at least loud — but the
|
|
// same copy also went on asserting the OLD single-page shape for a
|
|
// release, silently.
|
|
UI lua.UIExtension `json:"ui"`
|
|
Stages []string `json:"stages"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
// The payload carries every page in one list; ui.page is no longer a
|
|
// separate slot (it was, and a second plugin contributing a page overwrote
|
|
// whatever was there).
|
|
var billing *lua.UIPage
|
|
for i, pg := range view.UI.Pages {
|
|
if pg != nil && pg.PageID == "billing" {
|
|
billing = view.UI.Pages[i]
|
|
}
|
|
}
|
|
if billing == nil {
|
|
t.Fatalf("no billing page in the inject payload; pages=%d", len(view.UI.Pages))
|
|
}
|
|
if !strings.Contains(billing.Mount, "billing-root") {
|
|
t.Error("the page mount came back empty")
|
|
}
|
|
if len(view.UI.Elements) == 0 {
|
|
t.Error("billing contributes an element to the status page but it is missing")
|
|
}
|
|
for _, e := range view.UI.Elements {
|
|
if e.Target != "status" {
|
|
t.Errorf("element target = %q, want \"status\"", e.Target)
|
|
}
|
|
}
|
|
// Derived from AllStages, not hardcoded: the previous assertion of "3"
|
|
// is exactly what let chain_step go missing from this payload unnoticed.
|
|
if len(view.Stages) != len(lua.AllStages) {
|
|
t.Errorf("stages = %v, want %d (one per AllStages entry)", view.Stages, len(lua.AllStages))
|
|
}
|
|
for i, st := range lua.AllStages {
|
|
if i >= len(view.Stages) || view.Stages[i] != string(st) {
|
|
t.Errorf("stages[%d] = %v, want %q", i, view.Stages, string(st))
|
|
}
|
|
}
|
|
if !containsStr(view.Stages, string(lua.StageChainStep)) {
|
|
t.Error("the discovery payload does not advertise chain_step; a plugin " +
|
|
"author would conclude the stage does not exist")
|
|
}
|
|
}
|
|
|
|
// TestUIInjectIsEmptyWithoutPlugins: a gateway with no plugins must still answer
|
|
// 200 with an empty (not missing, not null) payload. The WebUI calls this
|
|
// unconditionally at boot, so a 404 or a null `ui` would break every dashboard.
|
|
func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"ui"`) {
|
|
t.Error("no ui key in the response; the WebUI would have nothing to read")
|
|
}
|
|
}
|
|
|
|
// containsStr reports whether list has s.
|
|
func containsStr(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// newRecorderFor pushes a request through the full handler chain.
|
|
func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// TestPluginsListAndStateAPI covers the management surface the plugin docs
|
|
// promise: listing, and reading a plugin's own published state.
|
|
func TestPluginsListAndStateAPI(t *testing.T) {
|
|
g := gatewayWithBilling(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/plugins = %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
for _, want := range []string{"billing", "hook_errors", "plugin_dir", "request_end"} {
|
|
if !strings.Contains(rr.Body.String(), want) {
|
|
t.Errorf("/api/plugins response lacks %q", want)
|
|
}
|
|
}
|
|
// state read: the plugin published its (empty) state, so the key exists.
|
|
rr = doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"total"`) {
|
|
t.Errorf("billing state lacks the total bucket: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPluginStatePUTIsAdminOnly: only the WRITE side is gated. A user key must
|
|
// be able to READ its own billing widget's data, but must not be able to
|
|
// rewrite the price table.
|
|
func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
|
g := gatewayWithBilling(t)
|
|
|
|
// Build a user-role key and remember its secret.
|
|
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
userKey := rec.Key
|
|
|
|
// A user key may read the state.
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/plugins/billing/state", nil)
|
|
req.Header.Set("Authorization", "Bearer "+userKey)
|
|
rr := newRecorderFor(t, g, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("user GET state = %d, want 200 (the billing widget must render for users)", rr.Code)
|
|
}
|
|
|
|
// A user key may NOT write it.
|
|
put, _ := http.NewRequest(http.MethodPut, "/api/plugins/billing/state",
|
|
strings.NewReader(`{"prices":{"default":{"prompt":0}}}`))
|
|
put.Header.Set("Authorization", "Bearer "+userKey)
|
|
put.Header.Set("Content-Type", "application/json")
|
|
prr := newRecorderFor(t, g, put)
|
|
if prr.Code != http.StatusForbidden {
|
|
t.Errorf("user PUT state = %d, want 403 (a user must not rewrite the price table)", prr.Code)
|
|
}
|
|
|
|
// An admin key may.
|
|
adm := doReq(t, g, http.MethodPut, "/api/plugins/billing/state",
|
|
`{"prices":{"default":{"prompt":1e-6}}}`)
|
|
if adm.Code != http.StatusOK {
|
|
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
|
|
// overrides and the endpoint access table from /api/plugins. Without them in the
|
|
// response the operator has to open a .lua or config.yaml to learn who can see
|
|
// what, which is exactly what the UI is meant to avoid.
|
|
func TestPluginsListCarriesAccessTables(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/plugins = %d", rr.Code)
|
|
}
|
|
var out struct {
|
|
Visibility map[string]string `json:"visibility"`
|
|
HTTPAccess []map[string]interface{} `json:"http_access"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
|
}
|
|
// The calltest plugin registers no endpoints, so the table is present but
|
|
// empty — the KEY is that the field exists at all. A missing field decodes to
|
|
// nil and the UI silently renders nothing, which looks identical to "this
|
|
// plugin has no endpoints".
|
|
if !strings.Contains(rr.Body.String(), "http_access") {
|
|
t.Error("/api/plugins response lacks the http_access field")
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "visibility") {
|
|
t.Error("/api/plugins response lacks the visibility field")
|
|
}
|
|
}
|
|
|
|
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
|
|
// edits a plugin's overall audience through the same route it edits endpoints
|
|
// with. Before this existed the UI had a visibility selector whose save 404'd,
|
|
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
|
|
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
|
`{"access":"admin, user"}`)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"ok":true`) {
|
|
t.Errorf("unexpected body: %s", rr.Body.String())
|
|
}
|
|
// A user role is a legitimate audience; a nonsense one is not.
|
|
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
|
`{"access":"../../etc"}`)
|
|
if bad.Code != http.StatusBadRequest {
|
|
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
|
|
}
|
|
}
|