diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfba0a..1b47025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 +## [0.5.0] + +### Added + +- xAI 官方格式文本转语音端点 `POST /ai/v1/tts`:接受 xAI 官方 TTS 请求(`text` / `language` 必填,`voice_id`、`output_format`{codec, sample_rate, bit_rate}、`speed` 等),网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与渠道调度;`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format` 对象与 `speed` 透传生效,xAI SDK / 客户端可直接指向网关 +- 「过滤弃用模型」开关(`GET` / `PUT /api/v1/ai-settings`,持久化):开启后 OCI 已宣布弃用(即使未到退役日)的模型从模型列表与路由中同时排除,关闭恢复;渠道同步入库与 30 天退役提醒不受影响 + +### Changed + +- AI 网关文档更名为 `docs/AI网关.md`(原 `docs/ai-gateway.md`),README 引用同步 + ## [0.4.0] ### Added diff --git a/DASH_VERSION b/DASH_VERSION index fb7a04c..b043aa6 100644 --- a/DASH_VERSION +++ b/DASH_VERSION @@ -1 +1 @@ -v0.4.0 +v0.5.0 diff --git a/README.md b/README.md index cb3c81a..a23d881 100644 --- a/README.md +++ b/README.md @@ -206,11 +206,12 @@ server { | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE | | `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 | | `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice) | 否 | +| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 | | `POST /ai/v1/rerank` | 文档重排(Cohere Rerank) | 否 | | `POST /ai/v1/moderations` | 内容审核(OCI Guardrails) | 否 | | `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 | -协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](docs/ai-gateway.md)**。 +协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。 ## API 与配置 diff --git a/docs/ai-gateway.md b/docs/AI网关.md similarity index 90% rename from docs/ai-gateway.md rename to docs/AI网关.md index 4456357..05a8591 100644 --- a/docs/ai-gateway.md +++ b/docs/AI网关.md @@ -12,6 +12,7 @@ AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE | | `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 | | `POST /ai/v1/audio/speech` | OpenAI Audio Speech,文本转语音(xAI Voice) | 否 | +| `POST /ai/v1/tts` | xAI 官方格式文本转语音(同一上游,网关转换) | 否 | | `POST /ai/v1/rerank` | 文档重排(Cohere Rerank,Jina 风格协议) | 否 | | `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 | | `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 | @@ -25,6 +26,7 @@ AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer - Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供 - 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试 - Audio Speech 直通 OCI 兼容面(模型 `xai.grok-tts`,voice 取 xAI Grok Voice 列表:`ara`/`eve`/`leo`/`rex`/`sal`);上游把 `language` 当必填,缺省时网关自动注入 `"auto"`,xAI 专属参数(`output_format` 等)可平铺在请求体透传;仅单请求返回音频,不提供 WebSocket 流式 +- `/ai/v1/tts` 为 xAI 官方 TTS 格式(`text`/`language` 必填、`voice_id`、`output_format` 对象)的转换端点:网关转换为 OpenAI 兼容形态后走同一上游与渠道调度,`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format`(codec/sample_rate/bit_rate)与 `speed` 透传生效 - Rerank 走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),请求 `{model, query, documents[], top_n?, return_documents?}`,响应 `results[].index` 指向入参下标;无 token 用量口径,调用日志只记时延 - Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails:`input` 为字符串或字符串数组(至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(阈值 0.5 判定 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged);`model` 字段接受但忽略,无模型白名单维度;实测中文人名/手机号识别较弱,英文 PII 识别正常 - Responses 的 `input_file` 内容块(file_url / file_data)实测被上游拒绝:`File content is currently unsupported for ZDR customers`——网关强制 `store:false` 属 ZDR 形态,该能力在上游侧不可用 @@ -217,6 +219,32 @@ Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除 +
+POST /ai/v1/tts 对比 xAI TTS + +`/ai/v1/tts` 接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)(OCI 无 HTTP 版 xAI 面,网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与调度)。 + +| 标准字段 | 状态 | 网关行为 | +| --- | :---: | --- | +| `text` | 🔄 | 必填非空,转换为上游 `input` | +| `language` | ◐ | 必填(对齐 xAI 官方;接受 BCP-47 或 `auto`),原样透传 | +| `voice_id` | 🔄 | 转换为上游 `voice`;缺省交上游默认(`eve`) | +| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象原样透传,实测生效(44.1kHz/192kbps 验证) | +| `speed` | ➡️ | 原样透传,实测接受 | +| `model`(网关扩展) | ◐ | xAI 官方无此字段;缺省注入 `xai.grok-tts`,可显式覆盖,受密钥白名单限制 | +| `optimize_streaming_latency`、`text_normalization`、`with_timestamps` 及其他未知字段 | ➡️ | 原样透传,是否生效由上游决定 | + +不支持:流式音频输出(xAI 官方 `optimize_streaming_latency` 面向流式场景,本端点响应恒为一次性完整音频);WebSocket 流式(OCI 另有 `wss://…/xai/v1/tts` 私有协议面,网关未代理)。 + +响应边界: + +- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`) +- 无 token 用量口径,调用日志只记时延与渠道(端点名 `tts`) + +实现依据:[`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go) + +
+
POST /ai/v1/rerank 对比 Cohere Rerank @@ -263,4 +291,4 @@ Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 /
-`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。 +`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。面板「过滤弃用模型」开关开启时,OCI 已宣布弃用(即使未到退役日)的模型同时从模型列表与路由中排除,关闭后恢复。 diff --git a/docs/docs.go b/docs/docs.go index b43985c..9d8de37 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -220,6 +220,33 @@ const docTemplate = `{ } } }, + "/ai/v1/tts": { + "post": { + "tags": [ + "AI 网关" + ], + "summary": "xAI 官方格式文本转语音端点", + "parameters": [ + { + "description": "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oci-portal_internal_aiwire.TtsRequest" + } + } + ], + "responses": { + "200": { + "description": "音频字节(Content-Type 透传上游,默认 audio/mpeg)", + "schema": { + "type": "file" + } + } + } + } + }, "/api/v1/about": { "get": { "security": [ @@ -705,6 +732,57 @@ const docTemplate = `{ } } }, + "/api/v1/ai-settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "AI 网关全局设置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.aiSettingsResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "更新 AI 网关全局设置", + "parameters": [ + { + "description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.aiSettingsResponse" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.aiSettingsResponse" + } + } + } + } + }, "/api/v1/auth/credentials": { "get": { "security": [ @@ -5893,6 +5971,14 @@ const docTemplate = `{ } } }, + "internal_api.aiSettingsResponse": { + "type": "object", + "properties": { + "filterDeprecated": { + "type": "boolean" + } + } + }, "internal_api.attachBootVolumeRequest": { "type": "object", "required": [ @@ -7689,6 +7775,43 @@ const docTemplate = `{ } } }, + "oci-portal_internal_aiwire.TtsOutputFormat": { + "type": "object", + "properties": { + "bit_rate": { + "type": "integer" + }, + "codec": { + "type": "string" + }, + "sample_rate": { + "type": "integer" + } + } + }, + "oci-portal_internal_aiwire.TtsRequest": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "model": { + "type": "string" + }, + "output_format": { + "$ref": "#/definitions/oci-portal_internal_aiwire.TtsOutputFormat" + }, + "speed": { + "type": "number" + }, + "text": { + "type": "string" + }, + "voice_id": { + "type": "string" + } + } + }, "oci-portal_internal_aiwire.Usage": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 9c2e24d..2ae5293 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -213,6 +213,33 @@ } } }, + "/ai/v1/tts": { + "post": { + "tags": [ + "AI 网关" + ], + "summary": "xAI 官方格式文本转语音端点", + "parameters": [ + { + "description": "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oci-portal_internal_aiwire.TtsRequest" + } + } + ], + "responses": { + "200": { + "description": "音频字节(Content-Type 透传上游,默认 audio/mpeg)", + "schema": { + "type": "file" + } + } + } + } + }, "/api/v1/about": { "get": { "security": [ @@ -698,6 +725,57 @@ } } }, + "/api/v1/ai-settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "AI 网关全局设置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.aiSettingsResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "更新 AI 网关全局设置", + "parameters": [ + { + "description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.aiSettingsResponse" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.aiSettingsResponse" + } + } + } + } + }, "/api/v1/auth/credentials": { "get": { "security": [ @@ -5886,6 +5964,14 @@ } } }, + "internal_api.aiSettingsResponse": { + "type": "object", + "properties": { + "filterDeprecated": { + "type": "boolean" + } + } + }, "internal_api.attachBootVolumeRequest": { "type": "object", "required": [ @@ -7682,6 +7768,43 @@ } } }, + "oci-portal_internal_aiwire.TtsOutputFormat": { + "type": "object", + "properties": { + "bit_rate": { + "type": "integer" + }, + "codec": { + "type": "string" + }, + "sample_rate": { + "type": "integer" + } + } + }, + "oci-portal_internal_aiwire.TtsRequest": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "model": { + "type": "string" + }, + "output_format": { + "$ref": "#/definitions/oci-portal_internal_aiwire.TtsOutputFormat" + }, + "speed": { + "type": "number" + }, + "text": { + "type": "string" + }, + "voice_id": { + "type": "string" + } + } + }, "oci-portal_internal_aiwire.Usage": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 51f46d9..d3ace40 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -46,6 +46,11 @@ definitions: key: type: string type: object + internal_api.aiSettingsResponse: + properties: + filterDeprecated: + type: boolean + type: object internal_api.attachBootVolumeRequest: properties: bootVolumeId: @@ -1219,6 +1224,30 @@ definitions: type: type: string type: object + oci-portal_internal_aiwire.TtsOutputFormat: + properties: + bit_rate: + type: integer + codec: + type: string + sample_rate: + type: integer + type: object + oci-portal_internal_aiwire.TtsRequest: + properties: + language: + type: string + model: + type: string + output_format: + $ref: '#/definitions/oci-portal_internal_aiwire.TtsOutputFormat' + speed: + type: number + text: + type: string + voice_id: + type: string + type: object oci-portal_internal_aiwire.Usage: properties: completion_tokens: @@ -2752,6 +2781,23 @@ paths: summary: OpenAI Responses 兼容端点 tags: - AI 网关 + /ai/v1/tts: + post: + parameters: + - description: xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传) + in: body + name: body + required: true + schema: + $ref: '#/definitions/oci-portal_internal_aiwire.TtsRequest' + responses: + "200": + description: 音频字节(Content-Type 透传上游,默认 audio/mpeg) + schema: + type: file + summary: xAI 官方格式文本转语音端点 + tags: + - AI 网关 /api/v1/about: get: responses: @@ -3040,6 +3086,36 @@ paths: summary: '---- 聚合模型与调用日志 ----' tags: - AI 管理 + /api/v1/ai-settings: + get: + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.aiSettingsResponse' + security: + - BearerAuth: [] + summary: AI 网关全局设置 + tags: + - AI 管理 + put: + parameters: + - description: 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api.aiSettingsResponse' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.aiSettingsResponse' + security: + - BearerAuth: [] + summary: 更新 AI 网关全局设置 + tags: + - AI 管理 /api/v1/auth/credentials: get: responses: diff --git a/internal/aiwire/moderations.go b/internal/aiwire/moderations.go index 7d35cce..7f24df1 100644 --- a/internal/aiwire/moderations.go +++ b/internal/aiwire/moderations.go @@ -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"` +} diff --git a/internal/api/aiadmin.go b/internal/api/aiadmin.go index 0c87489..ae026e3 100644 --- a/internal/api/aiadmin.go +++ b/internal/api/aiadmin.go @@ -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()}) +} diff --git a/internal/api/aigateway_extras.go b/internal/api/aigateway_extras.go index 51130ef..162bdfe 100644 --- a/internal/api/aigateway_extras.go +++ b/internal/api/aigateway_extras.go @@ -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" } diff --git a/internal/api/aigateway_test.go b/internal/api/aigateway_test.go index ff6a8f1..82ffdf1 100644 --- a/internal/api/aigateway_test.go +++ b/internal/api/aigateway_test.go @@ -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) { diff --git a/internal/api/routes_ai.go b/internal/api/routes_ai.go index f311d79..fd8ad13 100644 --- a/internal/api/routes_ai.go +++ b/internal/api/routes_ai.go @@ -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) diff --git a/internal/service/aigateway.go b/internal/service/aigateway.go index 4762a36..4767a33 100644 --- a/internal/service/aigateway.go +++ b/internal/service/aigateway.go @@ -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{}} diff --git a/internal/service/aigateway_chat.go b/internal/service/aigateway_chat.go index 1f4e738..8c24461 100644 --- a/internal/service/aigateway_chat.go +++ b/internal/service/aigateway_chat.go @@ -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 diff --git a/internal/service/aigateway_extras.go b/internal/service/aigateway_extras.go index 6f6efa8..da0f9d0 100644 --- a/internal/service/aigateway_extras.go +++ b/internal/service/aigateway_extras.go @@ -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{} diff --git a/internal/service/aigateway_extras_test.go b/internal/service/aigateway_extras_test.go index 5818956..7cee53c 100644 --- a/internal/service/aigateway_extras_test.go +++ b/internal/service/aigateway_extras_test.go @@ -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 拒绝", ``, 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 { diff --git a/internal/service/aigateway_test.go b/internal/service/aigateway_test.go index 5b48f39..4b426e9 100644 --- a/internal/service/aigateway_test.go +++ b/internal/service/aigateway_test.go @@ -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)