Merge pull request #1911 from gaoren002/fix/codex-responses-payload-normalization-mainbase
fix(openai): normalize codex responses payloads
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -153,6 +154,9 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
|
|||||||
if normalizeCodexTools(reqBody) {
|
if normalizeCodexTools(reqBody) {
|
||||||
result.Modified = true
|
result.Modified = true
|
||||||
}
|
}
|
||||||
|
if normalizeCodexToolChoice(reqBody) {
|
||||||
|
result.Modified = true
|
||||||
|
}
|
||||||
|
|
||||||
if v, ok := reqBody["prompt_cache_key"].(string); ok {
|
if v, ok := reqBody["prompt_cache_key"].(string); ok {
|
||||||
result.PromptCacheKey = strings.TrimSpace(v)
|
result.PromptCacheKey = strings.TrimSpace(v)
|
||||||
@@ -173,6 +177,14 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
|
|||||||
|
|
||||||
// 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。
|
// 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。
|
||||||
if input, ok := reqBody["input"].([]any); ok {
|
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)
|
input = filterCodexInput(input, needsToolContinuation)
|
||||||
reqBody["input"] = input
|
reqBody["input"] = input
|
||||||
result.Modified = true
|
result.Modified = true
|
||||||
@@ -197,6 +209,183 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
|
|||||||
return result
|
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 {
|
func normalizeCodexModel(model string) string {
|
||||||
model = strings.TrimSpace(model)
|
model = strings.TrimSpace(model)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
@@ -729,6 +918,22 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
|
|||||||
delete(newItem, "call_id")
|
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 {
|
if !preserveReferences {
|
||||||
ensureCopy()
|
ensureCopy()
|
||||||
delete(newItem, "id")
|
delete(newItem, "id")
|
||||||
@@ -746,6 +951,7 @@ func isCodexToolCallItemType(typ string) bool {
|
|||||||
"local_shell_call",
|
"local_shell_call",
|
||||||
"tool_search_call",
|
"tool_search_call",
|
||||||
"custom_tool_call",
|
"custom_tool_call",
|
||||||
|
"mcp_tool_call",
|
||||||
"function_call_output",
|
"function_call_output",
|
||||||
"mcp_tool_call_output",
|
"mcp_tool_call_output",
|
||||||
"custom_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 {
|
func normalizeCodexTools(reqBody map[string]any) bool {
|
||||||
rawTools, ok := reqBody["tools"]
|
rawTools, ok := reqBody["tools"]
|
||||||
if !ok || rawTools == nil {
|
if !ok || rawTools == nil {
|
||||||
|
|||||||
@@ -164,6 +164,163 @@ func TestApplyCodexOAuthTransform_ImageAndWebSearchCallsDoNotGainCallID(t *testi
|
|||||||
require.False(t, hasCallID)
|
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) {
|
func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
|
||||||
// 续链场景:显式 store=false 不再强制为 true,保持 false。
|
// 续链场景:显式 store=false 不再强制为 true,保持 false。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user