mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 背景
用户报三问题:① channel 注册的模型无法使用;② 额度显示依旧异常;
③ 其他渠道应也存在类似问题。三条全部核实并给出结论(③ 见文档)。
## ① 网关解不开 `源名:模型名` 限定名(internal/provider/registry.go)
`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
而渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**),于是
`channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 被切成
src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
⇒ 源名永不匹配 ⇒ `Resolve()` 返回 nil
⇒ 客户端看到 `model "..." is not configured`。
**为什么这个写法必须支持**:本实例有 **44 个模型被多源同时提供**
(`glm-5.2` 由 6 个源提供),WebUI 的模型下拉框用 `源名:模型名` 作为
option value 是**有意设计** —— 用户必须能指定"从哪个源调用"。
修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
这是唯一可靠办法),保留旧 `-:/` 兜底以兼容非源名形态。
`EffectiveModel` 同步修(同一拆分规则,避免两处漂移)。
## ② trae 适配器三连错(internal/lua/plugins/channels.lua)
三个错误逐层叠加,每个都被上一层掩盖:
**(a) 没有 `build_headers`** ⇒ 内核兜底发 `Authorization: Bearer`,
而 trae 推理端点要 `Cloud-IDE-JWT` + 设备身份头 ⇒
401「We're sorry, but we are not able to authenticate you」。
→ 与上一轮「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点漏了)。
教训:修"某一族请求的鉴权"时,必须把该族的**所有出口**列出来逐个对齐。
**(b) header 常量的值是错的**(照参考 trae-product.ts 的 TRAE 配置逐项核对):
· `X-App-Id` 抄成 `6eefa6c9`,真实是 `6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`
· `app_version_code` 用了 `0.1.52`,真实是**日期式** `20260811`
⇒ 上游 `4001 bad request: binding: expr_path=app_version_code,
cause=parameter type does not match binding data`
**(c) 流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**:
event:output → data:{"response":"###","reasoning_content":null,...}
正文在 `response`,推理在 `reasoning_content`。
· 套 `__prelude.openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`
· 改成 OpenAI 外壳 `{choices:[{delta}]}` **仍然空** —— 内核
`json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全被丢弃
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。
⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
因此判别帧类型只能靠**内容特征**(`response` / `reasoning_content` /
`finish_reason` / `prompt_tokens`),拿不到 event 名。
## ③ 余额恒为 0(同文件 balance.total_fn)
读的是 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
(参考 trae-credits.ts:341)+ `pack.usage.credits_amount`。
实测上游:`免费`(无该字段) / `每月登录赠送`(500) / `签到奖励`(100) ⇒ 实际 600。
修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
修后余额 600 → 实测 598.516(真实扣减)。
## ④ 夹具与生产形状不一致(internal/core/channels_trae_test.go)
夹具原先返回**扁平** `{"Token":...}`,与生产 `{"Result":{"Token":...}}` 不符
⇒ 「代码只读顶层」这个缺陷在夹具里**恰好是对的**,所有 trae 判据都绿。
**夹具形状错 = 判据必然虚假通过**。这是 ExchangeToken 缺陷藏了两轮的原因。
## 判据(新增,变异 4/4 咬住)
- `internal/provider/registry_pinned_name_test.go`(4 用例)
- `internal/core/channels_trae_chat_headers_test.go`(3 条)
- `internal/core/channels_trae_solo_sse_test.go`(2 条)
- `internal/core/channels_trae_credits_hdr_test.go`(1 条)
- `internal/core/channels_trae_exchange_shape_test.go`(2 条)
## 文档
`docs/channel-repair-plan.md`:现状快照(生产实测)+ 各渠道缺陷矩阵 +
后续计划(P0-P4)。**如实记录**:其余 4 个渠道(zcode/cline/gemini/qoder)
有同类缺陷风险,但**账号数均为 0、无法做生产验证**。
## 生产已实测
- `channels-trae-...:Doubao-Seed-2.1-Pro` → 80 帧,正文 27 + 推理 299
- `channels-trae-...:glm-5.2` / `channels-buddy-...:hy4-preview` → OK
- `credits/status` → active:true, dailyCredit:100, todayCheckedIn:true
- `credits/balances` → total:598.516 credits
347 lines
11 KiB
Go
347 lines
11 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 {
|
||
model = strings.TrimSpace(model)
|
||
if model == "" {
|
||
return model
|
||
}
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
// 与 ResolvePinned 同一个拆分器:源名可含 `-`,不能用第一个分隔符切。
|
||
src, m, ok := splitPinned(model, r.providers)
|
||
if !ok {
|
||
return model
|
||
}
|
||
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 {
|
||
model = strings.TrimSpace(model)
|
||
if model == "" {
|
||
return nil
|
||
}
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
src, m, ok := splitPinned(model, r.providers)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
for _, p := range r.providers {
|
||
if !strings.EqualFold(p.Name(), src) {
|
||
continue
|
||
}
|
||
if p.ModelByID(m) != nil {
|
||
return p
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// splitPinned 把 "源名 + 分隔符 + 模型名" 拆开。
|
||
//
|
||
// ★★ 不能用 `IndexAny(model, "-:/")` 找**第一个**分隔符。
|
||
// 源名本身常常含 `-`(本仓的渠道源是 `channels-<平台>-<账号id>`),
|
||
// 于是 `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 会被切成
|
||
//
|
||
// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
|
||
//
|
||
// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil ⇒ 客户端看到
|
||
// `model "channels-trae-xxx:Doubao-Seed-2.1-Pro" is not configured`。
|
||
//
|
||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||
// WebUI 的模型下拉框用 `源名:模型名` 作为 option value(这是**有意**的:
|
||
// 44 个模型被多个源同时提供,必须能指定从哪个源调用),而网关解不开它。
|
||
//
|
||
// 修法:**按已知源名做最长前缀匹配**,命中的那个源名才是真正的分隔点。
|
||
// 这样既支持含 `-` 的源名,也保留 "src:model" / "src/model" 等旧写法。
|
||
func splitPinned(model string, providers []*Provider) (src, m string, ok bool) {
|
||
// ① 最长源名前缀匹配(唯一可靠的办法:源名是已知集合)
|
||
best := ""
|
||
for _, p := range providers {
|
||
n := p.Name()
|
||
if n == "" || len(n) >= len(model) {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(model), strings.ToLower(n)) {
|
||
continue
|
||
}
|
||
// 源名之后必须紧跟一个分隔符
|
||
if c := model[len(n)]; c != ':' && c != '/' && c != '-' {
|
||
continue
|
||
}
|
||
if len(n) > len(best) {
|
||
best = n
|
||
}
|
||
}
|
||
if best != "" {
|
||
return best, model[len(best)+1:], true
|
||
}
|
||
// ② 兜底:旧行为(第一个分隔符)。仅当没有任何源名匹配时才走到这里,
|
||
// 保持对非源名形态(例如第三方模型名自带 ":" 的)的兼容。
|
||
sep := strings.IndexAny(model, "-:/")
|
||
if sep < 1 || sep == len(model)-1 {
|
||
return "", "", false
|
||
}
|
||
return model[:sep], model[sep+1:], true
|
||
}
|
||
|
||
// 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
|
||
}
|