From e1f8a0539c122b686a0602ba62cb10d24d94683a Mon Sep 17 00:00:00 2001
From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io>
Date: Wed, 15 Jul 2026 10:39:56 +0800
Subject: [PATCH] =?UTF-8?q?AI=E7=BD=91=E5=85=B3:Responses=E7=9B=B4?=
=?UTF-8?q?=E9=80=9Acodex=E5=85=BC=E5=AE=B9=E4=B8=8E=E6=B5=81=E5=BC=8F?=
=?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=9B=9E=E9=80=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 16 +
docs/AI网关.md | 614 ++++++++++++++++++---------
docs/docs.go | 2 +-
docs/swagger.json | 2 +-
docs/swagger.yaml | 4 +-
internal/api/aigateway.go | 118 ++++-
internal/service/airesponses.go | 435 ++++++++++++++++++-
internal/service/airesponses_test.go | 369 +++++++++++++++-
8 files changed, 1339 insertions(+), 221 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6cf11c..1d30f52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
格式参考 [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
diff --git a/docs/AI网关.md b/docs/AI网关.md
index 05a8591..cc5c7e4 100644
--- a/docs/AI网关.md
+++ b/docs/AI网关.md
@@ -1,294 +1,512 @@
# AI 网关
-> 本文是 OCI Portal AI 网关的完整使用与兼容性文档:端点定位、协议兼容边界、已知上游限制与字段兼容矩阵。
-> 路由与鉴权的机器可读定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 表达的兼容边界以本文为准。
+> [!NOTE]
+> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
+> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
+>
+> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
+> 为准;协议差异、兼容改写和实测边界以本文为准。
-AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
+**兼容快照:2026-07-15**
-| 端点 | 定位 | 流式 |
+## 快速导航
+
+- [快速接入](#quick-start)
+- [端点一览](#endpoints)
+- [路由与全局行为](#routing)
+- [Codex 接入](#codex)
+- [已知限制](#limitations)
+- [字段兼容矩阵](#compatibility)
+- [实现索引](#implementation)
+
+
+
+## 快速接入
+
+### 基础地址与鉴权
+
+```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"
+```
+
+
+
+## 端点一览
+
+| 协议域 | 端点 | 角色 | 流式 |
+| --- | --- | --- | :---: |
+| 对话 | `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 使用各自专用端点
+
+
+
+## 路由与全局行为
+
+### 请求链路
+
+```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 |
+
+
+
+## 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 工具兼容现状:
+
+| 工具形态 | 网关处理 | 验证状态 |
| --- | --- | --- |
-| `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/tts` | xAI 官方格式文本转语音(同一上游,网关转换) | 否 |
-| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank,Jina 风格协议) | 否 |
-| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
-| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
+| `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)。
-- 对话请求统一转发 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 流式
-- `/ai/v1/tts` 为 xAI 官方 TTS 格式(`text`/`language` 必填、`voice_id`、`output_format` 对象)的转换端点:网关转换为 OpenAI 兼容形态后走同一上游与渠道调度,`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format`(codec/sample_rate/bit_rate)与 `speed` 透传生效
-- Rerank 走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),请求 `{model, query, documents[], top_n?, return_documents?}`,响应 `results[].index` 指向入参下标;无 token 用量口径,调用日志只记时延
-- Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails:`input` 为字符串或字符串数组(至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(阈值 0.5 判定 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged);`model` 字段接受但忽略,无模型白名单维度;实测中文人名/手机号识别较弱,英文 PII 识别正常
-- Responses 的 `input_file` 内容块(file_url / file_data)实测被上游拒绝:`File content is currently unsupported for ZDR customers`——网关强制 `store:false` 属 ZDR 形态,该能力在上游侧不可用
+
-### 已知上游限制:大 system 区流式断流
+## 已知限制
-实测(2026-07-13)OCI 兼容面对 `instructions` 与 `tools` 合计超约 64.5KB 的**流式**请求会在发出少量事件后静默断开连接(无任何错误事件;同请求非流式正常),与模型、字符集、消息正文大小均无关——消息正文(`input`)不计入该限制。Chat Completions 与 Messages 的 system/developer 提示会转换为 `instructions`,因此 Claude Code 等自带大体量系统提示与工具定义的客户端极易触发。
+### 超大流式请求
-网关侧应对:
+> [!WARNING]
+> 2026-07 实测:OCI xAI 兼容面对**完整请求体超过约 82 KB** 的流式请求,
+> 可能在推理阶段提前断流;该现象由请求体积触发,与工具构成或字符集无关,
+> 同一请求改为非流式可正常完成。
-- Messages 与 Chat Completions 的流式请求在客户端尚未收到任何输出时遭遇上游断流,会自动降级为非流式重做,并按标准事件/chunk 序列一次推送;调用日志记 `retries=1` 与降级标记
-- Responses 直通因初始事件已转发、协议上无法透明降级,调用日志记「上游流提前终止」,客户端需自行回退非流式
-- 应急规避:将超长 system 内容移入首条 user 消息正文可绕过该限制(正文不计入),但语义有别,根治有待上游修复
+网关按协议采取不同保护:
-这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。
+- **Responses**:兼容改写后的请求体超过 **76 KB** 时,预防性改为非流式上游
+ 请求,再合成最小 SSE 序列(`response.created` →
+ `response.output_item.done` → `response.completed`);结果语义保留,但没有增量输出
+- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
+ 非流式重做,并合成对应 chunk / event 序列
+- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
+ 部分事件
-### 字段兼容矩阵
+### ZDR 与文件输入
-以下矩阵以 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) 协议。
+Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
-这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。
+```text
+File content is currently unsupported for ZDR customers
+```
-矩阵只列网关支持的字段;不支持(本地拒绝)与被忽略的字段不入表,在各端点段落末尾以文字简述。
+网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
+
+### 规格与实测边界
+
+本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
+部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
+
+
+
+## 字段兼容矩阵
+
+矩阵基线:
+
+- [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 上游决定 |
-| 🔄 | 网关进行字段或协议转换后支持 |
+| ➡️ | 原样保留或透传;是否生效由 OCI 上游决定 |
+| 🔄 | 网关执行字段或协议转换后支持 |
| ◐ | 部分支持、存在前置条件或语义降级 |
-
-POST /ai/v1/responses 对比 OpenAI Responses
+### OpenAI Responses
-Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
+**`POST /ai/v1/responses` · 无状态主接口**
+
+网关以原始 JSON 为基底保留未知字段,但会强制关闭上游存储,并执行下表列出的
+Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转发。
+
+
+核心字段
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
-| `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 原样透传,本地不校验枚举或结构 |
+| `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` | ✅ | 普通请求、`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)
+| `stream` | ◐ | 支持 SSE;请求体超过 76 KB 时改为非流式上游并合成 SSE |
+| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
-
-POST /ai/v1/chat/completions 对比 OpenAI Chat Completions
+
+工具兼容
-Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。转换器只搬运下表字段,其余字段不进入上游请求。
+| 工具或参数 | 状态 | 网关行为 |
+| --- | :---: | --- |
+| `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` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
+
+
+
+本地拒绝(返回 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。
+只有下表字段会进入上游请求。
+
+
+展开字段矩阵
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
-| `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、函数名和参数 |
+| `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` | 🔄 | 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` |
+| `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` |
-不支持(返回 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` 及其他未知字段。
+不支持(返回 400):`input_audio`、`file`、`refusal` 等消息内容块,以及
+`function` 之外的工具类型。
+
+
+静默忽略字段
+
+消息级 `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`
+及其他未知字段。
+
+
响应边界:
-- 非流式固定生成一个 `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 错误事件
+- 非流式固定生成一个 `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 错误事件
-实现依据:[`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) · [`aigateway.go`](../internal/api/aigateway.go)
+### Anthropic Messages
-
+**`POST /ai/v1/messages` · Anthropic 协议转换层**
+
+支持 `Authorization: Bearer` 与 `x-api-key`,但不校验或使用
+`anthropic-version`、`anthropic-beta` 请求头。
-POST /ai/v1/embeddings 对比 OpenAI Embeddings
+展开顶层字段矩阵
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
-| `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)
-
-
-
-
-POST /ai/v1/messages 对比 Anthropic Messages
-
-网关接受 `Authorization: Bearer` 或 `x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version`、`anthropic-beta` 请求头。
-
-| 标准字段 | 状态 | 网关行为 |
-| --- | :---: | --- |
-| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 |
-| `max_tokens` | 🔄 | 可缺省(缺省或 ≤0 时按默认值 8192),转换为 `max_output_tokens` |
+| `model` | ◐ | 用于模型与渠道选择;空字符串通常最终返回模型不存在 |
+| `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` |
+| `system` | ◐ | 支持字符串或 text 块数组;文本块拼接,附加字段忽略 |
+| `temperature`、`top_p` | ◐ | 写入 Responses,不校验范围或模型能力 |
+| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 标准事件序列 |
+| `tools` | ◐ | 每个工具转为 Responses `function`;服务端工具类型不保留原语义 |
+| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool` |
+| `output_config.effort` | 🔄 | 转小写后映射为 `reasoning.effort` |
-顶层静默忽略(能解析但不传上游):`top_k`、`stop_sequences`(响应 `stop_sequence` 恒为 `null`)、`metadata`、`thinking`(不控制上游思考预算)、`output_config` 其余子字段、`cache_control`、`container`、`inference_geo`、`service_tier` 及其他未知顶层字段。
+
-`messages[].content`:
+内容块兼容:
-| 标准内容块 | 状态 | 网关行为 |
+| `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 中消失,不返回参数错误 |
+| `image` | ◐ | 仅支持 `base64` 与 `url` source |
+| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称与输入 |
+| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 与非文本结果丢失 |
+| `null` / 空块数组 | ◐ | 消息可能从上游 input 中消失,不返回参数错误 |
-不支持的内容块(返回 400):`document`、服务端工具结果及其他未知块。历史 `thinking` / `redacted_thinking` 块会被删除,不进入上游上下文。
+不支持(返回 400):`document`、服务端工具结果及未知内容块。历史
+`thinking` / `redacted_thinking` 会删除。
+
+
+静默忽略字段
+
+`top_k`、`stop_sequences`、`metadata`、`thinking`、`output_config` 其余子字段、
+`cache_control`、`container`、`inference_geo`、`service_tier` 及其他未知顶层字段。
+
+
响应边界:
-- 非流式只把 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` 事件
+- 仅将 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` 事件
-实现依据:[`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) · [`aigateway.go`](../internal/api/aigateway.go)
+### OpenAI Embeddings
-
-
-
-POST /ai/v1/audio/speech 对比 OpenAI Audio Speech
-
-Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除缺省注入 `language` 外不重建请求体,未知字段原样透传。
+**`POST /ai/v1/embeddings` · 向量化专用端点**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
-| `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`),是否生效由上游决定 |
+| `model` | ◐ | 必填,受白名单限制,且需要 `EMBEDDING` 能力渠道 |
+| 字符串 `input` | ✅ | 包装为单个输入后调用 OCI |
+| 字符串数组 `input` | ✅ | 按原顺序调用 OCI |
+| `dimensions` | ◐ | 映射为 OCI 输出维度,不校验范围或模型能力 |
+| `encoding_format=float` | ✅ | 返回 float 数组;省略时相同 |
-不支持:流式音频(`stream_format` 等流式选项无效,响应恒为一次性完整音频)与 WebSocket 语音会话。
+不支持(返回 400):token ID 数组、空数组、`null` 和
+`encoding_format=base64`。`user` 与其他未知字段静默忽略。
-响应边界:
+响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
-- 成功响应为音频字节,`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)
+两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
-
+#### OpenAI Audio Speech
-
-POST /ai/v1/tts 对比 xAI TTS
-
-`/ai/v1/tts` 接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)(OCI 无 HTTP 版 xAI 面,网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与调度)。
+**`POST /ai/v1/audio/speech`**
| 标准字段 | 状态 | 网关行为 |
| --- | :---: | --- |
-| `text` | 🔄 | 必填非空,转换为上游 `input` |
-| `language` | ◐ | 必填(对齐 xAI 官方;接受 BCP-47 或 `auto`),原样透传 |
-| `voice_id` | 🔄 | 转换为上游 `voice`;缺省交上游默认(`eve`) |
-| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象原样透传,实测生效(44.1kHz/192kbps 验证) |
-| `speed` | ➡️ | 原样透传,实测接受 |
-| `model`(网关扩展) | ◐ | xAI 官方无此字段;缺省注入 `xai.grok-tts`,可显式覆盖,受密钥白名单限制 |
-| `optimize_streaming_latency`、`text_normalization`、`with_timestamps` 及其他未知字段 | ➡️ | 原样透传,是否生效由上游决定 |
+| `model` | ◐ | 必填,受白名单限制,需要 `TTS` 能力渠道;当前为 `xai.grok-tts` |
+| `input` | ◐ | 必填非空,随后保留 |
+| `voice` | ➡️ | 使用 xAI 音色 `ara` / `eve` / `leo` / `rex` / `sal` |
+| `response_format` | ➡️ | 实测 `mp3` 可用,其余格式由上游决定 |
+| `language`(扩展) | 🔄 | 上游必填;缺省时注入 `"auto"` |
+| `speed`、`instructions` 与未知字段 | ➡️ | 保留,是否生效由上游决定 |
-不支持:流式音频输出(xAI 官方 `optimize_streaming_latency` 面向流式场景,本端点响应恒为一次性完整音频);WebSocket 流式(OCI 另有 `wss://…/xai/v1/tts` 私有协议面,网关未代理)。
+#### xAI TTS
-响应边界:
+**`POST /ai/v1/tts`**
-- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
-- 无 token 用量口径,调用日志只记时延与渠道(端点名 `tts`)
-
-实现依据:[`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
-
-
-
-
-POST /ai/v1/rerank 对比 Cohere Rerank
-
-Rerank 无 OpenAI 对应端点,协议取 Jina / Cohere 通行风格,上游走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`)。
+接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech),
+转换为 OpenAI 兼容形态后进入同一上游。
| 字段 | 状态 | 网关行为 |
| --- | :---: | --- |
-| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `RERANK` 能力的渠道 |
-| `query` | ✅ | 必填非空 |
-| `documents` | ◐ | 必填,仅接受字符串数组;Cohere 旧版 `{"text": ...}` 对象数组形态返回 400 |
-| `top_n` | ✅ | 可选,传给上游限制返回条数;缺省返回全部文档的重排结果 |
-| `return_documents` | ✅ | 可选,`true` 时 `results[].document.text` 回带原文 |
+| `text` | 🔄 | 必填非空,转换为 `input` |
+| `language` | ◐ | 必填,接受 BCP-47 或 `auto` |
+| `voice_id` | 🔄 | 转为 `voice`;缺省交给上游默认值 `eve` |
+| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象保留,实测生效 |
+| `speed` | ➡️ | 保留,实测可用 |
+| `model`(网关扩展) | ◐ | 缺省注入 `xai.grok-tts`,可覆盖,受白名单限制 |
+| 其余未知字段 | ➡️ | 保留,是否生效由上游决定 |
-静默忽略:`max_tokens_per_doc` 等 Cohere 专属参数及其他未知字段。
+共同响应边界:
-响应边界:
-
-- `results[]` 按相关度降序,`index` 指向入参 `documents` 下标,`relevance_score` 为 0–1 浮点;响应 `model` 回显请求值
+- 成功响应为一次性完整音频,`Content-Type` 透传上游,缺省 `audio/mpeg`
+- 不提供 HTTP 流式音频或 WebSocket 代理
- 无 token 用量口径,调用日志只记时延与渠道
-实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
+### Rerank
-
+**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
-
-POST /ai/v1/moderations 对比 OpenAI Moderations
+上游走 OCI typed 面,模型为 `cohere.rerank-v4.0-pro` 或 `-fast`。
-Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入)。上游是服务级 API,没有模型与白名单维度;渠道按分组直接挑选。
+| 字段 | 状态 | 网关行为 |
+| --- | :---: | --- |
+| `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 条 |
+| 字符串 `input` | ✅ | 单条审核 |
+| 字符串数组 `input` | ✅ | 逐条审核,单次 1~8 条 |
-不支持(返回 400):多模态 input(图片等对象数组形态)、空字符串条目、空数组或超过 8 条的数组。静默忽略:`model`(接受任意值,不校验白名单)及其他未知字段。
+不支持(返回 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 识别正常
+- categories 使用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`
+- 任一维度得分 ≥ 0.5 时 `flagged=true`
+- 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)
+### Models
-
+**`GET /ai/v1/models` · 当前密钥可见模型列表**
-`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。面板「过滤弃用模型」开关开启时,OCI 已宣布弃用(即使未到退役日)的模型同时从模型列表与路由中排除,关闭后恢复。
+响应使用 OpenAI Models 列表外壳,只返回同时通过以下条件的模型:
+
+1. 存在于当前渠道目录
+2. 匹配密钥绑定的渠道分组
+3. 不在全局模型黑名单
+4. 命中密钥模型白名单(配置时)
+5. 未被「过滤弃用模型」开关排除
+
+网关不提供标准的单模型检索端点。
+
+
+
+## 实现索引
+
+| 端点 / 能力 | 主要实现 |
+| --- | --- |
+| 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 和实测结果为准。
diff --git a/docs/docs.go b/docs/docs.go
index aa053cb..ea57b19 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -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,
diff --git a/docs/swagger.json b/docs/swagger.json
index 4d731a9..4d563b2 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -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,
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 70670eb..e227ee4 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -2780,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
diff --git a/internal/api/aigateway.go b/internal/api/aigateway.go
index ceb385d..a89782f 100644
--- a/internal/api/aigateway.go
+++ b/internal/api/aigateway.go
@@ -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
+}
diff --git a/internal/service/airesponses.go b/internal/service/airesponses.go
index d4825bc..a07fd78 100644
--- a/internal/service/airesponses.go
+++ b/internal/service/airesponses.go
@@ -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(日志记零)。
diff --git a/internal/service/airesponses_test.go b/internal/service/airesponses_test.go
index 6553f26..187d91a 100644
--- a/internal/service/airesponses_test.go
+++ b/internal/service/airesponses_test.go
@@ -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)
+ }
+}