mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
只需填 URL 和 key 即可自动扫描上游模型并配置,不用再手抄模型 id。 端点 POST /api/sources/discover 只探测,不写任何东西 POST /api/sources/discover/apply 探测后落库 核心难点是 URL 形态而不是 /models 调用本身。网关按 base_url + adapter endpoint 拼路径(openai 提供 "/chat/completions"),而各家厂商对版本段 放在 base 还是 path 上没有共识,也没有哪个响应头能说明。所以探测不信任 调用方给的 URL,而是按偏好顺序试若干候选,返回答的那个,并给出归一化 后的 base_url + endpoint 组合。这正是第一次配 vela 时 /v1/images/ generations 返回 404 的成因:base_url 里已有 /v1,默认图像路径再拼一次。 归一化的不变量是「版本段在 base_url + endpoint 里恰好出现一次」。留在两边 会重复;两边都没有则 chat 请求在要求版本段的厂商上会打错路径。 kind 只信上游自己声明的 modality,绝不从模型名猜。"gpt-image-2" 这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持图像 生成的 id,其后每一层都被它拖垮。 不做的事: - 不逐模型发单 token 探活。34 个模型的目录跑一轮要花钱且耗时数分钟, /models 是足够便宜的信号;模型是否真能应答留给正常请求路径。 - 不猜能力。「vision」出现在 id 里不等于该模型接受图片。 - 不自动写盘。探测只读,保存仍需操作员按按钮——错误的 base_url 会产出 一个看起来合理却无法使用的源。 已存在的源必须显式 replace_models=true 才会覆盖模型列表:重新探测顺手 抹掉为 AUTO 链手工调过的 priority 是最难排查的一类数据丢失。 测试用真实抓取的 amr-link /models 响应做夹具,它把 modality 嵌在 architecture 下、metadata 下还有 cost/context_limit,34 个模型里混着 5 个 图像生成 id——朴素解析器正好在这三处出错。 变异测试 9/9 全杀(TestRealMutants,baseline 43 个测试)。过程中发现并 修掉四个真缺陷:嵌套 metadata 丢失、null 被当空目录、候选盲目拼接出 /v1/v1、以及 discoveryResult 零值序列化成 null 会让前端渲染出 "undefined"。早先的 shell 版变异脚本因 -run 转义损坏报出 4 个"存活", 实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
220 lines
6.5 KiB
Go
220 lines
6.5 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/config"
|
|
)
|
|
|
|
// upstream that answers /v1/models with the real captured catalog
|
|
func catalogUpstream(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer sk-secret" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(realCatalogBody))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestDefaultSourceName(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://api.vendor.com/v1": "vendor.com",
|
|
"https://api.openai.com/v1": "openai.com",
|
|
"https://www.vendor.com/v1": "vendor.com",
|
|
"http://127.0.0.1:8080/v1": "127.0.0.1",
|
|
"https://amr-link.open-design.ai/v1": "amr-link.open-design.ai",
|
|
"not a url at all": "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := defaultSourceName(in); got != want {
|
|
t.Errorf("defaultSourceName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSelectModels_IncludeUnknownIsAnError(t *testing.T) {
|
|
chat := []string{"a", "b"}
|
|
image := []string{"img"}
|
|
// Silently dropping a typo'd id would create a source that looks
|
|
// configured and 404s on every request — much worse than a rejection.
|
|
_, _, _, err := selectModels(chat, image, []string{"a", "typo"}, nil)
|
|
if err == nil {
|
|
t.Fatal("expected an error for an include id that is not in the catalog")
|
|
}
|
|
if !strings.Contains(err.Error(), "typo") {
|
|
t.Errorf("error should name the offending id, got %q", err)
|
|
}
|
|
}
|
|
|
|
func TestSelectModels_IncludeNarrows(t *testing.T) {
|
|
chat, image, _, err := selectModels([]string{"a", "b", "c"}, []string{"img1", "img2"}, []string{"b", "img1"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat) != 1 || chat[0] != "b" {
|
|
t.Errorf("chat = %v, want [b]", chat)
|
|
}
|
|
if len(image) != 1 || image[0] != "img1" {
|
|
t.Errorf("image = %v, want [img1]", image)
|
|
}
|
|
}
|
|
|
|
func TestSelectModels_ExcludeUnknownIsFine(t *testing.T) {
|
|
chat, _, _, err := selectModels([]string{"a"}, nil, nil, []string{"never-existed"})
|
|
if err != nil {
|
|
t.Errorf("excluding a model that is not present should be allowed: %v", err)
|
|
}
|
|
if len(chat) != 1 {
|
|
t.Errorf("chat = %v, want [a] unchanged", chat)
|
|
}
|
|
}
|
|
|
|
func TestSelectModels_NeverReturnsNil(t *testing.T) {
|
|
chat, image, warns, err := selectModels(nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if chat == nil || image == nil {
|
|
t.Fatal("nil slices would serialize as JSON null and break the UI")
|
|
}
|
|
if len(warns) == 0 {
|
|
t.Error("an empty catalog deserves a warning, not silence")
|
|
}
|
|
}
|
|
|
|
func TestSelectModels_WarnsAboutImageOnlyCatalog(t *testing.T) {
|
|
_, _, warns, err := selectModels(nil, []string{"gpt-image-2"}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(warns, " ")
|
|
if !strings.Contains(joined, "AUTO") {
|
|
t.Errorf("warnings %v should say AUTO has nothing to select", warns)
|
|
}
|
|
}
|
|
|
|
func TestSelectModels_WarnsThatImagesAreNotInAUTO(t *testing.T) {
|
|
_, _, warns, err := selectModels([]string{"a"}, []string{"img"}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(strings.Join(warns, " "), "images/generations") {
|
|
t.Errorf("warnings %v should point at the image endpoint", warns)
|
|
}
|
|
}
|
|
|
|
// TestDiscoveryResultAndConfigRoundTrip checks the fields the WebUI will echo
|
|
// back actually survive into a config.Source — a mismatch here means the
|
|
// operator previews one set of values and saves another.
|
|
func TestDiscoveryResultAndConfigRoundTrip(t *testing.T) {
|
|
res := &discoveryResult{
|
|
BaseURL: "https://h",
|
|
Endpoint: "/v1/chat/completions",
|
|
Models: []string{"m1", "m2"},
|
|
ImageModels: []string{"img1"},
|
|
}
|
|
src := config.Source{
|
|
Name: "x",
|
|
BaseURL: res.BaseURL,
|
|
Endpoint: res.Endpoint,
|
|
Models: modelsToConfig(res.Models, res.ImageModels),
|
|
}
|
|
if len(src.Models) != 3 {
|
|
t.Fatalf("models = %d, want 3", len(src.Models))
|
|
}
|
|
if src.Endpoint != "/v1/chat/completions" {
|
|
t.Errorf("endpoint lost: %q", src.Endpoint)
|
|
}
|
|
var img int
|
|
for _, m := range src.Models {
|
|
if m.Kind == "image" {
|
|
img++
|
|
}
|
|
}
|
|
if img != 1 {
|
|
t.Errorf("image models = %d, want 1", img)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_SendsAuthorizationHeader pins the only way the feature
|
|
// works against a real vendor: without the bearer token every paid /models
|
|
// endpoint returns 401.
|
|
func TestDiscoverModels_SendsAuthorizationHeader(t *testing.T) {
|
|
var seen string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seen != "Bearer sk-secret" {
|
|
t.Errorf("Authorization = %q, want %q", seen, "Bearer sk-secret")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverModels_EmptyKeyOmitsHeader(t *testing.T) {
|
|
var present bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, present = r.Header["Authorization"]
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if present {
|
|
t.Error("an empty key should omit the header, not send an empty bearer")
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_RealCatalogEndToEnd walks the actual captured response
|
|
// through the whole path and asserts the numbers an operator would see.
|
|
func TestDiscoverModels_RealCatalogEndToEnd(t *testing.T) {
|
|
srv := catalogUpstream(t)
|
|
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret")
|
|
if err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if res.BaseURL != srv.URL+"/v1" {
|
|
t.Errorf("base = %q, want the typed URL unchanged", res.BaseURL)
|
|
}
|
|
if res.Endpoint != "" {
|
|
t.Errorf("endpoint = %q, want empty (base already carries /v1)", res.Endpoint)
|
|
}
|
|
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
|
|
t.Errorf("chat/image = %d/%d, want 2/2", len(res.Models), len(res.ImageModels))
|
|
}
|
|
if res.Adapter != "openai" {
|
|
t.Errorf("adapter = %q, want openai", res.Adapter)
|
|
}
|
|
|
|
// The response must be JSON the UI can consume directly.
|
|
b, _ := json.Marshal(res)
|
|
var round map[string]interface{}
|
|
if err := json.Unmarshal(b, &round); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if round["ok"] != nil {
|
|
// discoverResponse adds ok; discoveryResult does not. Both marshal
|
|
// fine, so this is only a shape note.
|
|
_ = round
|
|
}
|
|
}
|