feat: 网关路由对齐参考实现 + 用量落库 + e2e 全绿
路由与故障转移(参考 openteam 语义) - channel.Candidates:绑定模型优先(携带 upstream_model 映射), 未绑定模型回退到权重最低的健康备用渠道;新增 Pick 加权随机与 FilterHealthy 内存健康过滤 - gateway.Dispatch:遍历候选渠道,可重试失败(连接错误/429/5xx)自动故障转移, 4xx 透传;不再使用单一 SelectChannel - 修复 gorm default 标签把渠道 weight=0 静默改写为 1 的问题(去掉 default, 权重 0 语义 = 不参与加权选择,仅作备用承接 unbound 流量) - RecordFailure 连续 2 次进入 degraded 快速熔断,健康检查成功或冷却过期后复位 网关功能补全 - /v1/models 返回 DB 中启用的模型列表(替换 TODO 存根) - 请求级 request_id 生成与用量记录接入:流式 SSE 逐块累计 usage、 非流式从响应提取,按模型定价计算成本后经 usage.Recorder 异步落库 - 流式结束检测:chat 的 [DONE]、messages 的 message_stop、responses 的 response.completed,避免 keep-alive 上游发完不关连接导致读阻塞到超时 - ResponsesRequest.input 兼容字符串与条目数组两种客户端写法 测试 - 修复 convert_test 对新 input 形态的断言 - 网关 e2e(/tmp/test_gateway.py + mock upstream)72/72 全部通过,连续 3 次稳定
This commit is contained in:
@@ -2,8 +2,6 @@ package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/store"
|
||||
@@ -40,41 +38,74 @@ func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// SelectChannel selects the best channel for a given model using weighted random selection
|
||||
func (s *Service) SelectChannel(ctx context.Context, modelName string) (*store.Channel, error) {
|
||||
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get channels for model %s: %w", modelName, err)
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
|
||||
}
|
||||
// SelectedRoute 一次路由决策的完整结果:渠道 + 命中的模型绑定。
|
||||
// Binding 可能为 nil(渠道经回退路径选中、无绑定记录)。
|
||||
type SelectedRoute struct {
|
||||
Channel *store.Channel
|
||||
Binding *store.ChannelModelBinding
|
||||
}
|
||||
|
||||
// Filter out unhealthy channels
|
||||
candidates := s.filterHealthy(channels)
|
||||
if len(candidates) == 0 {
|
||||
// If all channels are unhealthy, try the first one anyway
|
||||
candidates = channels[:1]
|
||||
}
|
||||
|
||||
// Weighted random selection
|
||||
totalWeight := 0
|
||||
for _, ch := range candidates {
|
||||
totalWeight += ch.Weight
|
||||
}
|
||||
if totalWeight == 0 {
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
r := rand.Intn(totalWeight)
|
||||
for _, ch := range candidates {
|
||||
r -= ch.Weight
|
||||
if r < 0 {
|
||||
return ch, nil
|
||||
// Candidates 返回可用渠道候选:健康 + 启用。
|
||||
// model 非空时优先取绑定该模型的渠道(携带 upstream_model 映射,权重降序);
|
||||
// 无绑定则回退到未绑定模型路径:按权重升序(闲置渠道优先探活)。
|
||||
func (s *Service) Candidates(model string) []Candidate {
|
||||
if model != "" {
|
||||
var b []store.ChannelModelBinding
|
||||
var modelIDs []uint64
|
||||
s.modelDAO.DB().Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
|
||||
if len(modelIDs) > 0 {
|
||||
s.channelDAO.DB().Where("model_id IN ?", modelIDs).Find(&b)
|
||||
if cands := s.loadBound(b); len(cands) > 0 {
|
||||
return cands
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未绑定模型回退:取优先级最低的空闲健康渠道作为"备用渠道"承接搭车流量
|
||||
// (排序与绑定候选一致:priority ASC, weight DESC, id ASC,取末位)。
|
||||
// weight=0 的渠道不被加权随机选中,但可作为最后备用承接 unbound 流量。
|
||||
var chs []store.Channel
|
||||
s.channelDAO.DB().Where("enabled = ?", true).
|
||||
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||
all := make([]Candidate, 0, len(chs))
|
||||
for i := range chs {
|
||||
all = append(all, Candidate{Channel: &chs[i]})
|
||||
}
|
||||
healthy := s.FilterHealthy(all)
|
||||
if len(healthy) == 0 {
|
||||
return nil
|
||||
}
|
||||
return healthy[len(healthy)-1:]
|
||||
}
|
||||
|
||||
return candidates[0], nil
|
||||
// loadBound 按绑定顺序加载渠道候选,过滤健康/启用,携带 upstream_model 映射。
|
||||
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
// channel_id -> 绑定(取该渠道对该模型的映射)
|
||||
byChannel := map[uint64]store.ChannelModelBinding{}
|
||||
ids := make([]uint64, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
if _, ok := byChannel[b.ChannelID]; !ok {
|
||||
ids = append(ids, b.ChannelID)
|
||||
}
|
||||
byChannel[b.ChannelID] = b
|
||||
}
|
||||
var chs []store.Channel
|
||||
s.channelDAO.DB().Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
|
||||
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||
byID := map[uint64]*store.Channel{}
|
||||
for i := range chs {
|
||||
byID[chs[i].ID] = &chs[i]
|
||||
}
|
||||
out := make([]Candidate, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if ch, ok := byID[id]; ok {
|
||||
b := byChannel[id]
|
||||
out = append(out, Candidate{Channel: ch, Binding: &b})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetChannelByKeyID decrypts the API key for a channel
|
||||
@@ -102,7 +133,9 @@ func (s *Service) RecordSuccess(channelID uint64) {
|
||||
h.lastCheck = time.Now()
|
||||
}
|
||||
|
||||
// RecordFailure records a failed request to a channel
|
||||
// RecordFailure records a failed request to a channel.
|
||||
// 连续 2 次失败进入 degraded(快速熔断):失败过的渠道让位给健康渠道,
|
||||
// 健康检查成功或冷却过期后复位。
|
||||
func (s *Service) RecordFailure(channelID uint64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -111,7 +144,7 @@ func (s *Service) RecordFailure(channelID uint64) {
|
||||
h.consecutive++
|
||||
h.lastCheck = time.Now()
|
||||
|
||||
if h.consecutive >= 3 {
|
||||
if h.consecutive >= 2 {
|
||||
h.status = store.ChannelHealthDegraded
|
||||
h.cooldown = time.Now().Add(5 * time.Minute)
|
||||
}
|
||||
@@ -179,49 +212,62 @@ func (s *Service) GetHealthStatus(channelID uint64) string {
|
||||
return h.status
|
||||
}
|
||||
|
||||
// ChannelCandidate represents a channel with its resolved API key
|
||||
type ChannelCandidate struct {
|
||||
// Candidate 一个候选渠道 + 该模型的映射关系。
|
||||
type Candidate struct {
|
||||
Channel *store.Channel
|
||||
APIKey string
|
||||
Format string
|
||||
Binding *store.ChannelModelBinding // 全局模型在此渠道的映射(无绑定则 nil)
|
||||
}
|
||||
|
||||
// SelectCandidates returns candidates for a model, sorted by priority
|
||||
func (s *Service) SelectCandidates(ctx context.Context, modelName string, preferredFormat string) ([]ChannelCandidate, error) {
|
||||
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Pick 按权重加权随机选一个候选渠道(负载均衡;weight<=0 按 1 计)。
|
||||
func (s *Service) Pick(cands []Candidate) *Candidate {
|
||||
if len(cands) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var candidates []ChannelCandidate
|
||||
for _, ch := range channels {
|
||||
// Check if channel supports the preferred format
|
||||
formats := ch.FormatsEffective()
|
||||
supported := false
|
||||
for _, f := range formats {
|
||||
if f == preferredFormat || preferredFormat == "" {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
total := 0
|
||||
for _, c := range cands {
|
||||
w := c.Channel.Weight
|
||||
if w <= 0 {
|
||||
w = 1
|
||||
}
|
||||
if !supported {
|
||||
total += w
|
||||
}
|
||||
r := rand.Intn(total)
|
||||
acc := 0
|
||||
for i := range cands {
|
||||
w := cands[i].Channel.Weight
|
||||
if w <= 0 {
|
||||
w = 1
|
||||
}
|
||||
acc += w
|
||||
if r < acc {
|
||||
return &cands[i]
|
||||
}
|
||||
}
|
||||
return &cands[len(cands)-1]
|
||||
}
|
||||
|
||||
// FilterHealthy 过滤掉内存健康状态异常的渠道候选(degraded/cooldown 均排除,
|
||||
// 冷却/降级过期后复位放行)。degraded 由单次请求失败触发,作为快速熔断:
|
||||
// 后续请求先走其他渠道,健康检查成功后恢复。
|
||||
func (s *Service) FilterHealthy(cands []Candidate) []Candidate {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Candidate, 0, len(cands))
|
||||
now := time.Now()
|
||||
for _, c := range cands {
|
||||
h, ok := s.healthStatus[c.Channel.ID]
|
||||
if !ok || h.status == store.ChannelHealthHealthy {
|
||||
out = append(out, c)
|
||||
continue
|
||||
}
|
||||
|
||||
apiKey, err := crypto.Decrypt(ch.APIKeyEnc)
|
||||
if err != nil {
|
||||
log.Printf("Failed to decrypt API key for channel %s: %v", ch.Name, err)
|
||||
continue
|
||||
// 冷却/降级已过期:复位并放行
|
||||
if !h.cooldown.IsZero() && now.After(h.cooldown) {
|
||||
h.status = store.ChannelHealthHealthy
|
||||
h.consecutive = 0
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
candidates = append(candidates, ChannelCandidate{
|
||||
Channel: ch,
|
||||
APIKey: apiKey,
|
||||
Format: preferredFormat,
|
||||
})
|
||||
}
|
||||
|
||||
return candidates, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
|
||||
|
||||
Reference in New Issue
Block a user