fix(openai): normalize codex responses payloads
This commit is contained in:
@@ -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")
|
||||
@@ -756,6 +961,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 {
|
||||
|
||||
@@ -164,6 +164,131 @@ 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_ExplicitStoreFalsePreserved(t *testing.T) {
|
||||
// 续链场景:显式 store=false 不再强制为 true,保持 false。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user