渠道模型列表与测试端点,修复探测无配额误判
This commit is contained in:
@@ -274,6 +274,55 @@ func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||
}
|
||||
|
||||
// @Summary 渠道模型缓存列表
|
||||
// @Tags AI 管理
|
||||
// @Param id path int true "渠道 ID"
|
||||
// @Success 200 {object} itemsResponse[model.AiModelCache]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-channels/{id}/models [get]
|
||||
func (h *aiAdminHandler) listChannelModels(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
models, err := h.gw.ChannelModels(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||
}
|
||||
|
||||
type testChannelModelRequest struct {
|
||||
Model string `json:"model" binding:"required"`
|
||||
}
|
||||
|
||||
// @Summary 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)
|
||||
// @Tags AI 管理
|
||||
// @Param id path int true "渠道 ID"
|
||||
// @Param body body testChannelModelRequest true "模型名"
|
||||
// @Success 200 {object} model.AiChannel
|
||||
// @Failure 502 {object} errorResponse "试调未通过"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-channels/{id}/test-model [post]
|
||||
func (h *aiAdminHandler) testChannelModel(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req testChannelModelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ch, err := h.gw.TestChannelModel(c.Request.Context(), id, req.Model)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
// ---- 聚合模型与调用日志 ----
|
||||
|
||||
// @Summary ---- 聚合模型与调用日志 ----
|
||||
|
||||
@@ -36,6 +36,8 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
||||
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
||||
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||
|
||||
@@ -268,8 +268,12 @@ type AiChannel struct {
|
||||
LastProbeAt *time.Time `json:"lastProbeAt"`
|
||||
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
||||
ProbeError string `gorm:"size:512" json:"probeError"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
|
||||
ProbeModel string `gorm:"size:96" json:"probeModel"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// ModelCount 是渠道模型缓存计数,列表查询回填,不落库
|
||||
ModelCount int64 `gorm:"-" json:"modelCount"`
|
||||
}
|
||||
|
||||
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
||||
|
||||
@@ -149,11 +149,12 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
||||
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;
|
||||
// 配额探测专用的最小聊天,返回 HTTP 状态码。max_output_tokens 取 16:
|
||||
// openai.gpt-oss 系列要求 >=16,其余模型均兼容,成本差异可忽略。
|
||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
|
||||
"max_output_tokens": 1, "store": false})
|
||||
"max_output_tokens": 16, "store": false})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user