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) + } +}