fix(openai): normalize codex responses payloads

This commit is contained in:
gaoren002
2026-04-24 12:03:19 +00:00
parent 1ce9dc03f9
commit e65574dea9
2 changed files with 339 additions and 0 deletions

View File

@@ -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 {