AI网关:Responses直通codex兼容与流式升级回退
This commit is contained in:
@@ -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(日志记零)。
|
||||
|
||||
Reference in New Issue
Block a user