mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
## ① 图像模型曾与对话模型不可区分
根因是结构性的,不是漏了个过滤:
Provider.Models() → []string ← 只有 ID,Kind 被丢掉
SourceStatus.Models → []string ← 扁平列表
于是 /api/status 给前端的 34 个模型(29 对话 + 5 图像)长得一模一样。选了
gpt-image-2 就 POST 到 /api/chat —— 那个端点服务不了图像模型,必然失败,
而且报错完全不提「这是图像模型」。
修法:新增 ModelKinds []ModelKindInfo([{id,kind}]),纯增量、omitempty,
老 UI 不受影响;前端按 optgroup 分组,且这些 option 是 **disabled** 的。
分组还不够 —— 分组只是视觉分隔,operator 照样能选,选了照样失败。
disabled 的 option 会自解释(「请到生图页」),可选的只会失败。
★ 写这段时踩了一次 String.replace 的坑:`imgOpts.join("").replace(...)`
只替换**第一个**匹配,于是 6 个图像模型里只有第 1 个被禁用。浏览器实测
逐条读 e.disabled 才抓到(siliconflow 禁用了、vela 的 5 个没有)。
判据只看 value 里有没有 "image" 的话,第一次跑就会永远报警——那是判据
自己错了,比没判据更糟。
## ② 生图二级页面
侧栏新增 tab,与「对话」并列。网格展示 + 提示词输入 + 数量/尺寸,
调用 /v1/images/generations(不是 /api/chat)。
按钮显示已用秒数:实测该上游 15.7s~112.5s,波动 7.6 倍,不显示进度
看起来就像卡死了。
浏览器实测(几何测量,非仅 DOM 断言):
tab-image 容器 986×523 visible
img-grid 840×374 visible
提示词输入框 634×64 visible
生图页 6 个模型,全部 kind=image,全部可选
对话页 6 个图像模型,全部 disabled;97 个对话模型全部可选
## ③ base_url 引导不足
文案直接给出截断位置和具体后果,而不是泛泛地说「填 URL」:
填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:
若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。
不确定就用上方「自动探测模型」。
/v1/v1 不是假设:ImageURL 是 BaseURL + ImageEndpoint(),而图像默认路径
是**绝对**的 /v1/images/generations,chat 的默认是相对的 /chat/completions。
两者对「版本段归谁管」的假设相反。今天在 vela 上为此排查了两次。
## 判据
新增 6 个契约测试:
- 对话页必须读 model_kinds(不读就会退回扁平列表)
- 禁用图像模型不能用 joined-HTML 的 String.replace(只改第一个)
- 生图页存在且注册进 TABS(漏了则 pane 永不可见,症状是「页面空白」)
- sendImage 必须打 /v1/images/generations
- urlHint 必须同时提到 /v1 和 404
- TestImageEndpointComposesUnderRealConditions 用真实形态验证:
相对路径拼出 /v1/images/generations 得 200,默认路径拼出 /v1/v1/… 得 404
195 lines
7.0 KiB
Go
195 lines
7.0 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/provider"
|
|
)
|
|
|
|
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
|
|
// started this: the chat page listed image models beside chat ones, and picking
|
|
// one posted to /api/chat, which cannot serve it.
|
|
//
|
|
// The contract is asserted on the HTML source rather than by running a browser,
|
|
// because the failure this must catch is a source-level one — an <option>
|
|
// without `disabled`. A DOM assertion in a headless browser would pass right
|
|
// up until someone removed the attribute, and then only if a CI machine had the
|
|
// whole stack up.
|
|
func TestUI_ChatPageGroupsImageModels(t *testing.T) {
|
|
ui := uiSource(t)
|
|
|
|
// The dropdown must read model_kinds, not the flat models list. Reading the
|
|
// flat list is what made chat and image models indistinguishable.
|
|
if !strings.Contains(ui, "model_kinds") {
|
|
t.Error("chat page never reads model_kinds; image models will be " +
|
|
"indistinguishable from chat models again")
|
|
}
|
|
|
|
// Image options must be rendered disabled. A plain String.replace over the
|
|
// joined HTML only disables the FIRST match — that bug shipped once here
|
|
// (one source disabled, five selectable) — so the assertion looks for the
|
|
// per-item map, not for a global replace.
|
|
if strings.Contains(ui, `imgOpts.join("").replace`) {
|
|
t.Error("chat page disables image options with String.replace over the " +
|
|
"joined HTML, which only rewrites the first option")
|
|
}
|
|
if !strings.Contains(ui, "disabled") {
|
|
t.Error("chat page never emits a disabled attribute for image models")
|
|
}
|
|
}
|
|
|
|
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
|
|
// need somewhere they CAN be tested.
|
|
func TestUI_HasDedicatedImagePage(t *testing.T) {
|
|
ui := uiSource(t)
|
|
|
|
for _, want := range []string{
|
|
`data-tab="image"`, // sidebar entry
|
|
`id="tab-image"`, // pane
|
|
`function renderImage`, // renderer registered with refresh()
|
|
`tab === "image"`, // dispatched
|
|
`/v1/images/generations`, // the endpoint the page actually calls
|
|
} {
|
|
if !strings.Contains(ui, want) {
|
|
t.Errorf("image page is missing %q", want)
|
|
}
|
|
}
|
|
|
|
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
|
|
// visibility, so a pane outside that list is present but permanently hidden —
|
|
// which reads as "the page is blank" because the content is in the DOM.
|
|
if !strings.Contains(ui, `"image"`) {
|
|
t.Error(`"image" does not appear in the TABS list`)
|
|
}
|
|
}
|
|
|
|
// TestUI_ImagePageCallsTheImageEndpoint is the discriminator: a page that
|
|
// renders an image result but posts to the chat endpoint would produce exactly
|
|
// the original failure, just prettier.
|
|
func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) {
|
|
ui := uiSource(t)
|
|
|
|
i := strings.Index(ui, "async function sendImage(")
|
|
if i < 0 {
|
|
t.Fatal("sendImage() not found")
|
|
}
|
|
// Bound the search to the function body rather than the whole file, so a
|
|
// /v1/images/generations mention elsewhere cannot satisfy this.
|
|
body := ui[i:]
|
|
if end := strings.Index(body, "\n function "); end > 0 {
|
|
body = body[:end]
|
|
}
|
|
if end := strings.Index(body, "\n async function "); end > 0 {
|
|
if s := body[:end]; strings.Contains(s, "/v1/images/generations") {
|
|
body = s
|
|
} else {
|
|
body = body[:end]
|
|
}
|
|
}
|
|
if !strings.Contains(body, "/v1/images/generations") {
|
|
t.Error("sendImage() does not post to /v1/images/generations")
|
|
}
|
|
}
|
|
|
|
// TestSourceStatus_ExposesModelKindsToTheUI is the server half of the same
|
|
// contract: the field the UI reads must actually be serialized.
|
|
func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) {
|
|
st := provider.SourceStatus{
|
|
Name: "vela",
|
|
Models: []string{"claude-sonnet-5.5", "gpt-image-2"},
|
|
ModelKinds: []provider.ModelKindInfo{
|
|
{ID: "claude-sonnet-5.5", Kind: "chat"},
|
|
{ID: "gpt-image-2", Kind: "image"},
|
|
},
|
|
}
|
|
b, err := json.Marshal(st)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got map[string]interface{}
|
|
if err := json.Unmarshal(b, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mk, ok := got["model_kinds"].([]interface{})
|
|
if !ok || len(mk) != 2 {
|
|
t.Fatalf("model_kinds = %v, want 2 entries; the UI cannot split the list without it", got["model_kinds"])
|
|
}
|
|
last := mk[1].(map[string]interface{})
|
|
if last["kind"] != "image" {
|
|
t.Errorf("model_kinds[1].kind = %v, want \"image\"", last["kind"])
|
|
}
|
|
}
|
|
|
|
// TestUI_BaseURLFieldExplainsWhereToCut keeps the second bug fixed: the URL
|
|
// hint has to mention the concrete failure, not just say "enter the URL".
|
|
func TestUI_BaseURLFieldExplainsWhereToCut(t *testing.T) {
|
|
ui := uiSource(t)
|
|
if !strings.Contains(ui, "urlHint") {
|
|
t.Fatal("no urlHint; the base_url field gives no guidance about /v1")
|
|
}
|
|
hint := ui[strings.Index(ui, "urlHint:"):]
|
|
if len(hint) > 700 {
|
|
hint = hint[:700]
|
|
}
|
|
// The trap is specific and reproducible: base_url ending in /v1 makes chat
|
|
// work and image generation 404. Saying only "enter the URL" reproduces the
|
|
// confusion the hint exists to remove.
|
|
for _, want := range []string{"/v1", "404"} {
|
|
if !strings.Contains(hint, want) {
|
|
t.Errorf("urlHint does not mention %q — the hint must name the concrete failure", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestImageEndpointComposesUnderRealConditions proves the hint describes a
|
|
// reachable path rather than a plausible-sounding one.
|
|
//
|
|
// The case that matters: base_url already ends in /v1 (which is what an
|
|
// operator pastes from an SDK example), so the RELATIVE image endpoint composes
|
|
// to …/v1/images/generations and works, while the gateway's default ABSOLUTE
|
|
// path would produce …/v1/v1/… and 404.
|
|
//
|
|
// The doubled form is served as a 404 here so the test would fail if the code
|
|
// ever reverted to relying on the default.
|
|
func TestImageEndpointComposesUnderRealConditions(t *testing.T) {
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/images/generations":
|
|
_, _ = w.Write([]byte(`{"data":[{"url":"http://example.invalid/x.png"}]}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer up.Close()
|
|
|
|
// base_url as an operator types it: host root + /v1.
|
|
base := up.URL + "/v1"
|
|
|
|
// What discovery writes when base carries a version segment.
|
|
relative := base + "/images/generations"
|
|
resp, err := http.Post(relative, "application/json", strings.NewReader(`{"model":"m"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("POST %s = %d, want 200 — the form discovery writes must work", relative, resp.StatusCode)
|
|
}
|
|
|
|
// What the gateway's built-in default would produce for the same base_url.
|
|
doubled := base + "/v1/images/generations"
|
|
resp2, err := http.Post(doubled, "application/json", strings.NewReader(`{"model":"m"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusNotFound {
|
|
t.Errorf("POST %s = %d, want 404 — this test's premise is that the "+
|
|
"default image path doubles the version segment; if the upstream ever "+
|
|
"accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode)
|
|
}
|
|
} |