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:
@@ -43,3 +43,21 @@ type SpeechRequest struct {
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// TtsRequest 描述 /ai/v1/tts 请求体的已知字段(xAI 官方 TTS 格式,文档用途)。
|
||||
// model 为网关扩展字段(缺省 xai.grok-tts);未列字段原样透传上游。
|
||||
type TtsRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language"`
|
||||
VoiceID string `json:"voice_id,omitempty"`
|
||||
OutputFormat *TtsOutputFormat `json:"output_format,omitempty"`
|
||||
Speed float64 `json:"speed,omitempty"`
|
||||
}
|
||||
|
||||
// TtsOutputFormat 是 xAI TTS 输出格式配置(缺省 MP3 24kHz/128kbps)。
|
||||
type TtsOutputFormat struct {
|
||||
Codec string `json:"codec,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
BitRate int `json:"bit_rate,omitempty"`
|
||||
}
|
||||
|
||||
@@ -372,3 +372,40 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||
type aiSettingsResponse struct {
|
||||
FilterDeprecated bool `json:"filterDeprecated"`
|
||||
}
|
||||
|
||||
// aiSettings 返回 AI 网关全局设置。
|
||||
//
|
||||
// @Summary AI 网关全局设置
|
||||
// @Tags AI 管理
|
||||
// @Success 200 {object} aiSettingsResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-settings [get]
|
||||
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||
}
|
||||
|
||||
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
||||
//
|
||||
// @Summary 更新 AI 网关全局设置
|
||||
// @Tags AI 管理
|
||||
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
||||
// @Success 200 {object} aiSettingsResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-settings [put]
|
||||
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||
var req aiSettingsResponse
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||
}
|
||||
|
||||
@@ -29,12 +29,38 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
h.speechRespond(c, "speech", modelName, raw, body)
|
||||
}
|
||||
|
||||
// tts 是 xAI 官方格式 TTS 端点(/ai/v1/tts),转换为上游 OpenAI 兼容形态后复用 Speech 编排。
|
||||
//
|
||||
// @Summary xAI 官方格式文本转语音端点
|
||||
// @Tags AI 网关
|
||||
// @Param body body aiwire.TtsRequest true "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)"
|
||||
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||
// @Router /ai/v1/tts [post]
|
||||
func (h *aiGatewayHandler) tts(c *gin.Context) {
|
||||
raw, err := c.GetRawData()
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
modelName, body, err := service.TtsBodyConvert(raw)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
h.speechRespond(c, "tts", modelName, raw, body)
|
||||
}
|
||||
|
||||
// speechRespond 是 audioSpeech / tts 的公共主体:白名单校验、上游调用、日志与音频响应。
|
||||
func (h *aiGatewayHandler) speechRespond(c *gin.Context, endpoint, modelName string, raw, body []byte) {
|
||||
if !checkKeyModel(c, modelName) {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c))
|
||||
entry := h.logEntry(c, "speech", modelName, false, meta, start)
|
||||
entry := h.logEntry(c, endpoint, modelName, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
@@ -43,7 +69,7 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "speech", modelName, false, string(raw), nil)
|
||||
h.maybeLogContent(c, callID, endpoint, modelName, false, string(raw), nil)
|
||||
if contentType == "" {
|
||||
contentType = "audio/mpeg"
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||
`{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
|
||||
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
|
||||
`{"input":[]}`, 400, []string{"invalid_request_error"}},
|
||||
{"tts 缺 language 拒绝", "open-key-12345", "/ai/v1/tts",
|
||||
`{"text":"你好"}`, 400, []string{"invalid_request_error"}},
|
||||
{"tts 白名单外拦截", "limited-key-1234", "/ai/v1/tts",
|
||||
`{"model":"meta.llama-3.3-70b-instruct","text":"你好","language":"zh"}`, 404, deny},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -16,6 +16,7 @@ func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
||||
ai.POST("/messages", aih.messages)
|
||||
ai.POST("/embeddings", aih.embeddings)
|
||||
ai.POST("/audio/speech", aih.audioSpeech)
|
||||
ai.POST("/tts", aih.tts)
|
||||
ai.POST("/rerank", aih.rerank)
|
||||
ai.POST("/moderations", aih.moderations)
|
||||
ai.GET("/models", aih.listModels)
|
||||
@@ -36,6 +37,8 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
|
||||
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
|
||||
|
||||
@@ -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