mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
449 lines
16 KiB
Go
449 lines
16 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/core"
|
|
)
|
|
|
|
func mockUpstream() *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var req map[string]interface{}
|
|
_ = json.Unmarshal(body, &req)
|
|
if stream, _ := req["stream"].(bool); stream {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(200)
|
|
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"Hel"}}]}`)
|
|
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"lo"}}]}`)
|
|
fmt.Fprintln(w, `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}`)
|
|
fmt.Fprintln(w, "data: [DONE]")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
fmt.Fprintf(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
|
}))
|
|
}
|
|
|
|
func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
|
|
t.Helper()
|
|
cfg := &config.Config{
|
|
AdapterDir: filepath.Join(t.TempDir(), "adapters"),
|
|
RuntimeFile: filepath.Join(t.TempDir(), "runtime.json"),
|
|
Sources: srcs,
|
|
}
|
|
if err := cfg.ApplyDefaults(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := core.NewFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("core: %v", err)
|
|
}
|
|
t.Cleanup(c.Close)
|
|
g, err := New(c, []string{"sk-test"})
|
|
if err != nil {
|
|
t.Fatalf("gateway: %v", err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func doReq(t *testing.T, g *Gateway, method, path, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer sk-test")
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestChatSingle(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var cc ChatCompletion
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &cc); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if cc.Choices[0].Message.Content != "pong" {
|
|
t.Fatalf("content = %q", cc.Choices[0].Message.Content)
|
|
}
|
|
if cc.Usage == nil || cc.Usage.Total != 4 {
|
|
t.Fatalf("usage = %+v", cc.Usage)
|
|
}
|
|
if cc.Model != "mock-model" {
|
|
t.Fatalf("model = %q", cc.Model)
|
|
}
|
|
}
|
|
|
|
func TestChatDisableThinkingPassthrough(t *testing.T) {
|
|
got := ""
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
got = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","disable_thinking":true,"messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var sent map[string]interface{}
|
|
if err := json.Unmarshal([]byte(got), &sent); err != nil {
|
|
t.Fatalf("upstream body: %v", err)
|
|
}
|
|
if _, has := sent["disable_thinking"]; has {
|
|
t.Fatalf("disable_thinking not stripped: %s", got)
|
|
}
|
|
// openai adapter strips disable_thinking; deepseek would map it to extra_body.thinking.
|
|
// with the passthrough fix the flag now reaches the VM at all.
|
|
}
|
|
|
|
func TestChatMultimodalPassthrough(t *testing.T) {
|
|
gotBody := ""
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","messages":[{"role":"user","content":[
|
|
{"type":"text","text":"what is this?"},
|
|
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}
|
|
]}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var sent struct {
|
|
Messages []struct {
|
|
Content []map[string]interface{} `json:"content"`
|
|
} `json:"messages"`
|
|
}
|
|
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
|
|
t.Fatalf("upstream body: %v", err)
|
|
}
|
|
if len(sent.Messages) != 1 || len(sent.Messages[0].Content) != 2 {
|
|
t.Fatalf("multimodal content lost: %s", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestChatAUTO(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m", Priority: 10}}},
|
|
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m", Priority: 100}}},
|
|
)
|
|
// no model -> AUTO -> picks the highest priority source
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var cc ChatCompletion
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &cc)
|
|
if cc.Model != "high-m" {
|
|
t.Fatalf("AUTO picked %q, want high-m", cc.Model)
|
|
}
|
|
}
|
|
|
|
func TestChatAuthRequired(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
req, _ := http.NewRequest("POST", "/v1/chat/completions",
|
|
strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`))
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestChatStream(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("missing DONE, body=%s", body)
|
|
}
|
|
if !strings.Contains(body, "Hel") || !strings.Contains(body, "lo") {
|
|
t.Fatalf("missing content chunks, body=%s", body)
|
|
}
|
|
}
|
|
|
|
func TestImageGeneration(t *testing.T) {
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"created":1,"data":[{"b64_json":"QUJD"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "img", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "flux-1", Kind: "image"}}})
|
|
rr := doReq(t, g, "POST", "/v1/images/generations",
|
|
`{"model":"flux-1","prompt":"a cat"}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var out map[string]interface{}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
|
data, _ := out["data"].([]interface{})
|
|
if len(data) != 1 {
|
|
t.Fatalf("image data len = %d", len(data))
|
|
}
|
|
}
|
|
|
|
func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
|
|
imageHits := 0
|
|
chatHits := 0
|
|
img := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
imageHits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"created":1,"data":[{"b64_json":"QUJD"}]}`)
|
|
}))
|
|
defer img.Close()
|
|
chatUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
chatHits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer chatUp.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m", Priority: 100}}},
|
|
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image", Priority: 1}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/images/generations",
|
|
`{"model":"AUTO","prompt":"a cat"}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if chatHits != 0 {
|
|
t.Fatalf("image AUTO hit chat-only provider: %d chat hits", chatHits)
|
|
}
|
|
if imageHits == 0 {
|
|
t.Fatalf("image AUTO did not hit image provider")
|
|
}
|
|
}
|
|
|
|
func TestKimicodeSigning(t *testing.T) {
|
|
var gotAuth, gotSign string
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.ReadAll(r.Body)
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotSign = r.Header.Get("X-App-Sign")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
|
|
g := newTestGateway(t, config.Source{
|
|
Name: "kimi",
|
|
BaseURL: up.URL,
|
|
Adapter: "kimicode",
|
|
APIKey: "sk-kimi",
|
|
Models: []config.Model{{ID: "kimi-k2"}},
|
|
Meta: map[string]interface{}{"app_id": "app-1", "app_secret": "s3cr3t", "app_agent": "code-agent", "api_key": "sk-kimi"},
|
|
})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"kimi-k2","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if gotAuth == "" || !strings.Contains(gotAuth, "sk-kimi") {
|
|
t.Fatalf("expected signed auth, got %q", gotAuth)
|
|
}
|
|
if gotSign == "" {
|
|
t.Fatalf("expected app signature header")
|
|
}
|
|
}
|
|
|
|
func TestModelRoutingPrefix(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-a"}}},
|
|
config.Source{Name: "b", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-b"}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"model-b","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestModelsEndpoint(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-a"}}},
|
|
config.Source{Name: "b", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-b"}}},
|
|
)
|
|
rr := doReq(t, g, "GET", "/v1/models", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d", rr.Code)
|
|
}
|
|
var out map[string]interface{}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
|
if !strings.Contains(rr.Body.String(), "model-a") || !strings.Contains(rr.Body.String(), "model-b") {
|
|
t.Fatalf("missing models: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebUIServesPage(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "GET", "/", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "ModelRouter") {
|
|
t.Fatalf("ui not served")
|
|
}
|
|
}
|
|
|
|
func TestAdaptersAPIUpload(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
// upload adapter
|
|
rr := doReq(t, g, "POST", "/api/adapters",
|
|
`{"name":"testadp","code":"return {name='testadp',endpoint='/chat/completions',transform_request=function(raw) return raw end,transform_response=function(raw) return raw end}"}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("upload status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
rr = doReq(t, g, "GET", "/api/status", "")
|
|
if !strings.Contains(rr.Body.String(), "testadp") {
|
|
t.Fatalf("adapter not listed: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSourcesAPIAddAndPersist(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t)
|
|
rr := doReq(t, g, "POST", "/api/sources",
|
|
fmt.Sprintf(`{"name":"added","base_url":"%s","adapter":"openai","models":[{"id":"new-m","priority":5}]}`, up.URL))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("add source status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
rr = doReq(t, g, "GET", "/v1/models", "")
|
|
if !strings.Contains(rr.Body.String(), "new-m") {
|
|
t.Fatalf("new model not live: %s", rr.Body.String())
|
|
}
|
|
// verify persistence file exists
|
|
if _, err := os.Stat(g.core.Config().RuntimeFile); err != nil {
|
|
t.Fatalf("runtime file not written: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUIRequiresAuth(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
// / without a key -> redirect to /login
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusFound {
|
|
t.Fatalf("expected 302 for /, got %d", rr.Code)
|
|
}
|
|
if loc := rr.Header().Get("Location"); !strings.Contains(loc, "/login") {
|
|
t.Fatalf("expected redirect to /login, got %q", loc)
|
|
}
|
|
// /api/status without a key -> 401
|
|
req, _ = http.NewRequest("GET", "/api/status", nil)
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for /api/status, got %d", rr.Code)
|
|
}
|
|
// /login page is public
|
|
req, _ = http.NewRequest("GET", "/login", nil)
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "登录") {
|
|
t.Fatalf("login page: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoginAPIAndCookie(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
// bad key -> 401
|
|
req, _ := http.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("bad login: %d", rr.Code)
|
|
}
|
|
// good key -> cookie
|
|
req, _ = http.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"sk-test"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("login: %d", rr.Code)
|
|
}
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) == 0 || cookies[0].Name != "gw_key" {
|
|
t.Fatalf("no gw_key cookie set")
|
|
}
|
|
// use cookie to access /api/status
|
|
req, _ = http.NewRequest("GET", "/api/status", nil)
|
|
req.AddCookie(cookies[0])
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("cookie authed status: %d", rr.Code)
|
|
}
|
|
var out map[string]interface{}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
|
if out["base_url"] == "" {
|
|
t.Fatalf("status missing base_url: %s", rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "sk-test") {
|
|
t.Fatalf("status missing gateway_keys: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAPIChatInternal(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/api/chat",
|
|
`{"model":"mock-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("api chat status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "pong") {
|
|
t.Fatalf("api chat body=%s", rr.Body.String())
|
|
}
|
|
} |