AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s

This commit is contained in:
2026-07-12 17:48:28 +08:00
parent 7706f59549
commit 489cb49cb3
34 changed files with 2602 additions and 2901 deletions
+147 -177
View File
@@ -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)。