Files
ModelRouter/internal/gateway/discover_mutation_test.go
root c40b5b5981 fix(discovery): 错误分类 + 去掉重复请求 URL
演练错误路径时发现两个真问题。

**① 密钥错误被报成 502(上游故障)。** 这是最常见的失败,却和「厂商挂了」
给出完全相同的错误。两者需要的动作正好相反:重新输 key vs 去查厂商状态页。
操作员只能猜。现在 401/403 会得到 401 并在信息里点名 api key;
DNS 不解析、TLS 失败、连接被拒也各自带一句可执行的说明。

**② 错误信息里的 tried 列表有重复。** base "https://h/v1" + path "/models"
和 base "https://h" + path "/v1/models" 请求的是同一个地址,但候选去重只按
(base, path) 对,于是同一条 URL 在错误信息里出现两次——读起来像「试了两个
地方」,其实只试了一个。改成按最终 URL 去重。

去重后候选从 3 个变成 2 个,两个判据的期望值需要跟着改:
TestDiscoveryCandidates_DedupesAndPrefersTyped 和变异测试的
「fallback search」原本断言 >= 3,那是在测量 bug 而不是覆盖度。覆盖没有丢失:
剩下的两条是 https://h/v1/models 和 https://h/models,两个不同地址。

新增判据:
  TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway
  TestDiscoverModels_UnreachableStaysBadGateway(防止过度纠正)
  TestDiscoveryCandidates_NoDuplicateRequests
  TestDiscoverModels_FailureListsEachURLOnce(对操作员真正读到的文本断言)
2026-10-06 15:02:42 +08:00

173 lines
6.6 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"
"net/http"
"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("a rejected key is reported as unauthorized, not as an outage", func(t *testing.T) {
// The mutant: collapse every failure into one generic error, which is
// what made a typo'd key look like the vendor being down.
if classifyAuthStatus(http.StatusUnauthorized, []byte(`{"error":"invalid api key"}`)) == "" {
t.Error("mutant survived: 401 is not classified")
}
if classifyAuthStatus(http.StatusNotFound, []byte("nope")) != "" {
t.Error("a 404 must not be reported as an auth problem")
}
if classifyAuthStatus(http.StatusOK, nil) != "" {
t.Error("a 200 must not be reported as an auth problem")
}
})
t.Run("duplicate candidate URLs are collapsed", func(t *testing.T) {
seen := map[string]bool{}
for _, c := range discoveryCandidates("https://h/v1") {
u := c.baseURL + c.modelsPath
if seen[u] {
t.Errorf("mutant survived: %s requested twice", u)
}
seen[u] = true
}
})
t.Run("discovery falls back across URL forms", func(t *testing.T) {
cands := discoveryCandidates("https://h/v1")
// Two distinct URLs is the correct count after deduping: the two
// spellings of "https://h/v1/models" collapse into one request. An
// earlier expectation of three was measuring the bug, not the coverage.
distinct := map[string]bool{}
for _, c := range cands {
distinct[c.baseURL+c.modelsPath] = true
}
if len(distinct) != 2 {
t.Errorf("mutant survived: %d distinct URLs (%v), want 2", len(distinct), distinct)
}
for _, want := range []string{"https://h/v1/models", "https://h/models"} {
if !distinct[want] {
t.Errorf("mutant survived: %s is not searched", want)
}
}
})
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)
}
})
}