mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」
## 一、删除 priority(它从未参与跨源调度)
`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。
- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority
## 二、一通道 = 一个适配器 = 一个源(channels 插件)
旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效
新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
(内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
时给当前账号打 termErr,下次选号跳过;成功则清标记。
⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发
## 三、discover 支持用已存凭据(真实缺口)
刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。
- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
做探测,那就成了 SSRF/密钥外带);响应不回显 key
## 四、image_models 显式声明(补元数据缺失)
`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。
不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。
## 五、UI:对话与生图合并为一个导航项
两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。
## 测试
- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
(TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
(验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"
gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。
BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。
646 lines
24 KiB
Go
646 lines
24 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// realCatalogBody is the actual /models response captured from the amr-link
|
|
// endpoint, not a hand-written fixture. Two properties of it are the reason
|
|
// this test exists at all:
|
|
//
|
|
// - it nests modalities under "architecture", which the naive parser misses;
|
|
// - it lists five image-generation ids among 34 chat ids, so a parser that
|
|
// returns everything as "chat" silently poisons the AUTO chain.
|
|
const realCatalogBody = `{"data":[{"id":"claude-fable-5","object":"model","created":1781081344,"owned_by":"vela","architecture":{"input_modalities":["text","image","file"],"output_modalities":["text"]},"metadata":{"cost":"very_high","capability":"best_quality","context_limit":1000000,"context_budget":232000},"default":false,"enabled":true},{"id":"gpt-image-2","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true},{"id":"deepseek-v4-flash","object":"model","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"metadata":{"cost":"medium","capability":"standard","context_limit":1048000},"enabled":true},{"id":"seedream-5.0-pro","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true}]}`
|
|
|
|
func TestParseModelCatalog_RealUpstreamShape(t *testing.T) {
|
|
models, err := parseModelCatalog([]byte(realCatalogBody))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(models) != 4 {
|
|
t.Fatalf("want 4 models, got %d", len(models))
|
|
}
|
|
|
|
kind := map[string]string{}
|
|
for _, m := range models {
|
|
kind[m.ID] = m.Kind
|
|
}
|
|
// The two image generators must be classified by the upstream's declared
|
|
// output modality, not by their names.
|
|
for _, id := range []string{"gpt-image-2", "seedream-5.0-pro"} {
|
|
if kind[id] != "image" {
|
|
t.Errorf("%s: got kind %q, want \"image\"", id, kind[id])
|
|
}
|
|
}
|
|
for _, id := range []string{"claude-fable-5", "deepseek-v4-flash"} {
|
|
if kind[id] != "chat" {
|
|
t.Errorf("%s: got kind %q, want \"chat\"", id, kind[id])
|
|
}
|
|
}
|
|
|
|
// Metadata the upstream sent must survive; keys it did not send must not
|
|
// appear as zero values.
|
|
if got := models[0].Meta["context_limit"]; got == nil {
|
|
t.Error("claude-fable-5: context_limit dropped (it is nested under metadata in the real response)")
|
|
} else if f, ok := got.(float64); !ok || f != 1000000 {
|
|
t.Errorf("context_limit = %#v, want numeric 1000000 so the UI can sort on it", got)
|
|
}
|
|
if _, present := models[0].Meta["cost"]; !present {
|
|
t.Error("claude-fable-5: cost dropped")
|
|
}
|
|
// context_budget is present on this entry and must not be dropped either.
|
|
if _, present := models[0].Meta["context_budget"]; !present {
|
|
t.Error("claude-fable-5: context_budget dropped")
|
|
}
|
|
// deepseek-v4-flash DOES send metadata; gpt-image-2 sends only capability.
|
|
// Neither may lose it.
|
|
if models[2].Meta["context_limit"] == nil {
|
|
t.Error("deepseek-v4-flash: context_limit dropped")
|
|
}
|
|
if models[2].Meta["cost"] == nil {
|
|
t.Error("deepseek-v4-flash: cost dropped")
|
|
}
|
|
if _, bogus := models[0].Meta["made_up_field"]; bogus {
|
|
t.Error("invented a metadata key that the upstream never sent")
|
|
}
|
|
}
|
|
|
|
// TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata is the regression guard
|
|
// for the failure mode that motivated keeping kind="chat" as the default: a
|
|
// name-based heuristic marks "ideogram-v2" or "gpt-image-1" as an image model,
|
|
// but a catalog that also serves chat for that id would then have its chat
|
|
// entry removed from the AUTO chain. Only the upstream's declared OUTPUT
|
|
// modality may classify a model as image.
|
|
func TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in upstreamModel
|
|
want string
|
|
}{
|
|
{
|
|
// A catalog that DECLARES output modality image is authoritative.
|
|
// The "kind" string saying chat must not override it.
|
|
name: "declared output modality image wins over a chat kind string",
|
|
in: func() upstreamModel {
|
|
var m upstreamModel
|
|
m.Kind = "chat"
|
|
m.Architecture.OutputModalities = []string{"image"}
|
|
return m
|
|
}(),
|
|
want: "image",
|
|
},
|
|
{
|
|
name: "bare array modality string",
|
|
in: upstreamModel{ID: "x", Modality: "text-to-image"},
|
|
want: "image",
|
|
},
|
|
{
|
|
name: "image INPUT is still a chat model",
|
|
in: func() upstreamModel {
|
|
var m upstreamModel
|
|
m.Architecture.InputModalities = []string{"text", "image"}
|
|
return m
|
|
}(),
|
|
want: "chat",
|
|
},
|
|
{
|
|
name: "file input is still a chat model",
|
|
in: func() upstreamModel {
|
|
var m upstreamModel
|
|
m.Architecture.InputModalities = []string{"text", "file"}
|
|
return m
|
|
}(),
|
|
want: "chat",
|
|
},
|
|
{
|
|
name: "vision capability flag is chat",
|
|
in: upstreamModel{ID: "y", Capabilities: modelCaps{Vision: true}},
|
|
want: "chat",
|
|
},
|
|
{
|
|
name: "capabilities.image_generation is image",
|
|
in: upstreamModel{ID: "z", Capabilities: modelCaps{ImageGeneration: true}},
|
|
want: "image",
|
|
},
|
|
{
|
|
name: "no modality info at all defaults to chat",
|
|
in: upstreamModel{ID: "plain"},
|
|
want: "chat",
|
|
},
|
|
{
|
|
// The default must be chat even when the name LOOKS like an image
|
|
// model. Guessing from the name is what puts gpt-image-2 into the
|
|
// chat AUTO chain, where every request to it fails.
|
|
name: "image-looking name without upstream metadata stays chat",
|
|
in: upstreamModel{ID: "gpt-image-2"},
|
|
want: "chat",
|
|
},
|
|
{
|
|
name: "vision-looking name without upstream metadata stays chat",
|
|
in: upstreamModel{ID: "ideogram-v2"},
|
|
want: "chat",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := discoveredModelKind(tc.in); got != tc.want {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseModelCatalog_RejectsNonCatalog(t *testing.T) {
|
|
// A 200 with valid JSON that is not a catalog must NOT be reported as an
|
|
// empty result. Reporting zero models would let a login page or a
|
|
// rate-limit body create a source with no models in it.
|
|
for name, body := range map[string]string{
|
|
"login page html": `<html><body>Sign in</body></html>`,
|
|
"rate limit json": `{"error":{"message":"slow down","type":"rate_limit"}}`,
|
|
"empty object": `{}`,
|
|
"null": `null`,
|
|
"wrong shape": `{"objects":[{"id":"m"}]}`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := parseModelCatalog([]byte(body)); err == nil {
|
|
t.Errorf("expected an error for %q, got nil (would create an empty source)", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseModelCatalog_AcceptsBareArray(t *testing.T) {
|
|
models, err := parseModelCatalog([]byte(`[{"id":"a"},{"id":"b"}]`))
|
|
if err != nil {
|
|
t.Fatalf("bare array should parse: %v", err)
|
|
}
|
|
if len(models) != 2 {
|
|
t.Fatalf("want 2, got %d", len(models))
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers: operator pastes
|
|
// "https://host/v1" (the form every SDK example shows) but the vendor serves
|
|
// the catalog at "https://host/models" — the version segment on the path is
|
|
// implied, not doubled. Discovery must strip the segment off the base and
|
|
// report it on the endpoint, because openai's adapter endpoint is
|
|
// "/chat/completions" and the two are concatenated.
|
|
func TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers(t *testing.T) {
|
|
var paths []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
paths = append(paths, r.URL.Path)
|
|
if r.URL.Path != "/models" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(realCatalogBody))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-test")
|
|
if err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if res.BaseURL != srv.URL {
|
|
t.Errorf("base_url = %q, want %q (version segment moved off the base)", res.BaseURL, srv.URL)
|
|
}
|
|
// "/models" carries no version segment, but the operator typed one — the
|
|
// endpoint has to restore it or the chat call would miss /v1.
|
|
if res.Endpoint != "/v1/chat/completions" {
|
|
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
|
|
}
|
|
if res.Note == "" {
|
|
t.Error("silently rewriting the operator's base_url is worse than saying so")
|
|
}
|
|
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
|
|
t.Errorf("got %d chat / %d image, want 2 / 2", len(res.Models), len(res.ImageModels))
|
|
}
|
|
if len(paths) < 2 {
|
|
t.Errorf("tried only %v; expected a fallback search", paths)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_BareHostWithVersionedModelsPath: operator pastes the host
|
|
// root, vendor serves /v1/models. The base needs no rewriting; the endpoint
|
|
// does.
|
|
func TestDiscoverModels_BareHostWithVersionedModelsPath(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"m1"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res, err := DiscoverModels(context.Background(), srv.URL, "")
|
|
if err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if res.BaseURL != srv.URL {
|
|
t.Errorf("base_url = %q, want %q", res.BaseURL, srv.URL)
|
|
}
|
|
if res.Endpoint != "/v1/chat/completions" {
|
|
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_KeepsWorkingBaseURLUnchanged covers the vendor that really
|
|
// does serve "/v1/models": the typed URL must survive untouched so no needless
|
|
// config rewrite is suggested.
|
|
func TestDiscoverModels_KeepsWorkingBaseURLUnchanged(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Base typed WITH "/v1", catalog served at "…/v1/models". That is exactly
|
|
// the amr-link shape: first candidate succeeds, nothing is rewritten, and
|
|
// the adapter's default endpoint already composes.
|
|
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "")
|
|
if err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if res.BaseURL != srv.URL+"/v1" {
|
|
t.Errorf("base_url = %q, want it unchanged", res.BaseURL)
|
|
}
|
|
if res.Endpoint != "" {
|
|
t.Errorf("endpoint = %q, want empty (the typed base already carries /v1)", res.Endpoint)
|
|
}
|
|
if res.Note != "" {
|
|
t.Errorf("note = %q, want empty when nothing was rewritten", res.Note)
|
|
}
|
|
if len(res.Models) != 2 {
|
|
t.Errorf("got %d chat models, want 2", len(res.Models))
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_ErrorNamesEveryTriedURL: a failed probe is useless if it
|
|
// only reports the last URL. The operator needs to see the full search.
|
|
func TestDiscoverModels_ErrorNamesEveryTriedURL(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
|
if err == nil {
|
|
t.Fatal("expected failure against an all-404 server")
|
|
}
|
|
msg := err.Error()
|
|
for _, want := range []string{"/models", "/v1/models", "404"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Errorf("error %q should mention %q", msg, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryCandidates_DedupesAndPrefersTyped(t *testing.T) {
|
|
cands := discoveryCandidates("https://h/v1/")
|
|
if len(cands) == 0 {
|
|
t.Fatal("no candidates")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, c := range cands {
|
|
// Dedup is on the requested URL, so that is the key to assert on.
|
|
k := c.baseURL + c.modelsPath
|
|
if seen[k] {
|
|
t.Errorf("duplicate request for %s", k)
|
|
}
|
|
seen[k] = true
|
|
}
|
|
// The versioned spelling of the typed URL must be reachable: on the real
|
|
// amr-link endpoint that is what answers, and it is the preferred candidate.
|
|
if !seen["https://h/v1/models"] {
|
|
t.Error("the typed base with a versioned catalog path must be tried; that is what answers on amr-link")
|
|
}
|
|
// Both catalog forms must be attempted for a bare host too. Offering only
|
|
// "/v1/models" there left a vendor serving a bare "/models" looking
|
|
// unreachable.
|
|
if !seen["https://h/models"] {
|
|
t.Error("host root with a bare /models path was never tried")
|
|
}
|
|
// The first candidate is preferred, so its URL is what gets reported when
|
|
// several answer.
|
|
if cands[0].baseURL+cands[0].modelsPath != "https://h/v1/models" {
|
|
t.Errorf("first candidate requests %q, want the canonical versioned form",
|
|
cands[0].baseURL+cands[0].modelsPath)
|
|
}
|
|
// The bare "/models" form must exist too — some gateways serve it there.
|
|
hasBare := false
|
|
for _, c := range cands {
|
|
if c.modelsPath == "/models" {
|
|
hasBare = true
|
|
}
|
|
}
|
|
if !hasBare {
|
|
t.Error("no /models candidate; vendors serving the catalog at /models would be missed")
|
|
}
|
|
// Preference must be monotonically non-decreasing so the sort is stable.
|
|
for i := 1; i < len(cands); i++ {
|
|
if cands[i].prefer < cands[i-1].prefer {
|
|
t.Errorf("candidates out of preference order at %d: %d < %d", i, cands[i].prefer, cands[i-1].prefer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVersionSegment(t *testing.T) {
|
|
cases := map[string]string{
|
|
"/v1/models": "/v1",
|
|
"/v1beta/models": "/v1beta",
|
|
"/models": "",
|
|
"/api/models": "",
|
|
"/vision/models": "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := versionSegment(in); got != want {
|
|
t.Errorf("versionSegment(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrimVersionSuffix(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://h/v1": "https://h",
|
|
"https://h/v2": "https://h",
|
|
"https://h/v1beta": "https://h",
|
|
"https://h/api/v1": "https://h/api",
|
|
"https://h/vision": "https://h/vision", // not numeric — a real path segment
|
|
"https://h": "https://h",
|
|
"https://h/openai": "https://h/openai",
|
|
"https://h/v1/extra": "https://h/v1/extra",
|
|
// A segment that merely STARTS with v must not be stripped: stripping
|
|
// it would rewrite base "https://h/vision" to "https://h" and, worse,
|
|
// would treat a real vendor path as a version.
|
|
"https://h/vertex": "https://h/vertex",
|
|
"https://h/volcengine": "https://h/volcengine",
|
|
"https://h/vertex-ai/v1": "https://h/vertex-ai",
|
|
}
|
|
for in, want := range cases {
|
|
if got := trimVersionSuffix(in); got != want {
|
|
t.Errorf("trimVersionSuffix(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsVersionSegment_RequiresDigits pins the distinction the whole endpoint
|
|
// derivation rests on: "v1"/"v1beta" are versions, "vision"/"vertex" are path
|
|
// segments. Without the digit requirement, stripping turns a vendor's real
|
|
// base URL into a different one.
|
|
func TestIsVersionSegment_RequiresDigits(t *testing.T) {
|
|
yes := []string{"v1", "v2", "v10", "v1beta", "v1alpha", "v1preview"}
|
|
no := []string{"vision", "vertex", "volcengine", "openai", "v", "vv", "api", "beta", "v1x"}
|
|
for _, s := range yes {
|
|
if !isVersionSegment(s) {
|
|
t.Errorf("isVersionSegment(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range no {
|
|
if isVersionSegment(s) {
|
|
t.Errorf("isVersionSegment(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestComposeEndpoint_ExactlyOneVersionSegment is the invariant test. Every row
|
|
// below is a URL form that was observed to 404 or to route wrongly when the
|
|
// version segment ended up in the wrong place.
|
|
func TestComposeEndpoint_ExactlyOneVersionSegment(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
modelsPath string
|
|
persistedBase string
|
|
typedBase string
|
|
want string
|
|
}{
|
|
{
|
|
name: "typed base has /v1 and catalog served there: base keeps it, endpoint empty",
|
|
modelsPath: "/models", persistedBase: "https://h/v1", typedBase: "https://h/v1",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "catalog served at versioned path, base stripped: endpoint carries it",
|
|
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h/v1",
|
|
want: "/v1/chat/completions",
|
|
},
|
|
{
|
|
name: "bare host, bare catalog, nothing typed: adapter default composes",
|
|
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare host, versioned catalog: endpoint owns the version",
|
|
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h",
|
|
want: "/v1/chat/completions",
|
|
},
|
|
{
|
|
name: "typed /v1 but catalog only at bare /models: strip base, restore on endpoint",
|
|
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h/v1",
|
|
want: "/v1/chat/completions",
|
|
},
|
|
{
|
|
name: "non-version segment must never be promoted onto the endpoint",
|
|
modelsPath: "/models", persistedBase: "https://h/vision", typedBase: "https://h/vision",
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := composeEndpoint(tc.modelsPath, tc.persistedBase, tc.typedBase)
|
|
if got != tc.want {
|
|
t.Errorf("composeEndpoint = %q, want %q", got, tc.want)
|
|
}
|
|
// Whichever way it resolved, base + endpoint must never carry the
|
|
// same version segment twice.
|
|
onBase := trimVersionSuffix(tc.persistedBase) != tc.persistedBase
|
|
onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions")+"/models") != ""
|
|
if onBase && onEndpoint {
|
|
t.Errorf("version segment duplicated: base %q + endpoint %q", tc.persistedBase, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_DoesNotProbeEveryModel guards the decision not to spend
|
|
// money verifying each catalog entry. Only the catalog request is made.
|
|
func TestDiscoverModels_DoesNotProbeEveryModel(t *testing.T) {
|
|
var calls int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"},{"id":"c"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k"); err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("made %d requests, want exactly 1 (the catalog)", calls)
|
|
}
|
|
}
|
|
|
|
func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) {
|
|
got := modelsToConfig([]string{"a", "b"}, []string{"img1"})
|
|
if len(got) != 3 {
|
|
t.Fatalf("want 3 models, got %d", len(got))
|
|
}
|
|
if got[2].ID != "img1" || got[2].Kind != "image" {
|
|
t.Errorf("image model mis-sorted: %+v", got[2])
|
|
}
|
|
}
|
|
|
|
// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end,
|
|
// not just on the struct: the WebUI does out.concat(image_models), and a JSON
|
|
// null there renders "undefined" next to every image model row. This asserts on
|
|
// the served bytes rather than the Go field, because that is what the bug was.
|
|
func TestDiscoverModels_ImageModelsNeverNull(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Only chat models — the case where the slice is never appended to.
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
|
if err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if len(res.ImageModels) != 0 {
|
|
t.Fatalf("precondition: want zero image models, got %d", len(res.ImageModels))
|
|
}
|
|
b, err := json.Marshal(res)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(b), `"image_models":[]`) {
|
|
t.Errorf("served %s\nwant \"image_models\":[] — null breaks concat in the UI", b)
|
|
}
|
|
}
|
|
|
|
// TestComposeImageEndpoint pins the rule that fixed a real 404.
|
|
//
|
|
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
|
|
// is the ABSOLUTE "/v1/images/generations" — while chat's default is the
|
|
// relative "/chat/completions". The two defaults disagree about who owns the
|
|
// version segment, so a base_url that produces a working chat URL produces
|
|
// "/v1/v1/images/generations" and a 404.
|
|
func TestComposeImageEndpoint(t *testing.T) {
|
|
cases := []struct {
|
|
base string
|
|
want string
|
|
}{
|
|
// base without a version: the absolute default composes correctly.
|
|
{"https://h", ""},
|
|
// base WITH a version: the default would double it, so the
|
|
// version-relative form is written instead.
|
|
{"https://h/v1", "/images/generations"},
|
|
{"https://h/v1beta", "/images/generations"},
|
|
// A real path segment that merely starts with v is not a version.
|
|
{"https://h/vision", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
got := composeImageEndpoint(tc.base)
|
|
if got != tc.want {
|
|
t.Errorf("composeImageEndpoint(%q) = %q, want %q", tc.base, got, tc.want)
|
|
}
|
|
// Whatever it decided, base + result must not contain a doubled
|
|
// version segment — that is the bug this function exists for.
|
|
joined := tc.base + got
|
|
if strings.Contains(joined, "/v1/v1") {
|
|
t.Errorf("composed %q contains a doubled version segment", joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion is the end-to-end
|
|
// version: a pasted base_url with "/v1" must come back with an image endpoint,
|
|
// or the image models it reported will all 404 on use.
|
|
func TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(realCatalogBody))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.ImageModels) == 0 {
|
|
t.Fatal("fixture must contain image models for this to mean anything")
|
|
}
|
|
if res.BaseURL != srv.URL+"/v1" {
|
|
t.Fatalf("base = %q, want the typed URL with /v1", res.BaseURL)
|
|
}
|
|
if res.ImageEndpoint != "/images/generations" {
|
|
t.Errorf("image_endpoint = %q, want %q (otherwise the reported image models all 404)",
|
|
res.ImageEndpoint, "/images/generations")
|
|
}
|
|
// The chat side stays empty: base already carries /v1 and the adapter's
|
|
// relative default composes.
|
|
if res.Endpoint != "" {
|
|
t.Errorf("endpoint = %q, want empty", res.Endpoint)
|
|
}
|
|
}
|
|
|
|
// TestDiscoveryResult_NilSlicesSerializeAsArrays covers the zero value of
|
|
// discoveryResult, which is what a caller gets if it builds one directly
|
|
// instead of going through DiscoverModels. DiscoverModels happens to
|
|
// initialize the slices, so testing only that path would let the MarshalJSON
|
|
// nil guard look redundant — and removing it would ship a latent UI bug the
|
|
// first time some other caller builds the struct.
|
|
func TestDiscoveryResult_NilSlicesSerializeAsArrays(t *testing.T) {
|
|
b, err := json.Marshal(&discoveryResult{BaseURL: "https://h"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(b)
|
|
for _, want := range []string{`"models":[]`, `"image_models":[]`, `"tried":[]`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("zero value serialized %s\nwant %s — a nil slice becomes JSON null and the UI renders \"undefined\"", got, want)
|
|
}
|
|
}
|
|
for _, bad := range []string{`"models":null`, `"image_models":null`, `"tried":null`} {
|
|
if strings.Contains(got, bad) {
|
|
t.Errorf("serialized %s, which the UI cannot concatenate", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDiscoveryResultJSONShape pins the field names the WebUI will bind to.
|
|
func TestDiscoveryResultJSONShape(t *testing.T) {
|
|
b, err := json.Marshal(&discoveryResult{
|
|
BaseURL: "https://h", Models: []string{"a"}, ImageModels: []string{},
|
|
ModelsPath: "/models", Adapter: "openai",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got map[string]interface{}
|
|
if err := json.Unmarshal(b, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// image_models must serialize as [] and never null: the UI does
|
|
// out.concat(image_models) and null would render "undefined".
|
|
if v, ok := got["image_models"]; !ok || v == nil {
|
|
t.Errorf("image_models = %v, want an empty array (null breaks concat in the UI)", v)
|
|
}
|
|
for _, k := range []string{"base_url", "endpoint", "models", "image_models", "models_path", "tried"} {
|
|
if _, ok := got[k]; !ok {
|
|
t.Errorf("missing field %q in discovery response", k)
|
|
}
|
|
}
|
|
}
|