mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(codex): 剥离续链 function_call item 的非法 item_* id
OpenAI OAuth 转发续链请求时,function_call 等 call-input 类 item 的 id 被客户端以 item_* 形式回放,但上游要求以 fc 开头,返回 400 "Expected an ID that begins with 'fc'",导致工具续链反复失败。 filterCodexInputWithOptions 在 PreserveReferences=true 路径下对 call-input 类 item(function_call/tool_call/local_shell_call 等) 增加 id 前缀检查:非 fc 开头即删除。output 类(function_call_output 等)的 id 无此约束,不动。 Fixes #3785
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFilterCodexInput_StripsFunctionCallItemID_WhenPreservingReferences
|
||||||
|
// verifies that function_call items with non-fc id (e.g. item_*) have their
|
||||||
|
// id stripped even when PreserveReferences is true. OpenAI upstream requires
|
||||||
|
// function_call ids to begin with "fc" and rejects item_* with 400:
|
||||||
|
// "Expected an ID that begins with 'fc'." (#3785)
|
||||||
|
func TestFilterCodexInput_StripsFunctionCallItemID_WhenPreservingReferences(t *testing.T) {
|
||||||
|
input := []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "function_call",
|
||||||
|
"id": "item_A9v0SNfS3VaLrfX0j3y4xhyK",
|
||||||
|
"call_id": "fc_abc123",
|
||||||
|
"name": "bash",
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "fc_abc123",
|
||||||
|
"output": "done",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{
|
||||||
|
PreserveReferences: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, filtered, 2)
|
||||||
|
|
||||||
|
fc, ok := filtered[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "function_call", fc["type"])
|
||||||
|
_, hasID := fc["id"]
|
||||||
|
require.False(t, hasID, "item_* id should be stripped from function_call")
|
||||||
|
require.Equal(t, "fc_abc123", fc["call_id"], "call_id must be preserved")
|
||||||
|
require.Equal(t, "bash", fc["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilterCodexInput_KeepsFcID_WhenPreservingReferences
|
||||||
|
// verifies that function_call items with a valid fc* id are kept when
|
||||||
|
// PreserveReferences is true.
|
||||||
|
func TestFilterCodexInput_KeepsFcID_WhenPreservingReferences(t *testing.T) {
|
||||||
|
input := []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "function_call",
|
||||||
|
"id": "fc_validID123",
|
||||||
|
"call_id": "fc_validID123",
|
||||||
|
"name": "bash",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{
|
||||||
|
PreserveReferences: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, filtered, 1)
|
||||||
|
fc, ok := filtered[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "fc_validID123", fc["id"], "valid fc* id must be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes verifies that
|
||||||
|
// item_* ids are stripped from all call-input types (not output types).
|
||||||
|
func TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes(t *testing.T) {
|
||||||
|
types := []string{"function_call", "tool_call", "local_shell_call", "custom_tool_call", "mcp_tool_call"}
|
||||||
|
|
||||||
|
for _, typ := range types {
|
||||||
|
input := []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": typ,
|
||||||
|
"id": "item_xyz",
|
||||||
|
"call_id": "fc_001",
|
||||||
|
"name": "tool",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{
|
||||||
|
PreserveReferences: true,
|
||||||
|
})
|
||||||
|
require.Len(t, filtered, 1)
|
||||||
|
item, ok := filtered[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
_, hasID := item["id"]
|
||||||
|
require.False(t, hasID, "item_* id should be stripped from %s", typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilterCodexInput_OutputTypeKeepsItemID ensures tool-output items
|
||||||
|
// (e.g. function_call_output) keep their id — only call-input types have
|
||||||
|
// the fc* constraint.
|
||||||
|
func TestFilterCodexInput_OutputTypeKeepsItemID(t *testing.T) {
|
||||||
|
input := []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"id": "o1",
|
||||||
|
"call_id": "fc_abc",
|
||||||
|
"output": "done",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{
|
||||||
|
PreserveReferences: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, filtered, 1)
|
||||||
|
out, ok := filtered[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "o1", out["id"], "output item id should be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilterCodexInput_NonToolCallItemKeepsID ensures non-tool-call items
|
||||||
|
// (e.g. message) still keep their id when PreserveReferences is true.
|
||||||
|
func TestFilterCodexInput_NonToolCallItemKeepsID(t *testing.T) {
|
||||||
|
input := []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "message",
|
||||||
|
"id": "item_msg_001",
|
||||||
|
"role": "user",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{
|
||||||
|
PreserveReferences: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, filtered, 1)
|
||||||
|
msg, ok := filtered[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "item_msg_001", msg["id"], "non-tool-call items keep their id in preserve mode")
|
||||||
|
}
|
||||||
@@ -1303,6 +1303,16 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
|
|||||||
if !opts.PreserveReferences {
|
if !opts.PreserveReferences {
|
||||||
ensureCopy()
|
ensureCopy()
|
||||||
delete(newItem, "id")
|
delete(newItem, "id")
|
||||||
|
} else if isCodexToolCallInputType(typ) {
|
||||||
|
// 续链模式下保留 id 以维持上下文引用,但 function_call 等
|
||||||
|
// call-input 类 item 的 id 必须以 "fc" 开头(上游校验
|
||||||
|
// "Expected an ID that begins with 'fc'")。item_* 形式的 id
|
||||||
|
// 来自客户端回放,需要删除。
|
||||||
|
// 注意:function_call_output 等 output 类的 id 无此约束,不动。
|
||||||
|
if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "fc") {
|
||||||
|
ensureCopy()
|
||||||
|
delete(newItem, "id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered = append(filtered, newItem)
|
filtered = append(filtered, newItem)
|
||||||
@@ -1328,6 +1338,22 @@ func isCodexToolCallItemType(typ string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isCodexToolCallInputType 仅匹配 call-input 类型(不含 output),这些类型的
|
||||||
|
// id 必须以 "fc" 开头,上游会校验 "Expected an ID that begins with 'fc'."。
|
||||||
|
func isCodexToolCallInputType(typ string) bool {
|
||||||
|
switch typ {
|
||||||
|
case "function_call",
|
||||||
|
"tool_call",
|
||||||
|
"local_shell_call",
|
||||||
|
"tool_search_call",
|
||||||
|
"custom_tool_call",
|
||||||
|
"mcp_tool_call":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func codexInputItemRequiresName(typ string) bool {
|
func codexInputItemRequiresName(typ string) bool {
|
||||||
switch strings.TrimSpace(typ) {
|
switch strings.TrimSpace(typ) {
|
||||||
case "function_call", "custom_tool_call", "mcp_tool_call":
|
case "function_call", "custom_tool_call", "mcp_tool_call":
|
||||||
|
|||||||
Reference in New Issue
Block a user