Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f8a0539c | ||
|
|
a8bde89b56 | ||
|
|
1da2197a6c | ||
|
|
79c9e4d9b9 | ||
|
|
2fea315430 | ||
|
|
b4ef98a25e |
@@ -2,6 +2,52 @@
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||
|
||||
## [0.6.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Responses 直通 codex 兼容层,codex CLI(`wire_api = "responses"`)指向网关即用,实测 codex-cli 0.144.1 主会话与 multi-agent 子代理全链路可用:
|
||||
- `namespace` 工具组(承载 codex multi-agent 与全部 MCP 工具,仅 OpenAI 原生后端识别,OCI 实测 422)拍平为限定名 `function` 工具上送(`ns__child`,`mcp__` 开头子工具名不加前缀),响应中命中的 `function_call` 还原为短名并补回 `namespace` 字段(codex 以该双字段路由);多轮历史 `function_call` 与对象形态 `tool_choice` 上的 `namespace` 字段自动重限定
|
||||
- `custom` 自由格式工具(apply_patch 及 GPT-5.x 档位子代理形态,OCI 不识别)转换为带 `{"input": string}` 包装 schema 的 `function`,响应回转 `custom_tool_call` 并解包 `input`,多轮历史的 `custom_tool_call(_output)` 逆向转换,往返无损;例外:`apply_patch` 整体剥离(grok 系未训练 codex 补丁格式,模型自然回落 shell 编辑)
|
||||
- `tool_search`(codex 工具目录搜索)剥离:namespace 已全量拍平上送,搜索语义冗余且上游不识别
|
||||
- `web_search` 的 OpenAI 专有参数 `external_web_access`(OCI 实测 400):`true` 仅删键放行(等价上游默认行为),`false` 为"仅缓存检索"降权模式,按不越权原则连工具剥离
|
||||
- 以上改写动作(拍平 / 转换 / 剥离)与校验拒绝均记服务端日志可观测
|
||||
- Responses 直通流式升级回退:实测 OCI 上游对超过约 82KB 的流式请求会在推理阶段掐断流(纯请求体积触发,与工具构成无关,非流式不受影响),请求体超 76KB 时自动改调非流式上游并合成最小 SSE 事件序列(`created` → 逐项 `output_item.done` → `completed`)返回,语义完整仅丢失增量输出——0.3.1 中「Responses 直通无法透明降级」的限制自此按体积预判解除
|
||||
|
||||
### Changed
|
||||
|
||||
- `/ai/v1/responses` 工具类型白名单扩展:`namespace` / `custom` / `tool_search` 不再 400 拒绝,按上述兼容策略处理后转发;其余未知工具类型维持请求前置拒绝
|
||||
|
||||
## [0.6.0]
|
||||
|
||||
### Added
|
||||
|
||||
- 渠道模型缓存列表端点 `GET /api/v1/ai-channels/{id}/models`:黑名单模型查询层兜底排除,「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)
|
||||
- 单模型测试端点 `POST /api/v1/ai-channels/{id}/test-model`:对指定模型发 max_tokens=16 试调,通过即写入渠道「探测验证模型」(此后手动探测与每日后台任务将其置于试调候选首位),渠道探测状态不为可用时顺带置可用并复位熔断;未通过如实返回上游错误且不改动渠道状态
|
||||
- 渠道列表响应回填 `modelCount`(模型缓存计数,与模型列表同口径:排除黑名单、随过滤弃用开关)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复渠道探测「无配额」误判:配额试调遇 401/403/鉴权 404 不再立即定论租户无配额(可能仅个别模型无权限),改为继续尝试其余候选,任一成功即判可用,全部失败且出现过鉴权拒绝才判无配额
|
||||
- 探测试调 `max_output_tokens` 由 1 提升到 16:openai.gpt-oss 系列要求 ≥16,原值被 400 拒导致仅有该系列对话模型的渠道被误判
|
||||
|
||||
## [0.5.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复外部身份(OAuth2)登录 / 绑定无系统日志的问题:回调成功记 200(用户名为面板账号)、失败记 401(附失败原因);此前回调为 GET 请求不经写操作日志中间件,完全无记录
|
||||
|
||||
## [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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.4.0
|
||||
v0.6.0
|
||||
|
||||
@@ -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 与配置
|
||||
|
||||
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
# AI 网关
|
||||
|
||||
> [!NOTE]
|
||||
> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
|
||||
> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
|
||||
>
|
||||
> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
|
||||
> 为准;协议差异、兼容改写和实测边界以本文为准。
|
||||
|
||||
**兼容快照:2026-07-15**
|
||||
|
||||
## 快速导航
|
||||
|
||||
- [快速接入](#quick-start)
|
||||
- [端点一览](#endpoints)
|
||||
- [路由与全局行为](#routing)
|
||||
- [Codex 接入](#codex)
|
||||
- [已知限制](#limitations)
|
||||
- [字段兼容矩阵](#compatibility)
|
||||
- [实现索引](#implementation)
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## 快速接入
|
||||
|
||||
### 基础地址与鉴权
|
||||
|
||||
```text
|
||||
Base URL: https://<网关地址>/ai/v1
|
||||
```
|
||||
|
||||
网关密钥在管理面板中创建,支持以下任一请求头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
```http
|
||||
x-api-key: sk-...
|
||||
```
|
||||
|
||||
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
||||
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
||||
列表与路由中移除。
|
||||
|
||||
可先用模型列表验证地址与密钥:
|
||||
|
||||
```bash
|
||||
curl "https://<网关地址>/ai/v1/models" \
|
||||
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
||||
```
|
||||
|
||||
<a id="endpoints"></a>
|
||||
|
||||
## 端点一览
|
||||
|
||||
| 协议域 | 端点 | 角色 | 流式 |
|
||||
| --- | --- | --- | :---: |
|
||||
| 对话 | `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE |
|
||||
| 对话 | `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
|
||||
| 对话 | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||
| 向量 | `POST /ai/v1/embeddings` | OpenAI Embeddings | — |
|
||||
| 语音 | `POST /ai/v1/audio/speech` | OpenAI Audio Speech 外壳 | — |
|
||||
| 语音 | `POST /ai/v1/tts` | xAI TTS 格式转换层 | — |
|
||||
| 检索 | `POST /ai/v1/rerank` | Cohere / Jina 风格文档重排 | — |
|
||||
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
||||
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
||||
|
||||
选择建议:
|
||||
|
||||
- 新客户端优先使用 **Responses**
|
||||
- Anthropic SDK 或 Claude 生态客户端使用 **Messages**
|
||||
- 仅支持旧 OpenAI 对话协议的客户端使用 **Chat Completions**
|
||||
- Embeddings、TTS、Rerank 与 Moderations 使用各自专用端点
|
||||
|
||||
<a id="routing"></a>
|
||||
|
||||
## 路由与全局行为
|
||||
|
||||
### 请求链路
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[客户端] --> B[网关密钥鉴权]
|
||||
B --> C[分组、白名单与全局过滤]
|
||||
C --> D[模型与能力匹配]
|
||||
D --> E[选择最小 Priority]
|
||||
E --> F[同优先级按 Weight 加权]
|
||||
F --> G[OCI GenAI]
|
||||
G -. 可重试且流未建立 .-> D
|
||||
```
|
||||
|
||||
单次请求最多尝试三个渠道。模型不可用、限流、上游服务错误或网络错误可触发
|
||||
换渠道;流式连接建立后不会切换渠道重试。
|
||||
|
||||
### 全局兼容边界
|
||||
|
||||
| 主题 | 当前行为 |
|
||||
| --- | --- |
|
||||
| 会话状态 | 网关不保存会话历史,客户端必须在每次请求中携带完整上下文 |
|
||||
| 上游存储 | Responses 请求始终强制 `store:false` |
|
||||
| 有状态字段 | Responses 拒绝非空 `previous_response_id`、非 `null` 的 `conversation` 和 `background:true` |
|
||||
| 对话上游 | 对话请求统一进入 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主 |
|
||||
| 推理强度 | Responses `reasoning.effort`、Messages `output_config.effort`、Chat `reasoning_effort` 会传给上游;可用档位由模型决定 |
|
||||
| 服务端工具 | Responses 支持 `web_search`、`x_search`、`code_interpreter` 和远程 `mcp`;命名容器管理与 File Search 不提供 |
|
||||
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
||||
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
||||
|
||||
<a id="codex"></a>
|
||||
|
||||
## Codex 接入
|
||||
|
||||
### 主配置
|
||||
|
||||
自定义模型提供方必须写在用户级 `~/.codex/config.toml`。Codex 的项目级
|
||||
`.codex/config.toml` 不允许改写 `model_provider` 与 `model_providers`。
|
||||
|
||||
```toml
|
||||
model = "xai.grok-4.3"
|
||||
model_provider = "oci"
|
||||
|
||||
[model_providers.oci]
|
||||
name = "oci-portal"
|
||||
base_url = "https://<网关地址>/ai/v1"
|
||||
env_key = "OCI_PORTAL_KEY"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
### 自定义子代理
|
||||
|
||||
新版 Codex 可在 `~/.codex/agents/` 或项目级 `.codex/agents/` 放置独立 TOML
|
||||
文件,并为子代理覆盖模型。每个文件都需要 `name`、`description` 和
|
||||
`developer_instructions`:
|
||||
|
||||
```toml
|
||||
# .codex/agents/oci-worker.toml
|
||||
name = "oci-worker"
|
||||
description = "通过 OCI Portal 网关执行通用开发任务。"
|
||||
developer_instructions = """
|
||||
完成被分配的开发任务,保持改动聚焦,并返回验证结果。
|
||||
"""
|
||||
model = "xai.grok-4.3"
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `codex-cli 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
||||
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
||||
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
||||
|
||||
Codex 工具兼容现状:
|
||||
|
||||
| 工具形态 | 网关处理 | 验证状态 |
|
||||
| --- | --- | --- |
|
||||
| `function` | 原样透传 | 可用 |
|
||||
| `namespace` | 子工具拍平为限定名 `function`,响应时还原 | multi-agent 已端到端实测;MCP 同路径有单测 |
|
||||
| `custom` | 顶层普通工具转换为 `function`,调用与历史记录双向回转 | 可用,但存在降级边界 |
|
||||
| `custom:apply_patch` | 直接剥离,让模型回落到其他编辑方式 | 有意限制 |
|
||||
| `tool_search` | namespace 已完整展开,目录搜索语义冗余,直接剥离 | 有意降级 |
|
||||
|
||||
配置语法参考 [Codex Subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
|
||||
与 [Codex Advanced Configuration](https://learn.chatgpt.com/docs/config-file/config-advanced)。
|
||||
|
||||
<a id="limitations"></a>
|
||||
|
||||
## 已知限制
|
||||
|
||||
### 超大流式请求
|
||||
|
||||
> [!WARNING]
|
||||
> 2026-07 实测:OCI xAI 兼容面对**完整请求体超过约 82 KB** 的流式请求,
|
||||
> 可能在推理阶段提前断流;该现象由请求体积触发,与工具构成或字符集无关,
|
||||
> 同一请求改为非流式可正常完成。
|
||||
|
||||
网关按协议采取不同保护:
|
||||
|
||||
- **Responses**:兼容改写后的请求体超过 **76 KB** 时,预防性改为非流式上游
|
||||
请求,再合成最小 SSE 序列(`response.created` →
|
||||
`response.output_item.done` → `response.completed`);结果语义保留,但没有增量输出
|
||||
- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
|
||||
非流式重做,并合成对应 chunk / event 序列
|
||||
- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
|
||||
部分事件
|
||||
|
||||
### ZDR 与文件输入
|
||||
|
||||
Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
|
||||
|
||||
```text
|
||||
File content is currently unsupported for ZDR customers
|
||||
```
|
||||
|
||||
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
||||
|
||||
### 规格与实测边界
|
||||
|
||||
本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
|
||||
部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
||||
|
||||
<a id="compatibility"></a>
|
||||
|
||||
## 字段兼容矩阵
|
||||
|
||||
矩阵基线:
|
||||
|
||||
- [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)
|
||||
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
||||
- [OpenAI Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create)
|
||||
- [OpenAI Audio Speech](https://developers.openai.com/api/reference/resources/audio/methods/speech)
|
||||
- [OpenAI Moderations](https://developers.openai.com/api/reference/resources/moderations/methods/create)
|
||||
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
||||
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
||||
|
||||
| 标记 | 含义 |
|
||||
| :---: | --- |
|
||||
| ✅ | 网关直接支持 |
|
||||
| ➡️ | 原样保留或透传;是否生效由 OCI 上游决定 |
|
||||
| 🔄 | 网关执行字段或协议转换后支持 |
|
||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||
|
||||
### OpenAI Responses
|
||||
|
||||
**`POST /ai/v1/responses` · 无状态主接口**
|
||||
|
||||
网关以原始 JSON 为基底保留未知字段,但会强制关闭上游存储,并执行下表列出的
|
||||
Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转发。
|
||||
|
||||
<details open>
|
||||
<summary><strong>核心字段</strong></summary>
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填;通过密钥白名单并匹配可用渠道后送往上游 |
|
||||
| `input` | ➡️ | 支持字符串或 item 数组;普通内容保留,Codex 工具历史项可能改写 |
|
||||
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后保留,不做范围或模型能力校验 |
|
||||
| `temperature`、`top_p` | ➡️ | 保留,不做取值范围校验 |
|
||||
| `parallel_tool_calls` | ◐ | 通常保留;若全部工具被剥离,则与 `tool_choice` 一并删除 |
|
||||
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个对象保留;是否生效由 OCI 模型决定 |
|
||||
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||
| `stream` | ◐ | 支持 SSE;请求体超过 76 KB 时改为非流式上游并合成 SSE |
|
||||
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>工具兼容</strong></summary>
|
||||
|
||||
| 工具或参数 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `tools[].type=function` | ➡️ | 非流式与流式均可,工具对象保留 |
|
||||
| `web_search` / `x_search` / `code_interpreter` | ◐ | Oracle 文档化的 xAI 服务端工具;参数与限制遵循 [xAI 规格](https://docs.oracle.com/en-us/iaas/Content/generative-ai/get-started-agents.htm#xai-compatible-tools) |
|
||||
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
||||
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
||||
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
||||
| `custom:apply_patch` | ◐ | 整体剥离;grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
||||
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
||||
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
||||
|
||||
</details>
|
||||
|
||||
本地拒绝(返回 400):
|
||||
|
||||
- 非空 `previous_response_id`
|
||||
- 非 `null` 的 `conversation`
|
||||
- `background:true`
|
||||
- 未列入白名单的工具类型,例如 `web_search_preview`、`file_search`、
|
||||
`computer`、`image_generation`、`shell`
|
||||
|
||||
响应边界:
|
||||
|
||||
- 无工具还原且未触发流式升级时,普通响应保持直通语义
|
||||
- namespace / custom 调用会在非流式响应与命中的 SSE `data` 事件中定向还原
|
||||
- 普通 SSE 不补 `data: [DONE]`,reasoning 增量不会被过滤
|
||||
- 未知模型返回 404,无可用渠道返回 503
|
||||
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
||||
|
||||
### OpenAI Chat Completions
|
||||
|
||||
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
||||
|
||||
请求先转换为 Responses,再将 OCI Responses 响应桥接回 Chat Completions。
|
||||
只有下表字段会进入上游请求。
|
||||
|
||||
<details>
|
||||
<summary><strong>展开字段矩阵</strong></summary>
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制 |
|
||||
| `messages` | 🔄 | 必填,转换为 Responses `input` / `instructions` |
|
||||
| `system` / `developer` 消息 | ◐ | 文本按顺序合并为 `instructions`;块数组中的非文本内容忽略 |
|
||||
| `user` / `assistant` 文本 | 🔄 | 字符串和 `text` 块分别转为 `input_text` / `output_text` |
|
||||
| `image_url` | ◐ | URL 或 data URI 转为 `input_image`;`detail` 忽略 |
|
||||
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output` |
|
||||
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
||||
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
||||
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 写入 Responses,不校验范围或模型能力 |
|
||||
| `stream` | 🔄 | Responses SSE 转为 `chat.completion.chunk`,末尾补 `data: [DONE]` |
|
||||
| `stream_options.include_usage` | 🔄 | 控制终块后的独立 usage 块 |
|
||||
| `tools[].type=function` | ◐ | 支持 `name`、`description`、`parameters`;`strict` 忽略 |
|
||||
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function |
|
||||
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format` |
|
||||
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort` |
|
||||
| `store` | 🔄 | 客户端取值忽略,上游始终使用 `store:false` |
|
||||
|
||||
</details>
|
||||
|
||||
不支持(返回 400):`input_audio`、`file`、`refusal` 等消息内容块,以及
|
||||
`function` 之外的工具类型。
|
||||
|
||||
<details>
|
||||
<summary><strong>静默忽略字段</strong></summary>
|
||||
|
||||
消息级 `name` / `refusal` / `audio` / 旧式 `function_call`;`stop`、`seed`、
|
||||
`n`、`frequency_penalty`、`presence_penalty`、`logprobs`、`top_logprobs`、
|
||||
`logit_bias`;`user`、`audio`、`modalities`、`prediction`、`metadata`、
|
||||
`moderation`、`prompt_cache_key`、`safety_identifier`、`service_tier`、
|
||||
`verbosity`、`web_search_options`、`stream_options.include_obfuscation`
|
||||
及其他未知字段。
|
||||
|
||||
</details>
|
||||
|
||||
响应边界:
|
||||
|
||||
- 非流式固定生成一个 `choices[0]`;文本合并,函数调用转为 `tool_calls`
|
||||
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`
|
||||
- reasoning、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
||||
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 与
|
||||
`prompt_tokens_details.cached_tokens`
|
||||
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
||||
|
||||
### Anthropic Messages
|
||||
|
||||
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
||||
|
||||
支持 `Authorization: Bearer` 与 `x-api-key`,但不校验或使用
|
||||
`anthropic-version`、`anthropic-beta` 请求头。
|
||||
|
||||
<details>
|
||||
<summary><strong>展开顶层字段矩阵</strong></summary>
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 用于模型与渠道选择;空字符串通常最终返回模型不存在 |
|
||||
| `max_tokens` | 🔄 | 缺省或 ≤0 时使用 8192,再转为 `max_output_tokens` |
|
||||
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
||||
| `system` | ◐ | 支持字符串或 text 块数组;文本块拼接,附加字段忽略 |
|
||||
| `temperature`、`top_p` | ◐ | 写入 Responses,不校验范围或模型能力 |
|
||||
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 标准事件序列 |
|
||||
| `tools` | ◐ | 每个工具转为 Responses `function`;服务端工具类型不保留原语义 |
|
||||
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool` |
|
||||
| `output_config.effort` | 🔄 | 转小写后映射为 `reasoning.effort` |
|
||||
|
||||
</details>
|
||||
|
||||
内容块兼容:
|
||||
|
||||
| `messages[].content` | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
||||
| `image` | ◐ | 仅支持 `base64` 与 `url` source |
|
||||
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称与输入 |
|
||||
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 与非文本结果丢失 |
|
||||
| `null` / 空块数组 | ◐ | 消息可能从上游 input 中消失,不返回参数错误 |
|
||||
|
||||
不支持(返回 400):`document`、服务端工具结果及未知内容块。历史
|
||||
`thinking` / `redacted_thinking` 会删除。
|
||||
|
||||
<details>
|
||||
<summary><strong>静默忽略字段</strong></summary>
|
||||
|
||||
`top_k`、`stop_sequences`、`metadata`、`thinking`、`output_config` 其余子字段、
|
||||
`cache_control`、`container`、`inference_geo`、`service_tier` 及其他未知顶层字段。
|
||||
|
||||
</details>
|
||||
|
||||
响应边界:
|
||||
|
||||
- 仅将 Responses `output_text` 转为 `text`,`function_call` 转为 `tool_use`
|
||||
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`
|
||||
- 不生成 Anthropic thinking / signature 内容块
|
||||
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
||||
|
||||
### OpenAI Embeddings
|
||||
|
||||
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受白名单限制,且需要 `EMBEDDING` 能力渠道 |
|
||||
| 字符串 `input` | ✅ | 包装为单个输入后调用 OCI |
|
||||
| 字符串数组 `input` | ✅ | 按原顺序调用 OCI |
|
||||
| `dimensions` | ◐ | 映射为 OCI 输出维度,不校验范围或模型能力 |
|
||||
| `encoding_format=float` | ✅ | 返回 float 数组;省略时相同 |
|
||||
|
||||
不支持(返回 400):token ID 数组、空数组、`null` 和
|
||||
`encoding_format=base64`。`user` 与其他未知字段静默忽略。
|
||||
|
||||
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||
|
||||
### 语音生成
|
||||
|
||||
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
||||
|
||||
#### OpenAI Audio Speech
|
||||
|
||||
**`POST /ai/v1/audio/speech`**
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受白名单限制,需要 `TTS` 能力渠道;当前为 `xai.grok-tts` |
|
||||
| `input` | ◐ | 必填非空,随后保留 |
|
||||
| `voice` | ➡️ | 使用 xAI 音色 `ara` / `eve` / `leo` / `rex` / `sal` |
|
||||
| `response_format` | ➡️ | 实测 `mp3` 可用,其余格式由上游决定 |
|
||||
| `language`(扩展) | 🔄 | 上游必填;缺省时注入 `"auto"` |
|
||||
| `speed`、`instructions` 与未知字段 | ➡️ | 保留,是否生效由上游决定 |
|
||||
|
||||
#### xAI TTS
|
||||
|
||||
**`POST /ai/v1/tts`**
|
||||
|
||||
接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech),
|
||||
转换为 OpenAI 兼容形态后进入同一上游。
|
||||
|
||||
| 字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `text` | 🔄 | 必填非空,转换为 `input` |
|
||||
| `language` | ◐ | 必填,接受 BCP-47 或 `auto` |
|
||||
| `voice_id` | 🔄 | 转为 `voice`;缺省交给上游默认值 `eve` |
|
||||
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象保留,实测生效 |
|
||||
| `speed` | ➡️ | 保留,实测可用 |
|
||||
| `model`(网关扩展) | ◐ | 缺省注入 `xai.grok-tts`,可覆盖,受白名单限制 |
|
||||
| 其余未知字段 | ➡️ | 保留,是否生效由上游决定 |
|
||||
|
||||
共同响应边界:
|
||||
|
||||
- 成功响应为一次性完整音频,`Content-Type` 透传上游,缺省 `audio/mpeg`
|
||||
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||
- 无 token 用量口径,调用日志只记时延与渠道
|
||||
|
||||
### Rerank
|
||||
|
||||
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||
|
||||
上游走 OCI typed 面,模型为 `cohere.rerank-v4.0-pro` 或 `-fast`。
|
||||
|
||||
| 字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受白名单限制,需要 `RERANK` 能力渠道 |
|
||||
| `query` | ✅ | 必填非空 |
|
||||
| `documents` | ◐ | 仅接受字符串数组;旧版 `{"text": ...}` 对象数组返回 400 |
|
||||
| `top_n` | ✅ | 可选,限制返回条数 |
|
||||
| `return_documents` | ✅ | 为 `true` 时回带原文 |
|
||||
|
||||
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
||||
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
||||
|
||||
### Moderations
|
||||
|
||||
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| 字符串 `input` | ✅ | 单条审核 |
|
||||
| 字符串数组 `input` | ✅ | 逐条审核,单次 1~8 条 |
|
||||
|
||||
不支持(返回 400):多模态 input、空字符串条目、空数组或超过 8 条。
|
||||
`model` 接受但忽略,不参与白名单判断。
|
||||
|
||||
响应边界:
|
||||
|
||||
- categories 使用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`
|
||||
- 任一维度得分 ≥ 0.5 时 `flagged=true`
|
||||
- PII 命中放在扩展字段 `results[].pii`,包含 `text`、`label`、`score`、
|
||||
`offset` 与 `length`,不参与 `flagged`
|
||||
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||
- 响应 `model` 恒为 `oci-guardrails`
|
||||
|
||||
### Models
|
||||
|
||||
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
||||
|
||||
响应使用 OpenAI Models 列表外壳,只返回同时通过以下条件的模型:
|
||||
|
||||
1. 存在于当前渠道目录
|
||||
2. 匹配密钥绑定的渠道分组
|
||||
3. 不在全局模型黑名单
|
||||
4. 命中密钥模型白名单(配置时)
|
||||
5. 未被「过滤弃用模型」开关排除
|
||||
|
||||
网关不提供标准的单模型检索端点。
|
||||
|
||||
<a id="implementation"></a>
|
||||
|
||||
## 实现索引
|
||||
|
||||
| 端点 / 能力 | 主要实现 |
|
||||
| --- | --- |
|
||||
| Responses 直通与 Codex 工具兼容 | [`airesponses.go`](../internal/service/airesponses.go) · [`responses.go`](../internal/aiwire/responses.go) |
|
||||
| Chat Completions 转换 | [`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) |
|
||||
| Anthropic Messages 转换 | [`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) |
|
||||
| 渠道路由与 Embeddings | [`aigateway_chat.go`](../internal/service/aigateway_chat.go) |
|
||||
| TTS 上游 | [`genai_speech.go`](../internal/oci/genai_speech.go) · [`aigateway_extras.go`](../internal/service/aigateway_extras.go) |
|
||||
| Rerank / Moderations 上游 | [`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`moderations.go`](../internal/aiwire/moderations.go) |
|
||||
| HTTP Handler 与流式桥接 | [`aigateway.go`](../internal/api/aigateway.go) · [`aigateway_extras.go`](../internal/api/aigateway_extras.go) |
|
||||
|
||||
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
||||
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
||||
@@ -1,266 +0,0 @@
|
||||
# AI 网关
|
||||
|
||||
> 本文是 OCI Portal AI 网关的完整使用与兼容性文档:端点定位、协议兼容边界、已知上游限制与字段兼容矩阵。
|
||||
> 路由与鉴权的机器可读定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 表达的兼容边界以本文为准。
|
||||
|
||||
AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
|
||||
|
||||
| 端点 | 定位 | 流式 |
|
||||
| --- | --- | --- |
|
||||
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE |
|
||||
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions,存量客户端兼容层 | SSE |
|
||||
| `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/rerank` | 文档重排(Cohere Rerank,Jina 风格协议) | 否 |
|
||||
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
|
||||
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||
|
||||
兼容边界:
|
||||
|
||||
- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响
|
||||
- 网关不保存会话历史,客户端需要携带完整上下文;Responses 拒绝非空 `previous_response_id`、非 `null` `conversation` 和 `background:true`
|
||||
- Responses 支持 Oracle 文档化的 xAI 服务端工具 `web_search` / `x_search` / `code_interpreter` 与远程 `mcp` 工具(非流式与流式均可),工具参数与限制遵循 [xAI 规格](https://docs.oracle.com/en-us/iaas/Content/generative-ai/get-started-agents.htm#xai-compatible-tools);`code_interpreter` 的命名容器管理(containers API)与 File Search 不提供
|
||||
- Responses 的 `reasoning.effort`、Messages 的 `output_config.effort` 和 Chat Completions 的 `reasoning_effort` 会传给上游,实际档位和效果由模型决定
|
||||
- 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 流式
|
||||
- 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 形态,该能力在上游侧不可用
|
||||
|
||||
### 已知上游限制:大 system 区流式断流
|
||||
|
||||
实测(2026-07-13)OCI 兼容面对 `instructions` 与 `tools` 合计超约 64.5KB 的**流式**请求会在发出少量事件后静默断开连接(无任何错误事件;同请求非流式正常),与模型、字符集、消息正文大小均无关——消息正文(`input`)不计入该限制。Chat Completions 与 Messages 的 system/developer 提示会转换为 `instructions`,因此 Claude Code 等自带大体量系统提示与工具定义的客户端极易触发。
|
||||
|
||||
网关侧应对:
|
||||
|
||||
- Messages 与 Chat Completions 的流式请求在客户端尚未收到任何输出时遭遇上游断流,会自动降级为非流式重做,并按标准事件/chunk 序列一次推送;调用日志记 `retries=1` 与降级标记
|
||||
- Responses 直通因初始事件已转发、协议上无法透明降级,调用日志记「上游流提前终止」,客户端需自行回退非流式
|
||||
- 应急规避:将超长 system 内容移入首条 user 消息正文可绕过该限制(正文不计入),但语义有别,根治有待上游修复
|
||||
|
||||
这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。
|
||||
|
||||
### 字段兼容矩阵
|
||||
|
||||
以下矩阵以 2026-07-13 的 [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)、[Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)、[Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create)、[Audio Speech](https://developers.openai.com/api/reference/resources/audio/methods/speech)、[Moderations](https://developers.openai.com/api/reference/resources/moderations/methods/create) 和 [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) 为标准基线,并与当前实现逐项核对;Rerank 无 OpenAI 对应端点,基线取 [Cohere Rerank](https://docs.cohere.com/reference/rerank) 协议。
|
||||
|
||||
这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。
|
||||
|
||||
矩阵只列网关支持的字段;不支持(本地拒绝)与被忽略的字段不入表,在各端点段落末尾以文字简述。
|
||||
|
||||
| 标记 | 含义 |
|
||||
| :---: | --- |
|
||||
| ✅ | 网关直接支持 |
|
||||
| ➡️ | 网关原样透传;是否生效由 OCI 上游决定 |
|
||||
| 🔄 | 网关进行字段或协议转换后支持 |
|
||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/responses</code> 对比 OpenAI Responses</summary>
|
||||
|
||||
Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 |
|
||||
| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 |
|
||||
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 |
|
||||
| `temperature`、`top_p`、`parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 |
|
||||
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 |
|
||||
| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 |
|
||||
| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 |
|
||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||
| `stream` | ✅ | 普通请求、`function` 与服务端工具均支持 SSE |
|
||||
| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 |
|
||||
| `tools[].type=web_search` / `x_search` / `code_interpreter` | ◐ | Oracle 文档化的 xAI 服务端工具,参数与限制遵循 xAI 规格(如 `allowed_domains` 上限 10、`container` 支持 `{"type":"auto"}`),非流式与流式均实测可用;`x_search` 不是 OpenAI 标准工具 |
|
||||
| `tools[].type=mcp` | ➡️ | 远程 MCP 服务由上游直连调用(`server_url` / `require_approval` / `authorization` 等原样透传),非流式与流式均实测可用 |
|
||||
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等未建模字段一律保留在原始请求中,由 OCI 决定是否接受 |
|
||||
|
||||
不支持(请求到达上游前返回 400):非空 `previous_response_id`、非 `null` 的 `conversation`、`background:true`——网关无状态,不保存历史响应;以及 `function` / xAI 服务端工具 / `mcp` 之外的工具类型(`web_search_preview`、`file_search`、`computer`、`image_generation`、`shell`、`custom` 等)。
|
||||
|
||||
响应边界:
|
||||
|
||||
- 非流式成功响应不做转换,OCI JSON 原样返回;usage 解析只用于面板调用日志
|
||||
- SSE 事件逐行原样转发,不补 `data: [DONE]`,推理增量也不会被网关过滤
|
||||
- 流建立前最多切换三个渠道;流建立后中断不重试,客户端可能只收到部分事件
|
||||
- 未知模型返回 404、无渠道返回 503;上游错误会套入 OpenAI 风格错误体,不保证与标准 OpenAI 错误字段完全相同
|
||||
|
||||
实现依据:[`airesponses.go`](../internal/service/airesponses.go) · [`responses.go`](../internal/aiwire/responses.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/chat/completions</code> 对比 OpenAI Chat Completions</summary>
|
||||
|
||||
Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。转换器只搬运下表字段,其余字段不进入上游请求。
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制;模型名保留到上游请求 |
|
||||
| `messages` | 🔄 | 必填并转换为 Responses `input` / `instructions` |
|
||||
| `system` / `developer` 消息 | ◐ | 文本按出现顺序合并为 `instructions`;块数组中的非文本内容被忽略 |
|
||||
| `user` / `assistant` 文本内容 | 🔄 | 字符串及 `text` 块分别转为 `input_text` / `output_text` |
|
||||
| `image_url` 内容块 | ◐ | URL 或 data URI 转为 `input_image`;`image_url.detail` 被忽略 |
|
||||
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 |
|
||||
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
||||
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
||||
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 原值写入 Responses 请求,但不校验范围或模型能力 |
|
||||
| `stream` | 🔄 | OCI Responses SSE 桥接为 `chat.completion.chunk`,末尾补 `data: [DONE]`;上游断流且尚无输出时自动降级非流式重做,结果按 chunk 序列一次推送 |
|
||||
| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` 的 usage 块 |
|
||||
| `tools[].type=function` | ◐ | `name`、`description`、`parameters` 支持;`function.strict` 被忽略 |
|
||||
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 |
|
||||
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 |
|
||||
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 |
|
||||
| `store` | 🔄 | 客户端取值被忽略,转换后的上游请求始终使用 `store:false` |
|
||||
|
||||
不支持(返回 400):`input_audio`、`file`、`refusal` 等消息内容块;`function` 以外的工具类型(含 `custom`)。
|
||||
|
||||
静默忽略(转换后的上游请求不包含):消息级 `name` / `refusal` / `audio` / 旧式 `function_call`;采样与输出控制类 `stop`、`seed`、`n`(恒返回单个 choice)、`frequency_penalty`、`presence_penalty`、`logprobs`、`top_logprobs`、`logit_bias`;平台类 `user`、`audio`、`modalities`、`prediction`、`metadata`、`moderation`、`prompt_cache_key`、`safety_identifier`、`service_tier`、`verbosity`、`web_search_options`、`stream_options.include_obfuscation` 及其他未知字段。
|
||||
|
||||
响应边界:
|
||||
|
||||
- 非流式固定生成一个 `choices[0]`;文本会合并,函数调用转为 `tool_calls`
|
||||
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`;其他上游终止原因不保留
|
||||
- reasoning 输出、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
||||
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 和 `prompt_tokens_details.cached_tokens`
|
||||
- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件
|
||||
|
||||
实现依据:[`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/embeddings</code> 对比 OpenAI Embeddings</summary>
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 |
|
||||
| `input` 为字符串 | ✅ | 包装为单个输入后调用 OCI |
|
||||
| `input` 为字符串数组 | ✅ | 按原顺序调用 OCI;空字符串不在本地拒绝,由 OCI 决定 |
|
||||
| `dimensions` | ◐ | 映射为 OCI 输出维度,不做范围或模型能力校验 |
|
||||
| `encoding_format=float` | ✅ | 返回 float 数组;省略时行为相同 |
|
||||
|
||||
不支持(返回 400):token ID 数组(一维或二维)形态的 `input`、空数组、`null`,以及 `encoding_format=base64`。静默忽略:`user` 及其他未知字段。
|
||||
|
||||
响应使用标准的 `object:"list"`、`data[].object:"embedding"`、`index`、`model` 和可选 `usage` 外壳;向量为 `float32` 数组,不支持流式。
|
||||
|
||||
实现依据:[`embeddings.go`](../internal/aiwire/embeddings.go) · [`aigateway_chat.go`](../internal/service/aigateway_chat.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/messages</code> 对比 Anthropic Messages</summary>
|
||||
|
||||
网关接受 `Authorization: Bearer` 或 `x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version`、`anthropic-beta` 请求头。
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 |
|
||||
| `max_tokens` | 🔄 | 可缺省(缺省或 ≤0 时按默认值 8192),转换为 `max_output_tokens` |
|
||||
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
||||
| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control` 等附加字段被忽略 |
|
||||
| `temperature`、`top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 |
|
||||
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列;上游断流且尚无输出时自动降级非流式重做,结果按事件序列一次推送 |
|
||||
| `tools` | ◐ | 每个工具都转换成 Responses `function`;自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 |
|
||||
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool`;`disable_parallel_tool_use` 等附加字段被忽略 |
|
||||
| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` |
|
||||
|
||||
顶层静默忽略(能解析但不传上游):`top_k`、`stop_sequences`(响应 `stop_sequence` 恒为 `null`)、`metadata`、`thinking`(不控制上游思考预算)、`output_config` 其余子字段、`cache_control`、`container`、`inference_geo`、`service_tier` 及其他未知顶层字段。
|
||||
|
||||
`messages[].content`:
|
||||
|
||||
| 标准内容块 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
||||
| `image` | ◐ | 仅支持 `base64` 和 `url` source;缺字段或其他 source 类型返回 400 |
|
||||
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称和输入 |
|
||||
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 和非文本结果丢失 |
|
||||
| `null` / 空块数组 | ◐ | 该消息可能从上游 input 中消失,不返回参数错误 |
|
||||
|
||||
不支持的内容块(返回 400):`document`、服务端工具结果及其他未知块。历史 `thinking` / `redacted_thinking` 块会被删除,不进入上游上下文。
|
||||
|
||||
响应边界:
|
||||
|
||||
- 非流式只把 Responses `output_text` 转成 `text`、`function_call` 转成 `tool_use`
|
||||
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`;`stop_sequence` 恒为 `null`
|
||||
- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature
|
||||
- usage 只保留 `input_tokens`、`output_tokens` 和 `cache_read_input_tokens`,不提供 `cache_creation_input_tokens`
|
||||
- 流式输出标准事件骨架,但不生成 `thinking_delta` 和 `signature_delta`;上游错误事件与无终态断流会转成 Anthropic `error` 事件
|
||||
|
||||
实现依据:[`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/audio/speech</code> 对比 OpenAI Audio Speech</summary>
|
||||
|
||||
Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除缺省注入 `language` 外不重建请求体,未知字段原样透传。
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `TTS` 能力的渠道(当前上游仅 `xai.grok-tts`) |
|
||||
| `input` | ◐ | 必填非空,随后原样透传 |
|
||||
| `voice` | ➡️ | 原样透传;取 xAI Grok Voice 音色(`ara` / `eve` / `leo` / `rex` / `sal`),OpenAI 标准音色名不可用 |
|
||||
| `response_format` | ➡️ | 原样透传;实测 `mp3` 可用,其余格式由上游决定 |
|
||||
| `language`(扩展字段) | 🔄 | 上游必填;客户端缺省时网关自动注入 `"auto"` |
|
||||
| `speed`、`instructions` 及其他未知字段 | ➡️ | 原样透传(含 xAI 专属参数如 `output_format`),是否生效由上游决定 |
|
||||
|
||||
不支持:流式音频(`stream_format` 等流式选项无效,响应恒为一次性完整音频)与 WebSocket 语音会话。
|
||||
|
||||
响应边界:
|
||||
|
||||
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
|
||||
- 无 token 用量口径,调用日志只记时延与渠道
|
||||
|
||||
实现依据:[`genai_speech.go`](../internal/oci/genai_speech.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/rerank</code> 对比 Cohere Rerank</summary>
|
||||
|
||||
Rerank 无 OpenAI 对应端点,协议取 Jina / Cohere 通行风格,上游走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`)。
|
||||
|
||||
| 字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `RERANK` 能力的渠道 |
|
||||
| `query` | ✅ | 必填非空 |
|
||||
| `documents` | ◐ | 必填,仅接受字符串数组;Cohere 旧版 `{"text": ...}` 对象数组形态返回 400 |
|
||||
| `top_n` | ✅ | 可选,传给上游限制返回条数;缺省返回全部文档的重排结果 |
|
||||
| `return_documents` | ✅ | 可选,`true` 时 `results[].document.text` 回带原文 |
|
||||
|
||||
静默忽略:`max_tokens_per_doc` 等 Cohere 专属参数及其他未知字段。
|
||||
|
||||
响应边界:
|
||||
|
||||
- `results[]` 按相关度降序,`index` 指向入参 `documents` 下标,`relevance_score` 为 0–1 浮点;响应 `model` 回显请求值
|
||||
- 无 token 用量口径,调用日志只记时延与渠道
|
||||
|
||||
实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/moderations</code> 对比 OpenAI Moderations</summary>
|
||||
|
||||
Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入)。上游是服务级 API,没有模型与白名单维度;渠道按分组直接挑选。
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `input` 为字符串 | ✅ | 单条审核 |
|
||||
| `input` 为字符串数组 | ✅ | 逐条审核,单次 1~8 条 |
|
||||
|
||||
不支持(返回 400):多模态 input(图片等对象数组形态)、空字符串条目、空数组或超过 8 条的数组。静默忽略:`model`(接受任意值,不校验白名单)及其他未知字段。
|
||||
|
||||
响应边界:
|
||||
|
||||
- `categories` / `category_scores` 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`,而非 OpenAI 标准类目(`hate` / `violence` 等);任一维度得分 ≥ 0.5 判定 `flagged`
|
||||
- PII 命中放扩展字段 `results[].pii`(`text` / `label` / `score` / `offset` / `length`),不参与 `flagged` 判定;实测中文人名 / 手机号识别较弱,英文 PII 识别正常
|
||||
- 响应 `model` 恒为 `oci-guardrails`
|
||||
|
||||
实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`moderations.go`](../internal/aiwire/moderations.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
|
||||
|
||||
</details>
|
||||
|
||||
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。
|
||||
+218
-1
@@ -201,7 +201,7 @@ const docTemplate = `{
|
||||
"summary": "OpenAI Responses 兼容端点",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;未列字段原样透传上游)",
|
||||
"description": "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;codex 兼容:namespace 工具组拍平为限定名 function 并在响应还原,custom 工具转 function 包装并回转 custom_tool_call(apply_patch 丢弃),tool_search 剥离,web_search.external_web_access 上游不支持自动处理,超 76KB 流式请求自动改非流式合成 SSE;未列字段原样透传上游)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -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": [
|
||||
@@ -431,6 +458,36 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-channels/{id}/models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "渠道模型缓存列表",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "渠道 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_model_AiModelCache"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-channels/{id}/probe": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -491,6 +548,51 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-channels/{id}/test-model": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "渠道 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "模型名",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.testChannelModelRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/oci-portal_internal_model.AiChannel"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "试调未通过",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.errorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-content-logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -705,6 +807,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 +6046,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.aiSettingsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filterDeprecated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.attachBootVolumeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6646,6 +6807,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.testChannelModelRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.tokenResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7689,6 +7861,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": {
|
||||
@@ -7798,6 +8007,10 @@ const docTemplate = `{
|
||||
"lastProbeAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelCount": {
|
||||
"description": "ModelCount 是渠道模型缓存计数,列表查询回填,不落库",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7810,6 +8023,10 @@ const docTemplate = `{
|
||||
"probeError": {
|
||||
"type": "string"
|
||||
},
|
||||
"probeModel": {
|
||||
"description": "ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位",
|
||||
"type": "string"
|
||||
},
|
||||
"probeStatus": {
|
||||
"description": "ok / no_service / no_quota / error",
|
||||
"type": "string"
|
||||
|
||||
+218
-1
@@ -194,7 +194,7 @@
|
||||
"summary": "OpenAI Responses 兼容端点",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;未列字段原样透传上游)",
|
||||
"description": "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;codex 兼容:namespace 工具组拍平为限定名 function 并在响应还原,custom 工具转 function 包装并回转 custom_tool_call(apply_patch 丢弃),tool_search 剥离,web_search.external_web_access 上游不支持自动处理,超 76KB 流式请求自动改非流式合成 SSE;未列字段原样透传上游)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -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": [
|
||||
@@ -424,6 +451,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-channels/{id}/models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "渠道模型缓存列表",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "渠道 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_model_AiModelCache"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-channels/{id}/probe": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -484,6 +541,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-channels/{id}/test-model": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "渠道 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "模型名",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.testChannelModelRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/oci-portal_internal_model.AiChannel"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "试调未通过",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.errorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-content-logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -698,6 +800,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 +6039,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.aiSettingsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filterDeprecated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.attachBootVolumeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6639,6 +6800,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.testChannelModelRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.tokenResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7682,6 +7854,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": {
|
||||
@@ -7791,6 +8000,10 @@
|
||||
"lastProbeAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelCount": {
|
||||
"description": "ModelCount 是渠道模型缓存计数,列表查询回填,不落库",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7803,6 +8016,10 @@
|
||||
"probeError": {
|
||||
"type": "string"
|
||||
},
|
||||
"probeModel": {
|
||||
"description": "ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位",
|
||||
"type": "string"
|
||||
},
|
||||
"probeStatus": {
|
||||
"description": "ok / no_service / no_quota / error",
|
||||
"type": "string"
|
||||
|
||||
+138
-1
@@ -46,6 +46,11 @@ definitions:
|
||||
key:
|
||||
type: string
|
||||
type: object
|
||||
internal_api.aiSettingsResponse:
|
||||
properties:
|
||||
filterDeprecated:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_api.attachBootVolumeRequest:
|
||||
properties:
|
||||
bootVolumeId:
|
||||
@@ -543,6 +548,13 @@ definitions:
|
||||
required:
|
||||
- regionKey
|
||||
type: object
|
||||
internal_api.testChannelModelRequest:
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
required:
|
||||
- model
|
||||
type: object
|
||||
internal_api.tokenResponse:
|
||||
properties:
|
||||
expiresAt:
|
||||
@@ -1219,6 +1231,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:
|
||||
@@ -1291,6 +1327,9 @@ definitions:
|
||||
type: integer
|
||||
lastProbeAt:
|
||||
type: string
|
||||
modelCount:
|
||||
description: ModelCount 是渠道模型缓存计数,列表查询回填,不落库
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
ociConfigId:
|
||||
@@ -1299,6 +1338,9 @@ definitions:
|
||||
type: integer
|
||||
probeError:
|
||||
type: string
|
||||
probeModel:
|
||||
description: ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
|
||||
type: string
|
||||
probeStatus:
|
||||
description: ok / no_service / no_quota / error
|
||||
type: string
|
||||
@@ -2738,7 +2780,9 @@ paths:
|
||||
post:
|
||||
parameters:
|
||||
- description: OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp
|
||||
含流式;未列字段原样透传上游)
|
||||
含流式;codex 兼容:namespace 工具组拍平为限定名 function 并在响应还原,custom 工具转 function 包装并回转
|
||||
custom_tool_call(apply_patch 丢弃),tool_search 剥离,web_search.external_web_access
|
||||
上游不支持自动处理,超 76KB 流式请求自动改非流式合成 SSE;未列字段原样透传上游)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -2752,6 +2796,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:
|
||||
@@ -2877,6 +2938,24 @@ paths:
|
||||
summary: 更新 AI 渠道
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-channels/{id}/models:
|
||||
get:
|
||||
parameters:
|
||||
- description: 渠道 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_model_AiModelCache'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 渠道模型缓存列表
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-channels/{id}/probe:
|
||||
post:
|
||||
parameters:
|
||||
@@ -2913,6 +2992,34 @@ paths:
|
||||
summary: 同步渠道模型缓存
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-channels/{id}/test-model:
|
||||
post:
|
||||
parameters:
|
||||
- description: 渠道 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 模型名
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.testChannelModelRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/oci-portal_internal_model.AiChannel'
|
||||
"502":
|
||||
description: 试调未通过
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.errorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-content-logs:
|
||||
get:
|
||||
responses:
|
||||
@@ -3040,6 +3147,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:
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/purell v1.2.2 h1:irlAloIzZaJ5RP/+UcaT1Nw0H9on2HKWdRehCsbJWJw=
|
||||
github.com/PuerkitoBio/purell v1.2.2/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
@@ -29,14 +45,20 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
@@ -44,13 +66,35 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic=
|
||||
github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE=
|
||||
github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0=
|
||||
github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8=
|
||||
github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8=
|
||||
github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE=
|
||||
github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g=
|
||||
github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI=
|
||||
github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4=
|
||||
github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio=
|
||||
github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o=
|
||||
github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -59,14 +103,22 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
|
||||
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -74,8 +126,11 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -84,6 +139,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -96,6 +153,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -109,8 +168,12 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -118,11 +181,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.120.0 h1:qpGdts2Yleg6TdmtxXkL8MAnsASO+SOG+iG/Nd8wUGk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.120.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
@@ -131,6 +198,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -175,17 +244,27 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
@@ -193,12 +272,16 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -209,11 +292,14 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -221,6 +307,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -228,9 +316,13 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -254,11 +346,19 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -274,6 +274,55 @@ func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||
}
|
||||
|
||||
// @Summary 渠道模型缓存列表
|
||||
// @Tags AI 管理
|
||||
// @Param id path int true "渠道 ID"
|
||||
// @Success 200 {object} itemsResponse[model.AiModelCache]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-channels/{id}/models [get]
|
||||
func (h *aiAdminHandler) listChannelModels(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
models, err := h.gw.ChannelModels(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||
}
|
||||
|
||||
type testChannelModelRequest struct {
|
||||
Model string `json:"model" binding:"required"`
|
||||
}
|
||||
|
||||
// @Summary 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)
|
||||
// @Tags AI 管理
|
||||
// @Param id path int true "渠道 ID"
|
||||
// @Param body body testChannelModelRequest true "模型名"
|
||||
// @Success 200 {object} model.AiChannel
|
||||
// @Failure 502 {object} errorResponse "试调未通过"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-channels/{id}/test-model [post]
|
||||
func (h *aiAdminHandler) testChannelModel(c *gin.Context) {
|
||||
id, ok := aiPathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req testChannelModelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ch, err := h.gw.TestChannelModel(c.Request.Context(), id, req.Model)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
// ---- 聚合模型与调用日志 ----
|
||||
|
||||
// @Summary ---- 聚合模型与调用日志 ----
|
||||
@@ -372,3 +421,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()})
|
||||
}
|
||||
|
||||
+106
-12
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -552,7 +553,7 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
||||
//
|
||||
// @Summary OpenAI Responses 兼容端点
|
||||
// @Tags AI 网关
|
||||
// @Param body body aiwire.RespRequest true "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;未列字段原样透传上游)"
|
||||
// @Param body body aiwire.RespRequest true "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;codex 兼容:namespace 工具组拍平为限定名 function 并在响应还原,custom 工具转 function 包装并回转 custom_tool_call(apply_patch 丢弃),tool_search 剥离,web_search.external_web_access 上游不支持自动处理,超 76KB 流式请求自动改非流式合成 SSE;未列字段原样透传上游)"
|
||||
// @Success 200 {object} aiwire.RespResponse "OpenAI 兼容响应(非流式;流式为 SSE);直通仅建模常用字段,未列字段原样返回"
|
||||
// @Router /ai/v1/responses [post]
|
||||
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||
@@ -576,16 +577,22 @@ func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||
// OCI /actions/v1/responses,响应原样透传。
|
||||
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
|
||||
if err := service.RespPassthroughValidate(req); err != nil {
|
||||
log.Printf("responses 直通(model=%s): 校验拒绝: %v", req.Model, err)
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
body, err := service.RespPassthroughBody(raw)
|
||||
body, compat, err := service.RespPassthroughBody(raw)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
logRespCompat(req.Model, compat)
|
||||
if req.Stream {
|
||||
h.responsesPassthroughStream(c, body, req)
|
||||
if len(body) > service.RespStreamUpgradeLimit {
|
||||
h.responsesStreamUpgrade(c, body, req, compat)
|
||||
return
|
||||
}
|
||||
h.responsesPassthroughStream(c, body, req, compat)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
@@ -597,6 +604,7 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
payload = service.RespRestoreToolCalls(payload, compat)
|
||||
entry.Status = http.StatusOK
|
||||
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||
callID := h.gw.LogCall(entry)
|
||||
@@ -604,9 +612,74 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
|
||||
}
|
||||
|
||||
// logRespCompat 记录直通请求的 codex 兼容改写动作(观测)。
|
||||
func logRespCompat(model string, compat service.RespCompat) {
|
||||
if len(compat.Flattened) == 0 && len(compat.Dropped) == 0 && len(compat.Converted) == 0 {
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
if len(compat.Flattened) > 0 {
|
||||
parts = append(parts, "拍平: "+strings.Join(compat.Flattened, ", "))
|
||||
}
|
||||
if len(compat.Converted) > 0 {
|
||||
parts = append(parts, "转换: "+strings.Join(compat.Converted, ", "))
|
||||
}
|
||||
if len(compat.Dropped) > 0 {
|
||||
parts = append(parts, "剥离: "+strings.Join(compat.Dropped, ", "))
|
||||
}
|
||||
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// responsesStreamUpgrade 流式升级回退:上游对超大流式请求会在推理途中掐断
|
||||
// (实测 >~82KB,见 RespStreamUpgradeLimit),超限时改调非流式上游拿完整响应,
|
||||
// 本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
||||
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||
start := time.Now()
|
||||
nsBody, err := service.RespDisableStream(body)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("responses 直通(model=%s): 请求体 %dKB 超流式安全上限,改走非流式合成 SSE", req.Model, len(body)/1024)
|
||||
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), nsBody, req.Model, keyGroup(c))
|
||||
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
payload = service.RespRestoreToolCalls(payload, compat)
|
||||
writeSynthSSE(c, payload)
|
||||
entry.Status = http.StatusOK
|
||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "responses", req.Model, true, req, json.RawMessage(payload))
|
||||
}
|
||||
|
||||
// writeSynthSSE 把完整响应按合成事件序列写出;合成失败时降级为一次性 JSON,
|
||||
// 客户端至少拿到完整结果。
|
||||
func writeSynthSSE(c *gin.Context, payload []byte) {
|
||||
events, err := service.RespSynthSSEEvents(payload)
|
||||
if err != nil {
|
||||
log.Printf("responses 直通: 合成 SSE 失败,降级 JSON 返回: %v", err)
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
|
||||
return
|
||||
}
|
||||
sseHeaders(c)
|
||||
for _, ev := range events {
|
||||
c.Writer.Write([]byte("data: "))
|
||||
c.Writer.Write(ev)
|
||||
c.Writer.Write([]byte("\n\n"))
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
|
||||
// 逐行扫描 completed 事件提取 usage 记账。
|
||||
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest) {
|
||||
// 逐行扫描 completed 事件提取 usage 记账;refs 非空时对 function_call 事件做
|
||||
// namespace 还原后再转发。
|
||||
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||
start := time.Now()
|
||||
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||
@@ -618,7 +691,7 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
|
||||
}
|
||||
defer upstream.Close()
|
||||
sseHeaders(c)
|
||||
usage, upErr, err := forwardSSE(c, upstream)
|
||||
usage, upErr, err := forwardSSE(c, upstream, compat)
|
||||
if err != nil {
|
||||
entry.ErrMsg = err.Error()
|
||||
} else if upErr != "" {
|
||||
@@ -635,25 +708,29 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
|
||||
}
|
||||
|
||||
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
||||
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息。
|
||||
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, string, error) {
|
||||
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息;
|
||||
// refs 非空时 data 行先做 namespace 还原(未改动的行原样转发)。
|
||||
func forwardSSE(c *gin.Context, upstream io.Reader, compat service.RespCompat) (*aiwire.Usage, string, error) {
|
||||
reader := bufio.NewReader(upstream)
|
||||
var usage *aiwire.Usage
|
||||
var upErr string
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
c.Writer.Write(line)
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if len(trimmed) == 0 {
|
||||
c.Writer.Flush()
|
||||
} else if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
||||
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
||||
c.Writer.Write(restoreSSELine(line, data, compat))
|
||||
if u := service.RespStreamCompletedUsage(data); u != nil {
|
||||
usage = u
|
||||
}
|
||||
if m := service.RespStreamErrorMsg(data); m != "" && upErr == "" {
|
||||
upErr = m
|
||||
}
|
||||
} else {
|
||||
c.Writer.Write(line)
|
||||
if len(trimmed) == 0 {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -665,3 +742,20 @@ func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, string, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restoreSSELine 对一行 data 事件做工具调用项还原(namespace/custom),
|
||||
// 未改动时原行透传(字节级直通)。
|
||||
func restoreSSELine(line, data []byte, compat service.RespCompat) []byte {
|
||||
if !compat.NeedRestore() {
|
||||
return line
|
||||
}
|
||||
restored, changed := service.RespRestoreToolCallsEvent(data, compat)
|
||||
if !changed {
|
||||
return line
|
||||
}
|
||||
out := make([]byte, 0, len(restored)+8)
|
||||
out = append(out, "data: "...)
|
||||
out = append(out, restored...)
|
||||
out = append(out, '\n')
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -29,12 +29,38 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
h.speechRespond(c, "speech", modelName, raw, body)
|
||||
}
|
||||
|
||||
// tts 是 xAI 官方格式 TTS 端点(/ai/v1/tts),转换为上游 OpenAI 兼容形态后复用 Speech 编排。
|
||||
//
|
||||
// @Summary xAI 官方格式文本转语音端点
|
||||
// @Tags AI 网关
|
||||
// @Param body body aiwire.TtsRequest true "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)"
|
||||
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||
// @Router /ai/v1/tts [post]
|
||||
func (h *aiGatewayHandler) tts(c *gin.Context) {
|
||||
raw, err := c.GetRawData()
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
modelName, body, err := service.TtsBodyConvert(raw)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
h.speechRespond(c, "tts", modelName, raw, body)
|
||||
}
|
||||
|
||||
// speechRespond 是 audioSpeech / tts 的公共主体:白名单校验、上游调用、日志与音频响应。
|
||||
func (h *aiGatewayHandler) speechRespond(c *gin.Context, endpoint, modelName string, raw, body []byte) {
|
||||
if !checkKeyModel(c, modelName) {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c))
|
||||
entry := h.logEntry(c, "speech", modelName, false, meta, start)
|
||||
entry := h.logEntry(c, endpoint, modelName, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
@@ -43,7 +69,7 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "speech", modelName, false, string(raw), nil)
|
||||
h.maybeLogContent(c, callID, endpoint, modelName, false, string(raw), nil)
|
||||
if contentType == "" {
|
||||
contentType = "audio/mpeg"
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||
`{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
|
||||
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
|
||||
`{"input":[]}`, 400, []string{"invalid_request_error"}},
|
||||
{"tts 缺 language 拒绝", "open-key-12345", "/ai/v1/tts",
|
||||
`{"text":"你好"}`, 400, []string{"invalid_request_error"}},
|
||||
{"tts 白名单外拦截", "limited-key-1234", "/ai/v1/tts",
|
||||
`{"model":"meta.llama-3.3-70b-instruct","text":"你好","language":"zh"}`, 404, deny},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
+25
-2
@@ -5,10 +5,11 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
_ "oci-portal/internal/model" // swagger 注解引用
|
||||
"oci-portal/internal/model"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
type authxHandler struct {
|
||||
auth *service.AuthService
|
||||
oauth *service.OAuthService
|
||||
logs *service.SystemLogService
|
||||
}
|
||||
|
||||
// ---- TOTP(JWT 组内) ----
|
||||
@@ -301,10 +303,12 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
||||
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
|
||||
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
||||
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
start := time.Now()
|
||||
provider := c.Param("provider")
|
||||
token, _, mode, err := h.oauth.HandleCallback(
|
||||
token, username, mode, err := h.oauth.HandleCallback(
|
||||
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
||||
if err != nil {
|
||||
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
||||
target := "/login"
|
||||
if mode == "bind" {
|
||||
target = "/settings"
|
||||
@@ -312,6 +316,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||
return
|
||||
}
|
||||
h.recordOauth(c, username, http.StatusOK, "", start)
|
||||
if mode == "bind" {
|
||||
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
||||
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
||||
@@ -321,6 +326,24 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// recordOauth 把外部登录 / 绑定结果记入系统日志——回调是 GET,
|
||||
// 不经写方法中间件;实际响应恒为 302,日志按语义记 200 / 401。
|
||||
func (h *authxHandler) recordOauth(c *gin.Context, username string, status int, errMsg string, start time.Time) {
|
||||
if h.logs == nil {
|
||||
return
|
||||
}
|
||||
h.logs.Record(model.SystemLog{
|
||||
Username: username,
|
||||
Method: c.Request.Method,
|
||||
Path: requestPath(c),
|
||||
Status: status,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||
ErrMsg: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||
func oauthErrText(err error) string {
|
||||
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
||||
|
||||
@@ -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)
|
||||
@@ -35,7 +36,11 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
||||
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
||||
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
|
||||
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
|
||||
|
||||
@@ -12,7 +12,7 @@ func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *s
|
||||
v1.POST("/auth/login", ah.login)
|
||||
|
||||
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
|
||||
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||
|
||||
@@ -268,8 +268,12 @@ type AiChannel struct {
|
||||
LastProbeAt *time.Time `json:"lastProbeAt"`
|
||||
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
||||
ProbeError string `gorm:"size:512" json:"probeError"`
|
||||
// ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
|
||||
ProbeModel string `gorm:"size:96" json:"probeModel"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// ModelCount 是渠道模型缓存计数,列表查询回填,不落库
|
||||
ModelCount int64 `gorm:"-" json:"modelCount"`
|
||||
}
|
||||
|
||||
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
||||
|
||||
@@ -149,11 +149,12 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
||||
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;
|
||||
// 配额探测专用的最小聊天,返回 HTTP 状态码。max_output_tokens 取 16:
|
||||
// openai.gpt-oss 系列要求 >=16,其余模型均兼容,成本差异可忽略。
|
||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
|
||||
"max_output_tokens": 1, "store": false})
|
||||
"max_output_tokens": 16, "store": false})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+148
-15
@@ -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 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||
@@ -239,11 +268,34 @@ func valueOr(p *int, def int) int {
|
||||
return def
|
||||
}
|
||||
|
||||
// Channels 列出全部渠道。
|
||||
// Channels 列出全部渠道并回填各自的模型缓存计数。
|
||||
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
|
||||
var chs []model.AiChannel
|
||||
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
|
||||
return chs, err
|
||||
if err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []struct {
|
||||
ChannelID uint
|
||||
N int64
|
||||
}
|
||||
q := s.db.WithContext(ctx).Model(&model.AiModelCache{}).
|
||||
Select("channel_id, COUNT(*) AS n").
|
||||
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
err := q.Group("channel_id").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[uint]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
counts[r.ChannelID] = r.N
|
||||
}
|
||||
for i := range chs {
|
||||
chs[i].ModelCount = counts[chs[i].ID]
|
||||
}
|
||||
return chs, nil
|
||||
}
|
||||
|
||||
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
|
||||
@@ -286,7 +338,7 @@ func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error {
|
||||
|
||||
// ---- 探测与模型同步 ----
|
||||
|
||||
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。
|
||||
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → 极小 max_tokens 配额试调。
|
||||
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
|
||||
var ch model.AiChannel
|
||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||
@@ -335,13 +387,15 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *
|
||||
return s.probeChat(ctx, cred, ch, models)
|
||||
}
|
||||
|
||||
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
|
||||
// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404
|
||||
// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。
|
||||
// probeChat 按候选顺序试调(上限 8,已验证的探测模型置首位):遇「模型不可按需
|
||||
// 调用」(微调基座 400 / 实体不存在 404)换下一个候选,错误信息带模型名供用户加入
|
||||
// 黑名单;401/403 与鉴权类 404 可能只是模型级无权限,记录后继续换候选,全部候选
|
||||
// 失败且出现过鉴权拒绝才定论 no_quota;其余错误累计 3 次止损。
|
||||
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
||||
status, detail := "error", "无可试调对话模型"
|
||||
quotaDetail := ""
|
||||
errBudget := 3
|
||||
for _, m := range probeCandidates(models) {
|
||||
for _, m := range probeCandidates(ch.ProbeModel, models) {
|
||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
||||
switch {
|
||||
case code == 200 || code == 429:
|
||||
@@ -349,7 +403,7 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
case oci.IsModelUnavailable(err):
|
||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
||||
case code == 401 || code == 403 || code == 404:
|
||||
return "no_quota", truncateErr(oci.CompactError(err))
|
||||
quotaDetail = truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||
default:
|
||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||
if errBudget--; errBudget == 0 {
|
||||
@@ -357,6 +411,9 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
}
|
||||
}
|
||||
}
|
||||
if quotaDetail != "" {
|
||||
return "no_quota", quotaDetail
|
||||
}
|
||||
return status, detail
|
||||
}
|
||||
|
||||
@@ -364,13 +421,18 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials,
|
||||
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||
const probeCandidateCap = 8
|
||||
|
||||
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
|
||||
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:用户已验证的
|
||||
// probeModel 固定放首位(不做能力过滤,测试通过即有效),其余主流文本模型优先;
|
||||
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
|
||||
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
|
||||
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
|
||||
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
||||
var sorted []oci.GenAiModel
|
||||
func probeCandidates(probeModel string, models []oci.GenAiModel) []oci.GenAiModel {
|
||||
var pinned, sorted []oci.GenAiModel
|
||||
for _, m := range models {
|
||||
if probeModel != "" && m.Name == probeModel {
|
||||
pinned = append(pinned, m)
|
||||
continue
|
||||
}
|
||||
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
|
||||
sorted = append(sorted, m)
|
||||
}
|
||||
@@ -378,7 +440,7 @@ func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
||||
})
|
||||
return diversifyByVendor(sorted, probeCandidateCap)
|
||||
return append(pinned, diversifyByVendor(sorted, probeCandidateCap)...)
|
||||
}
|
||||
|
||||
// diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。
|
||||
@@ -497,12 +559,80 @@ func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, mo
|
||||
})
|
||||
}
|
||||
|
||||
// channelModels 列出渠道模型缓存;黑名单模型查询层兜底过滤
|
||||
// (拉黑即删缓存,正常不会残留,防御旧数据 / 并发窗口);
|
||||
// 「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)。
|
||||
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
|
||||
q := s.db.WithContext(ctx).Where("channel_id = ?", channelID).
|
||||
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
|
||||
err := q.Order("name ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ChannelModels 列出渠道的模型缓存(名称排序),渠道不存在时报错。
|
||||
func (s *AiGatewayService) ChannelModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
||||
var n int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("渠道不存在")
|
||||
}
|
||||
return s.channelModels(ctx, id)
|
||||
}
|
||||
|
||||
// TestChannelModel 对渠道缓存中的指定模型发极小试调;通过时把该模型设
|
||||
// 为渠道探测验证模型(此后探测置于候选首位),渠道探测状态不为 ok 时顺带置 ok 并
|
||||
// 复位熔断;未通过仅返回错误,不改动渠道状态。
|
||||
func (s *AiGatewayService) TestChannelModel(ctx context.Context, id uint, name string) (*model.AiChannel, error) {
|
||||
var ch model.AiChannel
|
||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("渠道不存在")
|
||||
}
|
||||
var mc model.AiModelCache
|
||||
err := s.db.WithContext(ctx).Where("channel_id = ? AND name = ?", id, name).First(&mc).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型不在该渠道缓存中,请先同步模型")
|
||||
}
|
||||
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, mc.ModelOcid, mc.Name)
|
||||
if code != 200 && code != 429 {
|
||||
msg := fmt.Sprintf("HTTP %d", code)
|
||||
if err != nil {
|
||||
msg = oci.CompactError(err)
|
||||
}
|
||||
return nil, fmt.Errorf("测试未通过:%s", truncateErr(msg))
|
||||
}
|
||||
return s.adoptProbeModel(ctx, &ch, name)
|
||||
}
|
||||
|
||||
// adoptProbeModel 记录探测验证模型并返回更新后的渠道;
|
||||
// 状态不为 ok 时一并置 ok 并复位熔断。
|
||||
func (s *AiGatewayService) adoptProbeModel(ctx context.Context, ch *model.AiChannel, name string) (*model.AiChannel, error) {
|
||||
updates := map[string]any{"probe_model": name}
|
||||
if ch.ProbeStatus != "ok" {
|
||||
updates["probe_status"] = "ok"
|
||||
updates["probe_error"] = ""
|
||||
updates["last_probe_at"] = time.Now()
|
||||
updates["fail_count"] = 0
|
||||
updates["disabled_until"] = gorm.Expr("NULL")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
|
||||
var fresh model.AiChannel
|
||||
err := s.db.WithContext(ctx).First(&fresh, ch.ID).Error
|
||||
return &fresh, err
|
||||
}
|
||||
|
||||
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
|
||||
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
||||
@@ -511,6 +641,9 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
||||
if group != "" {
|
||||
q = q.Where("ai_channels.channel_group = ?", group)
|
||||
}
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
||||
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
||||
|
||||
@@ -164,6 +164,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
|
||||
} else {
|
||||
q = q.Where("capability = ?", capability)
|
||||
}
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -40,6 +40,42 @@ func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// ttsDefaultModel 是 /ai/v1/tts 缺省注入的模型(xAI 官方格式无 model 字段)。
|
||||
const ttsDefaultModel = "xai.grok-tts"
|
||||
|
||||
// TtsBodyConvert 把 xAI 官方 TTS 格式转换为上游 OpenAI 兼容 audio/speech 形态:
|
||||
// text→input、voice_id→voice,text 与 language 必填(对齐 xAI 官方);
|
||||
// model 为网关扩展字段,缺省注入 ttsDefaultModel;其余字段原样保留。
|
||||
func TtsBodyConvert(raw []byte) (string, []byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var body map[string]any
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||
}
|
||||
text, _ := body["text"].(string)
|
||||
language, _ := body["language"].(string)
|
||||
if strings.TrimSpace(text) == "" || strings.TrimSpace(language) == "" {
|
||||
return "", nil, fmt.Errorf("text 与 language 不能为空")
|
||||
}
|
||||
modelName, _ := body["model"].(string)
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = ttsDefaultModel
|
||||
body["model"] = modelName
|
||||
}
|
||||
delete(body, "text")
|
||||
body["input"] = text
|
||||
if voice, ok := body["voice_id"].(string); ok && strings.TrimSpace(voice) != "" {
|
||||
body["voice"] = voice
|
||||
}
|
||||
delete(body, "voice_id")
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||
}
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
|
||||
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
|
||||
meta := ChatMeta{}
|
||||
|
||||
@@ -45,6 +45,64 @@ func TestSpeechBodyNormalize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTtsBodyConvert 断言 xAI 官方 TTS 格式到 OpenAI 兼容形态的转换。
|
||||
func TestTtsBodyConvert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
wantModel string
|
||||
}{
|
||||
{"缺 text 拒绝", `{"language":"zh"}`, true, ""},
|
||||
{"缺 language 拒绝", `{"text":"你好"}`, true, ""},
|
||||
{"缺省注入默认模型", `{"text":"你好","language":"zh"}`, false, "xai.grok-tts"},
|
||||
{"model 扩展字段可覆盖", `{"model":"xai.other-tts","text":"你好","language":"auto"}`, false, "xai.other-tts"},
|
||||
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
modelName, _, err := TtsBodyConvert([]byte(tt.raw))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if modelName != tt.wantModel {
|
||||
t.Fatalf("model = %s, want %s", modelName, tt.wantModel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTtsBodyConvertMapping 断言字段映射与未知字段保留。
|
||||
func TestTtsBodyConvertMapping(t *testing.T) {
|
||||
raw := `{"text":"你好","language":"zh","voice_id":"ara","speed":1.2,` +
|
||||
`"output_format":{"codec":"mp3","sample_rate":44100}}`
|
||||
_, body, err := TtsBodyConvert([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if out["input"] != "你好" || out["voice"] != "ara" {
|
||||
t.Fatalf("input/voice 映射错误: %v", out)
|
||||
}
|
||||
if _, ok := out["text"]; ok {
|
||||
t.Fatal("text 字段应被移除")
|
||||
}
|
||||
if _, ok := out["voice_id"]; ok {
|
||||
t.Fatal("voice_id 字段应被移除")
|
||||
}
|
||||
of, _ := out["output_format"].(map[string]any)
|
||||
if of == nil || of["codec"] != "mp3" {
|
||||
t.Fatalf("output_format 应原样保留: %v", out["output_format"])
|
||||
}
|
||||
if out["language"] != "zh" || out["speed"] == nil {
|
||||
t.Fatalf("language/speed 应保留: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
|
||||
func TestModerationInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credent
|
||||
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
||||
t.Helper()
|
||||
svc := newTestService(t, client)
|
||||
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||
if err := svc.db.AutoMigrate(&model.Setting{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||
t.Fatalf("auto migrate ai tables: %v", err)
|
||||
}
|
||||
return NewAiGatewayService(svc.db, svc, client), svc
|
||||
@@ -228,6 +228,46 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
||||
return ch
|
||||
}
|
||||
|
||||
// TestFilterDeprecatedModels 断言「过滤弃用模型」开关对列表 / 路由的过滤与持久化。
|
||||
func TestFilterDeprecatedModels(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
dep := time.Now().Add(-24 * time.Hour)
|
||||
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
|
||||
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
|
||||
if err := gw.db.Create(old).Error; err != nil {
|
||||
t.Fatalf("seed deprecated cache: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
list, _ := gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 2 {
|
||||
t.Fatalf("开关关:应含弃用模型,got %d", len(list.Data))
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||
}
|
||||
list, _ = gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
|
||||
t.Fatalf("开关开:弃用模型应被过滤,got %+v", list.Data)
|
||||
}
|
||||
if _, err := gw.pick(ctx, "meta.llama-old", "", "CHAT", map[uint]bool{}); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("开关开:弃用模型路由应不可达,err = %v", err)
|
||||
}
|
||||
// 持久化:重建 service 后开关仍生效
|
||||
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||
if !gw2.FilterDeprecated() {
|
||||
t.Error("重建后开关状态应保持开启")
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, false); err != nil {
|
||||
t.Fatalf("关闭开关: %v", err)
|
||||
}
|
||||
if list, _ = gw.GatewayModels(ctx, ""); len(list.Data) != 2 {
|
||||
t.Errorf("开关关:弃用模型应恢复,got %d", len(list.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPriorityAndBreaker(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
@@ -413,7 +453,7 @@ func TestProbeCandidates(t *testing.T) {
|
||||
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
||||
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
|
||||
}
|
||||
got := probeCandidates(models)
|
||||
got := probeCandidates("", models)
|
||||
if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
|
||||
t.Errorf("候选排序 = %+v", got)
|
||||
}
|
||||
@@ -433,7 +473,7 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
|
||||
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
|
||||
{Ocid: "g1", Name: "xai.grok-4"},
|
||||
}
|
||||
got := probeCandidates(models)
|
||||
got := probeCandidates("", models)
|
||||
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
|
||||
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
|
||||
}
|
||||
@@ -449,11 +489,38 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) {
|
||||
for i := 0; i < 12; i++ {
|
||||
many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)})
|
||||
}
|
||||
if capped := probeCandidates(many); len(capped) != probeCandidateCap {
|
||||
if capped := probeCandidates("", many); len(capped) != probeCandidateCap {
|
||||
t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped))
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeCandidatesPinsProbeModel 断言探测验证模型置首位且不占常规候选逻辑。
|
||||
func TestProbeCandidatesPinsProbeModel(t *testing.T) {
|
||||
models := []oci.GenAiModel{
|
||||
{Ocid: "o2", Name: "cohere.command-r-plus"},
|
||||
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
||||
{Ocid: "o4", Name: "xai.grok-4"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
probeModel string
|
||||
wantFirst string
|
||||
wantLen int
|
||||
}{
|
||||
{"验证模型置首位", "xai.grok-4", "xai.grok-4", 3},
|
||||
{"未设置走常规排序", "", "meta.llama-3.3-70b-instruct", 3},
|
||||
{"验证模型已不在缓存则忽略", "gone.model", "meta.llama-3.3-70b-instruct", 3},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := probeCandidates(tt.probeModel, models)
|
||||
if len(got) != tt.wantLen || got[0].Name != tt.wantFirst {
|
||||
t.Errorf("probeCandidates(%q) = %+v, want first %q", tt.probeModel, got, tt.wantFirst)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
|
||||
func entityNotFoundErr() stubServiceError {
|
||||
return stubServiceError{status: 404,
|
||||
@@ -520,6 +587,128 @@ func TestProbeAuth404StillNoQuota(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelModelsExcludeBlacklist 断言模型列表与数量统计对黑名单做查询层兜底过滤。
|
||||
func TestChannelModelsExcludeBlacklist(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)
|
||||
ctx := context.Background()
|
||||
|
||||
// 直插黑名单行但保留缓存,模拟脏数据 / 并发窗口
|
||||
gw.db.Create(&model.AiModelBlacklist{Name: "meta.llama-3.3-70b-instruct"})
|
||||
rows, err := gw.ChannelModels(ctx, ch.ID)
|
||||
if err != nil || len(rows) != 0 {
|
||||
t.Errorf("黑名单模型应被过滤: %+v, %v", rows, err)
|
||||
}
|
||||
chs, err := gw.Channels(ctx)
|
||||
if err != nil || len(chs) != 1 || chs[0].ModelCount != 0 {
|
||||
t.Errorf("模型数量统计应剔除黑名单: %+v, %v", chs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelModelsFilterDeprecated 断言「过滤弃用模型」开关同样作用于
|
||||
// 渠道模型列表与数量统计(数据保留,展示口径过滤)。
|
||||
func TestChannelModelsFilterDeprecated(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)
|
||||
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "xai.grok-3",
|
||||
Vendor: "xai", SyncedAt: time.Now(), DeprecatedAt: &dep})
|
||||
ctx := context.Background()
|
||||
|
||||
rows, _ := gw.ChannelModels(ctx, ch.ID)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("开关关:弃用模型应在列,got %d", len(rows))
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||
}
|
||||
rows, _ = gw.ChannelModels(ctx, ch.ID)
|
||||
if len(rows) != 1 || rows[0].Name != "meta.llama-3.3-70b-instruct" {
|
||||
t.Errorf("开关开:弃用模型应被过滤,got %+v", rows)
|
||||
}
|
||||
chs, _ := gw.Channels(ctx)
|
||||
if len(chs) != 1 || chs[0].ModelCount != 1 {
|
||||
t.Errorf("开关开:数量统计应同口径,got %+v", chs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbe403ContinuesToNextCandidate 断言模型级 403 不再武断定论渠道无配额:
|
||||
// 后续候选成功 → ok;全部候选鉴权拒绝 → 仍 no_quota。
|
||||
func TestProbe403ContinuesToNextCandidate(t *testing.T) {
|
||||
deny := probeResult{403, stubServiceError{status: 403, msg: "NotAuthorizedOrNotFound"}}
|
||||
tests := []struct {
|
||||
name string
|
||||
seq []probeResult
|
||||
wantStatus string
|
||||
}{
|
||||
{"403 后换候选成功", []probeResult{deny, {200, nil}}, "ok"},
|
||||
{"403 后换候选限流也算可用", []probeResult{deny, {429, stubServiceError{status: 429}}}, "ok"},
|
||||
{"全部候选 403", []probeResult{deny, deny}, "no_quota"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
models: []oci.GenAiModel{
|
||||
{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"},
|
||||
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
|
||||
},
|
||||
probeSeq: tt.seq,
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
|
||||
probed, err := gw.ProbeChannel(ctx, ch.ID)
|
||||
if err != nil || probed.ProbeStatus != tt.wantStatus {
|
||||
t.Fatalf("ProbeChannel = %+v, %v, want %q", probed, err, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelModelTest 断言单模型试调:通过时写探测验证模型并翻转状态,失败不动。
|
||||
func TestChannelModelTest(t *testing.T) {
|
||||
client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
gw.db.Model(ch).Updates(map[string]any{"probe_status": "no_quota", "probe_error": "旧错误", "fail_count": 6})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := gw.TestChannelModel(ctx, ch.ID, "not.exists"); err == nil {
|
||||
t.Error("缓存外模型应报错")
|
||||
}
|
||||
client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
|
||||
if _, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct"); err == nil {
|
||||
t.Error("403 试调应报测试未通过")
|
||||
}
|
||||
var still model.AiChannel
|
||||
gw.db.First(&still, ch.ID)
|
||||
if still.ProbeStatus != "no_quota" || still.ProbeModel != "" {
|
||||
t.Errorf("失败不应改动渠道: %+v", still)
|
||||
}
|
||||
client.probeCode, client.probeErr = 200, nil
|
||||
fresh, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct")
|
||||
if err != nil {
|
||||
t.Fatalf("TestChannelModel: %v", err)
|
||||
}
|
||||
if fresh.ProbeModel != "meta.llama-3.3-70b-instruct" || fresh.ProbeStatus != "ok" ||
|
||||
fresh.ProbeError != "" || fresh.FailCount != 0 {
|
||||
t.Errorf("通过后应写验证模型并置可用: %+v", fresh)
|
||||
}
|
||||
// 已 ok 渠道再测另一模型:仅更新验证模型,不重写探测时间
|
||||
cache2 := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m2", Name: "xai.grok-4", Vendor: "xai", SyncedAt: time.Now()}
|
||||
gw.db.Create(cache2)
|
||||
fresh2, err := gw.TestChannelModel(ctx, ch.ID, "xai.grok-4")
|
||||
if err != nil || fresh2.ProbeModel != "xai.grok-4" {
|
||||
t.Fatalf("已可用渠道更新验证模型: %+v, %v", fresh2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
|
||||
// 微调基座 400 换渠道重试成功,且不计入熔断失败
|
||||
client := &gatewayStubClient{
|
||||
|
||||
@@ -23,8 +23,9 @@ func respRejectStateful(req aiwire.RespRequest) error {
|
||||
}
|
||||
|
||||
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
||||
// 只放行 function 与 Oracle 文档化的服务端工具(web_search / x_search /
|
||||
// code_interpreter / mcp)。
|
||||
// 放行 function、Oracle 文档化的服务端工具(web_search / x_search /
|
||||
// code_interpreter / mcp)与 codex 特有形态(namespace 工具分组、custom 自由
|
||||
// 格式,上游不识别,转发前拍平/转换)。
|
||||
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
return fmt.Errorf("model 不能为空")
|
||||
@@ -34,7 +35,7 @@ func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||
}
|
||||
for _, t := range req.Tools {
|
||||
switch t.Type {
|
||||
case "function", "web_search", "x_search", "code_interpreter", "mcp":
|
||||
case "function", "web_search", "x_search", "code_interpreter", "mcp", "namespace", "custom", "tool_search":
|
||||
default:
|
||||
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search / code_interpreter / mcp", t.Type)
|
||||
}
|
||||
@@ -42,17 +43,439 @@ func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespNsRef 一条拍平映射:限定名对应的原 namespace 与短名。codex 的回程路由
|
||||
// 依赖 function_call 项上的 name+namespace 双字段,响应侧按此映射还原。
|
||||
type RespNsRef struct {
|
||||
Namespace string
|
||||
Name string
|
||||
}
|
||||
|
||||
// RespCompat 是直通请求 codex 兼容改写的结果:Flattened/Converted/Dropped 供
|
||||
// 调用方记日志;NsRefs / CustomNames 非空表示响应侧需做工具调用项还原。
|
||||
type RespCompat struct {
|
||||
Flattened []string
|
||||
Converted []string
|
||||
Dropped []string
|
||||
NsRefs map[string]RespNsRef
|
||||
CustomNames map[string]struct{}
|
||||
}
|
||||
|
||||
// NeedRestore 报告响应是否需要做工具调用项还原。
|
||||
func (c RespCompat) NeedRestore() bool {
|
||||
return len(c.NsRefs) > 0 || len(c.CustomNames) > 0
|
||||
}
|
||||
|
||||
// RespPassthroughBody 以原始请求体为基构造上游 body:强制 store:false(禁上游
|
||||
// 存态),stream 原样保留(流式直通);用 json.Number 保真未知字段与数值。
|
||||
func RespPassthroughBody(raw []byte) ([]byte, error) {
|
||||
// 同时做 codex 兼容改写(namespace 拍平、多轮历史与 tool_choice 重限定)。
|
||||
func RespPassthroughBody(raw []byte) ([]byte, RespCompat, 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)
|
||||
return nil, RespCompat{}, fmt.Errorf("解析请求体: %w", err)
|
||||
}
|
||||
body["store"] = false
|
||||
return json.Marshal(body)
|
||||
compat := respCompatTools(body)
|
||||
respCompatInputCalls(body)
|
||||
respCompatToolChoice(body)
|
||||
out, err := json.Marshal(body)
|
||||
return out, compat, err
|
||||
}
|
||||
|
||||
// respCompatTools 对 tools 做 codex 兼容改写(上游实测行为见任务档案):
|
||||
// namespace 工具组上游 422,拆平为限定名 function 工具;custom(自由格式)上游
|
||||
// 同样 422,apply_patch 丢弃(grok 系未训练该补丁格式,失败编辑不如 shell 回退),
|
||||
// 其余转 function 包装;web_search 的 external_web_access 参数上游 400,false 是
|
||||
// "仅缓存检索"降权模式,按不越权原则连工具剥离,true 等价默认行为仅删键;
|
||||
// 剔空后删 tools,并连删 tool_choice 与 parallel_tool_calls(上游拒绝无 tools
|
||||
// 带 tool_choice)。
|
||||
func respCompatTools(body map[string]any) RespCompat {
|
||||
tools, ok := body["tools"].([]any)
|
||||
if !ok {
|
||||
return RespCompat{}
|
||||
}
|
||||
compat := RespCompat{NsRefs: map[string]RespNsRef{}, CustomNames: map[string]struct{}{}}
|
||||
kept := make([]any, 0, len(tools))
|
||||
for _, item := range tools {
|
||||
tool, _ := item.(map[string]any)
|
||||
switch {
|
||||
case tool != nil && tool["type"] == "namespace":
|
||||
ns, _ := tool["name"].(string)
|
||||
compat.Flattened = append(compat.Flattened, "namespace:"+ns)
|
||||
kept = append(kept, respFlattenNsTool(ns, tool, &compat)...)
|
||||
continue
|
||||
case tool != nil && tool["type"] == "custom":
|
||||
name, _ := tool["name"].(string)
|
||||
if name == "apply_patch" {
|
||||
compat.Dropped = append(compat.Dropped, "custom:apply_patch")
|
||||
continue
|
||||
}
|
||||
respConvertCustomTool(tool)
|
||||
if name != "" {
|
||||
compat.CustomNames[name] = struct{}{}
|
||||
compat.Converted = append(compat.Converted, "custom:"+name)
|
||||
}
|
||||
kept = append(kept, tool)
|
||||
continue
|
||||
}
|
||||
if desc, drop := respToolDrop(tool); drop {
|
||||
compat.Dropped = append(compat.Dropped, desc)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, item)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(body, "tools")
|
||||
delete(body, "tool_choice")
|
||||
delete(body, "parallel_tool_calls")
|
||||
} else {
|
||||
body["tools"] = kept
|
||||
}
|
||||
return compat
|
||||
}
|
||||
|
||||
// respConvertCustomTool 把 custom(自由格式)工具改写为 function:补 input 包装
|
||||
// schema(custom 工具本无 parameters),模型以 {"input": 文本} 形态调用,响应侧
|
||||
// 按 CustomNames 回转;custom 专有的 format(语法约束)字段一并移除。
|
||||
func respConvertCustomTool(tool map[string]any) {
|
||||
tool["type"] = "function"
|
||||
delete(tool, "format")
|
||||
if _, ok := tool["parameters"]; !ok {
|
||||
tool["parameters"] = map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"input": map[string]any{"type": "string"}},
|
||||
"required": []any{"input"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// respFlattenNsTool 把 namespace 组内子工具上提为顶层工具:限定改名、缺
|
||||
// parameters 补空 schema(上游必填),映射写入 NsRefs 供响应侧还原;非
|
||||
// function 子工具(如 custom)上游同样不认,剥离并记录。
|
||||
func respFlattenNsTool(ns string, tool map[string]any, compat *RespCompat) []any {
|
||||
children, _ := tool["tools"].([]any)
|
||||
out := make([]any, 0, len(children))
|
||||
for _, c := range children {
|
||||
child, _ := c.(map[string]any)
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
short, _ := child["name"].(string)
|
||||
if t, _ := child["type"].(string); t != "" && t != "function" {
|
||||
compat.Dropped = append(compat.Dropped, ns+"."+short+"(type="+t+")")
|
||||
continue
|
||||
}
|
||||
qualified := respQualifyNsName(ns, short)
|
||||
if qualified == "" {
|
||||
continue
|
||||
}
|
||||
child["name"] = qualified
|
||||
if _, ok := child["parameters"]; !ok {
|
||||
child["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
}
|
||||
compat.NsRefs[qualified] = RespNsRef{Namespace: ns, Name: short}
|
||||
out = append(out, child)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// respQualifyNsName 生成拍平后的限定名,与响应侧还原互逆(对齐 CLIProxyAPI):
|
||||
// mcp__ 开头的子工具名自带全局前缀不再限定;ns 以 __ 结尾直接拼接;已带前缀
|
||||
// 不重复添加。
|
||||
func respQualifyNsName(ns, name string) string {
|
||||
ns, name = strings.TrimSpace(ns), strings.TrimSpace(name)
|
||||
if ns == "" || name == "" || strings.HasPrefix(name, "mcp__") {
|
||||
return name
|
||||
}
|
||||
prefix := ns
|
||||
if !strings.HasSuffix(prefix, "__") {
|
||||
prefix += "__"
|
||||
}
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return name
|
||||
}
|
||||
return prefix + name
|
||||
}
|
||||
|
||||
// respCompatInputCalls 改写多轮历史 input:function_call 的 namespace 字段重限定
|
||||
// (上游不认识该字段);custom_tool_call / custom_tool_call_output 转换为上游
|
||||
// 认识的 function_call(_output) 形态(上游 item 变体表不含 custom 系)。
|
||||
func respCompatInputCalls(body map[string]any) {
|
||||
input, _ := body["input"].([]any)
|
||||
for _, it := range input {
|
||||
item, _ := it.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
switch item["type"] {
|
||||
case "function_call":
|
||||
respQualifyCallField(item)
|
||||
case "custom_tool_call":
|
||||
item["type"] = "function_call"
|
||||
item["arguments"] = respCustomCallArguments(item["input"])
|
||||
delete(item, "input")
|
||||
case "custom_tool_call_output":
|
||||
item["type"] = "function_call_output"
|
||||
item["output"] = respCustomCallOutput(item["output"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// respCustomCallArguments 把 custom_tool_call 的 input 包装为 function_call 的
|
||||
// arguments JSON 串(对齐 CLIProxyAPI):input 串本身是 JSON 对象则直用,普通
|
||||
// 文本包一层 {"input": 文本};对象取序列化;缺失为 {}。与响应侧
|
||||
// respUnwrapCustomInput 互逆,保证多轮往返无损。
|
||||
func respCustomCallArguments(input any) string {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
var probe map[string]any
|
||||
if json.Unmarshal([]byte(trimmed), &probe) == nil && probe != nil {
|
||||
return trimmed
|
||||
}
|
||||
enc, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return `{"input":` + string(enc) + `}`
|
||||
case nil:
|
||||
return "{}"
|
||||
default:
|
||||
enc, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(enc)
|
||||
}
|
||||
}
|
||||
|
||||
// respCustomCallOutput 归一 output 为字符串:非字符串时序列化保底。
|
||||
func respCustomCallOutput(output any) any {
|
||||
if _, ok := output.(string); ok {
|
||||
return output
|
||||
}
|
||||
if output == nil {
|
||||
return ""
|
||||
}
|
||||
enc, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(enc)
|
||||
}
|
||||
|
||||
// respCompatToolChoice 限定化对象形态的 tool_choice 及其 allowed_tools 列表
|
||||
// (上游不认 tool_choice 上的 namespace 字段)。
|
||||
func respCompatToolChoice(body map[string]any) {
|
||||
tc, _ := body["tool_choice"].(map[string]any)
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
if tc["type"] == "function" {
|
||||
respQualifyCallField(tc)
|
||||
}
|
||||
list, _ := tc["tools"].([]any)
|
||||
for _, it := range list {
|
||||
if sub, _ := it.(map[string]any); sub != nil && sub["type"] == "function" {
|
||||
respQualifyCallField(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// respQualifyCallField 把一个带 name/namespace 的对象改写为限定名形态。
|
||||
func respQualifyCallField(m map[string]any) {
|
||||
ns, _ := m["namespace"].(string)
|
||||
if strings.TrimSpace(ns) == "" {
|
||||
return
|
||||
}
|
||||
name, _ := m["name"].(string)
|
||||
if q := respQualifyNsName(ns, name); q != "" {
|
||||
m["name"] = q
|
||||
}
|
||||
delete(m, "namespace")
|
||||
}
|
||||
|
||||
// respToolDrop 判定单个工具是否剥离。web_search 的 external_web_access 参数
|
||||
// 上游 400:false 表示 OpenAI 的"仅缓存检索"降权模式,上游无对应能力,按不
|
||||
// 越权原则整个工具剥离;true 等价上游默认行为,仅删键放行。tool_search(codex
|
||||
// 的工具目录搜索)上游不识别,且 namespace 已全量拍平上送、搜索语义冗余,剥离。
|
||||
func respToolDrop(tool map[string]any) (string, bool) {
|
||||
if tool == nil {
|
||||
return "", false
|
||||
}
|
||||
switch tool["type"] {
|
||||
case "tool_search":
|
||||
return "tool_search", true
|
||||
case "web_search":
|
||||
access, has := tool["external_web_access"]
|
||||
if !has {
|
||||
return "", false
|
||||
}
|
||||
delete(tool, "external_web_access")
|
||||
if access == false {
|
||||
return "web_search(external_web_access=false)", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// RespStreamUpgradeLimit 是流式直通的请求体安全上限。实测(2026-07,xai 面):
|
||||
// 超过 ~82KB 的流式请求上游会在推理阶段掐断流(非流式不受影响,纯体积触发,
|
||||
// 与工具构成无关),预留余量取 76KB;超限时网关改走非流式上游并合成 SSE。
|
||||
const RespStreamUpgradeLimit = 76 * 1024
|
||||
|
||||
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
|
||||
// 原样保留。
|
||||
func RespDisableStream(body []byte) ([]byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||
dec.UseNumber()
|
||||
var m map[string]any
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("解析请求体: %w", err)
|
||||
}
|
||||
m["stream"] = false
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// RespSynthSSEEvents 把一份非流式响应合成为最小 SSE 事件序列:created →
|
||||
// 每个输出项一条 output_item.done → completed。流式升级回退用,客户端拿到
|
||||
// 完整事件语义但无增量;输出项与 usage 原样承载。
|
||||
func RespSynthSSEEvents(payload []byte) ([][]byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
||||
dec.UseNumber()
|
||||
var resp map[string]any
|
||||
if err := dec.Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("解析上游响应: %w", err)
|
||||
}
|
||||
output, _ := resp["output"].([]any)
|
||||
status, hadStatus := resp["status"]
|
||||
resp["status"], resp["output"] = "in_progress", []any{}
|
||||
created, err := json.Marshal(map[string]any{"type": "response.created", "response": resp})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hadStatus {
|
||||
resp["status"] = status
|
||||
} else {
|
||||
delete(resp, "status")
|
||||
}
|
||||
resp["output"] = output
|
||||
events := [][]byte{created}
|
||||
for i, item := range output {
|
||||
ev, err := json.Marshal(map[string]any{"type": "response.output_item.done", "output_index": i, "item": item})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
completed, err := json.Marshal(map[string]any{"type": "response.completed", "response": resp})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(events, completed), nil
|
||||
}
|
||||
|
||||
// RespRestoreToolCalls 非流式响应还原:output 数组中命中 NsRefs 的 function_call
|
||||
// 把限定名还原为短名并补回 namespace 字段(codex 以该双字段路由);命中
|
||||
// CustomNames 的回转 custom_tool_call(codex 期望 input 字段形态)。无需还原或
|
||||
// 解析失败时返回原字节,不破坏直通。
|
||||
func RespRestoreToolCalls(payload []byte, compat RespCompat) []byte {
|
||||
if !compat.NeedRestore() {
|
||||
return payload
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
||||
dec.UseNumber()
|
||||
var body map[string]any
|
||||
if dec.Decode(&body) != nil {
|
||||
return payload
|
||||
}
|
||||
if !respRestoreOutput(body["output"], compat) {
|
||||
return payload
|
||||
}
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RespRestoreToolCallsEvent 流式单事件还原:处理事件顶层 item(output_item.*)
|
||||
// 与 response.output(created/completed 等快照)。返回还原后数据与是否改动;
|
||||
// 未改动时调用方转发原始行,保持字节级直通。
|
||||
func RespRestoreToolCallsEvent(data []byte, compat RespCompat) ([]byte, bool) {
|
||||
if !compat.NeedRestore() {
|
||||
return data, false
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.UseNumber()
|
||||
var ev map[string]any
|
||||
if dec.Decode(&ev) != nil {
|
||||
return data, false
|
||||
}
|
||||
changed := respRestoreCallItem(ev["item"], compat)
|
||||
if resp, _ := ev["response"].(map[string]any); resp != nil {
|
||||
changed = respRestoreOutput(resp["output"], compat) || changed
|
||||
}
|
||||
if !changed {
|
||||
return data, false
|
||||
}
|
||||
out, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return data, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// respRestoreOutput 还原一个 output 数组,返回是否有改动。
|
||||
func respRestoreOutput(v any, compat RespCompat) bool {
|
||||
items, _ := v.([]any)
|
||||
changed := false
|
||||
for _, it := range items {
|
||||
if respRestoreCallItem(it, compat) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// respRestoreCallItem 还原单个 function_call 项,返回是否改动。namespace 还原与
|
||||
// custom 回转互斥:custom 工具不参与 namespace 拍平。
|
||||
func respRestoreCallItem(v any, compat RespCompat) bool {
|
||||
item, _ := v.(map[string]any)
|
||||
if item == nil || item["type"] != "function_call" {
|
||||
return false
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if ref, ok := compat.NsRefs[name]; ok {
|
||||
item["name"] = ref.Name
|
||||
item["namespace"] = ref.Namespace
|
||||
return true
|
||||
}
|
||||
if _, ok := compat.CustomNames[name]; ok {
|
||||
item["type"] = "custom_tool_call"
|
||||
item["input"] = respUnwrapCustomInput(item["arguments"])
|
||||
delete(item, "arguments")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// respUnwrapCustomInput 解包 arguments:形如 {"input": 串} 取其 input(非串取该
|
||||
// 值原文),其余情况整串返回。与请求侧 respCustomCallArguments 互逆。
|
||||
func respUnwrapCustomInput(arguments any) string {
|
||||
s, _ := arguments.(string)
|
||||
var probe map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(s), &probe) == nil {
|
||||
if raw, ok := probe["input"]; ok {
|
||||
var str string
|
||||
if json.Unmarshal(raw, &str) == nil {
|
||||
return str
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)。
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -43,6 +44,9 @@ func TestRespPassthroughValidate(t *testing.T) {
|
||||
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
||||
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
||||
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, true},
|
||||
{"namespace放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "namespace", Name: "multi_agent_v1"}}}, false},
|
||||
{"custom放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "custom", Name: "run_script"}}}, false},
|
||||
{"tool_search放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "tool_search"}}}, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -56,10 +60,13 @@ func TestRespPassthroughValidate(t *testing.T) {
|
||||
|
||||
func TestRespPassthroughBody(t *testing.T) {
|
||||
raw := []byte(`{"model":"m","input":"hi","stream":true,"store":true,"max_output_tokens":128,"custom_field":{"a":1.5}}`)
|
||||
out, err := RespPassthroughBody(raw)
|
||||
out, compat, err := RespPassthroughBody(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if len(compat.Dropped) != 0 || len(compat.Flattened) != 0 || len(compat.NsRefs) != 0 {
|
||||
t.Errorf("无 codex 工具不应有兼容改写: %+v", compat)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(out, &body); err != nil {
|
||||
t.Fatalf("unmarshal out: %v", err)
|
||||
@@ -76,11 +83,206 @@ func TestRespPassthroughBody(t *testing.T) {
|
||||
if !strings.Contains(string(out), `"custom_field"`) {
|
||||
t.Errorf("未知字段应保留: %s", out)
|
||||
}
|
||||
if _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
||||
if _, _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
||||
t.Error("非法 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespQualifyNsName 断言限定名生成规则与 CLIProxyAPI 对齐。
|
||||
func TestRespQualifyNsName(t *testing.T) {
|
||||
tests := []struct{ name, ns, tool, want string }{
|
||||
{"常规拼接", "multi_agent_v1", "spawn_agent", "multi_agent_v1__spawn_agent"},
|
||||
{"mcp子工具不加前缀", "mcp__sites", "mcp__sites__create_site", "mcp__sites__create_site"},
|
||||
{"ns以双下划线结尾", "codex_app__", "update", "codex_app__update"},
|
||||
{"已带前缀不重复", "ns1", "ns1__tool", "ns1__tool"},
|
||||
{"空ns原样", "", "tool", "tool"},
|
||||
{"空名返回空", "ns1", "", ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := respQualifyNsName(test.ns, test.tool); got != test.want {
|
||||
t.Fatalf("respQualifyNsName(%q,%q) = %q, want %q", test.ns, test.tool, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespPassthroughBodyCompatTools 断言 codex 工具兼容改写:namespace 拍平为
|
||||
// 限定名 function 并建映射,web_search 降权剥离,剔空连删 tool_choice。
|
||||
func TestRespPassthroughBodyCompatTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantFlattened []string
|
||||
wantDropped []string
|
||||
wantRefs map[string]RespNsRef
|
||||
wantContains []string
|
||||
wantAbsent []string
|
||||
}{
|
||||
{
|
||||
"namespace拍平保留function",
|
||||
`{"model":"m","tools":[{"type":"function","name":"exec"},{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}]}`,
|
||||
[]string{"namespace:multi_agent_v1"},
|
||||
nil,
|
||||
map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}},
|
||||
[]string{`"name":"multi_agent_v1__spawn_agent"`, `"name":"exec"`},
|
||||
[]string{`"type":"namespace"`},
|
||||
},
|
||||
{
|
||||
"mcp子工具保名并补parameters",
|
||||
`{"model":"m","tools":[{"type":"namespace","name":"mcp__sites","tools":[{"type":"function","name":"mcp__sites__create"}]}]}`,
|
||||
[]string{"namespace:mcp__sites"},
|
||||
nil,
|
||||
map[string]RespNsRef{"mcp__sites__create": {Namespace: "mcp__sites", Name: "mcp__sites__create"}},
|
||||
[]string{`"name":"mcp__sites__create"`, `"parameters":{"properties":{},"type":"object"}`},
|
||||
[]string{`"type":"namespace"`},
|
||||
},
|
||||
{
|
||||
"非function子工具剥离",
|
||||
`{"model":"m","tools":[{"type":"namespace","name":"ns1","tools":[{"type":"custom","name":"patch"},{"type":"function","name":"run"}]}]}`,
|
||||
[]string{"namespace:ns1"},
|
||||
[]string{"ns1.patch(type=custom)"},
|
||||
map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}},
|
||||
[]string{`"name":"ns1__run"`},
|
||||
[]string{"patch"},
|
||||
},
|
||||
{
|
||||
"剔空连删tool_choice",
|
||||
`{"model":"m","tool_choice":"auto","parallel_tool_calls":false,"tools":[{"type":"web_search","external_web_access":false}]}`,
|
||||
nil,
|
||||
[]string{"web_search(external_web_access=false)"},
|
||||
nil,
|
||||
nil,
|
||||
[]string{`"tools"`, `"tool_choice"`, `"parallel_tool_calls"`},
|
||||
},
|
||||
{
|
||||
"web_search全访问仅删键",
|
||||
`{"model":"m","tools":[{"type":"web_search","external_web_access":true}]}`,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
[]string{`"tools":[{"type":"web_search"}]`},
|
||||
[]string{"external_web_access"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out, compat, err := RespPassthroughBody([]byte(test.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if !slices.Equal(compat.Flattened, test.wantFlattened) {
|
||||
t.Errorf("Flattened = %v, want %v", compat.Flattened, test.wantFlattened)
|
||||
}
|
||||
if !slices.Equal(compat.Dropped, test.wantDropped) {
|
||||
t.Errorf("Dropped = %v, want %v", compat.Dropped, test.wantDropped)
|
||||
}
|
||||
for q, ref := range test.wantRefs {
|
||||
if compat.NsRefs[q] != ref {
|
||||
t.Errorf("NsRefs[%s] = %+v, want %+v", q, compat.NsRefs[q], ref)
|
||||
}
|
||||
}
|
||||
for _, s := range test.wantContains {
|
||||
if !strings.Contains(string(out), s) {
|
||||
t.Errorf("应包含 %s: %s", s, out)
|
||||
}
|
||||
}
|
||||
for _, s := range test.wantAbsent {
|
||||
if strings.Contains(string(out), s) {
|
||||
t.Errorf("不应包含 %s: %s", s, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatHistoryAndToolChoice 断言多轮历史 function_call 与对象 tool_choice
|
||||
// 的 namespace 字段被重限定并删除。
|
||||
func TestRespCompatHistoryAndToolChoice(t *testing.T) {
|
||||
raw := `{"model":"m",
|
||||
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"multi_agent_v1"},
|
||||
"input":[
|
||||
{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"c1","arguments":"{}"},
|
||||
{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"c1","output":"ok"}
|
||||
],
|
||||
"tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{}}]}]}`
|
||||
out, _, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
s := string(out)
|
||||
if !strings.Contains(s, `"tool_choice":{"name":"multi_agent_v1__spawn_agent","type":"function"}`) {
|
||||
t.Errorf("tool_choice 应限定化: %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"namespace":"multi_agent_v1"`) {
|
||||
t.Errorf("namespace 字段应全部删除: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"name":"exec"`) {
|
||||
t.Errorf("无 namespace 的历史项应原样: %s", s)
|
||||
}
|
||||
if c := strings.Count(s, "multi_agent_v1__spawn_agent"); c != 3 {
|
||||
t.Errorf("限定名应出现 3 次(tools/input/tool_choice), got %d: %s", c, s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespRestoreNamespace 断言非流式响应把限定名还原为短名+namespace 双字段。
|
||||
func TestRespRestoreNamespace(t *testing.T) {
|
||||
compat := RespCompat{NsRefs: map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}}}
|
||||
payload := []byte(`{"id":"r1","output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"c1","arguments":"{}"},{"type":"message","content":[]}],"usage":{"total_tokens":7}}`)
|
||||
out := RespRestoreToolCalls(payload, compat)
|
||||
s := string(out)
|
||||
if !strings.Contains(s, `"name":"spawn_agent"`) || !strings.Contains(s, `"namespace":"multi_agent_v1"`) {
|
||||
t.Errorf("应还原短名并补 namespace: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"total_tokens":7`) {
|
||||
t.Errorf("其余字段应保真: %s", s)
|
||||
}
|
||||
for name, payload := range map[string][]byte{
|
||||
"未命中原样": []byte(`{"output":[{"type":"function_call","name":"other","call_id":"c"}]}`),
|
||||
"非法JSON": []byte(`xx`),
|
||||
} {
|
||||
if got := RespRestoreToolCalls(payload, compat); string(got) != string(payload) {
|
||||
t.Errorf("%s: 应返回原字节, got %s", name, got)
|
||||
}
|
||||
}
|
||||
if got := RespRestoreToolCalls(payload, RespCompat{}); string(got) != string(payload) {
|
||||
t.Error("无需还原时应返回原字节")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespRestoreNamespaceEvent 断言流式事件还原覆盖 item 与 response.output
|
||||
// 两个位置,无关事件保持未改动。
|
||||
func TestRespRestoreNamespaceEvent(t *testing.T) {
|
||||
compat := RespCompat{NsRefs: map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}}}
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantChanged bool
|
||||
wantSub string
|
||||
}{
|
||||
{"output_item.done", `{"type":"response.output_item.done","item":{"type":"function_call","name":"ns1__run","call_id":"c1"}}`, true, `"namespace":"ns1"`},
|
||||
{"completed快照", `{"type":"response.completed","response":{"output":[{"type":"function_call","name":"ns1__run"}],"usage":{"total_tokens":1}}}`, true, `"name":"run"`},
|
||||
{"文本增量不动", `{"type":"response.output_text.delta","delta":"hi"}`, false, ""},
|
||||
{"未命中不动", `{"type":"response.output_item.done","item":{"type":"function_call","name":"exec"}}`, false, ""},
|
||||
{"非法JSON不动", `not-json`, false, ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out, changed := RespRestoreToolCallsEvent([]byte(test.data), compat)
|
||||
if changed != test.wantChanged {
|
||||
t.Fatalf("changed = %v, want %v", changed, test.wantChanged)
|
||||
}
|
||||
if !changed && string(out) != test.data {
|
||||
t.Fatalf("未改动应返回原字节: %s", out)
|
||||
}
|
||||
if test.wantSub != "" && !strings.Contains(string(out), test.wantSub) {
|
||||
t.Fatalf("应包含 %s: %s", test.wantSub, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespPassthroughUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -126,3 +328,166 @@ func TestRespStreamCompletedUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
||||
func TestRespDisableStream(t *testing.T) {
|
||||
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
||||
if err != nil {
|
||||
t.Fatalf("RespDisableStream: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), `"stream":false`) || !strings.Contains(string(out), `"max_output_tokens":64`) {
|
||||
t.Fatalf("字段不符: %s", out)
|
||||
}
|
||||
if _, err := RespDisableStream([]byte("x")); err == nil {
|
||||
t.Error("非法 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespSynthSSEEvents 断言合成事件序列:created(in_progress,空 output)→
|
||||
// 逐项 output_item.done → completed(完整响应)。
|
||||
func TestRespSynthSSEEvents(t *testing.T) {
|
||||
payload := []byte(`{"id":"r1","status":"completed","output":[{"type":"reasoning","summary":[]},{"type":"function_call","name":"run","call_id":"c1"}],"usage":{"total_tokens":9}}`)
|
||||
events, err := RespSynthSSEEvents(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("RespSynthSSEEvents: %v", err)
|
||||
}
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("事件数 = %d, want 4", len(events))
|
||||
}
|
||||
first := string(events[0])
|
||||
if !strings.Contains(first, `"type":"response.created"`) || !strings.Contains(first, `"status":"in_progress"`) || !strings.Contains(first, `"output":[]`) {
|
||||
t.Errorf("created 事件不符: %s", first)
|
||||
}
|
||||
if !strings.Contains(string(events[1]), `"type":"response.output_item.done"`) || !strings.Contains(string(events[1]), `"output_index":0`) {
|
||||
t.Errorf("item.done 事件不符: %s", events[1])
|
||||
}
|
||||
if !strings.Contains(string(events[2]), `"name":"run"`) {
|
||||
t.Errorf("第二项应为 function_call: %s", events[2])
|
||||
}
|
||||
last := string(events[3])
|
||||
if !strings.Contains(last, `"type":"response.completed"`) || !strings.Contains(last, `"status":"completed"`) ||
|
||||
!strings.Contains(last, `"total_tokens":9`) || !strings.Contains(last, `"name":"run"`) {
|
||||
t.Errorf("completed 事件不符: %s", last)
|
||||
}
|
||||
if _, err := RespSynthSSEEvents([]byte("x")); err == nil {
|
||||
t.Error("非法 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatCustomTools 断言 custom 工具转换:apply_patch 丢弃,其余转
|
||||
// function 并补 input 包装 schema、记入 CustomNames;format 字段移除。
|
||||
func TestRespCompatCustomTools(t *testing.T) {
|
||||
raw := `{"model":"m","tools":[
|
||||
{"type":"custom","name":"apply_patch","description":"edit files"},
|
||||
{"type":"custom","name":"run_script","description":"run it","format":{"type":"grammar"}},
|
||||
{"type":"function","name":"exec","parameters":{}}]}`
|
||||
out, compat, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if !slices.Equal(compat.Dropped, []string{"custom:apply_patch"}) {
|
||||
t.Errorf("Dropped = %v", compat.Dropped)
|
||||
}
|
||||
if !slices.Equal(compat.Converted, []string{"custom:run_script"}) {
|
||||
t.Errorf("Converted = %v", compat.Converted)
|
||||
}
|
||||
if _, ok := compat.CustomNames["run_script"]; !ok {
|
||||
t.Errorf("CustomNames 缺 run_script: %v", compat.CustomNames)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "apply_patch") || strings.Contains(s, `"type":"custom"`) || strings.Contains(s, "grammar") {
|
||||
t.Errorf("apply_patch/custom/format 应消失: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"required":["input"]`) || !strings.Contains(s, `"input":{"type":"string"}`) {
|
||||
t.Errorf("run_script 应补 input 包装 schema: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatCustomHistory 断言 input 历史的 custom_tool_call(_output) 转换
|
||||
// 与 arguments 包装三分支。
|
||||
func TestRespCompatCustomHistory(t *testing.T) {
|
||||
raw := `{"model":"m","input":[
|
||||
{"type":"custom_tool_call","call_id":"c1","name":"run_script","input":"plain text"},
|
||||
{"type":"custom_tool_call","call_id":"c2","name":"run_script","input":"{\"a\":1}"},
|
||||
{"type":"custom_tool_call","call_id":"c3","name":"run_script"},
|
||||
{"type":"custom_tool_call_output","call_id":"c1","output":"done"},
|
||||
{"type":"custom_tool_call_output","call_id":"c2","output":{"ok":true}}]}`
|
||||
out, _, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "custom_tool_call") {
|
||||
t.Fatalf("custom_tool_call 系应全部转换: %s", s)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"arguments":"{\"input\":\"plain text\"}"`,
|
||||
`"arguments":"{\"a\":1}"`,
|
||||
`"arguments":"{}"`,
|
||||
`"output":"done"`,
|
||||
`"output":"{\"ok\":true}"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("应包含 %s: %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespRestoreCustomToolCalls 断言响应侧回转:命中 CustomNames 的
|
||||
// function_call 变 custom_tool_call 且 input 解包;流式事件同样生效。
|
||||
func TestRespRestoreCustomToolCalls(t *testing.T) {
|
||||
compat := RespCompat{CustomNames: map[string]struct{}{"run_script": {}}}
|
||||
payload := []byte(`{"output":[{"type":"function_call","name":"run_script","call_id":"c1","arguments":"{\"input\":\"echo hi\"}"},{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"}]}`)
|
||||
s := string(RespRestoreToolCalls(payload, compat))
|
||||
if !strings.Contains(s, `"type":"custom_tool_call"`) || !strings.Contains(s, `"input":"echo hi"`) {
|
||||
t.Errorf("应回转 custom_tool_call 并解包 input: %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"arguments":"{\"input\":\"echo hi\"}"`) {
|
||||
t.Errorf("arguments 应删除: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"name":"exec"`) || strings.Count(s, "custom_tool_call") != 1 {
|
||||
t.Errorf("非转换名不应动: %s", s)
|
||||
}
|
||||
ev := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"run_script","arguments":"not-json"}}`)
|
||||
got, changed := RespRestoreToolCallsEvent(ev, compat)
|
||||
if !changed || !strings.Contains(string(got), `"input":"not-json"`) {
|
||||
t.Errorf("非 JSON arguments 应整串作 input: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespUnwrapCustomInput 断言解包分支:input 键取值、非串取原文、其余整串。
|
||||
func TestRespUnwrapCustomInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args any
|
||||
want string
|
||||
}{
|
||||
{"包装取input", `{"input":"hello"}`, "hello"},
|
||||
{"input非串取原文", `{"input":{"x":1}}`, `{"x":1}`},
|
||||
{"无input键整串", `{"cmd":"ls"}`, `{"cmd":"ls"}`},
|
||||
{"非JSON整串", "raw text", "raw text"},
|
||||
{"非串类型空串", 42, ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := respUnwrapCustomInput(test.args); got != test.want {
|
||||
t.Fatalf("respUnwrapCustomInput(%v) = %q, want %q", test.args, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespCompatToolSearchDrop 断言 tool_search 剥离(拍平后语义冗余,上游不识别)。
|
||||
func TestRespCompatToolSearchDrop(t *testing.T) {
|
||||
raw := `{"model":"m","tools":[{"type":"tool_search"},{"type":"function","name":"exec"}]}`
|
||||
out, compat, err := RespPassthroughBody([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("RespPassthroughBody: %v", err)
|
||||
}
|
||||
if !slices.Equal(compat.Dropped, []string{"tool_search"}) {
|
||||
t.Errorf("Dropped = %v", compat.Dropped)
|
||||
}
|
||||
if strings.Contains(string(out), "tool_search") || !strings.Contains(string(out), `"name":"exec"`) {
|
||||
t.Errorf("tool_search 应剥离且 function 保留: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ type externalIdentity struct {
|
||||
|
||||
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
||||
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, display, mode string, err error) {
|
||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, username, mode string, err error) {
|
||||
p, err := o.takeState(provider, state)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
@@ -198,14 +198,14 @@ func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code
|
||||
}
|
||||
if p.mode == "bind" {
|
||||
if err := o.bind(ctx, p.username, provider, ident); err != nil {
|
||||
return "", ident.Display, p.mode, err
|
||||
return "", p.username, p.mode, err
|
||||
}
|
||||
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
|
||||
token, _, err := o.auth.RevokeSessions(ctx, p.username)
|
||||
return token, ident.Display, p.mode, err
|
||||
return token, p.username, p.mode, err
|
||||
}
|
||||
token, display, err = o.loginByIdentity(ctx, provider, ident)
|
||||
return token, display, p.mode, err
|
||||
token, username, err = o.loginByIdentity(ctx, provider, ident)
|
||||
return token, username, p.mode, err
|
||||
}
|
||||
|
||||
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
||||
@@ -311,7 +311,7 @@ func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ide
|
||||
return "", "", fmt.Errorf("find bound user: %w", err)
|
||||
}
|
||||
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
|
||||
return token, ident.Display, err
|
||||
return token, user.Username, err
|
||||
}
|
||||
|
||||
// Identities 列出账号已绑定的外部身份。
|
||||
|
||||
@@ -169,12 +169,12 @@ func TestOAuthBindLoginUnbind(t *testing.T) {
|
||||
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
|
||||
}
|
||||
// 已绑定身份可登录并拿到有效 JWT
|
||||
token, display, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
||||
token, loginUser, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
||||
if err != nil || token == "" {
|
||||
t.Fatalf("loginByIdentity: %v", err)
|
||||
}
|
||||
if display != "octocat" {
|
||||
t.Errorf("display = %q", display)
|
||||
if loginUser != "admin" {
|
||||
t.Errorf("loginUser = %q, want admin", loginUser)
|
||||
}
|
||||
if username, err := auth.ParseToken(context.Background(), token); err != nil || username != "admin" {
|
||||
t.Errorf("token 应属 admin, got %q (%v)", username, err)
|
||||
|
||||
Reference in New Issue
Block a user