AI网关新增xAI格式TTS端点与过滤弃用模型开关
- POST /ai/v1/tts:xAI 官方 TTS 格式转换层(text/voice_id→input/voice),复用 Speech 编排,model 缺省 xai.grok-tts;实测 output_format/speed 透传生效 - 「过滤弃用模型」开关(GET/PUT /api/v1/ai-settings,settings 表持久化):开启后已宣布弃用(未退役)模型从列表与路由排除;同步入库与退役提醒不受影响 - AI 网关文档更名 docs/AI网关.md,补 tts 矩阵段与开关说明;README 引用同步 - CHANGELOG 0.5.0(0.4.0 已发版冻结);DASH_VERSION v0.5.0
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -58,11 +59,39 @@ type AiGatewayService struct {
|
||||
lastTouch map[uint]time.Time
|
||||
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
||||
onChannelsChanged func(context.Context)
|
||||
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||
filterDeprecated atomic.Bool
|
||||
}
|
||||
|
||||
// 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{}}
|
||||
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||
var row model.Setting
|
||||
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
||||
s.filterDeprecated.Store(row.Value == "1")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
||||
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||
|
||||
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||
|
||||
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||
value := "0"
|
||||
if on {
|
||||
value = "1"
|
||||
}
|
||||
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||
}
|
||||
s.filterDeprecated.Store(on)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||
@@ -511,6 +540,9 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
||||
if group != "" {
|
||||
q = q.Where("ai_channels.channel_group = ?", group)
|
||||
}
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
||||
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
||||
|
||||
@@ -164,6 +164,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
|
||||
} else {
|
||||
q = q.Where("capability = ?", capability)
|
||||
}
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -40,6 +40,42 @@ func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// ttsDefaultModel 是 /ai/v1/tts 缺省注入的模型(xAI 官方格式无 model 字段)。
|
||||
const ttsDefaultModel = "xai.grok-tts"
|
||||
|
||||
// TtsBodyConvert 把 xAI 官方 TTS 格式转换为上游 OpenAI 兼容 audio/speech 形态:
|
||||
// text→input、voice_id→voice,text 与 language 必填(对齐 xAI 官方);
|
||||
// model 为网关扩展字段,缺省注入 ttsDefaultModel;其余字段原样保留。
|
||||
func TtsBodyConvert(raw []byte) (string, []byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var body map[string]any
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||
}
|
||||
text, _ := body["text"].(string)
|
||||
language, _ := body["language"].(string)
|
||||
if strings.TrimSpace(text) == "" || strings.TrimSpace(language) == "" {
|
||||
return "", nil, fmt.Errorf("text 与 language 不能为空")
|
||||
}
|
||||
modelName, _ := body["model"].(string)
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = ttsDefaultModel
|
||||
body["model"] = modelName
|
||||
}
|
||||
delete(body, "text")
|
||||
body["input"] = text
|
||||
if voice, ok := body["voice_id"].(string); ok && strings.TrimSpace(voice) != "" {
|
||||
body["voice"] = voice
|
||||
}
|
||||
delete(body, "voice_id")
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||
}
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
|
||||
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
|
||||
meta := ChatMeta{}
|
||||
|
||||
@@ -45,6 +45,64 @@ func TestSpeechBodyNormalize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTtsBodyConvert 断言 xAI 官方 TTS 格式到 OpenAI 兼容形态的转换。
|
||||
func TestTtsBodyConvert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
wantModel string
|
||||
}{
|
||||
{"缺 text 拒绝", `{"language":"zh"}`, true, ""},
|
||||
{"缺 language 拒绝", `{"text":"你好"}`, true, ""},
|
||||
{"缺省注入默认模型", `{"text":"你好","language":"zh"}`, false, "xai.grok-tts"},
|
||||
{"model 扩展字段可覆盖", `{"model":"xai.other-tts","text":"你好","language":"auto"}`, false, "xai.other-tts"},
|
||||
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
modelName, _, err := TtsBodyConvert([]byte(tt.raw))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if modelName != tt.wantModel {
|
||||
t.Fatalf("model = %s, want %s", modelName, tt.wantModel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTtsBodyConvertMapping 断言字段映射与未知字段保留。
|
||||
func TestTtsBodyConvertMapping(t *testing.T) {
|
||||
raw := `{"text":"你好","language":"zh","voice_id":"ara","speed":1.2,` +
|
||||
`"output_format":{"codec":"mp3","sample_rate":44100}}`
|
||||
_, body, err := TtsBodyConvert([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if out["input"] != "你好" || out["voice"] != "ara" {
|
||||
t.Fatalf("input/voice 映射错误: %v", out)
|
||||
}
|
||||
if _, ok := out["text"]; ok {
|
||||
t.Fatal("text 字段应被移除")
|
||||
}
|
||||
if _, ok := out["voice_id"]; ok {
|
||||
t.Fatal("voice_id 字段应被移除")
|
||||
}
|
||||
of, _ := out["output_format"].(map[string]any)
|
||||
if of == nil || of["codec"] != "mp3" {
|
||||
t.Fatalf("output_format 应原样保留: %v", out["output_format"])
|
||||
}
|
||||
if out["language"] != "zh" || out["speed"] == nil {
|
||||
t.Fatalf("language/speed 应保留: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
|
||||
func TestModerationInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credent
|
||||
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.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||
if err := svc.db.AutoMigrate(&model.Setting{}, &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
|
||||
@@ -228,6 +228,46 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
||||
return ch
|
||||
}
|
||||
|
||||
// TestFilterDeprecatedModels 断言「过滤弃用模型」开关对列表 / 路由的过滤与持久化。
|
||||
func TestFilterDeprecatedModels(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)
|
||||
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
|
||||
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
|
||||
if err := gw.db.Create(old).Error; err != nil {
|
||||
t.Fatalf("seed deprecated cache: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
list, _ := gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 2 {
|
||||
t.Fatalf("开关关:应含弃用模型,got %d", len(list.Data))
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||
}
|
||||
list, _ = gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
|
||||
t.Fatalf("开关开:弃用模型应被过滤,got %+v", list.Data)
|
||||
}
|
||||
if _, err := gw.pick(ctx, "meta.llama-old", "", "CHAT", map[uint]bool{}); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("开关开:弃用模型路由应不可达,err = %v", err)
|
||||
}
|
||||
// 持久化:重建 service 后开关仍生效
|
||||
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||
if !gw2.FilterDeprecated() {
|
||||
t.Error("重建后开关状态应保持开启")
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, false); err != nil {
|
||||
t.Fatalf("关闭开关: %v", err)
|
||||
}
|
||||
if list, _ = gw.GatewayModels(ctx, ""); len(list.Data) != 2 {
|
||||
t.Errorf("开关关:弃用模型应恢复,got %d", len(list.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPriorityAndBreaker(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
Reference in New Issue
Block a user