AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
This commit is contained in:
+147
-177
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -30,10 +31,6 @@ const (
|
||||
aiBackoffCap = 30 * time.Minute
|
||||
// aiKeyTouchGap 是 LastUsedAt 的最小写库间隔,避免高频调用刷库
|
||||
aiKeyTouchGap = time.Minute
|
||||
// modelRecheckGap 是已标记不可用模型的复检间隔(恢复供给自动解除标记);
|
||||
// validateBatchCap 限制单渠道单轮验证的试调次数
|
||||
modelRecheckGap = 20 * time.Hour
|
||||
validateBatchCap = 32
|
||||
// 内容日志(红线例外)约束:开启必须限时(上限 7 天),正文截断,短保留
|
||||
aiContentLogMaxHours = 168
|
||||
aiContentLogRetention = 7 * 24 * time.Hour
|
||||
@@ -59,17 +56,13 @@ type AiGatewayService struct {
|
||||
// touchMu 保护各密钥的最近触达时间(内存节流,不追求跨实例精确)
|
||||
touchMu sync.Mutex
|
||||
lastTouch map[uint]time.Time
|
||||
// validateMu 保护 validating:同一渠道的模型验证不并发
|
||||
validateMu sync.Mutex
|
||||
validating map[uint]bool
|
||||
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
||||
onChannelsChanged func(context.Context)
|
||||
}
|
||||
|
||||
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
||||
return &AiGatewayService{db: db, configs: configs, client: client,
|
||||
lastTouch: map[uint]time.Time{}, validating: map[uint]bool{}}
|
||||
return &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||
}
|
||||
|
||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||
@@ -87,7 +80,7 @@ func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) {
|
||||
|
||||
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
|
||||
// group 非空时该密钥只在同分组渠道内路由。
|
||||
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string) (string, *model.AiKey, error) {
|
||||
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string, models []string) (string, *model.AiKey, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", nil, fmt.Errorf("密钥名称不能为空")
|
||||
@@ -103,13 +96,28 @@ func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, gro
|
||||
if len(raw) < 8 {
|
||||
return "", nil, fmt.Errorf("自定义密钥至少 8 个字符")
|
||||
}
|
||||
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Enabled: true}
|
||||
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Models: normalizeKeyModels(models), Enabled: true}
|
||||
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
|
||||
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
|
||||
}
|
||||
return raw, key, nil
|
||||
}
|
||||
|
||||
// normalizeKeyModels 规范化模型白名单:trim、剔空串、保序去重;结果为空返回 nil(= 不限)。
|
||||
func normalizeKeyModels(models []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range models {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" || seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hashKey(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
@@ -122,8 +130,8 @@ func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) {
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。
|
||||
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error {
|
||||
// UpdateKey 修改密钥名称 / 启用状态 / 分组 / 模型白名单(指针非空即覆盖,可置空)。
|
||||
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string, models *[]string) error {
|
||||
updates := map[string]any{}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
updates["name"] = name
|
||||
@@ -134,6 +142,14 @@ func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string,
|
||||
if group != nil {
|
||||
updates["key_group"] = strings.TrimSpace(*group)
|
||||
}
|
||||
if models != nil {
|
||||
// 手动序列化走 map 更新,不依赖 GORM map 路径对 serializer 的支持
|
||||
b, err := json.Marshal(normalizeKeyModels(*models))
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize models: %w", err)
|
||||
}
|
||||
updates["models"] = string(b)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -298,12 +314,17 @@ func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.Ai
|
||||
return &fresh, nil
|
||||
}
|
||||
|
||||
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步(保留不可用标记)。
|
||||
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步(黑名单模型不入库)。
|
||||
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
|
||||
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
|
||||
if err != nil {
|
||||
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
|
||||
}
|
||||
models = supportedGatewayModels(models)
|
||||
models, err = s.withoutBlacklisted(ctx, models)
|
||||
if err != nil {
|
||||
return "error", truncateErr(err.Error())
|
||||
}
|
||||
if len(models) == 0 {
|
||||
_ = s.replaceModels(ctx, ch.ID, nil)
|
||||
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
|
||||
@@ -311,31 +332,12 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *
|
||||
if err := s.replaceModels(ctx, ch.ID, models); err != nil {
|
||||
return "error", truncateErr(err.Error())
|
||||
}
|
||||
usable, err := s.usableModels(ctx, ch.ID, models)
|
||||
if err != nil {
|
||||
return "error", truncateErr(err.Error())
|
||||
}
|
||||
return s.probeChat(ctx, cred, ch, usable)
|
||||
return s.probeChat(ctx, cred, ch, models)
|
||||
}
|
||||
|
||||
// usableModels 过滤掉缓存中已标记不可按需调用的模型,探测候选不再反复踩坑。
|
||||
func (s *AiGatewayService) usableModels(ctx context.Context, channelID uint, models []oci.GenAiModel) ([]oci.GenAiModel, error) {
|
||||
marks, err := s.loadModelMarks(ctx, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]oci.GenAiModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !marks[m.Ocid].Unusable {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」标记剔除并换下一个;
|
||||
// 401/403 与鉴权类 404 属租户级直接定论 no_quota;其余错误(部分模型元数据标 CHAT
|
||||
// 但实际不可对话,如 voice agent)累计 3 次止损。
|
||||
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
|
||||
// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404
|
||||
// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。
|
||||
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
||||
status, detail := "error", "无可试调对话模型"
|
||||
errBudget := 3
|
||||
@@ -345,8 +347,7 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
case code == 200 || code == 429:
|
||||
return "ok", ""
|
||||
case oci.IsModelUnavailable(err):
|
||||
s.markModelUnusable(ctx, ch.ID, m.Ocid, oci.CompactError(err))
|
||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,已从模型池剔除", m.Name))
|
||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
||||
case code == 401 || code == 403 || code == 404:
|
||||
return "no_quota", truncateErr(oci.CompactError(err))
|
||||
default:
|
||||
@@ -359,106 +360,8 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
return status, detail
|
||||
}
|
||||
|
||||
// markModelUnusable 把 (渠道, 模型OCID) 标记为不可按需调用;失败仅记日志不阻断主流程。
|
||||
func (s *AiGatewayService) markModelUnusable(ctx context.Context, channelID uint, ocid, reason string) {
|
||||
err := s.db.WithContext(ctx).Model(&model.AiModelCache{}).
|
||||
Where("channel_id = ? AND model_ocid = ?", channelID, ocid).
|
||||
Updates(map[string]any{"unusable": true, "unusable_reason": shortReason(reason), "checked_at": time.Now()}).Error
|
||||
if err != nil {
|
||||
log.Printf("mark model unusable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func shortReason(reason string) string {
|
||||
if len(reason) > 200 {
|
||||
return reason[:200]
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// beginValidate 抢占渠道的验证执行权,同渠道同时只跑一轮。
|
||||
func (s *AiGatewayService) beginValidate(id uint) bool {
|
||||
s.validateMu.Lock()
|
||||
defer s.validateMu.Unlock()
|
||||
if s.validating[id] {
|
||||
return false
|
||||
}
|
||||
s.validating[id] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *AiGatewayService) endValidate(id uint) {
|
||||
s.validateMu.Lock()
|
||||
delete(s.validating, id)
|
||||
s.validateMu.Unlock()
|
||||
}
|
||||
|
||||
// validateTargets 取待验证行:从未验证的新模型,以及标记超过复检间隔的模型(仅对话能力,
|
||||
// 兼容存量空串);单轮上限 validateBatchCap 控制调用量。
|
||||
func (s *AiGatewayService) validateTargets(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
|
||||
stale := time.Now().Add(-modelRecheckGap)
|
||||
var rows []model.AiModelCache
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("channel_id = ? AND capability IN ?", channelID, []string{"CHAT", ""}).
|
||||
Where("checked_at IS NULL OR (unusable = ? AND checked_at < ?)", true, stale).
|
||||
Order("id ASC").Limit(validateBatchCap).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// validateChannelModels 逐个试调渠道内待验证模型并落结论,把不可按需调用的模型
|
||||
// 从池中剔除、把恢复供给的解除标记;ListModels 无字段可事先判别,只能试调习得。
|
||||
func (s *AiGatewayService) validateChannelModels(ctx context.Context, channelID uint) {
|
||||
if !s.beginValidate(channelID) {
|
||||
return
|
||||
}
|
||||
defer s.endValidate(channelID)
|
||||
var ch model.AiChannel
|
||||
if err := s.db.WithContext(ctx).First(&ch, channelID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.validateTargets(ctx, channelID)
|
||||
if err != nil {
|
||||
log.Printf("validate channel %d models: %v", channelID, err)
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
s.validateOne(ctx, cred, &ch, r)
|
||||
}
|
||||
}
|
||||
|
||||
// validateOne 试调单个模型并落库:可用(200/429)清除标记;模型级不可用标记剔除;
|
||||
// 其余 4xx 记录已检不改状态;5xx/网络类瞬态不落 checked_at,下轮再验。
|
||||
func (s *AiGatewayService) validateOne(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, r model.AiModelCache) {
|
||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, r.ModelOcid, r.Name)
|
||||
updates := map[string]any{"checked_at": time.Now()}
|
||||
switch {
|
||||
case code == 200 || code == 429:
|
||||
updates["unusable"], updates["unusable_reason"] = false, ""
|
||||
case oci.IsModelUnavailable(err):
|
||||
updates["unusable"], updates["unusable_reason"] = true, shortReason(oci.CompactError(err))
|
||||
case code == 0 || code >= 500:
|
||||
return
|
||||
}
|
||||
s.db.WithContext(ctx).Model(&model.AiModelCache{}).Where("id = ?", r.ID).Updates(updates)
|
||||
}
|
||||
|
||||
// validateModelsAsync 后台验证渠道模型池(手动同步后触发,不阻塞接口响应)。
|
||||
func (s *AiGatewayService) validateModelsAsync(ctx context.Context, channelID uint) {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
vctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute)
|
||||
defer cancel()
|
||||
s.validateChannelModels(vctx, channelID)
|
||||
}()
|
||||
}
|
||||
|
||||
// probeCandidateCap 是单次探测的候选上限:模型级不可用会被标记剔除不再重试,
|
||||
// 放宽到 8 让一次探测有机会越过整批坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||
// probeCandidateCap 是单次探测的候选上限:放宽到 8 让一次探测有机会越过整批
|
||||
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||
const probeCandidateCap = 8
|
||||
|
||||
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
|
||||
@@ -551,8 +454,7 @@ func truncateErr(msg string) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存(标记按 OCID 结转),
|
||||
// 随后触发后台验证:新模型逐个试调,不可按需调用的数十秒内从池中剔除。
|
||||
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存,黑名单中的模型不入库。
|
||||
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
||||
var ch model.AiChannel
|
||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||
@@ -566,29 +468,23 @@ func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiM
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
|
||||
}
|
||||
models = supportedGatewayModels(models)
|
||||
if models, err = s.withoutBlacklisted(ctx, models); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.replaceModels(ctx, id, models); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.validateModelsAsync(ctx, id)
|
||||
return s.channelModels(ctx, id)
|
||||
}
|
||||
|
||||
// replaceModels 以事务整组覆盖渠道模型缓存,按 OCID 结转不可用标记与验证时间
|
||||
// (OCID 变化视为新条目,自然回到待验证状态)。
|
||||
// replaceModels 以事务整组覆盖渠道模型缓存。
|
||||
func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, models []oci.GenAiModel) error {
|
||||
marks, err := s.loadModelMarks(ctx, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]model.AiModelCache, 0, len(models))
|
||||
now := time.Now()
|
||||
for _, m := range models {
|
||||
row := model.AiModelCache{ChannelID: channelID, ModelOcid: m.Ocid, Name: m.Name, Vendor: m.Vendor,
|
||||
Capability: m.Capability, SyncedAt: now, DeprecatedAt: m.Deprecated, RetiredAt: m.Retired}
|
||||
if prev, ok := marks[m.Ocid]; ok {
|
||||
row.Unusable, row.UnusableReason, row.CheckedAt = prev.Unusable, prev.UnusableReason, prev.CheckedAt
|
||||
}
|
||||
rows = append(rows, row)
|
||||
rows = append(rows, model.AiModelCache{ChannelID: channelID, ModelOcid: m.Ocid, Name: m.Name, Vendor: m.Vendor,
|
||||
Capability: m.Capability, SyncedAt: now, DeprecatedAt: m.Deprecated, RetiredAt: m.Retired})
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("channel_id = ?", channelID).Delete(&model.AiModelCache{}).Error; err != nil {
|
||||
@@ -601,33 +497,17 @@ func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, mo
|
||||
})
|
||||
}
|
||||
|
||||
// loadModelMarks 取渠道内带标记或已验证过的行(OCID → 行),供同步结转与候选过滤。
|
||||
func (s *AiGatewayService) loadModelMarks(ctx context.Context, channelID uint) (map[string]model.AiModelCache, error) {
|
||||
var rows []model.AiModelCache
|
||||
err := s.db.WithContext(ctx).Select("model_ocid", "unusable", "unusable_reason", "checked_at").
|
||||
Where("channel_id = ? AND (unusable = ? OR checked_at IS NOT NULL)", channelID, true).Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load model marks: %w", err)
|
||||
}
|
||||
marks := make(map[string]model.AiModelCache, len(rows))
|
||||
for _, r := range rows {
|
||||
marks[r.ModelOcid] = r
|
||||
}
|
||||
return marks, nil
|
||||
}
|
||||
|
||||
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
|
||||
var rows []model.AiModelCache
|
||||
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GatewayModels 聚合启用渠道的可用模型(按名称去重,剔除不可按需调用标记),
|
||||
// 供 /ai/v1/models;group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
|
||||
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
||||
q := s.db.WithContext(ctx).
|
||||
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true).
|
||||
Where("(ai_model_caches.unusable = ? OR ai_model_caches.unusable IS NULL)", false)
|
||||
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
|
||||
if group != "" {
|
||||
q = q.Where("ai_channels.channel_group = ?", group)
|
||||
}
|
||||
@@ -658,7 +538,6 @@ func (s *AiGatewayService) DeprecatingModels(ctx context.Context, within time.Du
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("(retired_at IS NOT NULL AND retired_at > ? AND retired_at <= ?) OR (deprecated_at IS NOT NULL AND deprecated_at >= ? AND deprecated_at <= ?)",
|
||||
now, deadline, now, deadline).
|
||||
Where("(unusable = ? OR unusable IS NULL)", false).
|
||||
Order("name ASC").Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -679,7 +558,7 @@ func (s *AiGatewayService) DeprecatingModels(ctx context.Context, within time.Du
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProbeAll 逐个探测全部渠道并顺带验证模型池,返回状态汇总;供 AI 探测后台任务调用。
|
||||
// ProbeAll 逐个探测全部渠道,返回状态汇总;供 AI 探测后台任务调用。
|
||||
func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
|
||||
chs, err := s.Channels(ctx)
|
||||
if err != nil {
|
||||
@@ -695,8 +574,6 @@ func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
|
||||
continue
|
||||
}
|
||||
counts[fresh.ProbeStatus]++
|
||||
// 后台任务里同步执行:新模型验证入池、坏模型定期复检,零人工收敛
|
||||
s.validateChannelModels(ctx, ch.ID)
|
||||
}
|
||||
msg := fmt.Sprintf("probed %d: %d ok, %d no_service, %d no_quota, %d error",
|
||||
len(chs), counts["ok"], counts["no_service"], counts["no_quota"], counts["error"])
|
||||
@@ -706,6 +583,99 @@ func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// ---- 模型黑名单 ----
|
||||
|
||||
// Blacklist 列出全部黑名单模型(按名称排序)。
|
||||
func (s *AiGatewayService) Blacklist(ctx context.Context) ([]model.AiModelBlacklist, error) {
|
||||
var rows []model.AiModelBlacklist
|
||||
err := s.db.WithContext(ctx).Order("name ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// AddBlacklist 把模型名加入黑名单并删除全部渠道缓存中的同名条目;
|
||||
// 该模型此后同步 / 探测均被过滤,直到移出黑名单后重新同步。
|
||||
func (s *AiGatewayService) AddBlacklist(ctx context.Context, name string) (*model.AiModelBlacklist, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("模型名不能为空")
|
||||
}
|
||||
row := model.AiModelBlacklist{Name: name}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var n int64
|
||||
if err := tx.Model(&model.AiModelBlacklist{}).Where("name = ?", name).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("模型已在黑名单中")
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("name = ?", name).Delete(&model.AiModelCache{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// RemoveBlacklist 把模型移出黑名单;缓存不回填,下次同步 / 探测自然恢复入池。
|
||||
func (s *AiGatewayService) RemoveBlacklist(ctx context.Context, id uint) error {
|
||||
res := s.db.WithContext(ctx).Delete(&model.AiModelBlacklist{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return fmt.Errorf("黑名单条目不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withoutBlacklisted 过滤掉黑名单中的模型,同步与探测入库前统一经此收口。
|
||||
// supportedGatewayModels 过滤模型目录:对话模型仅保留实测支持 OpenAI 兼容面的
|
||||
// 厂商(xai / meta / openai)——typed chat 面已剔除,google / cohere 对话模型无
|
||||
// 上游通路,不入目录(不出现在列表、路由与探测候选);EMBEDDING 模型不受影响。
|
||||
func supportedGatewayModels(models []oci.GenAiModel) []oci.GenAiModel {
|
||||
out := make([]oci.GenAiModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if m.Capability == "CHAT" && !compatChatVendor(m.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compatChatVendor(model string) bool {
|
||||
for _, prefix := range []string{"xai.", "meta.", "openai."} {
|
||||
if strings.HasPrefix(model, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *AiGatewayService) withoutBlacklisted(ctx context.Context, models []oci.GenAiModel) ([]oci.GenAiModel, error) {
|
||||
var names []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.AiModelBlacklist{}).Pluck("name", &names).Error; err != nil {
|
||||
return nil, fmt.Errorf("读取模型黑名单: %w", err)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
banned := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
banned[n] = true
|
||||
}
|
||||
out := make([]oci.GenAiModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !banned[m.Name] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- 调用日志 ----
|
||||
|
||||
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
|
||||
|
||||
Reference in New Issue
Block a user