mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(ui): 生图二级页面 + 图像模型不再混进对话页
## ① 图像模型曾与对话模型不可区分
根因是结构性的,不是漏了个过滤:
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
This commit is contained in:
107
internal/provider/model_kind_test.go
Normal file
107
internal/provider/model_kind_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// TestSourceStatus_CarriesModelKind is the fix for a UI bug where the chat
|
||||
// test page listed image models next to chat ones.
|
||||
//
|
||||
// The root cause was structural, not a missing filter: SourceStatus.Models was
|
||||
// a flat []string, so by the time the UI had the list there was nothing left to
|
||||
// tell "gpt-image-2" from "claude-sonnet-5.5". Picking the former sent the
|
||||
// request to /api/chat, which cannot serve it, and the failure said nothing
|
||||
// about the real reason.
|
||||
//
|
||||
// Models stays a []string for callers that only need names; ModelKinds is the
|
||||
// additive field that carries the missing dimension.
|
||||
func TestSourceStatus_CarriesModelKind(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "vela",
|
||||
BaseURL: "https://example.invalid",
|
||||
Adapter: "openai",
|
||||
APIKey: "sk-test",
|
||||
Endpoint: "/chat/completions",
|
||||
Models: []config.Model{
|
||||
{ID: "claude-sonnet-5.5"}, // kind omitted -> chat default
|
||||
{ID: "gpt-image-2", Kind: "image"}, // image
|
||||
{ID: "seedream-5.0", Kind: "image"},
|
||||
},
|
||||
}
|
||||
p := newTestProvider(t, src)
|
||||
|
||||
kinds := p.ModelsWithKind()
|
||||
if len(kinds) != 3 {
|
||||
t.Fatalf("want 3 entries, got %d", len(kinds))
|
||||
}
|
||||
|
||||
// Order must mirror Models() so the UI can zip the two lists.
|
||||
names := p.Models()
|
||||
for i := range kinds {
|
||||
if kinds[i].ID != names[i] {
|
||||
t.Errorf("index %d: ModelKinds has %q but Models has %q — the lists must stay aligned",
|
||||
i, kinds[i].ID, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"claude-sonnet-5.5": "chat",
|
||||
"gpt-image-2": "image",
|
||||
"seedream-5.0": "image",
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if got := want[k.ID]; k.Kind != got {
|
||||
t.Errorf("%s: kind = %q, want %q", k.ID, k.Kind, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelKinds_CountsImageModelsInARealCatalog: the number of image models is
|
||||
// what decides whether the UI needs the split at all, so assert against a
|
||||
// catalog with both kinds rather than a hand-picked pair.
|
||||
func TestModelKinds_CountsImageModelsInARealCatalog(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "vela", BaseURL: "https://example.invalid", Adapter: "openai", APIKey: "k",
|
||||
Models: []config.Model{
|
||||
{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"},
|
||||
{ID: "img1", Kind: "image"}, {ID: "img2", Kind: "image"},
|
||||
},
|
||||
}
|
||||
p := newTestProvider(t, src)
|
||||
var images int
|
||||
for _, k := range p.ModelsWithKind() {
|
||||
if k.Kind == "image" {
|
||||
images++
|
||||
}
|
||||
}
|
||||
if images != 2 {
|
||||
t.Errorf("image models = %d, want 2", images)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatusJSON_ModelKindsOmittedWhenEmpty pins the backward-compat
|
||||
// promise: an older UI parsing this JSON must not break on a new field, and a
|
||||
// source with no model list must not gain a "model_kinds": null.
|
||||
func TestSourceStatusJSON_ModelKindsOmittedWhenEmpty(t *testing.T) {
|
||||
b, err := json.Marshal(SourceStatus{Name: "x", Models: []string{"a"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := m["model_kinds"]; present {
|
||||
t.Errorf("model_kinds present when empty: %s — should be omitted for older UIs", b)
|
||||
}
|
||||
if _, present := m["models"]; !present {
|
||||
t.Errorf("models field disappeared: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestProvider (defined in provider_test.go) spins up a real Lua VM, which
|
||||
// is heavier than these tests need, but reusing it keeps one construction path
|
||||
// for the whole package.
|
||||
@ -451,6 +451,25 @@ func (p *Provider) Models() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ModelsWithKind returns the same ids as Models but paired with each model's
|
||||
// kind, in the same order.
|
||||
//
|
||||
// Models() cannot answer "which of these are image models", and that question
|
||||
// has to be answerable: the chat test page lists models from one source list,
|
||||
// and an image model offered there fails on every call because /api/chat is the
|
||||
// chat endpoint.
|
||||
func (p *Provider) ModelsWithKind() []ModelKindInfo {
|
||||
out := make([]ModelKindInfo, 0, len(p.cfg.Models))
|
||||
for _, m := range p.cfg.Models {
|
||||
kind := m.Kind
|
||||
if kind == "" {
|
||||
kind = "chat" // config.Model documents "" as the chat default
|
||||
}
|
||||
out = append(out, ModelKindInfo{ID: m.ID, Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ModelByID returns the model definition if owned by this source.
|
||||
func (p *Provider) ModelByID(id string) *config.Model {
|
||||
for i := range p.cfg.Models {
|
||||
|
||||
@ -196,12 +196,27 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModelKindInfo carries the per-model fields the chat test UI needs to tell a
|
||||
// chat model from an image one.
|
||||
type ModelKindInfo struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "chat" (default) | "image"
|
||||
}
|
||||
|
||||
// ModelStatus is a web-UI friendly snapshot per source.
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
// ModelKinds parallels Models: Models[i] is ModelKinds[i].ID.
|
||||
//
|
||||
// It exists because a flat []string cannot answer "is gpt-image-2
|
||||
// reachable from /api/chat?" — it cannot, and offering it there produced
|
||||
// a guaranteed failure with no explanation. Models stays unchanged for
|
||||
// callers that only need names; this is purely additive and omitted when
|
||||
// empty, so an older UI keeps working.
|
||||
ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
@ -259,6 +274,7 @@ func (r *Registry) Status() []SourceStatus {
|
||||
Adapter: p.Adapter(),
|
||||
BaseURL: p.Config().BaseURL,
|
||||
Models: p.Models(),
|
||||
ModelKinds: p.ModelsWithKind(),
|
||||
Available: p.Available(),
|
||||
Healthy: p.Available(),
|
||||
MaxConcurrent: p.MaxConcurrent(),
|
||||
|
||||
Reference in New Issue
Block a user