diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 20d303b3..e765d7e9 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "fmt" "strings" ) @@ -153,6 +154,9 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact if normalizeCodexTools(reqBody) { result.Modified = true } + if normalizeCodexToolChoice(reqBody) { + result.Modified = true + } if v, ok := reqBody["prompt_cache_key"].(string); ok { result.PromptCacheKey = strings.TrimSpace(v) @@ -173,6 +177,14 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact // 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。 if input, ok := reqBody["input"].([]any); ok { + if normalizedInput, modified := normalizeCodexToolRoleMessages(input); modified { + input = normalizedInput + result.Modified = true + } + if normalizedInput, modified := normalizeCodexMessageContentText(input); modified { + input = normalizedInput + result.Modified = true + } input = filterCodexInput(input, needsToolContinuation) reqBody["input"] = input result.Modified = true @@ -197,6 +209,183 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact return result } +func normalizeCodexToolChoice(reqBody map[string]any) bool { + choice, ok := reqBody["tool_choice"] + if !ok || choice == nil { + return false + } + choiceMap, ok := choice.(map[string]any) + if !ok { + return false + } + choiceType := strings.TrimSpace(firstNonEmptyString(choiceMap["type"])) + if choiceType == "" || codexToolsContainType(reqBody["tools"], choiceType) { + return false + } + reqBody["tool_choice"] = "auto" + return true +} + +func codexToolsContainType(rawTools any, toolType string) bool { + tools, ok := rawTools.([]any) + if !ok || strings.TrimSpace(toolType) == "" { + return false + } + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + if strings.TrimSpace(firstNonEmptyString(tool["type"])) == toolType { + return true + } + } + return false +} + +func normalizeCodexToolRoleMessages(input []any) ([]any, bool) { + if len(input) == 0 { + return input, false + } + + modified := false + normalized := make([]any, 0, len(input)) + for _, item := range input { + m, ok := item.(map[string]any) + if !ok { + normalized = append(normalized, item) + continue + } + role, _ := m["role"].(string) + if strings.TrimSpace(role) != "tool" { + normalized = append(normalized, item) + continue + } + + callID := firstNonEmptyString(m["call_id"], m["tool_call_id"], m["id"]) + callID = strings.TrimSpace(callID) + if callID == "" { + // Responses does not accept role:"tool". If no call id is available, + // preserve the text as a user message instead of sending invalid input. + fallback := make(map[string]any, len(m)) + for key, value := range m { + fallback[key] = value + } + fallback["role"] = "user" + delete(fallback, "tool_call_id") + normalized = append(normalized, fallback) + modified = true + continue + } + + output := extractTextFromContent(m["content"]) + if output == "" { + if value, ok := m["output"].(string); ok { + output = value + } + } + if output == "" && m["content"] != nil { + if b, err := json.Marshal(m["content"]); err == nil { + output = string(b) + } + } + + normalized = append(normalized, map[string]any{ + "type": "function_call_output", + "call_id": callID, + "output": output, + }) + modified = true + } + if !modified { + return input, false + } + return normalized, true +} + +func normalizeCodexMessageContentText(input []any) ([]any, bool) { + if len(input) == 0 { + return input, false + } + + modified := false + normalized := make([]any, 0, len(input)) + for _, item := range input { + m, ok := item.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(m["type"])) != "message" { + normalized = append(normalized, item) + continue + } + parts, ok := m["content"].([]any) + if !ok { + normalized = append(normalized, item) + continue + } + + var newItem map[string]any + var newParts []any + ensureItemCopy := func() { + if newItem != nil { + return + } + newItem = make(map[string]any, len(m)) + for key, value := range m { + newItem[key] = value + } + newParts = make([]any, len(parts)) + copy(newParts, parts) + } + + for i, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + text, hasText := part["text"] + if !hasText { + continue + } + if _, ok := text.(string); ok { + continue + } + + ensureItemCopy() + newPart := make(map[string]any, len(part)) + for key, value := range part { + newPart[key] = value + } + newPart["text"] = stringifyCodexContentText(text) + newParts[i] = newPart + modified = true + } + + if newItem != nil { + newItem["content"] = newParts + normalized = append(normalized, newItem) + continue + } + normalized = append(normalized, item) + } + if !modified { + return input, false + } + return normalized, true +} + +func stringifyCodexContentText(value any) string { + switch v := value.(type) { + case string: + return v + case nil: + return "" + default: + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return fmt.Sprint(v) + } +} + func normalizeCodexModel(model string) string { model = strings.TrimSpace(model) if model == "" { @@ -729,6 +918,22 @@ func filterCodexInput(input []any, preserveReferences bool) []any { delete(newItem, "call_id") } + if codexInputItemRequiresName(typ) { + if strings.TrimSpace(firstNonEmptyString(m["name"])) == "" { + name := firstNonEmptyString(m["tool_name"]) + if name == "" { + if function, ok := m["function"].(map[string]any); ok { + name = firstNonEmptyString(function["name"]) + } + } + if name == "" { + name = "tool" + } + ensureCopy() + newItem["name"] = name + } + } + if !preserveReferences { ensureCopy() delete(newItem, "id") @@ -746,6 +951,7 @@ func isCodexToolCallItemType(typ string) bool { "local_shell_call", "tool_search_call", "custom_tool_call", + "mcp_tool_call", "function_call_output", "mcp_tool_call_output", "custom_tool_call_output", @@ -756,6 +962,15 @@ func isCodexToolCallItemType(typ string) bool { } } +func codexInputItemRequiresName(typ string) bool { + switch strings.TrimSpace(typ) { + case "function_call", "custom_tool_call", "mcp_tool_call": + return true + default: + return false + } +} + func normalizeCodexTools(reqBody map[string]any) bool { rawTools, ok := reqBody["tools"] if !ok || rawTools == nil { diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index f655e61c..75f5c55c 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -164,6 +164,163 @@ func TestApplyCodexOAuthTransform_ImageAndWebSearchCallsDoNotGainCallID(t *testi 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, "fc1", 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_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, "fc1", 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, "fc1", 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, "fcabc", 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。