1 Commits
Author SHA1 Message Date
wangdefa b4ef98a25e 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
2026-07-14 09:49:28 +08:00
17 changed files with 626 additions and 7 deletions
+11
View File
@@ -2,6 +2,17 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 格式参考 [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] ## [0.4.0]
### Added ### Added
+1 -1
View File
@@ -1 +1 @@
v0.4.0 v0.5.0
+2 -1
View File
@@ -206,11 +206,12 @@ server {
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE | | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 | | `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
| `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice | 否 | | `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice | 否 |
| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 |
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank | 否 | | `POST /ai/v1/rerank` | 文档重排(Cohere Rerank | 否 |
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails | 否 | | `POST /ai/v1/moderations` | 内容审核(OCI Guardrails | 否 |
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 | | `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](docs/ai-gateway.md)**。 协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
## API 与配置 ## API 与配置
+29 -1
View File
@@ -12,6 +12,7 @@ AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE | | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 | | `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
| `POST /ai/v1/audio/speech` | OpenAI Audio Speech,文本转语音(xAI Voice | 否 | | `POST /ai/v1/audio/speech` | OpenAI Audio Speech,文本转语音(xAI Voice | 否 |
| `POST /ai/v1/tts` | xAI 官方格式文本转语音(同一上游,网关转换) | 否 |
| `POST /ai/v1/rerank` | 文档重排(Cohere RerankJina 风格协议) | 否 | | `POST /ai/v1/rerank` | 文档重排(Cohere RerankJina 风格协议) | 否 |
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 | | `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 | | `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
@@ -25,6 +26,7 @@ AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer
- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供 - 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 流式 - 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 用量口径,调用日志只记时延 - 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 识别正常 - 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 形态,该能力在上游侧不可用 - 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 直通 + 本地门禁”:除
</details> </details>
<details>
<summary><code>POST /ai/v1/tts</code> 对比 xAI TTS</summary>
`/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)
</details>
<details> <details>
<summary><code>POST /ai/v1/rerank</code> 对比 Cohere Rerank</summary> <summary><code>POST /ai/v1/rerank</code> 对比 Cohere Rerank</summary>
@@ -263,4 +291,4 @@ Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 /
</details> </details>
`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 已宣布弃用(即使未到退役日)的模型同时从模型列表与路由中排除,关闭后恢复。
+123
View File
@@ -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": { "/api/v1/about": {
"get": { "get": {
"security": [ "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": { "/api/v1/auth/credentials": {
"get": { "get": {
"security": [ "security": [
@@ -5893,6 +5971,14 @@ const docTemplate = `{
} }
} }
}, },
"internal_api.aiSettingsResponse": {
"type": "object",
"properties": {
"filterDeprecated": {
"type": "boolean"
}
}
},
"internal_api.attachBootVolumeRequest": { "internal_api.attachBootVolumeRequest": {
"type": "object", "type": "object",
"required": [ "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": { "oci-portal_internal_aiwire.Usage": {
"type": "object", "type": "object",
"properties": { "properties": {
+123
View File
@@ -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": { "/api/v1/about": {
"get": { "get": {
"security": [ "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": { "/api/v1/auth/credentials": {
"get": { "get": {
"security": [ "security": [
@@ -5886,6 +5964,14 @@
} }
} }
}, },
"internal_api.aiSettingsResponse": {
"type": "object",
"properties": {
"filterDeprecated": {
"type": "boolean"
}
}
},
"internal_api.attachBootVolumeRequest": { "internal_api.attachBootVolumeRequest": {
"type": "object", "type": "object",
"required": [ "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": { "oci-portal_internal_aiwire.Usage": {
"type": "object", "type": "object",
"properties": { "properties": {
+76
View File
@@ -46,6 +46,11 @@ definitions:
key: key:
type: string type: string
type: object type: object
internal_api.aiSettingsResponse:
properties:
filterDeprecated:
type: boolean
type: object
internal_api.attachBootVolumeRequest: internal_api.attachBootVolumeRequest:
properties: properties:
bootVolumeId: bootVolumeId:
@@ -1219,6 +1224,30 @@ definitions:
type: type:
type: string type: string
type: object 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: oci-portal_internal_aiwire.Usage:
properties: properties:
completion_tokens: completion_tokens:
@@ -2752,6 +2781,23 @@ paths:
summary: OpenAI Responses 兼容端点 summary: OpenAI Responses 兼容端点
tags: tags:
- AI 网关 - 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: /api/v1/about:
get: get:
responses: responses:
@@ -3040,6 +3086,36 @@ paths:
summary: '---- 聚合模型与调用日志 ----' summary: '---- 聚合模型与调用日志 ----'
tags: tags:
- AI 管理 - 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: /api/v1/auth/credentials:
get: get:
responses: responses:
+18
View File
@@ -43,3 +43,21 @@ type SpeechRequest struct {
ResponseFormat string `json:"response_format,omitempty"` ResponseFormat string `json:"response_format,omitempty"`
Language string `json:"language,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"`
}
+37
View File
@@ -372,3 +372,40 @@ func aiPathID(c *gin.Context) (uint, bool) {
} }
return uint(id), true 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()) aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return 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) { if !checkKeyModel(c, modelName) {
return return
} }
start := time.Now() start := time.Now()
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c)) 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 { if err != nil {
upstreamError(c, err) upstreamError(c, err)
entry.ErrMsg = err.Error() entry.ErrMsg = err.Error()
@@ -43,7 +69,7 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
} }
entry.Status = http.StatusOK entry.Status = http.StatusOK
callID := h.gw.LogCall(entry) 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 == "" { if contentType == "" {
contentType = "audio/mpeg" 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"}}, `{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations", {"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
`{"input":[]}`, 400, []string{"invalid_request_error"}}, `{"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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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("/messages", aih.messages)
ai.POST("/embeddings", aih.embeddings) ai.POST("/embeddings", aih.embeddings)
ai.POST("/audio/speech", aih.audioSpeech) ai.POST("/audio/speech", aih.audioSpeech)
ai.POST("/tts", aih.tts)
ai.POST("/rerank", aih.rerank) ai.POST("/rerank", aih.rerank)
ai.POST("/moderations", aih.moderations) ai.POST("/moderations", aih.moderations)
ai.GET("/models", aih.listModels) 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/probe", aiadmin.probeChannel)
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels) secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
secured.GET("/ai-models", aiadmin.gatewayModels) 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.GET("/ai-blacklist", aiadmin.listBlacklist)
secured.POST("/ai-blacklist", aiadmin.addBlacklist) secured.POST("/ai-blacklist", aiadmin.addBlacklist)
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist) secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
+33 -1
View File
@@ -12,6 +12,7 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -58,11 +59,39 @@ type AiGatewayService struct {
lastTouch map[uint]time.Time lastTouch map[uint]time.Time
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子 // onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
onChannelsChanged func(context.Context) onChannelsChanged func(context.Context)
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
filterDeprecated atomic.Bool
} }
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。 // NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService { 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 注册渠道数量变化钩子(渠道创建/删除成功后调用)。 // SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
@@ -511,6 +540,9 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
if group != "" { if group != "" {
q = q.Where("ai_channels.channel_group = ?", 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 var rows []model.AiModelCache
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}} list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
+3
View File
@@ -164,6 +164,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
} else { } else {
q = q.Where("capability = ?", capability) q = q.Where("capability = ?", capability)
} }
if s.FilterDeprecated() {
q = q.Where("deprecated_at IS NULL")
}
var rows []model.AiModelCache var rows []model.AiModelCache
if err := q.Find(&rows).Error; err != nil { if err := q.Find(&rows).Error; err != nil {
return nil, nil, err return nil, nil, err
+36
View File
@@ -40,6 +40,42 @@ func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
return modelName, out, nil 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 次)。 // Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) { func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
meta := ChatMeta{} meta := ChatMeta{}
+58
View File
@@ -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 解析与边界校验。 // TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
func TestModerationInputs(t *testing.T) { func TestModerationInputs(t *testing.T) {
tests := []struct { tests := []struct {
+41 -1
View File
@@ -136,7 +136,7 @@ func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credent
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) { func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
t.Helper() t.Helper()
svc := newTestService(t, client) 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) t.Fatalf("auto migrate ai tables: %v", err)
} }
return NewAiGatewayService(svc.db, svc, client), svc return NewAiGatewayService(svc.db, svc, client), svc
@@ -228,6 +228,46 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
return ch 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) { func TestPickPriorityAndBreaker(t *testing.T) {
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
cfg := importAliveConfig(t, svc) cfg := importAliveConfig(t, svc)