mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47: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
292 lines
8.6 KiB
Go
292 lines
8.6 KiB
Go
// Package provider also provides the top-level registry that owns all sources,
|
|
// routes model requests (explicit or AUTO), and supports hot reload.
|
|
package provider
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Registry holds all configured providers and routes model requests.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
providers []*Provider
|
|
byModel map[string][]*Provider // modelID -> providers (one per source)
|
|
defaultM string // default model id ("" means AUTO)
|
|
}
|
|
|
|
func NewRegistry(providers []*Provider, defaultModel string) *Registry {
|
|
r := &Registry{byModel: map[string][]*Provider{}, defaultM: defaultModel}
|
|
r.set(providers)
|
|
return r
|
|
}
|
|
|
|
// Replace atomically swaps the provider set (hot reload).
|
|
func (r *Registry) Replace(providers []*Provider) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.set(providers)
|
|
}
|
|
|
|
func (r *Registry) set(providers []*Provider) {
|
|
r.providers = providers
|
|
m := map[string][]*Provider{}
|
|
for _, p := range providers {
|
|
for _, model := range p.Models() {
|
|
key := strings.ToLower(model)
|
|
m[key] = append(m[key], p)
|
|
}
|
|
}
|
|
r.byModel = m
|
|
}
|
|
|
|
func (r *Registry) Providers() []*Provider {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]*Provider, len(r.providers))
|
|
copy(out, r.providers)
|
|
return out
|
|
}
|
|
|
|
func (r *Registry) Count() int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return len(r.providers)
|
|
}
|
|
|
|
// ModelList returns all exposed model ids (chat + image).
|
|
func (r *Registry) ModelList() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
seen := map[string]bool{}
|
|
for _, p := range r.providers {
|
|
for _, m := range p.Models() {
|
|
if !seen[m] {
|
|
seen[m] = true
|
|
}
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for m := range seen {
|
|
out = append(out, m)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Resolve returns the provider (or providers) serving a requested model,
|
|
// owning no AUTO scheduling logic anymore: AUTO chat scheduling is driven by
|
|
// the scheduler chain built from the runtime rules (see core/SaveAutoRules
|
|
// and scheduler.Chain).
|
|
//
|
|
// model "" or "AUTO" -> every provider in configured order. Used only by the
|
|
// image path (which then filters to image-capable sources) and tool-call
|
|
// anchoring; chat AUTO requests go through the chain instead.
|
|
// Otherwise the owning provider; "source-model"/"source:model"/"source/model"
|
|
// pinning resolves first; an unknown model resolves to nil (gateway answers
|
|
// 404) instead of silently falling back to the AUTO chain.
|
|
func (r *Registry) Resolve(model string) []*Provider {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
model = strings.TrimSpace(model)
|
|
if model == "" || strings.EqualFold(model, "AUTO") {
|
|
out := make([]*Provider, len(r.providers))
|
|
copy(out, r.providers)
|
|
return out
|
|
}
|
|
|
|
// "source-model" / "source:model" / "source/model" pinning — disambiguates
|
|
// duplicate model ids across sources.
|
|
if p := r.ResolvePinned(model); p != nil {
|
|
return []*Provider{p}
|
|
}
|
|
// explicit model — may be served by multiple sources
|
|
if ps, ok := r.byModel[strings.ToLower(model)]; ok {
|
|
out := make([]*Provider, len(ps))
|
|
copy(out, ps)
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EffectiveModel strips a "source-model" / "source:model" / "source/model"
|
|
// pinning prefix and returns the bare model id when that source serves it;
|
|
// otherwise it returns the input unchanged.
|
|
func (r *Registry) EffectiveModel(model string) string {
|
|
sep := strings.IndexAny(model, "-:/")
|
|
if sep < 1 || sep == len(model)-1 {
|
|
return model
|
|
}
|
|
src, m := model[:sep], model[sep+1:]
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
for _, p := range r.providers {
|
|
if strings.EqualFold(p.Name(), src) && p.ModelByID(m) != nil {
|
|
return m
|
|
}
|
|
}
|
|
return model
|
|
}
|
|
|
|
// ResolvePinned resolves "source-model" / "source:model" / "source/model" to
|
|
// the exact source, or nil if the source does not serve that model.
|
|
func (r *Registry) ResolvePinned(model string) *Provider {
|
|
sep := strings.IndexAny(model, "-:/")
|
|
if sep < 1 || sep == len(model)-1 {
|
|
return nil
|
|
}
|
|
src, m := model[:sep], model[sep+1:]
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
for _, p := range r.providers {
|
|
if !strings.EqualFold(p.Name(), src) {
|
|
continue
|
|
}
|
|
if p.ModelByID(m) != nil {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProviderForModel returns the provider owning the model id (nil if unknown).
|
|
// When the same model exists on multiple sources, returns the first (source
|
|
// config order). Callers that need a specific source use ProviderForSlot.
|
|
func (r *Registry) ProviderForModel(model string) *Provider {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
ps, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))]
|
|
if !ok || len(ps) == 0 {
|
|
return nil
|
|
}
|
|
return ps[0]
|
|
}
|
|
|
|
// ProviderForSlot returns the provider for a (model, source) slot. When
|
|
// source is empty it behaves like ProviderForModel (first owner of the model id);
|
|
// when source is set it returns only that exact source (nil if the source
|
|
// does not serve the model).
|
|
func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
|
model = strings.ToLower(strings.TrimSpace(model))
|
|
source = strings.TrimSpace(source)
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if source == "" {
|
|
ps, ok := r.byModel[model]
|
|
if !ok || len(ps) == 0 {
|
|
return nil
|
|
}
|
|
return ps[0]
|
|
}
|
|
for _, p := range r.providers {
|
|
if !strings.EqualFold(p.Name(), source) {
|
|
continue
|
|
}
|
|
for _, m := range p.cfg.Models {
|
|
if strings.EqualFold(m.ID, model) {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
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"`
|
|
LiveAvailable bool `json:"live_available"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastChecked int64 `json:"last_checked,omitempty"`
|
|
FailCount int `json:"fail_count,omitempty"`
|
|
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
|
Permanent bool `json:"permanent,omitempty"`
|
|
// RecentOK / RecentErr count real gateway requests served by this source
|
|
// within the last 300s (drives the status column so a source that is
|
|
// actually serving traffic can never be shown as down just because a
|
|
// probe was rate-limited by the upstream).
|
|
RecentOK int64 `json:"recent_ok,omitempty"`
|
|
RecentErr int64 `json:"recent_err,omitempty"`
|
|
// AvgFirstByteMs is the mean time-to-first-byte (ms) for recently
|
|
// successful requests served by this source (0 when unmeasured).
|
|
AvgFirstByteMs int64 `json:"avg_first_byte_ms,omitempty"`
|
|
// AvgTokPerS is the aggregate completion throughput (tokens/s) for
|
|
// recently successful requests served by this source (0 when no data).
|
|
AvgTokPerS int64 `json:"avg_tok_per_s,omitempty"`
|
|
}
|
|
|
|
// ProbeAll runs a live reachability check for every provider (in parallel).
|
|
func (r *Registry) ProbeAll(ctx context.Context) {
|
|
r.mu.RLock()
|
|
providers := append([]*Provider(nil), r.providers...)
|
|
r.mu.RUnlock()
|
|
var wg sync.WaitGroup
|
|
for _, p := range providers {
|
|
wg.Add(1)
|
|
go func(p *Provider) {
|
|
defer wg.Done()
|
|
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
p.Probe(probeCtx)
|
|
}(p)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func (r *Registry) Status() []SourceStatus {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]SourceStatus, 0, len(r.providers))
|
|
for _, p := range r.providers {
|
|
live, lastErr, lastAt := p.LastProbe()
|
|
fails, until, perm := p.HealthInfo()
|
|
backoffUntil := int64(0)
|
|
if !until.IsZero() {
|
|
backoffUntil = until.Unix()
|
|
}
|
|
s := SourceStatus{
|
|
Name: p.Name(),
|
|
Adapter: p.Adapter(),
|
|
BaseURL: p.Config().BaseURL,
|
|
Models: p.Models(),
|
|
ModelKinds: p.ModelsWithKind(),
|
|
Available: p.Available(),
|
|
Healthy: p.Available(),
|
|
MaxConcurrent: p.MaxConcurrent(),
|
|
LiveAvailable: live,
|
|
LastError: lastErr,
|
|
LastChecked: lastAt,
|
|
FailCount: fails,
|
|
BackoffUntil: backoffUntil,
|
|
Permanent: perm,
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|