渠道模型列表与测试端点,修复探测无配额误判

This commit is contained in:
2026-07-14 19:33:33 +08:00
parent 79c9e4d9b9
commit 1da2197a6c
11 changed files with 688 additions and 22 deletions
+115 -14
View File
@@ -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) {
+152 -3
View File
@@ -453,7 +453,7 @@ func TestProbeCandidates(t *testing.T) {
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
}
got := probeCandidates(models)
got := probeCandidates("", models)
if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
t.Errorf("候选排序 = %+v", got)
}
@@ -473,7 +473,7 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
{Ocid: "g1", Name: "xai.grok-4"},
}
got := probeCandidates(models)
got := probeCandidates("", models)
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
}
@@ -489,11 +489,38 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
for i := 0; i < 12; i++ {
many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)})
}
if capped := probeCandidates(many); len(capped) != probeCandidateCap {
if capped := probeCandidates("", many); len(capped) != probeCandidateCap {
t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped))
}
}
// TestProbeCandidatesPinsProbeModel 断言探测验证模型置首位且不占常规候选逻辑。
func TestProbeCandidatesPinsProbeModel(t *testing.T) {
models := []oci.GenAiModel{
{Ocid: "o2", Name: "cohere.command-r-plus"},
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
{Ocid: "o4", Name: "xai.grok-4"},
}
tests := []struct {
name string
probeModel string
wantFirst string
wantLen int
}{
{"验证模型置首位", "xai.grok-4", "xai.grok-4", 3},
{"未设置走常规排序", "", "meta.llama-3.3-70b-instruct", 3},
{"验证模型已不在缓存则忽略", "gone.model", "meta.llama-3.3-70b-instruct", 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := probeCandidates(tt.probeModel, models)
if len(got) != tt.wantLen || got[0].Name != tt.wantFirst {
t.Errorf("probeCandidates(%q) = %+v, want first %q", tt.probeModel, got, tt.wantFirst)
}
})
}
}
// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
func entityNotFoundErr() stubServiceError {
return stubServiceError{status: 404,
@@ -560,6 +587,128 @@ func TestProbeAuth404StillNoQuota(t *testing.T) {
}
}
// TestChannelModelsExcludeBlacklist 断言模型列表与数量统计对黑名单做查询层兜底过滤。
func TestChannelModelsExcludeBlacklist(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
ctx := context.Background()
// 直插黑名单行但保留缓存,模拟脏数据 / 并发窗口
gw.db.Create(&model.AiModelBlacklist{Name: "meta.llama-3.3-70b-instruct"})
rows, err := gw.ChannelModels(ctx, ch.ID)
if err != nil || len(rows) != 0 {
t.Errorf("黑名单模型应被过滤: %+v, %v", rows, err)
}
chs, err := gw.Channels(ctx)
if err != nil || len(chs) != 1 || chs[0].ModelCount != 0 {
t.Errorf("模型数量统计应剔除黑名单: %+v, %v", chs, err)
}
}
// TestChannelModelsFilterDeprecated 断言「过滤弃用模型」开关同样作用于
// 渠道模型列表与数量统计(数据保留,展示口径过滤)。
func TestChannelModelsFilterDeprecated(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
dep := time.Now().Add(-24 * time.Hour)
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "xai.grok-3",
Vendor: "xai", SyncedAt: time.Now(), DeprecatedAt: &dep})
ctx := context.Background()
rows, _ := gw.ChannelModels(ctx, ch.ID)
if len(rows) != 2 {
t.Fatalf("开关关:弃用模型应在列,got %d", len(rows))
}
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
t.Fatalf("SetFilterDeprecated: %v", err)
}
rows, _ = gw.ChannelModels(ctx, ch.ID)
if len(rows) != 1 || rows[0].Name != "meta.llama-3.3-70b-instruct" {
t.Errorf("开关开:弃用模型应被过滤,got %+v", rows)
}
chs, _ := gw.Channels(ctx)
if len(chs) != 1 || chs[0].ModelCount != 1 {
t.Errorf("开关开:数量统计应同口径,got %+v", chs)
}
}
// TestProbe403ContinuesToNextCandidate 断言模型级 403 不再武断定论渠道无配额:
// 后续候选成功 → ok;全部候选鉴权拒绝 → 仍 no_quota。
func TestProbe403ContinuesToNextCandidate(t *testing.T) {
deny := probeResult{403, stubServiceError{status: 403, msg: "NotAuthorizedOrNotFound"}}
tests := []struct {
name string
seq []probeResult
wantStatus string
}{
{"403 后换候选成功", []probeResult{deny, {200, nil}}, "ok"},
{"403 后换候选限流也算可用", []probeResult{deny, {429, stubServiceError{status: 429}}}, "ok"},
{"全部候选 403", []probeResult{deny, deny}, "no_quota"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &gatewayStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
models: []oci.GenAiModel{
{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"},
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
},
probeSeq: tt.seq,
}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
probed, err := gw.ProbeChannel(ctx, ch.ID)
if err != nil || probed.ProbeStatus != tt.wantStatus {
t.Fatalf("ProbeChannel = %+v, %v, want %q", probed, err, tt.wantStatus)
}
})
}
}
// TestChannelModelTest 断言单模型试调:通过时写探测验证模型并翻转状态,失败不动。
func TestChannelModelTest(t *testing.T) {
client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
gw, svc := newTestGateway(t, client)
cfg := importAliveConfig(t, svc)
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
gw.db.Model(ch).Updates(map[string]any{"probe_status": "no_quota", "probe_error": "旧错误", "fail_count": 6})
ctx := context.Background()
if _, err := gw.TestChannelModel(ctx, ch.ID, "not.exists"); err == nil {
t.Error("缓存外模型应报错")
}
client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
if _, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct"); err == nil {
t.Error("403 试调应报测试未通过")
}
var still model.AiChannel
gw.db.First(&still, ch.ID)
if still.ProbeStatus != "no_quota" || still.ProbeModel != "" {
t.Errorf("失败不应改动渠道: %+v", still)
}
client.probeCode, client.probeErr = 200, nil
fresh, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct")
if err != nil {
t.Fatalf("TestChannelModel: %v", err)
}
if fresh.ProbeModel != "meta.llama-3.3-70b-instruct" || fresh.ProbeStatus != "ok" ||
fresh.ProbeError != "" || fresh.FailCount != 0 {
t.Errorf("通过后应写验证模型并置可用: %+v", fresh)
}
// 已 ok 渠道再测另一模型:仅更新验证模型,不重写探测时间
cache2 := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m2", Name: "xai.grok-4", Vendor: "xai", SyncedAt: time.Now()}
gw.db.Create(cache2)
fresh2, err := gw.TestChannelModel(ctx, ch.ID, "xai.grok-4")
if err != nil || fresh2.ProbeModel != "xai.grok-4" {
t.Fatalf("已可用渠道更新验证模型: %+v, %v", fresh2, err)
}
}
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
// 微调基座 400 换渠道重试成功,且不计入熔断失败
client := &gatewayStubClient{