303 lines
9.2 KiB
Go
303 lines
9.2 KiB
Go
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, s.UpstreamWait())
|
|
}
|
|
|
|
// 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, s.UpstreamWait())
|
|
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<<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)
|
|
}
|