package service import ( "context" "errors" "io" "math/rand" "time" "gorm.io/gorm" "oci-portal/internal/aiwire" "oci-portal/internal/model" "oci-portal/internal/oci" ) // ChatMeta 是一次网关调用的路由结果,供 API 层写调用日志。 type ChatMeta struct { ChannelID uint ChannelName string Retries int } // aiCandidate 是某模型的一个可用渠道及其区域内模型 OCID。 type aiCandidate struct { ch model.AiChannel modelOcid string } // RespPassthrough 编排一次非流式直通调用:选渠道(priority→加权随机)→ 调用 → // 可重试错误换渠道(整请求上限 3 次)并维护熔断;group 非空时只在同分组渠道内路由。 // 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。 func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) { meta := ChatMeta{} excluded := map[uint]bool{} var lastErr error for attempt := 0; attempt < 3; attempt++ { cand, err := s.pick(ctx, modelName, group, "CHAT", excluded) if err != nil { return nil, meta, firstErr(lastErr, err) } meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name payload, err := s.passthroughOnce(ctx, cand, raw) if err == nil { s.markSuccess(ctx, cand.ch.ID) return payload, meta, nil } retry, penalize := switchable(err) if !retry { return nil, meta, err } if penalize { s.markFailure(ctx, cand.ch.ID) } excluded[cand.ch.ID] = true meta.Retries++ lastErr = err } return nil, meta, lastErr } func (s *AiGatewayService) passthroughOnce(ctx context.Context, cand *aiCandidate, raw []byte) ([]byte, error) { cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID) if err != nil { return nil, err } return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw) } // RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable // 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)。 func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) { meta := ChatMeta{} excluded := map[uint]bool{} var lastErr error for attempt := 0; attempt < 3; attempt++ { cand, err := s.pick(ctx, modelName, group, "CHAT", excluded) if err != nil { return nil, meta, firstErr(lastErr, err) } meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID) if err != nil { return nil, meta, err } stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw) if err == nil { s.markSuccess(ctx, cand.ch.ID) return stream, meta, nil } retry, penalize := switchable(err) if !retry { return nil, meta, err } if penalize { s.markFailure(ctx, cand.ch.ID) } excluded[cand.ch.ID] = true meta.Retries++ lastErr = err } return nil, meta, lastErr } // firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。 func firstErr(lastErr, pickErr error) error { if lastErr != nil && errors.Is(pickErr, ErrAiNoChannel) { return lastErr } return pickErr } // switchable 判断调用失败是否换渠道重试、是否计入熔断:模型级不可用(微调基座 // 400 / 实体不存在 404)换渠道但不计熔断——这是模型×区域供给问题而非渠道健康 // 问题,持久解决靠加入模型黑名单;429 / 5xx / 网络错误换渠道并计熔断;其余 4xx 直接透传。 func switchable(err error) (retry, penalize bool) { if oci.IsModelUnavailable(err) { return true, false } if status, ok := oci.ServiceStatus(err); ok { return status == 429 || status >= 500, true } return true, true } // pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。 func (s *AiGatewayService) pick(ctx context.Context, modelName, group, capability string, excluded map[uint]bool) (*aiCandidate, error) { ocids, channelIDs, err := s.modelChannels(ctx, modelName, capability) if err != nil { return nil, err } if len(channelIDs) == 0 { return nil, ErrAiUnknownModel } q := s.db.WithContext(ctx).Where("id IN ? AND enabled = ?", channelIDs, true) if group != "" { q = q.Where("channel_group = ?", group) } var channels []model.AiChannel if err := q.Find(&channels).Error; err != nil { return nil, err } now := time.Now() avail := channels[:0] for _, ch := range channels { if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) { continue } avail = append(avail, ch) } if len(avail) == 0 { return nil, ErrAiNoChannel } chosen := weightedPick(topPriority(avail)) return &aiCandidate{ch: chosen, modelOcid: ocids[chosen.ID]}, nil } // modelChannels 查出提供该模型的渠道 ID 及各自的模型 OCID; // capability=CHAT 时兼容存量空串(加列前只同步对话模型)。 func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capability string) (map[uint]string, []uint, error) { q := s.db.WithContext(ctx).Where("name = ?", modelName) if capability == "CHAT" { q = q.Where("capability IN ?", []string{"CHAT", ""}) } else { q = q.Where("capability = ?", capability) } if s.FilterDeprecated() { q = q.Where("deprecated_at IS NULL") } var rows []model.AiModelCache if err := q.Find(&rows).Error; err != nil { return nil, nil, err } ocids := make(map[uint]string, len(rows)) ids := make([]uint, 0, len(rows)) for _, r := range rows { ocids[r.ChannelID] = r.ModelOcid ids = append(ids, r.ChannelID) } return ocids, ids, nil } // topPriority 保留最小 Priority 值的渠道组。 func topPriority(chs []model.AiChannel) []model.AiChannel { best := chs[0].Priority for _, ch := range chs[1:] { if ch.Priority < best { best = ch.Priority } } out := chs[:0] for _, ch := range chs { if ch.Priority == best { out = append(out, ch) } } return out } // weightedPick 组内按权重随机;权重全部非正时等权。 func weightedPick(chs []model.AiChannel) model.AiChannel { total := 0 for _, ch := range chs { if ch.Weight > 0 { total += ch.Weight } } if total <= 0 { return chs[rand.Intn(len(chs))] } r := rand.Intn(total) for _, ch := range chs { if ch.Weight <= 0 { continue } r -= ch.Weight if r < 0 { return ch } } return chs[len(chs)-1] } // Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道(整请求上限 3 次)。 func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) { meta := ChatMeta{} excluded := map[uint]bool{} var lastErr error for attempt := 0; attempt < 3; attempt++ { cand, err := s.pick(ctx, req.Model, group, "EMBEDDING", excluded) if err != nil { return nil, meta, firstErr(lastErr, err) } meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name resp, err := s.embedOnce(ctx, cand, req) if err == nil { s.markSuccess(ctx, cand.ch.ID) return resp, meta, nil } retry, penalize := switchable(err) if !retry { return nil, meta, err } if penalize { s.markFailure(ctx, cand.ch.ID) } excluded[cand.ch.ID] = true meta.Retries++ lastErr = err } return nil, meta, lastErr } // embedOnce 调用渠道向量化并装配 OpenAI 形态响应。 func (s *AiGatewayService) embedOnce(ctx context.Context, cand *aiCandidate, req aiwire.EmbeddingsRequest) (*aiwire.EmbeddingsResponse, error) { cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID) if err != nil { return nil, err } vecs, usage, err := s.client.GenAiEmbed(ctx, cred, cand.ch.Region, cand.modelOcid, req.Input, req.Dimensions) if err != nil { return nil, err } out := &aiwire.EmbeddingsResponse{Object: "list", Model: req.Model, Data: make([]aiwire.Embedding, 0, len(vecs))} for i, v := range vecs { out.Data = append(out.Data, aiwire.Embedding{Object: "embedding", Index: i, Embedding: v}) } if usage != nil { out.Usage = &aiwire.EmbedUsage{PromptTokens: usage.PromptTokens, TotalTokens: usage.TotalTokens} } return out, nil } // markSuccess 复位失败计数与熔断窗口(无异常状态时零写库)。 func (s *AiGatewayService) markSuccess(ctx context.Context, id uint) { s.db.WithContext(ctx).Model(&model.AiChannel{}). Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id). Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")}) } // markFailure 递增失败计数;达到阈值后按 2^(超出次数) 分钟指数退避,封顶 30 分钟。 func (s *AiGatewayService) markFailure(ctx context.Context, id uint) { s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id). Update("fail_count", gorm.Expr("fail_count + 1")) var ch model.AiChannel if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil { return } if ch.FailCount < aiFailThreshold { return } n := ch.FailCount - aiFailThreshold if n > 10 { n = 10 } backoff := time.Duration(1< aiBackoffCap { backoff = aiBackoffCap } until := time.Now().Add(backoff) s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Update("disabled_until", until) }