渠道模型列表与测试端点,修复探测无配额误判
This commit is contained in:
+115
-14
@@ -268,11 +268,34 @@ func valueOr(p *int, def int) int {
|
||||
return def
|
||||
}
|
||||
|
||||
// Channels 列出全部渠道。
|
||||
// Channels 列出全部渠道并回填各自的模型缓存计数。
|
||||
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
|
||||
var chs []model.AiChannel
|
||||
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
|
||||
return chs, err
|
||||
if err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []struct {
|
||||
ChannelID uint
|
||||
N int64
|
||||
}
|
||||
q := s.db.WithContext(ctx).Model(&model.AiModelCache{}).
|
||||
Select("channel_id, COUNT(*) AS n").
|
||||
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
err := q.Group("channel_id").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[uint]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
counts[r.ChannelID] = r.N
|
||||
}
|
||||
for i := range chs {
|
||||
chs[i].ModelCount = counts[chs[i].ID]
|
||||
}
|
||||
return chs, nil
|
||||
}
|
||||
|
||||
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
|
||||
@@ -315,7 +338,7 @@ func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error {
|
||||
|
||||
// ---- 探测与模型同步 ----
|
||||
|
||||
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。
|
||||
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → 极小 max_tokens 配额试调。
|
||||
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
|
||||
var ch model.AiChannel
|
||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||
@@ -364,13 +387,15 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *
|
||||
return s.probeChat(ctx, cred, ch, models)
|
||||
}
|
||||
|
||||
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
|
||||
// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404
|
||||
// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。
|
||||
// probeChat 按候选顺序试调(上限 8,已验证的探测模型置首位):遇「模型不可按需
|
||||
// 调用」(微调基座 400 / 实体不存在 404)换下一个候选,错误信息带模型名供用户加入
|
||||
// 黑名单;401/403 与鉴权类 404 可能只是模型级无权限,记录后继续换候选,全部候选
|
||||
// 失败且出现过鉴权拒绝才定论 no_quota;其余错误累计 3 次止损。
|
||||
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
||||
status, detail := "error", "无可试调对话模型"
|
||||
quotaDetail := ""
|
||||
errBudget := 3
|
||||
for _, m := range probeCandidates(models) {
|
||||
for _, m := range probeCandidates(ch.ProbeModel, models) {
|
||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
||||
switch {
|
||||
case code == 200 || code == 429:
|
||||
@@ -378,7 +403,7 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
case oci.IsModelUnavailable(err):
|
||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
||||
case code == 401 || code == 403 || code == 404:
|
||||
return "no_quota", truncateErr(oci.CompactError(err))
|
||||
quotaDetail = truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||
default:
|
||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||
if errBudget--; errBudget == 0 {
|
||||
@@ -386,6 +411,9 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
}
|
||||
}
|
||||
}
|
||||
if quotaDetail != "" {
|
||||
return "no_quota", quotaDetail
|
||||
}
|
||||
return status, detail
|
||||
}
|
||||
|
||||
@@ -393,13 +421,18 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||
const probeCandidateCap = 8
|
||||
|
||||
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
|
||||
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:用户已验证的
|
||||
// probeModel 固定放首位(不做能力过滤,测试通过即有效),其余主流文本模型优先;
|
||||
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
|
||||
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
|
||||
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
|
||||
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
||||
var sorted []oci.GenAiModel
|
||||
func probeCandidates(probeModel string, models []oci.GenAiModel) []oci.GenAiModel {
|
||||
var pinned, sorted []oci.GenAiModel
|
||||
for _, m := range models {
|
||||
if probeModel != "" && m.Name == probeModel {
|
||||
pinned = append(pinned, m)
|
||||
continue
|
||||
}
|
||||
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
|
||||
sorted = append(sorted, m)
|
||||
}
|
||||
@@ -407,7 +440,7 @@ func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
||||
})
|
||||
return diversifyByVendor(sorted, probeCandidateCap)
|
||||
return append(pinned, diversifyByVendor(sorted, probeCandidateCap)...)
|
||||
}
|
||||
|
||||
// diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。
|
||||
@@ -526,12 +559,80 @@ func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, mo
|
||||
})
|
||||
}
|
||||
|
||||
// channelModels 列出渠道模型缓存;黑名单模型查询层兜底过滤
|
||||
// (拉黑即删缓存,正常不会残留,防御旧数据 / 并发窗口);
|
||||
// 「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)。
|
||||
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
|
||||
q := s.db.WithContext(ctx).Where("channel_id = ?", channelID).
|
||||
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
|
||||
err := q.Order("name ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ChannelModels 列出渠道的模型缓存(名称排序),渠道不存在时报错。
|
||||
func (s *AiGatewayService) ChannelModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
||||
var n int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("渠道不存在")
|
||||
}
|
||||
return s.channelModels(ctx, id)
|
||||
}
|
||||
|
||||
// TestChannelModel 对渠道缓存中的指定模型发极小试调;通过时把该模型设
|
||||
// 为渠道探测验证模型(此后探测置于候选首位),渠道探测状态不为 ok 时顺带置 ok 并
|
||||
// 复位熔断;未通过仅返回错误,不改动渠道状态。
|
||||
func (s *AiGatewayService) TestChannelModel(ctx context.Context, id uint, name string) (*model.AiChannel, error) {
|
||||
var ch model.AiChannel
|
||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("渠道不存在")
|
||||
}
|
||||
var mc model.AiModelCache
|
||||
err := s.db.WithContext(ctx).Where("channel_id = ? AND name = ?", id, name).First(&mc).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型不在该渠道缓存中,请先同步模型")
|
||||
}
|
||||
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, mc.ModelOcid, mc.Name)
|
||||
if code != 200 && code != 429 {
|
||||
msg := fmt.Sprintf("HTTP %d", code)
|
||||
if err != nil {
|
||||
msg = oci.CompactError(err)
|
||||
}
|
||||
return nil, fmt.Errorf("测试未通过:%s", truncateErr(msg))
|
||||
}
|
||||
return s.adoptProbeModel(ctx, &ch, name)
|
||||
}
|
||||
|
||||
// adoptProbeModel 记录探测验证模型并返回更新后的渠道;
|
||||
// 状态不为 ok 时一并置 ok 并复位熔断。
|
||||
func (s *AiGatewayService) adoptProbeModel(ctx context.Context, ch *model.AiChannel, name string) (*model.AiChannel, error) {
|
||||
updates := map[string]any{"probe_model": name}
|
||||
if ch.ProbeStatus != "ok" {
|
||||
updates["probe_status"] = "ok"
|
||||
updates["probe_error"] = ""
|
||||
updates["last_probe_at"] = time.Now()
|
||||
updates["fail_count"] = 0
|
||||
updates["disabled_until"] = gorm.Expr("NULL")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
|
||||
var fresh model.AiChannel
|
||||
err := s.db.WithContext(ctx).First(&fresh, ch.ID).Error
|
||||
return &fresh, err
|
||||
}
|
||||
|
||||
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
|
||||
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
||||
|
||||
Reference in New Issue
Block a user