AI网关新增xAI格式TTS端点与过滤弃用模型开关
CI / test (push) Successful in 32s
Release / release (push) Successful in 54s

- 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:
2026-07-14 09:49:28 +08:00
parent 0a86b5a291
commit b4ef98a25e
17 changed files with 626 additions and 7 deletions
+37
View File
@@ -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()})
}
+28 -2
View File
@@ -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"
}
+4
View File
@@ -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) {
+3
View File
@@ -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)