Files
oci-portal/internal/service/aigateway_chat.go
T

284 lines
8.5 KiB
Go

package service
import (
"context"
"errors"
"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
}
// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。
// group 非空时只在同分组渠道内路由(取自调用密钥)。
func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
resp, err := s.callOnce(ctx, cand, ir)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil
}
if !retryable(err) {
return nil, meta, err
}
s.markFailure(ctx, cand.ch.ID)
excluded[cand.ch.ID] = true
meta.Retries++
lastErr = err
}
return nil, meta, lastErr
}
func (s *AiGatewayService) callOnce(ctx context.Context, cand *aiCandidate, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, err
}
return s.client.GenAiChat(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
}
// OpenStream 编排流式调用:流建立成功后即绑定渠道,建立失败可换渠道重试。
// group 语义与 Chat 相同。
func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest, group string) (oci.GenAiStream, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, ir.Model, 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.GenAiChatStream(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return stream, meta, nil
}
if !retryable(err) {
return nil, meta, err
}
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
}
// retryable 判定是否换渠道重试:429 / 5xx / 网络错误可重试,其余 4xx 直接透传。
func retryable(err error) bool {
if status, ok := oci.ServiceStatus(err); ok {
return status == 429 || status >= 500
}
return 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)
}
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
}
if !retryable(err) {
return nil, meta, err
}
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<<uint(n)) * time.Minute
if backoff > aiBackoffCap {
backoff = aiBackoffCap
}
until := time.Now().Add(backoff)
s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Update("disabled_until", until)
}