mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17: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 个"存活", 实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
135 lines
5.2 KiB
Go
135 lines
5.2 KiB
Go
package gateway
|
|
|
|
// Mutation harness for the discovery code. Kept in the tree (not in a shell
|
|
// script) so the -run filter is a Go string literal rather than something the
|
|
// shell can mangle into "[no tests to run]" — which is exactly how an earlier
|
|
// version of this harness reported four surviving mutants that were simply
|
|
// never executed.
|
|
//
|
|
// Each mutant flips one behaviour; TestMutationHarness_Fails proves the
|
|
// harness itself is armed.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// discoveryTestFilter must cover every assertion that guards the behaviour
|
|
// below. Narrowing it is how mutants get to "survive" for free.
|
|
const discoveryTestFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates)"
|
|
|
|
func TestDiscoveryFilterRunsRealTests(t *testing.T) {
|
|
if discoveryTestFilter == "" {
|
|
t.Fatal("filter is empty; every mutant would survive trivially")
|
|
}
|
|
// The whole point: the catalog parser test must be inside the filter, or a
|
|
// mutant that stops reading nested metadata survives with a green run.
|
|
for _, group := range []string{
|
|
"ParseModelCatalog", // nested metadata + non-catalog rejection
|
|
"DiscoveredModelKind", // kind must follow upstream metadata
|
|
"Discover", // URL fallback + endpoint composition
|
|
"ComposeEndpoint", // version segment exactly once
|
|
"IsVersionSegment", // "v1beta" vs "vision"
|
|
} {
|
|
if !strings.Contains(discoveryTestFilter, group) {
|
|
t.Errorf("filter %q skips %s tests; mutants in that area survive", discoveryTestFilter, group)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMutantsAreDetectable is the harness's own regression guard: it runs the
|
|
// filter's worth of assertions against deliberately broken inputs and requires
|
|
// each to be rejected. If a future edit makes one of these pass, the
|
|
// corresponding protection is gone.
|
|
func TestMutantsAreDetectable(t *testing.T) {
|
|
t.Run("image classification follows upstream metadata, not the model name", func(t *testing.T) {
|
|
// The mutant: drop the output-modality check, so every model is chat.
|
|
m := upstreamModel{ID: "gpt-image-2"}
|
|
m.Architecture.OutputModalities = []string{"image"}
|
|
if got := discoveredModelKind(m); got != "image" {
|
|
t.Errorf("mutant survived: got %q, want \"image\"", got)
|
|
}
|
|
})
|
|
|
|
t.Run("nested metadata is read", func(t *testing.T) {
|
|
var ms []upstreamModel
|
|
if err := json.Unmarshal([]byte(realCatalogBody), &ms); err == nil {
|
|
t.Fatal("fixture must be a wrapper object, not a bare array")
|
|
}
|
|
var wrapper struct {
|
|
Data []upstreamModel `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(realCatalogBody), &wrapper); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(wrapper.Data) == 0 {
|
|
t.Fatal("fixture parsed to zero models")
|
|
}
|
|
// The mutant: skip copyFields(m.Metadata) entirely.
|
|
meta := modelMeta(wrapper.Data[0])
|
|
if meta == nil {
|
|
t.Fatal("mutant survived: metadata is nil, so cost/context_limit are lost")
|
|
}
|
|
for _, k := range []string{"context_limit", "cost", "context_budget"} {
|
|
if _, ok := meta[k]; !ok {
|
|
t.Errorf("mutant survived: %s dropped from nested metadata", k)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("non-catalog responses are rejected", func(t *testing.T) {
|
|
for _, body := range []string{`<html>login</html>`, `null`, `{"error":{"message":"x"}}`, `{}`} {
|
|
if _, err := parseModelCatalog([]byte(body)); err == nil {
|
|
t.Errorf("mutant survived: %q accepted as a catalog", body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("version segments are distinguished from real path segments", func(t *testing.T) {
|
|
if !isVersionSegment("v1beta") {
|
|
t.Error("mutant survived: v1beta not treated as a version")
|
|
}
|
|
for _, seg := range []string{"vision", "vertex", "v1x"} {
|
|
if isVersionSegment(seg) {
|
|
t.Errorf("mutant survived: %q wrongly treated as a version", seg)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("endpoint composition never duplicates the version segment", func(t *testing.T) {
|
|
// The mutant: never move the segment onto the endpoint.
|
|
if got := composeEndpoint("/v1/models", "https://h", "https://h/v1"); got != "/v1/chat/completions" {
|
|
t.Errorf("mutant survived: endpoint = %q", got)
|
|
}
|
|
// The other mutant: always move it, even when the base keeps it.
|
|
if got := composeEndpoint("/models", "https://h/v1", "https://h/v1"); got != "" {
|
|
t.Errorf("mutant survived: endpoint = %q would double /v1", got)
|
|
}
|
|
})
|
|
|
|
t.Run("image_models is never nil", func(t *testing.T) {
|
|
res := &discoveryResult{Models: []string{"a"}, ImageModels: nil}
|
|
b, _ := json.Marshal(res)
|
|
if strings.Contains(string(b), `"image_models":null`) {
|
|
t.Errorf("mutant survived: %s", b)
|
|
}
|
|
})
|
|
|
|
t.Run("discovery falls back across URL forms", func(t *testing.T) {
|
|
cands := discoveryCandidates("https://h/v1")
|
|
if len(cands) < 3 {
|
|
t.Errorf("mutant survived: only %d candidates, fallback search removed", len(cands))
|
|
}
|
|
})
|
|
|
|
t.Run("only the catalog is fetched, never each model", func(t *testing.T) {
|
|
// Guarding a NON-mutation: the design decision that discovery must not
|
|
// spend money. If a future change adds per-model probes, the counting
|
|
// test below catches it; this assertion documents the intent.
|
|
if discoveryModelsPath != "/v1/models" {
|
|
t.Errorf("catalog path changed to %q", discoveryModelsPath)
|
|
}
|
|
})
|
|
}
|