Merge pull request #6407 from wucm667/fix/issue-6402-delegation-bootstrap

fix(openai): accept delegation bootstrap without call id
This commit is contained in:
Wesley Liddick
2026-09-01 08:51:46 +08:00
committed by GitHub
2 changed files with 408 additions and 0 deletions
@@ -0,0 +1,176 @@
package handler
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const delegationEnvelope = `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>do the work</input></codex_delegation>`
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{
`<codex_delegation><source_thread_id>thread-1</source_thread_id></codex_delegation>`,
`<codex_delegation><source_thread_id></source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input> </input></codex_delegation>`,
`prefix<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>suffix`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input><extra>x</extra></codex_delegation>`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input><nested>work</nested></input></codex_delegation>`,
`<x:codex_delegation xmlns:x="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
`<x:codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
`<codex_delegation xmlns="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation xmlns:x="urn:test"><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation xmlns=""><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
}
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)
}
@@ -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,