AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
This commit is contained in:
+267
-229
@@ -1,9 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -45,15 +47,46 @@ type gatewayStubClient struct {
|
||||
probeCode int
|
||||
probeErr error
|
||||
// probeSeq 非空时逐次弹出,模拟按候选依次试调;弹尽后回落 probeCode/probeErr
|
||||
probeSeq []probeResult
|
||||
chatResp *aiwire.ChatResponse
|
||||
chatErrs []error
|
||||
chatCalls int
|
||||
regions []string
|
||||
probeSeq []probeResult
|
||||
// probedNames 记录试调过的模型名,供断言候选过滤
|
||||
probedNames []string
|
||||
chatCalls int
|
||||
regions []string
|
||||
|
||||
embedVecs [][]float32
|
||||
embedUsage *aiwire.Usage
|
||||
embedErr error
|
||||
|
||||
passPayload []byte
|
||||
passErrs []error
|
||||
passCalls int
|
||||
passRegions []string
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, error) {
|
||||
f.passCalls++
|
||||
f.passRegions = append(f.passRegions, region)
|
||||
if len(f.passErrs) > 0 {
|
||||
err := f.passErrs[0]
|
||||
f.passErrs = f.passErrs[1:]
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return f.passPayload, nil
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte) (io.ReadCloser, error) {
|
||||
f.passCalls++
|
||||
f.passRegions = append(f.passRegions, region)
|
||||
if len(f.passErrs) > 0 {
|
||||
err := f.passErrs[0]
|
||||
f.passErrs = f.passErrs[1:]
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(f.passPayload)), nil
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
||||
@@ -71,6 +104,7 @@ type probeResult struct {
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
|
||||
f.probedNames = append(f.probedNames, modelName)
|
||||
if len(f.probeSeq) > 0 {
|
||||
r := f.probeSeq[0]
|
||||
f.probeSeq = f.probeSeq[1:]
|
||||
@@ -79,23 +113,10 @@ func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credent
|
||||
return f.probeCode, f.probeErr
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiChat(ctx context.Context, cred oci.Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
||||
f.chatCalls++
|
||||
f.regions = append(f.regions, region)
|
||||
if len(f.chatErrs) > 0 {
|
||||
err := f.chatErrs[0]
|
||||
f.chatErrs = f.chatErrs[1:]
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return f.chatResp, nil
|
||||
}
|
||||
|
||||
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
||||
t.Helper()
|
||||
svc := newTestService(t, client)
|
||||
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
|
||||
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||
t.Fatalf("auto migrate ai tables: %v", err)
|
||||
}
|
||||
return NewAiGatewayService(svc.db, svc, client), svc
|
||||
@@ -105,14 +126,14 @@ func TestAiKeyLifecycle(t *testing.T) {
|
||||
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
ctx := context.Background()
|
||||
|
||||
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "")
|
||||
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "", nil)
|
||||
if err != nil || raw != "test-api-key" || key.Tail != "-key" {
|
||||
t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
|
||||
}
|
||||
if _, _, err := gw.CreateKey(ctx, "short", "abc", ""); err == nil {
|
||||
if _, _, err := gw.CreateKey(ctx, "short", "abc", "", nil); err == nil {
|
||||
t.Error("过短自定义密钥应被拒绝")
|
||||
}
|
||||
auto, _, err := gw.CreateKey(ctx, "auto", "", "")
|
||||
auto, _, err := gw.CreateKey(ctx, "auto", "", "", nil)
|
||||
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
|
||||
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
|
||||
}
|
||||
@@ -124,7 +145,7 @@ func TestAiKeyLifecycle(t *testing.T) {
|
||||
t.Errorf("错误密钥 err = %v", err)
|
||||
}
|
||||
off := false
|
||||
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil)
|
||||
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil, nil)
|
||||
if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
|
||||
t.Errorf("禁用后 VerifyKey err = %v", err)
|
||||
}
|
||||
@@ -187,51 +208,6 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
||||
return ch
|
||||
}
|
||||
|
||||
func TestAiChatRetrySwitchesChannel(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
|
||||
chatErrs: []error{stubServiceError{status: 429}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
// 两个同优先级渠道
|
||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, meta, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}, "")
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("Chat = %v, %v", resp, err)
|
||||
}
|
||||
if meta.Retries != 1 || client.chatCalls != 2 {
|
||||
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.chatCalls)
|
||||
}
|
||||
if len(client.regions) != 2 && client.regions[0] == client.regions[1] {
|
||||
t.Errorf("重试未换渠道: %v", client.regions)
|
||||
}
|
||||
// 未知模型
|
||||
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "no-such-model"}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("未知模型 err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiChatNonRetryablePassThrough(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
chatErrs: []error{stubServiceError{status: 400}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
|
||||
_, meta, err := gw.Chat(context.Background(), aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct"}, "")
|
||||
if err == nil || meta.Retries != 0 || client.chatCalls != 1 {
|
||||
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.chatCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPriorityAndBreaker(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
@@ -282,8 +258,8 @@ func TestMarkFailureBackoff(t *testing.T) {
|
||||
|
||||
func TestAiGroupRouting(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
@@ -292,17 +268,17 @@ func TestAiGroupRouting(t *testing.T) {
|
||||
gw.db.Model(vip).Update("channel_group", "vip")
|
||||
gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
|
||||
ctx := context.Background()
|
||||
req := aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}
|
||||
body := []byte(`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`)
|
||||
|
||||
// 分组密钥只落同分组渠道
|
||||
for i := 0; i < 5; i++ {
|
||||
_, meta, err := gw.Chat(ctx, req, "vip")
|
||||
_, meta, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "vip")
|
||||
if err != nil || meta.ChannelID != vip.ID {
|
||||
t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID)
|
||||
}
|
||||
}
|
||||
// 分组内无渠道 → 无可用渠道
|
||||
if _, _, err := gw.Chat(ctx, req, "nope"); !errors.Is(err, ErrAiNoChannel) {
|
||||
if _, _, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "nope"); !errors.Is(err, ErrAiNoChannel) {
|
||||
t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
|
||||
}
|
||||
// 模型列表按分组过滤
|
||||
@@ -315,12 +291,12 @@ func TestAiGroupRouting(t *testing.T) {
|
||||
t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
|
||||
}
|
||||
// 密钥分组落库
|
||||
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip")
|
||||
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip", nil)
|
||||
if err != nil || key.Group != "vip" {
|
||||
t.Fatalf("CreateKey group = %+v, %v", key, err)
|
||||
}
|
||||
empty := ""
|
||||
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty)
|
||||
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty, nil)
|
||||
var fresh model.AiKey
|
||||
gw.db.First(&fresh, key.ID)
|
||||
if fresh.Group != "" {
|
||||
@@ -465,7 +441,8 @@ func entityNotFoundErr() stubServiceError {
|
||||
}
|
||||
|
||||
func TestProbeSkipsUnavailableModels(t *testing.T) {
|
||||
// 首选 llama 不可按需调用(基座 400 / 实体 404)→ 标记剔除 → 换候选成功 → 渠道判可用
|
||||
// 首选 llama 不可按需调用(基座 400 / 实体 404)→ 换候选成功 → 渠道判可用;
|
||||
// 模型全量入库不再自动标记,持久剔除交由用户手动拉黑
|
||||
tests := []struct {
|
||||
name string
|
||||
bad probeResult
|
||||
@@ -496,112 +473,16 @@ func TestProbeSkipsUnavailableModels(t *testing.T) {
|
||||
if err != nil || probed.ProbeStatus != "ok" {
|
||||
t.Fatalf("坏候选后应换候选并判可用: %+v, %v", probed, err)
|
||||
}
|
||||
var row model.AiModelCache
|
||||
if err := gw.db.Where("channel_id = ? AND model_ocid = ?", ch.ID, "m1").First(&row).Error; err != nil || !row.Unusable {
|
||||
t.Fatalf("首个坏候选应被标记不可用: %+v, %v", row, err)
|
||||
}
|
||||
// 再次探测:同步保留标记,m1 不再进候选(probeSeq 只需一次 200)
|
||||
client.probeSeq = []probeResult{{200, nil}}
|
||||
probed, err = gw.ProbeChannel(ctx, ch.ID)
|
||||
if err != nil || probed.ProbeStatus != "ok" {
|
||||
t.Fatalf("复测应跳过已标记模型: %+v, %v", probed, err)
|
||||
}
|
||||
var again model.AiModelCache
|
||||
gw.db.Where("channel_id = ? AND model_ocid = ?", ch.ID, "m1").First(&again)
|
||||
if !again.Unusable {
|
||||
t.Error("探测触发的同步应保留不可用标记")
|
||||
}
|
||||
// 手动同步同样保留标记(坏模型不随重新同步复活);近期已检的标记不被后台验证翻转
|
||||
client.probeCode = 200
|
||||
if _, err := gw.SyncModels(ctx, ch.ID); err != nil {
|
||||
t.Fatalf("SyncModels: %v", err)
|
||||
}
|
||||
gw.Wait()
|
||||
var kept model.AiModelCache
|
||||
gw.db.Where("channel_id = ? AND model_ocid = ?", ch.ID, "m1").First(&kept)
|
||||
if !kept.Unusable {
|
||||
t.Error("手动同步不应清除不可用标记")
|
||||
rows, _ := gw.channelModels(ctx, ch.ID)
|
||||
if len(rows) != 3 {
|
||||
t.Errorf("模型应全量入库, got %d", len(rows))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateModelsAfterSync(t *testing.T) {
|
||||
// 同步后后台验证:坏模型标记剔除、好模型记录已检、其他 4xx 不改状态、非对话模型不试调
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
models: []oci.GenAiModel{
|
||||
{Ocid: "v1", Name: "meta.llama-4-maverick", Vendor: "meta", Capability: "CHAT"},
|
||||
{Ocid: "v2", Name: "xai.grok-4", Vendor: "xai", Capability: "CHAT"},
|
||||
{Ocid: "v3", Name: "xai.grok-voice-agent", Vendor: "xai", Capability: "CHAT"},
|
||||
{Ocid: "v4", Name: "cohere.embed-v4.0", Vendor: "cohere", Capability: "EMBEDDING"},
|
||||
},
|
||||
probeSeq: []probeResult{{404, entityNotFoundErr()}, {200, nil}, {400, stubServiceError{status: 400}}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ctx := context.Background()
|
||||
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "us-ashburn-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
if _, err := gw.SyncModels(ctx, ch.ID); err != nil {
|
||||
t.Fatalf("SyncModels: %v", err)
|
||||
}
|
||||
gw.Wait()
|
||||
|
||||
want := map[string]struct {
|
||||
unusable bool
|
||||
checked bool
|
||||
}{
|
||||
"v1": {true, true}, // 实体 404 → 标记剔除
|
||||
"v2": {false, true}, // 200 → 可用已检
|
||||
"v3": {false, true}, // 普通 400 → 已检不标记
|
||||
"v4": {false, false}, // EMBEDDING 不试调
|
||||
}
|
||||
rows, _ := gw.channelModels(ctx, ch.ID)
|
||||
for _, r := range rows {
|
||||
w := want[r.ModelOcid]
|
||||
if r.Unusable != w.unusable || (r.CheckedAt != nil) != w.checked {
|
||||
t.Errorf("%s: unusable=%v checked=%v, want %+v", r.ModelOcid, r.Unusable, r.CheckedAt != nil, w)
|
||||
}
|
||||
}
|
||||
list, err := gw.GatewayModels(ctx, "")
|
||||
if err != nil || len(list.Data) != 3 {
|
||||
t.Errorf("网关列表应只剔除被标记的坏模型(余 v2/v3/v4), got %+v, %v", list.Data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRecheckUnmarksRecovered(t *testing.T) {
|
||||
// 已标记模型超过复检间隔后重验:恢复供给(200)自动解除标记
|
||||
client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, probeCode: 200}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
ctx := context.Background()
|
||||
old := time.Now().Add(-25 * time.Hour)
|
||||
gw.db.Model(&model.AiModelCache{}).Where("channel_id = ?", ch.ID).
|
||||
Updates(map[string]any{"unusable": true, "unusable_reason": "x", "checked_at": old})
|
||||
|
||||
gw.validateChannelModels(ctx, ch.ID)
|
||||
var row model.AiModelCache
|
||||
gw.db.Where("channel_id = ?", ch.ID).First(&row)
|
||||
if row.Unusable || row.UnusableReason != "" || row.CheckedAt == nil || !row.CheckedAt.After(old) {
|
||||
t.Errorf("超期复检应解除标记并刷新已检时间: %+v", row)
|
||||
}
|
||||
// 未超期的标记不复检(probeSeq 为空、fallback 200 也不会被消费)
|
||||
fresh := time.Now()
|
||||
gw.db.Model(&model.AiModelCache{}).Where("channel_id = ?", ch.ID).
|
||||
Updates(map[string]any{"unusable": true, "checked_at": fresh})
|
||||
gw.validateChannelModels(ctx, ch.ID)
|
||||
gw.db.Where("channel_id = ?", ch.ID).First(&row)
|
||||
if !row.Unusable {
|
||||
t.Error("未超期的标记不应被复检翻转")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAuth404StillNoQuota(t *testing.T) {
|
||||
// 鉴权类 404(NotAuthorizedOrNotFound)仍属租户级,直接定论 no_quota 且不标记模型
|
||||
// 鉴权类 404(NotAuthorizedOrNotFound)仍属租户级,直接定论 no_quota
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
models: []oci.GenAiModel{{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
|
||||
@@ -617,31 +498,26 @@ func TestProbeAuth404StillNoQuota(t *testing.T) {
|
||||
if probed.ProbeStatus != "no_quota" {
|
||||
t.Errorf("鉴权 404 status = %q, want no_quota", probed.ProbeStatus)
|
||||
}
|
||||
var marked int64
|
||||
gw.db.Model(&model.AiModelCache{}).Where("unusable = ?", true).Count(&marked)
|
||||
if marked != 0 {
|
||||
t.Errorf("鉴权 404 不应标记模型, marked=%d", marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
|
||||
// 微调基座 400 换渠道重试成功,且不计入熔断失败
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
|
||||
chatErrs: []error{finetuneBaseErr()},
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
|
||||
passErrs: []error{finetuneBaseErr()},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
|
||||
resp, meta, err := gw.Chat(context.Background(), aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}, "")
|
||||
resp, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("Chat = %v, %v", resp, err)
|
||||
t.Fatalf("RespPassthrough = %v, %v", resp, err)
|
||||
}
|
||||
if meta.Retries != 1 || client.chatCalls != 2 {
|
||||
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.chatCalls)
|
||||
if meta.Retries != 1 || client.passCalls != 2 {
|
||||
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
|
||||
}
|
||||
var chs []model.AiChannel
|
||||
gw.db.Find(&chs)
|
||||
@@ -650,38 +526,93 @@ func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
|
||||
t.Errorf("微调基座 400 不应计入熔断: 渠道 %s failCount=%d", ch.Name, ch.FailCount)
|
||||
}
|
||||
}
|
||||
// 失败渠道的该模型被标记,不再参与路由;成功渠道不受影响
|
||||
region := client.regions[0]
|
||||
var row model.AiModelCache
|
||||
gw.db.Where("model_ocid = ?", "ocid1..m-"+region).First(&row)
|
||||
if !row.Unusable {
|
||||
t.Errorf("失败渠道的模型应被标记不可用: %+v", row)
|
||||
}
|
||||
|
||||
func TestBlacklistLifecycle(t *testing.T) {
|
||||
// 拉黑:全渠道同名缓存删除、列表与路由立即不可见;重复/空名被拒;
|
||||
// 移出黑名单后重新同步恢复入池
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
models: []oci.GenAiModel{{Ocid: "ocid1..m-eu-frankfurt-1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
|
||||
}
|
||||
var usable int64
|
||||
gw.db.Model(&model.AiModelCache{}).Where("unusable = ?", false).Count(&usable)
|
||||
if usable != 1 {
|
||||
t.Errorf("成功渠道模型应保持可用, usable=%d", usable)
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
ctx := context.Background()
|
||||
|
||||
item, err := gw.AddBlacklist(ctx, " meta.llama-3.3-70b-instruct ")
|
||||
if err != nil || item.Name != "meta.llama-3.3-70b-instruct" {
|
||||
t.Fatalf("AddBlacklist = %+v, %v", item, err)
|
||||
}
|
||||
var left int64
|
||||
gw.db.Model(&model.AiModelCache{}).Count(&left)
|
||||
if left != 0 {
|
||||
t.Errorf("拉黑应删除全部渠道的同名缓存, 剩 %d", left)
|
||||
}
|
||||
if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "meta.llama-3.3-70b-instruct", ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("拉黑后路由应按未知模型拒绝: %v", err)
|
||||
}
|
||||
if _, err := gw.AddBlacklist(ctx, "meta.llama-3.3-70b-instruct"); err == nil {
|
||||
t.Error("重复拉黑应被拒绝")
|
||||
}
|
||||
if _, err := gw.AddBlacklist(ctx, " "); err == nil {
|
||||
t.Error("空模型名应被拒绝")
|
||||
}
|
||||
rows, err := gw.Blacklist(ctx)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("Blacklist = %+v, %v", rows, err)
|
||||
}
|
||||
if err := gw.RemoveBlacklist(ctx, rows[0].ID); err != nil {
|
||||
t.Fatalf("RemoveBlacklist: %v", err)
|
||||
}
|
||||
if err := gw.RemoveBlacklist(ctx, rows[0].ID); err == nil {
|
||||
t.Error("移除不存在的条目应报错")
|
||||
}
|
||||
// 移出后重新同步:模型恢复入池
|
||||
if _, err := gw.SyncModels(ctx, ch.ID); err != nil {
|
||||
t.Fatalf("SyncModels: %v", err)
|
||||
}
|
||||
list, _ := gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 1 {
|
||||
t.Errorf("移出黑名单并同步后应恢复入池: %+v", list.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnusableModelExcludedFromPoolAndRouting(t *testing.T) {
|
||||
// 唯一渠道的模型被标记后:网关列表不再展示,路由按未知模型拒绝,渠道详情仍可见标记
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
func TestSyncAndProbeFilterBlacklisted(t *testing.T) {
|
||||
// 同步与探测都过滤黑名单模型:不入库、不进试调候选
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
models: []oci.GenAiModel{
|
||||
{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
|
||||
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
|
||||
},
|
||||
probeCode: 200,
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
ctx := context.Background()
|
||||
|
||||
gw.markModelUnusable(ctx, ch.ID, "ocid1..m-eu-frankfurt-1", "Entity with key … not found")
|
||||
list, err := gw.GatewayModels(ctx, "")
|
||||
if err != nil || len(list.Data) != 0 {
|
||||
t.Errorf("已标记模型不应出现在网关列表: %+v, %v", list.Data, err)
|
||||
if _, err := gw.AddBlacklist(ctx, "meta.llama-3-70b-instruct"); err != nil {
|
||||
t.Fatalf("AddBlacklist: %v", err)
|
||||
}
|
||||
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct"}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("已标记模型路由应拒绝: %v", err)
|
||||
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
rows, err := gw.channelModels(ctx, ch.ID)
|
||||
if err != nil || len(rows) != 1 || !rows[0].Unusable || rows[0].UnusableReason == "" {
|
||||
t.Errorf("渠道详情应保留标记行便于排查: %+v, %v", rows, err)
|
||||
probed, err := gw.ProbeChannel(ctx, ch.ID)
|
||||
if err != nil || probed.ProbeStatus != "ok" {
|
||||
t.Fatalf("ProbeChannel = %+v, %v", probed, err)
|
||||
}
|
||||
rows, _ := gw.channelModels(ctx, ch.ID)
|
||||
if len(rows) != 1 || rows[0].Name != "cohere.command-a-03-2025" {
|
||||
t.Errorf("探测同步应过滤黑名单模型: %+v", rows)
|
||||
}
|
||||
if len(client.probedNames) != 1 || client.probedNames[0] != "cohere.command-a-03-2025" {
|
||||
t.Errorf("试调候选不应包含黑名单模型: %v", client.probedNames)
|
||||
}
|
||||
models, err := gw.SyncModels(ctx, ch.ID)
|
||||
if err != nil || len(models) != 1 || models[0].Name != "cohere.command-a-03-2025" {
|
||||
t.Errorf("SyncModels 应过滤黑名单模型: %+v, %v", models, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,7 +676,7 @@ func TestAiEmbeddings(t *testing.T) {
|
||||
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
|
||||
}
|
||||
// embedding 模型名打 chat:同样未知模型
|
||||
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "cohere.embed-v4.0", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("x")}}}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||
if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "cohere.embed-v4.0", ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err)
|
||||
}
|
||||
}
|
||||
@@ -756,7 +687,7 @@ func TestAiContentLogSwitch(t *testing.T) {
|
||||
t.Fatalf("migrate content log: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "")
|
||||
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
@@ -793,23 +724,130 @@ func TestAiContentLogSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyNullUnusableRowsStayVisible(t *testing.T) {
|
||||
// 升级路径回归:AutoMigrate 加列后、首次重同步前,存量行 unusable 为 NULL,
|
||||
// 网关列表与路由必须照常包含这些行,不能因 unusable = false 过滤而整池消失
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
func TestRespPassthroughSwitchesChannel(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":3}}`),
|
||||
passErrs: []error{stubServiceError{status: 429}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
ctx := context.Background()
|
||||
if err := gw.db.Exec("UPDATE ai_model_caches SET unusable = NULL WHERE channel_id = ?", ch.ID).Error; err != nil {
|
||||
t.Fatalf("set legacy null: %v", err)
|
||||
}
|
||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
|
||||
list, err := gw.GatewayModels(ctx, "")
|
||||
if err != nil || len(list.Data) != 1 {
|
||||
t.Errorf("存量 NULL 行应仍在网关列表: %+v, %v", list.Data, err)
|
||||
payload, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||
if err != nil || len(payload) == 0 {
|
||||
t.Fatalf("RespPassthrough = %v, %v", payload, err)
|
||||
}
|
||||
_, ids, err := gw.modelChannels(ctx, "meta.llama-3.3-70b-instruct", "CHAT")
|
||||
if err != nil || len(ids) != 1 {
|
||||
t.Errorf("存量 NULL 行应仍参与路由: %v, %v", ids, err)
|
||||
if meta.Retries != 1 || client.passCalls != 2 {
|
||||
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
|
||||
}
|
||||
if len(client.passRegions) == 2 && client.passRegions[0] == client.passRegions[1] {
|
||||
t.Errorf("重试未换渠道: %v", client.passRegions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespPassthroughNonRetryable(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
passErrs: []error{stubServiceError{status: 400}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
|
||||
_, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||
if err == nil || meta.Retries != 0 || client.passCalls != 1 {
|
||||
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.passCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKeyModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{"nil 输入", nil, nil},
|
||||
{"全空串", []string{"", " "}, nil},
|
||||
{"trim 与保序去重", []string{" a ", "b", "a", "", "b "}, []string{"a", "b"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeKeyModels(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
for i := range tt.want {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAiKeyModelsPersistence 钉住 serializer:json 字段经 Create 与 Updates(map) 两条路径的往返。
|
||||
func TestAiKeyModelsPersistence(t *testing.T) {
|
||||
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
ctx := context.Background()
|
||||
|
||||
_, key, err := gw.CreateKey(ctx, "m-key", "model-key-1234", "", []string{" x-model ", "x-model", "y-model", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
fresh, err := gw.VerifyKey(ctx, "model-key-1234")
|
||||
if err != nil || len(fresh.Models) != 2 || fresh.Models[0] != "x-model" || fresh.Models[1] != "y-model" {
|
||||
t.Fatalf("创建后读回 models = %v, %v", fresh.Models, err)
|
||||
}
|
||||
set := []string{"z-model"}
|
||||
if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &set); err != nil {
|
||||
t.Fatalf("UpdateKey 覆盖: %v", err)
|
||||
}
|
||||
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
|
||||
if len(fresh.Models) != 1 || fresh.Models[0] != "z-model" {
|
||||
t.Fatalf("覆盖后 models = %v", fresh.Models)
|
||||
}
|
||||
if err := gw.UpdateKey(ctx, key.ID, "renamed", nil, nil, nil); err != nil {
|
||||
t.Fatalf("UpdateKey 不传 models: %v", err)
|
||||
}
|
||||
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
|
||||
if len(fresh.Models) != 1 {
|
||||
t.Fatalf("未传 models 却被改动: %v", fresh.Models)
|
||||
}
|
||||
empty := []string{}
|
||||
if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &empty); err != nil {
|
||||
t.Fatalf("UpdateKey 清空: %v", err)
|
||||
}
|
||||
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
|
||||
if len(fresh.Models) != 0 {
|
||||
t.Fatalf("清空后 models = %v, want 空", fresh.Models)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespPassthroughStreamSwitchesChannel 断言流式直通建立失败按 switchable 换渠道。
|
||||
func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
passPayload: []byte("data: {\"type\":\"response.completed\"}\n\n"),
|
||||
passErrs: []error{stubServiceError{status: 503}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
|
||||
stream, meta, err := gw.RespPassthroughStream(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||
if err != nil || stream == nil {
|
||||
t.Fatalf("RespPassthroughStream = %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if meta.Retries != 1 || client.passCalls != 2 {
|
||||
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
|
||||
}
|
||||
payload, _ := io.ReadAll(stream)
|
||||
if !bytes.Contains(payload, []byte("response.completed")) {
|
||||
t.Errorf("流内容未透传: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user