AI网关:Responses直通codex兼容与流式升级回退
CI / test (push) Successful in 31s
Release / release (push) Successful in 39s

This commit is contained in:
2026-07-15 10:39:56 +08:00
parent a8bde89b56
commit e1f8a0539c
8 changed files with 1339 additions and 221 deletions
+106 -12
View File
@@ -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
}
+429 -6
View File
@@ -23,8 +23,9 @@ func respRejectStateful(req aiwire.RespRequest) error {
}
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
// 放行 functionOracle 文档化的服务端工具(web_search / x_search /
// code_interpreter / mcp)
// 放行 functionOracle 文档化的服务端工具(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(日志记零)。
+367 -2
View File
@@ -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)
}
}