diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go new file mode 100644 index 0000000000..9e30752aa6 --- /dev/null +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -0,0 +1,176 @@ +package handler + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const delegationEnvelope = `thread-1do the work` + +func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) { + tests := []struct { + name string + namespace string + tool string + callID string + }{ + {name: "app create missing call id", namespace: "codex_app", tool: "create_thread"}, + {name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`}, + {name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`}, + {name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists()) + }) + } +} + +func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { + tests := []struct { + name string + item string + extra string + }{ + {name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`}, + {name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`}, + {name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`}, + {name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`}, + {name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`}, + {name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`}, + {name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`}, + {name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`}, + {name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`}, + {name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, + {name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + +func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { + tests := []struct { + name string + value string + changed bool + }{ + {name: "absent", changed: true}, + {name: "blank string", value: `,"previous_response_id":" "`, changed: true}, + {name: "non-empty string", value: `,"previous_response_id":"resp-1"`}, + {name: "null", value: `,"previous_response_id":null`}, + {name: "number", value: `,"previous_response_id":42`}, + {name: "boolean", value: `,"previous_response_id":false`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.Equal(t, tt.changed, changed) + if !tt.changed { + require.Equal(t, body, got) + } + }) + } +} + +func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "top-level previous response id", + body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`, + }, + { + name: "candidate discriminator", + body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`, + }, + { + name: "candidate call id", + body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`, + }, + { + name: "candidate output", + body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(tt.body) + got, changed := normalizeCodexDelegationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + +func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) { + body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw) +} + +func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) { + malformed := []string{ + `thread-1`, + `work`, + `thread-1 `, + `prefixthread-1work`, + `thread-1worksuffix`, + `thread-1workx`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + `thread-1work`, + } + for _, envelope := range malformed { + body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.False(t, changed, envelope) + require.Equal(t, body, got) + } +} + +func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`) + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String()) + require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String()) + require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String()) + + again, changedAgain := normalizeCodexDelegationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} + +func mustJSON(t *testing.T, value string) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index e57cc759e6..40763b1976 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1,10 +1,13 @@ package handler import ( + "bytes" "context" "encoding/json" + "encoding/xml" "errors" "fmt" + "io" "net/http" "runtime/debug" "strconv" @@ -435,6 +438,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { body = cappedBody } + if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed { + body = normalizedBody + reqLog.Info("openai.codex_delegation_bootstrap_normalized", + zap.String("normalization", "call_output_to_user_message"), + ) + } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { @@ -1581,6 +1590,229 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, return false } +func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { + if !hasUniqueJSONMembers(body) { + return body, false + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var request map[string]any + if err := decoder.Decode(&request); err != nil { + return body, false + } + if previousResponseID, exists := request["previous_response_id"]; exists { + value, ok := previousResponseID.(string) + if !ok || strings.TrimSpace(value) != "" { + return body, false + } + } + input, ok := request["input"].([]any) + if !ok { + return body, false + } + + // Any call/reference anchor makes a call-less output ambiguous. Responses + // built-ins follow the *_call / *_call_output naming convention, so classify + // by the wire type shape instead of maintaining an incomplete allowlist. + for _, raw := range input { + item, ok := raw.(map[string]any) + if !ok { + continue + } + typ := stringField(item, "type") + if typ == "item_reference" || strings.HasSuffix(typ, "_call") { + return body, false + } + if isResponsesCallOutputType(typ) { + callIDValue, exists := item["call_id"] + callID, isString := callIDValue.(string) + if exists && (!isString || strings.TrimSpace(callID) != "") { + return body, false + } + if !isCodexDelegationCandidate(item) { + return body, false + } + } + } + + changed := false + for i, raw := range input { + item, ok := raw.(map[string]any) + if !ok || !isCodexDelegationCandidate(item) { + continue + } + output, ok := item["output"].(string) + if !ok || !validCodexDelegationEnvelope(output) { + continue + } + input[i] = map[string]any{ + "type": "message", + "role": "user", + "content": []any{map[string]any{ + "type": "input_text", + "text": output, + }}, + } + changed = true + } + if !changed { + return body, false + } + normalized, err := json.Marshal(request) + if err != nil { + return body, false + } + return normalized, true +} + +func hasUniqueJSONMembers(body []byte) bool { + decoder := json.NewDecoder(bytes.NewReader(body)) + if !consumeUniqueJSONValue(decoder) { + return false + } + _, err := decoder.Token() + return err == io.EOF +} + +func consumeUniqueJSONValue(decoder *json.Decoder) bool { + token, err := decoder.Token() + if err != nil { + return false + } + delim, ok := token.(json.Delim) + if !ok { + return true + } + + switch delim { + case '{': + members := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return false + } + key, ok := keyToken.(string) + if !ok { + return false + } + if _, duplicate := members[key]; duplicate { + return false + } + members[key] = struct{}{} + if !consumeUniqueJSONValue(decoder) { + return false + } + } + end, err := decoder.Token() + return err == nil && end == json.Delim('}') + case '[': + for decoder.More() { + if !consumeUniqueJSONValue(decoder) { + return false + } + } + end, err := decoder.Token() + return err == nil && end == json.Delim(']') + default: + return false + } +} + +func isResponsesCallOutputType(typ string) bool { + return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output" +} + +func isCodexDelegationCandidate(item map[string]any) bool { + if stringField(item, "type") != "function_call_output" || + !isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) { + return false + } + output, ok := item["output"].(string) + return ok && validCodexDelegationEnvelope(output) +} + +func stringField(item map[string]any, key string) string { + value, _ := item[key].(string) + return value +} + +func isCodexDelegationTool(namespace, name string) bool { + return (namespace == "codex_app" || namespace == "codex_tui") && + (name == "create_thread" || name == "send_message_to_thread") +} + +func validCodexDelegationEnvelope(value string) bool { + decoder := xml.NewDecoder(strings.NewReader(value)) + var rootSeen, sourceSeen, inputSeen bool + var childName string + var childText bytes.Buffer + depth := 0 + for { + token, err := decoder.Token() + if err == io.EOF { + return rootSeen && depth == 0 && sourceSeen && inputSeen + } + if err != nil { + return false + } + switch current := token.(type) { + case xml.StartElement: + depth++ + if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 { + return false + } + if depth == 1 { + if rootSeen { + return false + } + rootSeen = true + continue + } + if current.Name.Local != "source_thread_id" && current.Name.Local != "input" { + return false + } + childName = current.Name.Local + childText.Reset() + case xml.EndElement: + if current.Name.Space != "" { + return false + } + if depth == 2 { + if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" { + return false + } + if childName == "source_thread_id" { + if sourceSeen { + return false + } + sourceSeen = true + } else { + if inputSeen { + return false + } + inputSeen = true + } + childName = "" + } + depth-- + if depth < 0 { + return false + } + case xml.CharData: + if depth == 2 { + _, _ = childText.Write(current) + } else if len(bytes.TrimSpace(current)) != 0 { + return false + } + case xml.Comment: + return false + case xml.ProcInst, xml.Directive: + return false + } + } +} + func (h *OpenAIGatewayHandler) acquireResponsesUserSlot( c *gin.Context, userID int64,