Files
ModelRouter/internal/provider/registry.go
JianFeeeee fc5326402f fix(channels,provider): 渠道模型不可用 + 额度异常的三个根因
## 背景
用户报三问题:① 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
2026-10-17 17:38:58 +08:00

347 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}