mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(openai): preserve named standalone Responses inputs
This commit is contained in:
@@ -7,7 +7,8 @@ import (
|
||||
const openAIResponsesInputTextMaxChars = 10000000
|
||||
|
||||
// sanitizeOpenAIResponsesOrphanToolOutputs removes tool-output items that have
|
||||
// no matching call or item reference anywhere in the current input.
|
||||
// no matching call or item reference anywhere in the current input. Named
|
||||
// function outputs without a call ID are standalone inputs, not orphan results.
|
||||
func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []any, hasPreviousResponseID bool) bool {
|
||||
if len(input) == 0 || hasPreviousResponseID {
|
||||
return false
|
||||
@@ -45,6 +46,15 @@ func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []an
|
||||
}
|
||||
|
||||
callID := strings.TrimSpace(firstNonEmptyString(item["call_id"]))
|
||||
// Codex sends externally supplied inputs (such as task delegation) as
|
||||
// named function outputs without a preceding function call. Preserve
|
||||
// their native type, namespace and output instead of silently dropping
|
||||
// the request that started the turn.
|
||||
if callID == "" && strings.TrimSpace(firstNonEmptyString(item["type"])) == "function_call_output" &&
|
||||
strings.TrimSpace(firstNonEmptyString(item["name"])) != "" {
|
||||
normalized = append(normalized, rawItem)
|
||||
continue
|
||||
}
|
||||
_, hasToolCall := toolCallIDs[callID]
|
||||
_, hasReference := referenceIDs[callID]
|
||||
if callID != "" && (hasToolCall || hasReference) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -11,6 +13,21 @@ import (
|
||||
)
|
||||
|
||||
func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) {
|
||||
t.Run("named standalone inputs do not legitimize orphan results", func(t *testing.T) {
|
||||
named := map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "namespace": "codex_app", "output": "delegation"}
|
||||
input := []any{
|
||||
named,
|
||||
map[string]any{"type": "function_call_output", "output": "missing name and call id"},
|
||||
map[string]any{"type": "function_call_output", "name": " ", "output": "blank name"},
|
||||
map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "call_id": "missing", "output": "orphan result"},
|
||||
map[string]any{"type": "custom_tool_call_output", "name": "apply_patch", "output": "missing call id"},
|
||||
}
|
||||
reqBody := map[string]any{"input": input}
|
||||
|
||||
require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
|
||||
require.Equal(t, []any{named}, reqBody["input"])
|
||||
})
|
||||
|
||||
t.Run("preserves matches regardless of item order", func(t *testing.T) {
|
||||
input := []any{
|
||||
map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"},
|
||||
@@ -71,6 +88,48 @@ func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSocketCompatibilityPreservesNamedDelegation(t *testing.T) {
|
||||
for _, toolName := range []string{"create_thread", "send_message_to_thread"} {
|
||||
for _, withHistory := range []bool{false, true} {
|
||||
for _, previousResponseID := range []string{"", "resp_previous"} {
|
||||
name := fmt.Sprintf("%s/history=%t/previous=%t", toolName, withHistory, previousResponseID != "")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
input := []any{}
|
||||
if withHistory {
|
||||
input = append(input,
|
||||
map[string]any{"type": "function_call", "call_id": "fc_history", "name": "lookup", "arguments": "{}"},
|
||||
map[string]any{"type": "function_call_output", "call_id": "fc_history", "output": "historical result"},
|
||||
)
|
||||
}
|
||||
input = append(input,
|
||||
map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Updated environment context."}}},
|
||||
map[string]any{"type": "function_call_output", "name": toolName, "namespace": "codex_app", "output": "<codex_delegation>\n <source_thread_id>source-task</source_thread_id>\n <input>Reply with DELEGATION_OK.</input>\n</codex_delegation>"},
|
||||
)
|
||||
reqBody := map[string]any{"type": "response.create", "model": "gpt-5.5", "input": input}
|
||||
if previousResponseID != "" {
|
||||
reqBody["previous_response_id"] = previousResponseID
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false)
|
||||
require.NoError(t, err)
|
||||
var got map[string]any
|
||||
require.NoError(t, json.Unmarshal(normalized, &got))
|
||||
require.Equal(t, input, got["input"], "preserve the delegation envelope, native type and history in order")
|
||||
require.Equal(t, reqBody["previous_response_id"], got["previous_response_id"])
|
||||
|
||||
again, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "normalization must be idempotent")
|
||||
require.JSONEq(t, string(normalized), string(again))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) {
|
||||
atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars)
|
||||
oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中"
|
||||
|
||||
Reference in New Issue
Block a user