Files
sub2api/backend/internal/service/openai_codex_transform_test.go
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1955 lines
59 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
// 续链场景:保留 item_reference 与 id,但不再强制 store=true。
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "item_reference", "id": "ref1", "text": "x"},
map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "ok", "id": "o1"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody, false, false)
// 未显式设置 store=true,默认为 false。
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
// 校验 input[0] 为 map,避免断言失败导致测试中断。
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "item_reference", first["type"])
require.Equal(t, "ref1", first["id"])
// 校验 input[1] 为 map,确保后续字段断言安全。
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "o1", second["id"])
require.Equal(t, "fc_1", second["call_id"])
}
func TestApplyCodexOAuthTransform_MessagesBridgePromptCacheKeyIsHeaderOnly(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.5",
"prompt_cache_key": "anthropic-metadata-session-1",
"input": []any{
map[string]any{
"type": "message",
"role": "developer",
"content": []any{
map[string]any{
"type": "input_text",
"text": openAICompatClaudeCodeTodoGuardMarker,
},
},
},
map[string]any{
"type": "message",
"role": "user",
"content": "hello",
},
},
}
result := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
SkipDefaultInstructions: true,
PreserveToolCallIDs: true,
})
require.Equal(t, "anthropic-metadata-session-1", result.PromptCacheKey)
require.True(t, result.Modified)
require.NotContains(t, reqBody, "prompt_cache_key")
}
func TestApplyCodexOAuthTransform_ToolContinuationPreservesNativeMessageAndReasoningIDs(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
map[string]any{"type": "item_reference", "id": "rs_123"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "msg_0", first["id"])
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "rs_123", second["id"])
}
func TestApplyCodexOAuthTransform_ToolContinuationNormalizesToolReferenceIDsOnly(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "item_reference", "id": "call_1"},
map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "ok"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "fc_1", first["id"])
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "fc_1", second["call_id"])
}
func TestApplyCodexOAuthTransform_BoundsLongCallIDsAndPreservesPairing(t *testing.T) {
suffix := strings.Repeat("z", 62)
for _, tc := range []struct {
name string
callID string
outputCallID string
}{
{name: "non-native boundary id", callID: "call-" + strings.Repeat("x", 59), outputCallID: "call-" + strings.Repeat("x", 59)},
{name: "overlong fc id", callID: "fc_" + strings.Repeat("y", 62), outputCallID: "fc_" + strings.Repeat("y", 62)},
{name: "equivalent prefixes", callID: "call_" + suffix, outputCallID: "fc_" + suffix},
} {
t.Run(tc.name, func(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "function_call", "call_id": tc.callID, "name": "shell"},
map[string]any{"type": "function_call_output", "call_id": tc.outputCallID, "output": "done"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
call, ok := input[0].(map[string]any)
require.True(t, ok)
output, ok := input[1].(map[string]any)
require.True(t, ok)
fixedCallID, ok := call["call_id"].(string)
require.True(t, ok)
require.LessOrEqual(t, len(fixedCallID), codexCallIDMaxLength)
require.True(t, strings.HasPrefix(fixedCallID, codexCallIDPrefix))
require.Equal(t, fixedCallID, output["call_id"])
require.Equal(t, fixedCallID, normalizeCodexCallID(tc.callID))
require.Equal(t, fixedCallID, normalizeCodexCallID(tc.outputCallID))
})
}
}
func TestApplyCodexOAuthTransform_PreservesCallIDsWithinLimitWhenRequested(t *testing.T) {
// preserve 模式下 ≤64 字符的 id 必须原样透传(含 64 字符等长边界),
// 不做任何前缀改写或压缩。
for _, tc := range []struct {
name string
callID string
}{
{name: "anthropic toolu id", callID: "toolu_01ABCdefGHIjklMNOpqrsTUV"},
{name: "boundary 64 chars", callID: "toolu_" + strings.Repeat("x", codexCallIDMaxLength-len("toolu_"))},
} {
t.Run(tc.name, func(t *testing.T) {
require.LessOrEqual(t, len(tc.callID), codexCallIDMaxLength)
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "function_call", "call_id": tc.callID, "name": "shell"},
map[string]any{"type": "function_call_output", "call_id": tc.callID, "output": "done"},
},
}
applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
PreserveToolCallIDs: true,
})
input, ok := reqBody["input"].([]any)
require.True(t, ok)
call, ok := input[0].(map[string]any)
require.True(t, ok)
output, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, tc.callID, call["call_id"])
require.Equal(t, tc.callID, output["call_id"])
})
}
}
func TestApplyCodexOAuthTransform_CompactsOverlongCallIDsWhenPreserveRequested(t *testing.T) {
// preserve 模式下超过 64 字符的 id 若原样透传,上游必然 400
// "Invalid 'input[N].call_id': string too long"),需退回确定性压缩;
// function_call 与 function_call_output 两侧压缩结果一致,配对保持。
callID := "srvtoolu_" + strings.Repeat("x", 69) // 78 字符,对应生产环境真实报错长度
require.Len(t, callID, 78)
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "function_call", "call_id": callID, "name": "shell"},
map[string]any{"type": "function_call_output", "call_id": callID, "output": "done"},
},
}
applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
PreserveToolCallIDs: true,
})
input, ok := reqBody["input"].([]any)
require.True(t, ok)
call, ok := input[0].(map[string]any)
require.True(t, ok)
output, ok := input[1].(map[string]any)
require.True(t, ok)
compacted, ok := call["call_id"].(string)
require.True(t, ok)
require.Len(t, compacted, codexCallIDMaxLength)
require.True(t, strings.HasPrefix(compacted, codexCallIDPrefix))
require.Equal(t, compacted, output["call_id"], "两侧压缩结果必须一致以保持配对")
require.Equal(t, compactCodexCallID(callID), compacted, "压缩必须是确定性的")
}
func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "tool_search_output", "call_id": "call_1", "output": "ok"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "tool_search_output", first["type"])
require.Equal(t, "fc_1", first["call_id"])
}
func TestApplyCodexOAuthTransform_CustomAndMCPToolOutputsPreserveCallID(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "ok"},
map[string]any{"type": "mcp_tool_call_output", "call_id": "call_mcp", "output": "ok"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "fc_custom", first["call_id"])
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "fc_mcp", second["call_id"])
}
func TestApplyCodexOAuthTransform_ImageAndWebSearchCallsDoNotGainCallID(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "image_generation_call", "id": "ig_123", "status": "completed"},
map[string]any{"type": "web_search_call", "call_id": "call_bad", "status": "completed"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "ig_123", first["id"])
_, hasCallID := first["call_id"]
require.False(t, hasCallID)
second, ok := input[1].(map[string]any)
require.True(t, ok)
_, hasCallID = second["call_id"]
require.False(t, hasCallID)
}
func TestApplyCodexOAuthTransform_ConvertsToolRoleMessageToFunctionCallOutput(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{
"type": "message",
"role": "tool",
"tool_call_id": "call_1",
"content": "ok",
},
},
}
applyCodexOAuthTransform(reqBody, true, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
item, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "function_call_output", item["type"])
require.Equal(t, "fc_1", item["call_id"])
require.Equal(t, "ok", item["output"])
_, hasRole := item["role"]
require.False(t, hasRole)
}
func TestApplyCodexOAuthTransform_StringifiesNonStringMessageContentText(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{
"type": "message",
"role": "user",
"content": []any{
map[string]any{"type": "input_text", "text": []any{"a", "b"}},
},
},
},
}
applyCodexOAuthTransform(reqBody, true, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
item, ok := input[0].(map[string]any)
require.True(t, ok)
content, ok := item["content"].([]any)
require.True(t, ok)
part, ok := content[0].(map[string]any)
require.True(t, ok)
require.Equal(t, `["a","b"]`, part["text"])
}
func TestApplyCodexOAuthTransform_DowngradesUnknownToolChoice(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"tools": []any{
map[string]any{"type": "function", "name": "shell"},
},
"tool_choice": map[string]any{"type": "custom"},
}
applyCodexOAuthTransform(reqBody, true, false)
require.Equal(t, "auto", reqBody["tool_choice"])
}
func TestApplyCodexOAuthTransform_PreservesKnownToolChoice(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"tools": []any{
map[string]any{"type": "custom", "name": "shell"},
},
"tool_choice": map[string]any{"type": "custom"},
}
applyCodexOAuthTransform(reqBody, true, false)
choice, ok := reqBody["tool_choice"].(map[string]any)
require.True(t, ok)
require.Equal(t, "custom", choice["type"])
}
func TestApplyCodexOAuthTransform_NormalizesLegacyFunctionToolChoice(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"tools": []any{
map[string]any{"type": "function", "name": "shell"},
},
"tool_choice": map[string]any{
"type": "function",
"function": map[string]any{"name": "shell"},
},
}
applyCodexOAuthTransform(reqBody, true, false)
choice, ok := reqBody["tool_choice"].(map[string]any)
require.True(t, ok)
require.Equal(t, "function", choice["type"])
require.Equal(t, "shell", choice["name"])
require.NotContains(t, choice, "function")
}
func TestApplyCodexOAuthTransform_DowngradesMissingFunctionToolChoice(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"tools": []any{
map[string]any{"type": "function", "name": "shell"},
},
"tool_choice": map[string]any{
"type": "function",
"function": map[string]any{"name": "missing"},
},
}
applyCodexOAuthTransform(reqBody, true, false)
require.Equal(t, "auto", reqBody["tool_choice"])
}
func TestApplyCodexOAuthTransform_AddsFallbackNameForFunctionCallInput(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{"type": "message", "role": "user", "content": "run tool"},
map[string]any{"type": "function_call", "call_id": "call_1", "arguments": "{}"},
},
}
applyCodexOAuthTransform(reqBody, true, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
item, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "function_call", item["type"])
require.Equal(t, "tool", item["name"])
require.Equal(t, "fc_1", item["call_id"])
}
func TestApplyCodexOAuthTransform_PreservesFunctionCallInputName(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{"type": "custom_tool_call", "call_id": "call_1", "name": "shell", "input": "pwd"},
},
}
applyCodexOAuthTransform(reqBody, true, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
item, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "shell", item["name"])
require.Equal(t, "fc_1", item["call_id"])
}
func TestApplyCodexOAuthTransform_PreservesMCPToolCallIDAndName(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{
"type": "mcp_tool_call",
"call_id": "call_abc",
"name": "remote_tool",
"arguments": "{}",
},
},
}
applyCodexOAuthTransform(reqBody, true, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
item, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "mcp_tool_call", item["type"])
require.Equal(t, "remote_tool", item["name"])
require.Equal(t, "fc_abc", item["call_id"])
}
func TestCodexInputItemRequiresNameTypesAllowCallID(t *testing.T) {
for _, typ := range []string{"function_call", "custom_tool_call", "mcp_tool_call"} {
require.True(t, codexInputItemRequiresName(typ), typ)
require.True(t, isCodexToolCallItemType(typ), typ)
}
}
func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
// 续链场景:显式 store=false 不再强制为 true,保持 false。
reqBody := map[string]any{
"model": "gpt-5.1",
"store": false,
"input": []any{
map[string]any{"type": "function_call_output", "call_id": "call_1"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody, false, false)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
}
func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
// 显式 store=true 也会强制为 false。
reqBody := map[string]any{
"model": "gpt-5.1",
"store": true,
"input": []any{
map[string]any{"type": "function_call_output", "call_id": "call_1"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody, false, false)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
}
func TestApplyCodexOAuthTransform_CompactForcesNonStreaming(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1-codex",
"store": true,
"stream": true,
}
result := applyCodexOAuthTransform(reqBody, true, true)
_, hasStore := reqBody["store"]
require.False(t, hasStore)
_, hasStream := reqBody["stream"]
require.False(t, hasStream)
require.True(t, result.Modified)
}
func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs(t *testing.T) {
// 非续链场景:未设置 store 时默认 false,并移除 input 中的 id。
reqBody := map[string]any{
"model": "gpt-5.1",
"input": []any{
map[string]any{"type": "text", "id": "t1", "text": "hi"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
// 校验 input[0] 为 map,避免类型不匹配触发 errcheck。
item, ok := input[0].(map[string]any)
require.True(t, ok)
_, hasID := item["id"]
require.False(t, hasID)
}
func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
input := []any{
map[string]any{"type": "item_reference", "id": "ref1"},
map[string]any{"type": "text", "id": "t1", "text": "hi"},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
// 校验 filtered[0] 为 map,确保字段检查可靠。
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "text", item["type"])
_, hasID := item["id"]
require.False(t, hasID)
}
func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunctionTools(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
"tools": []any{
map[string]any{
"type": "function",
"name": "bash",
"description": "desc",
"parameters": map[string]any{"type": "object"},
},
map[string]any{
"type": "function",
"function": nil,
},
},
}
applyCodexOAuthTransform(reqBody, false, false)
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 1)
first, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "function", first["type"])
require.Equal(t, "bash", first["name"])
}
func TestNormalizeOpenAIResponsesImageGenerationTools_RewritesLegacyFields(t *testing.T) {
reqBody := map[string]any{
"tools": []any{
map[string]any{
"type": "image_generation",
"format": "png",
"compression": 60,
},
},
}
modified := normalizeOpenAIResponsesImageGenerationTools(reqBody)
require.True(t, modified)
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
first, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "png", first["output_format"])
require.Equal(t, 60, first["output_compression"])
_, hasFormat := first["format"]
require.False(t, hasFormat)
_, hasCompression := first["compression"]
require.False(t, hasCompression)
}
func TestEnsureOpenAIResponsesImageGenerationTool_NoTools(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": "draw a cat",
}
modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
require.True(t, modified)
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 1)
tool, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "image_generation", tool["type"])
require.Equal(t, "png", tool["output_format"])
}
func TestEnsureOpenAIResponsesImageGenerationTool_SkipsSpark(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"input": "draw a cat",
}
modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
require.False(t, modified)
require.NotContains(t, reqBody, "tools")
}
func TestEnsureOpenAIResponsesImageGenerationTool_AppendsToExistingTools(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"tools": []any{
map[string]any{"type": "web_search"},
},
}
modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
require.True(t, modified)
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 2)
first, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "web_search", first["type"])
second, ok := tools[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "image_generation", second["type"])
require.Equal(t, "png", second["output_format"])
}
func TestEnsureOpenAIResponsesImageGenerationTool_PreservesExistingImageTool(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"tools": []any{
map[string]any{"type": "image_generation", "output_format": "webp"},
map[string]any{"type": "web_search"},
},
}
modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
require.False(t, modified)
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 2)
tool, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "webp", tool["output_format"])
}
func TestEnsureOpenAIResponsesImageGenerationTool_PreservesImageGenNamespace(t *testing.T) {
tests := []struct {
name string
reqBody map[string]any
}{
{
name: "top-level tools",
reqBody: map[string]any{
"model": "gpt-5.5",
"tools": []any{
map[string]any{
"type": "namespace",
"name": "image_gen",
"tools": []any{
map[string]any{"type": "function", "name": "imagegen"},
},
},
},
},
},
{
name: "responses lite additional_tools",
reqBody: map[string]any{
"model": "gpt-5.5",
"input": []any{
map[string]any{
"type": "additional_tools",
"tools": []any{
map[string]any{
"type": "namespace",
"name": "image_gen",
"tools": []any{
map[string]any{"type": "function", "name": "imagegen"},
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.True(t, hasOpenAIImageGenerationTool(tt.reqBody))
modified := ensureOpenAIResponsesImageGenerationTool(tt.reqBody)
require.False(t, modified)
tools, _ := tt.reqBody["tools"].([]any)
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
require.True(t, ok)
require.NotEqual(t, "image_generation", firstNonEmptyString(tool["type"]))
}
})
}
}
func TestCodexImageGenerationBridge_PreservesClientImageFunctionTools(t *testing.T) {
tests := []struct {
name string
reqBody map[string]any
wantClient bool
}{
{
name: "flat image_gen function",
reqBody: map[string]any{
"model": "gpt-5.5",
"input": "draw a cat",
"tools": []any{
map[string]any{"type": "function", "name": "image_gen.imagegen"},
},
},
wantClient: true,
},
{
name: "nested image_gen function",
reqBody: map[string]any{
"model": "gpt-5.5",
"input": "draw a cat",
"tools": []any{
map[string]any{
"type": "function",
"function": map[string]any{
"name": "image_gen.imagegen",
},
},
},
},
wantClient: true,
},
{
name: "similar function name still receives hosted bridge",
reqBody: map[string]any{
"model": "gpt-5.5",
"input": "draw a cat",
"tools": []any{
map[string]any{"type": "function", "name": "image_gen.imagegenerator"},
},
},
wantClient: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.reqBody["instructions"] = "existing instructions"
require.Equal(t, tt.wantClient, hasCodexImageGenerationFunctionTool(tt.reqBody))
toolModified := ensureOpenAIResponsesImageGenerationTool(tt.reqBody)
choiceModified := ensureOpenAIResponsesImageGenerationToolChoiceAuto(tt.reqBody)
instructionsModified := applyCodexImageGenerationBridgeInstructions(tt.reqBody)
require.Equal(t, !tt.wantClient, toolModified)
require.Equal(t, !tt.wantClient, choiceModified)
require.Equal(t, !tt.wantClient, instructionsModified)
hasHostedTool := false
tools, _ := tt.reqBody["tools"].([]any)
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if ok && firstNonEmptyString(tool["type"]) == "image_generation" {
hasHostedTool = true
}
}
require.Equal(t, !tt.wantClient, hasHostedTool)
if tt.wantClient {
require.NotContains(t, tt.reqBody, "tool_choice")
require.Equal(t, "existing instructions", tt.reqBody["instructions"])
} else {
require.Equal(t, "auto", tt.reqBody["tool_choice"])
require.Contains(t, tt.reqBody["instructions"], codexImageGenerationBridgeMarker)
}
})
}
}
func TestApplyCodexImageGenerationBridgeInstructions_AppendsBridgeOnce(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"instructions": "existing instructions",
"tools": []any{
map[string]any{"type": "image_generation", "output_format": "png"},
},
}
modified := applyCodexImageGenerationBridgeInstructions(reqBody)
require.True(t, modified)
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Contains(t, instructions, "existing instructions")
require.Contains(t, instructions, codexImageGenerationBridgeMarker)
require.Contains(t, instructions, "Responses native `image_generation` tool")
modified = applyCodexImageGenerationBridgeInstructions(reqBody)
require.False(t, modified)
}
func TestApplyCodexImageGenerationBridgeInstructions_SkipsSpark(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"instructions": "existing instructions",
"tools": []any{
map[string]any{"type": "image_generation", "output_format": "png"},
},
}
modified := applyCodexImageGenerationBridgeInstructions(reqBody)
require.False(t, modified)
require.Equal(t, "existing instructions", reqBody["instructions"])
}
func TestApplyCodexImageGenerationBridgeInstructions_SkipsWithoutImageTool(t *testing.T) {
reqBody := map[string]any{
"instructions": "existing instructions",
"tools": []any{
map[string]any{"type": "web_search"},
},
}
modified := applyCodexImageGenerationBridgeInstructions(reqBody)
require.False(t, modified)
require.Equal(t, "existing instructions", reqBody["instructions"])
}
func TestValidateCodexSparkInputRejectsInputImage(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"input": []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "input_text", "text": "describe"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
},
},
},
}
err := validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark")
require.Error(t, err)
require.Contains(t, err.Error(), "does not support image input")
}
func TestValidateCodexSparkInputRejectsChatImageURL(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"messages": []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,aGVsbG8="}},
},
},
},
}
err := validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark")
require.Error(t, err)
}
func TestValidateCodexSparkInputAllowsTextOnly(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"input": []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "input_text", "text": "hello"},
},
},
},
}
require.NoError(t, validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark"))
}
func TestApplyCodexOAuthTransform_AddsSparkImageUnsupportedInstructions(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"instructions": "existing instructions",
"input": "hello",
}
result := applyCodexOAuthTransform(reqBody, true, false)
require.True(t, result.Modified)
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Contains(t, instructions, "existing instructions")
require.Contains(t, instructions, codexSparkImageUnsupportedMarker)
require.Contains(t, instructions, "does not support image generation")
require.Contains(t, instructions, "switch to a non-Spark Codex model")
require.NotContains(t, instructions, codexImageGenerationBridgeMarker)
}
func TestApplyCodexOAuthTransform_DoesNotAddSparkImageUnsupportedForNonSpark(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"instructions": "existing instructions",
"input": "hello",
}
applyCodexOAuthTransform(reqBody, true, false)
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.NotContains(t, instructions, codexSparkImageUnsupportedMarker)
}
// gpt-5.3-codex-spark rejects the image_generation tool upstream (HTTP 400
// invalid_request_error, param=tools). Codex CLI advertises that tool by default,
// so the OAuth transform must strip it for spark while keeping the rest.
func TestApplyCodexOAuthTransform_StripsImageGenerationToolForSpark(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"input": "hello",
"tools": []any{
map[string]any{"type": "function", "name": "shell"},
map[string]any{"type": "image_generation", "output_format": "png"},
},
}
result := applyCodexOAuthTransform(reqBody, true, false)
require.True(t, result.Modified)
require.False(t, hasOpenAIImageGenerationTool(reqBody))
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 1)
first, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "function", first["type"])
require.Equal(t, "shell", first["name"])
}
// Spark reasoning-effort aliases (e.g. -low/-high) normalize to gpt-5.3-codex-spark,
// so they must be stripped too.
func TestApplyCodexOAuthTransform_StripsImageGenerationToolForSparkAlias(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark-high",
"input": "hello",
"tools": []any{
map[string]any{"type": "image_generation", "output_format": "png"},
},
}
result := applyCodexOAuthTransform(reqBody, true, false)
require.True(t, result.Modified)
require.False(t, hasOpenAIImageGenerationTool(reqBody))
// tools became empty after stripping the only entry; the key is dropped.
_, hasTools := reqBody["tools"]
require.False(t, hasTools)
}
func TestStripOpenAIImageGenerationTools_StripsNamespaceFormats(t *testing.T) {
imageNamespace := func() map[string]any {
return map[string]any{
"type": "namespace",
"name": "image_gen",
"tools": []any{
map[string]any{"type": "function", "name": "imagegen"},
},
}
}
codeNamespace := func() map[string]any {
return map[string]any{
"type": "namespace",
"name": "code_tools",
"tools": []any{
map[string]any{"type": "function", "name": "run"},
},
}
}
reqBody := map[string]any{
"model": "gpt-5.5",
"tools": []any{
map[string]any{"type": "function", "name": "shell"},
imageNamespace(),
codeNamespace(),
},
"input": []any{
map[string]any{"type": "message", "role": "user", "content": "hello"},
map[string]any{
"type": "additional_tools",
"tools": []any{imageNamespace(), codeNamespace()},
},
map[string]any{
"type": "additional_tools",
"tools": []any{imageNamespace()},
},
},
"tool_choice": map[string]any{"type": "namespace", "name": "image_gen"},
}
require.True(t, stripOpenAIImageGenerationTools(reqBody))
require.False(t, hasOpenAIImageGenerationTool(reqBody))
require.NotContains(t, reqBody, "tool_choice")
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 2)
firstTool, ok := tools[0].(map[string]any)
require.True(t, ok)
secondTool, ok := tools[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "shell", firstTool["name"])
require.Equal(t, "code_tools", secondTool["name"])
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
message, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "message", message["type"])
additionalToolsItem, ok := input[1].(map[string]any)
require.True(t, ok)
additionalTools, ok := additionalToolsItem["tools"].([]any)
require.True(t, ok)
require.Len(t, additionalTools, 1)
additionalTool, ok := additionalTools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "code_tools", additionalTool["name"])
require.False(t, stripOpenAIImageGenerationTools(reqBody), "stripping should be idempotent")
}
func TestStripOpenAIImageGenerationTools_KeepsNonImageNamespaces(t *testing.T) {
reqBody := map[string]any{
"tools": []any{
map[string]any{"type": "namespace", "name": "code_tools"},
},
"input": []any{
map[string]any{
"type": "additional_tools",
"tools": []any{
map[string]any{"type": "namespace", "name": "browser_tools"},
},
},
},
"tool_choice": "auto",
}
require.False(t, stripOpenAIImageGenerationTools(reqBody))
require.Equal(t, "auto", reqBody["tool_choice"])
require.False(t, hasOpenAIImageGenerationTool(reqBody))
}
func TestStripOpenAIImageGenerationTools_KeepsCustomImagegenFunctionChoice(t *testing.T) {
reqBody := map[string]any{
"tool_choice": map[string]any{
"function": map[string]any{"name": "imagegen"},
},
}
require.False(t, stripOpenAIImageGenerationTools(reqBody))
require.Contains(t, reqBody, "tool_choice")
}
// Non-spark Codex models support image_generation; the tool must be preserved.
func TestApplyCodexOAuthTransform_KeepsImageGenerationToolForNonSpark(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex",
"input": "hello",
"tools": []any{
map[string]any{"type": "image_generation", "output_format": "png"},
},
}
applyCodexOAuthTransform(reqBody, true, false)
require.True(t, hasOpenAIImageGenerationTool(reqBody))
}
func TestNormalizeOpenAIResponsesImageOnlyModel_BuildsImageToolRequest(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-image-2",
"prompt": "draw a cat",
"size": "1024x1024",
"output_format": "png",
}
modified := normalizeOpenAIResponsesImageOnlyModel(reqBody)
require.True(t, modified)
require.Equal(t, openAIImagesResponsesMainModel, reqBody["model"])
require.Equal(t, "draw a cat", reqBody["input"])
_, hasPrompt := reqBody["prompt"]
require.False(t, hasPrompt)
_, hasTopLevelSize := reqBody["size"]
require.False(t, hasTopLevelSize)
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 1)
tool, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "image_generation", tool["type"])
require.Equal(t, "gpt-image-2", tool["model"])
require.Equal(t, "1024x1024", tool["size"])
require.Equal(t, "png", tool["output_format"])
choice, ok := reqBody["tool_choice"].(map[string]any)
require.True(t, ok)
require.Equal(t, "image_generation", choice["type"])
}
func TestNormalizeOpenAIResponsesImageOnlyModel_PreservesExistingImageTool(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-image-2",
"input": "draw a cat",
"tools": []any{
map[string]any{
"type": "image_generation",
"model": "gpt-image-1.5",
},
},
"tool_choice": "auto",
}
modified := normalizeOpenAIResponsesImageOnlyModel(reqBody)
require.True(t, modified)
require.Equal(t, openAIImagesResponsesMainModel, reqBody["model"])
require.Equal(t, "auto", reqBody["tool_choice"])
tools, ok := reqBody["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 1)
tool, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "gpt-image-1.5", tool["model"])
}
func TestValidateOpenAIResponsesImageModel_RejectsImageOnlyModel(t *testing.T) {
err := validateOpenAIResponsesImageModel(map[string]any{
"tools": []any{
map[string]any{"type": "image_generation"},
},
}, "gpt-image-2")
require.ErrorContains(t, err, `/v1/responses image_generation requests require a Responses-capable text model`)
}
func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
// 空 input 应保持为空且不触发异常。
reqBody := map[string]any{
"model": "gpt-5.1",
"input": []any{},
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 0)
}
func TestNormalizeCodexModel_Gpt53(t *testing.T) {
cases := map[string]string{
"gpt-5.4": "gpt-5.4",
"gpt5.5": "gpt-5.5",
"openai/gpt5.5": "gpt-5.5",
"gpt-5.5-pro": "gpt-5.5-pro",
"gpt5.5-pro": "gpt-5.5-pro",
"openai/gpt5.5-pro": "gpt-5.5-pro",
"gpt-5.5-pro-high": "gpt-5.5-pro",
"codex-auto-review": "codex-auto-review",
"gpt5.4": "gpt-5.4",
"gpt-5.4-high": "gpt-5.4",
"gpt-5.4-chat-latest": "gpt-5.4",
"gpt 5.4": "gpt-5.4",
"gpt-5.4-mini": "gpt-5.4-mini",
"gpt5.4-mini": "gpt-5.4-mini",
"gpt5.4mini": "gpt-5.4-mini",
"gpt 5.4 mini": "gpt-5.4-mini",
"gpt-5.3": "gpt-5.3-codex",
"gpt5.3": "gpt-5.3-codex",
"gpt-5.3-codex": "gpt-5.3-codex",
"gpt5.3-codex": "gpt-5.3-codex",
"gpt5.3codex": "gpt-5.3-codex",
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
"gpt5.3-codex-spark": "gpt-5.3-codex-spark",
"gpt5.3codexspark": "gpt-5.3-codex-spark",
"gpt 5.3 codex spark": "gpt-5.3-codex-spark",
"gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark",
"gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark",
"gpt 5.3 codex": "gpt-5.3-codex",
}
for input, expected := range cases {
require.Equal(t, expected, normalizeCodexModel(input))
}
}
func TestNormalizeCodexModel_RemovedModelsFallbackToSupportedTargets(t *testing.T) {
cases := map[string]string{
"": "gpt-5.4",
"gpt-5": "gpt-5.4",
"gpt-5-mini": "gpt-5.4",
"gpt-5-nano": "gpt-5.4",
"gpt-5.1": "gpt-5.4",
"gpt-5.1-codex": "gpt-5.3-codex",
"gpt-5.1-codex-max": "gpt-5.3-codex",
"gpt-5.1-codex-mini": "gpt-5.3-codex",
"gpt-5.2-codex": "gpt-5.2",
"codex-mini-latest": "gpt-5.3-codex",
"gpt-5-codex": "gpt-5.3-codex",
}
for input, expected := range cases {
require.Equal(t, expected, normalizeCodexModel(input))
}
}
func TestApplyCodexOAuthTransform_PreservesBareSparkModel(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
"input": []any{},
}
result := applyCodexOAuthTransform(reqBody, false, false)
require.Equal(t, "gpt-5.3-codex-spark", reqBody["model"])
require.Equal(t, "gpt-5.3-codex-spark", result.NormalizedModel)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
}
func TestApplyCodexOAuthTransform_TrimmedModelWithoutPolicyRewrite(t *testing.T) {
reqBody := map[string]any{
"model": " gpt-5.3-codex-spark ",
"input": []any{},
}
result := applyCodexOAuthTransform(reqBody, false, false)
require.Equal(t, "gpt-5.3-codex-spark", reqBody["model"])
require.Equal(t, "gpt-5.3-codex-spark", result.NormalizedModel)
require.True(t, result.Modified)
}
func TestApplyCodexOAuthTransform_CodexCLI_PreservesExistingInstructions(t *testing.T) {
// Codex CLI 场景:已有 instructions 时不修改
reqBody := map[string]any{
"model": "gpt-5.1",
"instructions": "existing instructions",
}
result := applyCodexOAuthTransform(reqBody, true, false) // isCodexCLI=true
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Equal(t, "existing instructions", instructions)
// Modified 仍可能为 true(因为其他字段被修改),但 instructions 应保持不变
_ = result
}
func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T) {
// Codex CLI 场景:无 instructions 时补充默认值
reqBody := map[string]any{
"model": "gpt-5.1",
// 没有 instructions 字段
}
result := applyCodexOAuthTransform(reqBody, true, false) // isCodexCLI=true
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.NotEmpty(t, instructions)
require.True(t, result.Modified)
}
func TestApplyCodexOAuthTransform_GPT55SuppliesModelSpecificInstructions(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.5",
"instructions": " ",
}
result := applyCodexOAuthTransform(reqBody, true, false)
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Contains(t, instructions, "You are Codex, a coding agent based on GPT-5")
require.NotContains(t, instructions, "You are GPT-5.1 running in the Codex CLI")
require.True(t, result.Modified)
}
func TestApplyCodexOAuthTransform_NonCodexCLI_PreservesExistingInstructions(t *testing.T) {
// 非 Codex CLI 场景:已有 instructions 时保留客户端的值,不再覆盖
reqBody := map[string]any{
"model": "gpt-5.1",
"instructions": "old instructions",
}
applyCodexOAuthTransform(reqBody, false, false) // isCodexCLI=false
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Equal(t, "old instructions", instructions)
}
func TestApplyCodexOAuthTransform_StringInputConvertedToArray(t *testing.T) {
reqBody := map[string]any{"model": "gpt-5.4", "input": "Hello, world!"}
result := applyCodexOAuthTransform(reqBody, false, false)
require.True(t, result.Modified)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
msg, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "message", msg["type"])
require.Equal(t, "user", msg["role"])
require.Equal(t, "Hello, world!", msg["content"])
}
func TestApplyCodexOAuthTransform_EmptyStringInputBecomesEmptyArray(t *testing.T) {
reqBody := map[string]any{"model": "gpt-5.4", "input": ""}
result := applyCodexOAuthTransform(reqBody, false, false)
require.True(t, result.Modified)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 0)
}
func TestApplyCodexOAuthTransform_WhitespaceStringInputBecomesEmptyArray(t *testing.T) {
reqBody := map[string]any{"model": "gpt-5.4", "input": " "}
result := applyCodexOAuthTransform(reqBody, false, false)
require.True(t, result.Modified)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 0)
}
func TestApplyCodexOAuthTransform_StringInputWithToolsField(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": "Run the tests",
"tools": []any{map[string]any{"type": "function", "name": "bash"}},
}
applyCodexOAuthTransform(reqBody, false, false)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
}
func TestExtractSystemMessagesFromInput(t *testing.T) {
t.Run("no system messages", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{"role": "user", "content": "hello"},
},
}
result := extractSystemMessagesFromInput(reqBody, false)
require.False(t, result)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
_, hasInstructions := reqBody["instructions"]
require.False(t, hasInstructions)
})
t.Run("string content system message", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{"role": "system", "content": "You are an assistant."},
map[string]any{"role": "user", "content": "hello"},
},
}
result := extractSystemMessagesFromInput(reqBody, false)
require.True(t, result)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
msg, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", msg["role"])
require.Equal(t, "You are an assistant.", msg["content"])
user, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "user", user["role"])
require.Equal(t, "You are an assistant.", reqBody["instructions"])
})
t.Run("array content system message", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{
"role": "system",
"content": []any{
map[string]any{"type": "text", "text": "Be helpful."},
},
},
},
}
result := extractSystemMessagesFromInput(reqBody, false)
require.True(t, result)
require.Equal(t, "Be helpful.", reqBody["instructions"])
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
msg, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", msg["role"])
require.Equal(t, []any{
map[string]any{"type": "text", "text": "Be helpful."},
}, msg["content"])
require.Equal(t, "Be helpful.", reqBody["instructions"])
})
t.Run("multiple system messages concatenated", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{"role": "system", "content": "First."},
map[string]any{"role": "system", "content": "Second."},
map[string]any{"role": "user", "content": "hi"},
},
}
result := extractSystemMessagesFromInput(reqBody, false)
require.True(t, result)
require.Equal(t, "First.\n\nSecond.", reqBody["instructions"])
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 3)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", first["role"])
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", second["role"])
user, ok := input[2].(map[string]any)
require.True(t, ok)
require.Equal(t, "user", user["role"])
require.Equal(t, "First.\n\nSecond.", reqBody["instructions"])
})
t.Run("mixed system and non-system preserves non-system", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "system", "content": "Sys prompt."},
map[string]any{"role": "assistant", "content": "Hi there"},
},
}
result := extractSystemMessagesFromInput(reqBody, false)
require.True(t, result)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 3)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "user", first["role"])
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", second["role"])
third, ok := input[2].(map[string]any)
require.True(t, ok)
require.Equal(t, "assistant", third["role"])
require.Equal(t, "Sys prompt.", reqBody["instructions"])
})
t.Run("existing instructions prepended", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{"role": "system", "content": "Extracted."},
map[string]any{"role": "user", "content": "hi"},
},
"instructions": "Existing instructions.",
}
result := extractSystemMessagesFromInput(reqBody, false)
require.True(t, result)
require.Equal(t, "Extracted.\n\nExisting instructions.", reqBody["instructions"])
input, ok := reqBody["input"].([]any)
require.True(t, ok)
msg, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", msg["role"])
})
t.Run("omit losslessly promoted text-only messages", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{"role": "system", "content": "First."},
map[string]any{
"role": "system",
"content": []any{
map[string]any{"type": "text", "text": "Second "},
map[string]any{"type": "input_text", "text": "and "},
map[string]any{"type": "output_text", "text": "third."},
},
},
map[string]any{"role": "user", "content": "hi"},
},
"instructions": "Existing.",
}
result := extractSystemMessagesFromInput(reqBody, true)
require.True(t, result)
require.Equal(t, "First.\n\nSecond and third.\n\nExisting.", reqBody["instructions"])
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
user, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "user", user["role"])
})
t.Run("omit keeps mixed system content as developer", func(t *testing.T) {
reqBody := map[string]any{
"input": []any{
map[string]any{
"role": "system",
"content": []any{
map[string]any{"type": "input_text", "text": "Inspect this image."},
map[string]any{"type": "input_image", "image_url": "https://example.com/image.png"},
},
},
map[string]any{"role": "user", "content": "hi"},
},
}
result := extractSystemMessagesFromInput(reqBody, true)
require.True(t, result)
require.Equal(t, "Inspect this image.", reqBody["instructions"])
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
developer, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", developer["role"])
require.Len(t, developer["content"], 2)
})
}
// TestApplyCodexOAuthTransform_StripsPromptCacheRetention is a regression
// test: some clients (e.g. Cursor cloud via the Responses-shape compat path)
// send prompt_cache_retention, but the ChatGPT internal Codex endpoint
// rejects it with "Unsupported parameter: prompt_cache_retention".
func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
"prompt_cache_retention": "24h",
"input": []any{
map[string]any{"role": "user", "content": "hi"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
_, stillThere := reqBody["prompt_cache_retention"]
require.False(t, stillThere,
"prompt_cache_retention must be stripped before forwarding to Codex upstream")
}
func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"user": "user_123",
"metadata": map[string]any{"trace_id": "abc"},
"prompt_cache_retention": "24h",
"safety_identifier": "sid",
"stream_options": map[string]any{"include_usage": true},
"input": []any{
map[string]any{"role": "user", "content": "hi"},
},
}
result := applyCodexOAuthTransform(reqBody, true, false)
require.True(t, result.Modified)
for _, field := range openAIChatGPTInternalUnsupportedFields {
require.NotContains(t, reqBody, field)
}
}
func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
"input": []any{
map[string]any{"role": "system", "content": "You are a coding assistant."},
map[string]any{"role": "user", "content": "Write a function."},
},
}
result := applyCodexOAuthTransform(reqBody, false, false)
require.True(t, result.Modified)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
system, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", system["role"])
require.Equal(t, "You are a coding assistant.", system["content"])
user, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "user", user["role"])
require.Equal(t, "You are a coding assistant.", reqBody["instructions"])
}
func TestApplyCodexOAuthTransform_JsonObjectKeepsJsonInstructionInInput(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{
"role": "system",
"content": "You are an assistant. Output JSON only.",
},
map[string]any{
"role": "user",
"content": "symbol data without the keyword",
},
},
"text": map[string]any{
"format": map[string]any{
"type": "json_object",
},
},
}
result := applyCodexOAuthTransform(reqBody, false, false)
require.True(t, result.Modified)
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Contains(t, instructions, "JSON")
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
developer, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", developer["role"])
require.Contains(t, developer["content"], "JSON")
user, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "user", user["role"])
}
func TestIsInstructionsEmpty(t *testing.T) {
tests := []struct {
name string
reqBody map[string]any
expected bool
}{
{"missing field", map[string]any{}, true},
{"nil value", map[string]any{"instructions": nil}, true},
{"empty string", map[string]any{"instructions": ""}, true},
{"whitespace only", map[string]any{"instructions": " "}, true},
{"non-string", map[string]any{"instructions": 123}, true},
{"valid string", map[string]any{"instructions": "hello"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isInstructionsEmpty(tt.reqBody)
require.Equal(t, tt.expected, result)
})
}
}
// TestFilterCodexInput_PreservesReasoningStripsID covers the core OAuth-path
// reasoning contract (replaces the earlier "drops reasoning" test, whose
// premise was wrong). A reasoning item carrying encrypted_content is the
// official channel for replaying reasoning context across turns under
// store=false, so it must survive the filter with encrypted_content intact;
// only its rs_* id is stripped (always, independent of PreserveReferences)
// because a bare rs_* id replayed under store=false 404s upstream. Contracts
// 1/2/3, verified end-to-end against chatgpt.com codex (gpt-5.5). See issue
// #1957.
func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
build := func() []any {
return []any{
map[string]any{
"type": "reasoning",
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
"encrypted_content": "gAAAAAB-enc-payload",
"summary": []any{},
},
}
}
for _, preserve := range []bool{true, false} {
preserve := preserve
t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) {
filtered := filterCodexInput(build(), preserve)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
// Contract 2: the reasoning item survives the filter.
require.Equal(t, "reasoning", item["type"])
// Contract 2: encrypted_content (cross-turn channel) preserved verbatim.
require.Equal(t, "gAAAAAB-enc-payload", item["encrypted_content"])
// Contract 1/3: rs_* id stripped unconditionally, even when
// PreserveReferences=true (id lookup, not the item, triggers the 404).
_, hasID := item["id"]
require.False(t, hasID)
// summary passed through untouched.
summary, ok := item["summary"].([]any)
require.True(t, ok)
require.Len(t, summary, 0)
})
}
}
// TestFilterCodexInput_BareReasoningStripsIDBackfillsSummary covers contract 1
// plus 5: a reasoning item carrying only an rs_* id (no encrypted_content) is
// kept as an empty shell with the id stripped, and a missing summary is
// backfilled to [] so upstream does not reject it with 400 "Missing required
// parameter 'input[N].summary'". Verified against chatgpt.com codex (gpt-5.5).
func TestFilterCodexInput_BareReasoningStripsIDBackfillsSummary(t *testing.T) {
input := []any{
map[string]any{
"type": "reasoning",
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "reasoning", item["type"])
// Contract 1: id stripped.
_, hasID := item["id"]
require.False(t, hasID)
// Contract 5: summary backfilled to an empty array.
summary, ok := item["summary"].([]any)
require.True(t, ok)
require.Len(t, summary, 0)
}
// TestFilterCodexInput_ReasoningBackfillsMissingSummary isolates contract 5:
// even when a reasoning item carries other content (here encrypted_content),
// a missing summary field is always added as [] before forwarding upstream.
func TestFilterCodexInput_ReasoningBackfillsMissingSummary(t *testing.T) {
input := []any{
map[string]any{
"type": "reasoning",
"id": "rs_abc",
"encrypted_content": "gAAAAAB-enc",
},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
summary, ok := item["summary"].([]any)
require.True(t, ok)
require.Len(t, summary, 0)
// encrypted_content still preserved alongside the backfilled summary.
require.Equal(t, "gAAAAAB-enc", item["encrypted_content"])
}
// TestFilterCodexInput_PreservesReasoningSummaryAndContent verifies that a
// non-empty summary is not overwritten and that arbitrary reasoning fields
// (e.g. content) survive verbatim — only the id is removed.
func TestFilterCodexInput_PreservesReasoningSummaryAndContent(t *testing.T) {
summary := []any{
map[string]any{"type": "summary_text", "text": "Considered the options."},
}
content := []any{
map[string]any{"type": "reasoning_text", "text": "internal chain"},
}
input := []any{
map[string]any{
"type": "reasoning",
"id": "rs_abc",
"summary": summary,
"content": content,
"encrypted_content": "gAAAAAB-enc",
},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
// Non-empty summary preserved verbatim (not replaced with []).
require.Equal(t, summary, item["summary"])
// content preserved verbatim.
require.Equal(t, content, item["content"])
require.Equal(t, "gAAAAAB-enc", item["encrypted_content"])
_, hasID := item["id"]
require.False(t, hasID)
}
// TestFilterCodexInput_PreservesReasoningInMixedInput exercises contract 7:
// reasoning items are stripped of their rs_* ids but kept (with
// encrypted_content) while message / function_call / function_call_output
// items flow through unchanged, with tool-call pairing (call_id) intact.
func TestFilterCodexInput_PreservesReasoningInMixedInput(t *testing.T) {
build := func() []any {
return []any{
map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
map[string]any{
"type": "reasoning",
"id": "rs_1",
"encrypted_content": "gAAAAAB-enc-1",
"summary": []any{},
},
map[string]any{
"type": "reasoning",
"id": "rs_2",
"summary": []any{},
},
// call_id already in fc_ form so the unrelated call_->fc_
// normalization does not obscure the pairing assertion.
map[string]any{"type": "function_call", "id": "fc_1", "call_id": "fc_1", "name": "tool", "arguments": "{}"},
map[string]any{"type": "function_call_output", "call_id": "fc_1", "output": "{}"},
}
}
for _, preserve := range []bool{true, false} {
preserve := preserve
t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) {
filtered := filterCodexInput(build(), preserve)
// Nothing is dropped: both reasoning items are now preserved.
require.Len(t, filtered, 5)
byType := make(map[string][]map[string]any)
for _, raw := range filtered {
item, ok := raw.(map[string]any)
require.True(t, ok)
typ, _ := item["type"].(string)
byType[typ] = append(byType[typ], item)
// No surviving item may carry an rs_* id.
if id, ok := item["id"].(string); ok {
require.False(t, strings.HasPrefix(id, "rs_"),
"no item carrying an rs_* id should survive the filter")
}
}
// Both reasoning items kept, ids stripped, summary present.
require.Len(t, byType["reasoning"], 2)
for _, r := range byType["reasoning"] {
_, hasID := r["id"]
require.False(t, hasID)
_, hasSummary := r["summary"]
require.True(t, hasSummary)
}
require.Equal(t, "gAAAAAB-enc-1", byType["reasoning"][0]["encrypted_content"])
// message / function_call(+output) untouched by reasoning handling.
require.Len(t, byType["message"], 1)
// Contract 7: tool-call pairing by call_id is unaffected.
require.Len(t, byType["function_call"], 1)
require.Equal(t, "fc_1", byType["function_call"][0]["call_id"])
require.Len(t, byType["function_call_output"], 1)
require.Equal(t, "fc_1", byType["function_call_output"][0]["call_id"])
})
}
}