Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4ef98a25e |
@@ -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
@@ -1 +1 @@
|
|||||||
v0.4.0
|
v0.5.0
|
||||||
|
|||||||
@@ -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 与配置
|
||||||
|
|
||||||
|
|||||||
@@ -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 Rerank,Jina 风格协议) | 否 |
|
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank,Jina 风格协议) | 否 |
|
||||||
| `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
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user