Merge pull request #3657 from catoncat/fix/responses-input-id-dedupe-orphaned-output

fix(openai): keep referenced tool call when deduping websocket input IDs
This commit is contained in:
Luis Pater
2026-06-03 11:22:00 +08:00
committed by GitHub
2 changed files with 78 additions and 6 deletions
@@ -728,14 +728,63 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
return "", errUnmarshal
}
lastIndexByID := make(map[string]int, len(items))
// Parse each item's type, id and call_id once; gjson is a scan-based
// parser, so reusing this metadata avoids rescanning every item in each of
// the loops below as the conversation history grows.
type itemMetadata struct {
itemType string
id string
callID string
}
meta := make([]itemMetadata, len(items))
for i, item := range items {
if len(item) == 0 {
continue
}
itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
if itemID != "" {
lastIndexByID[itemID] = i
res := gjson.GetManyBytes(item, "type", "id", "call_id")
meta[i] = itemMetadata{
itemType: strings.TrimSpace(res[0].String()),
id: strings.TrimSpace(res[1].String()),
callID: strings.TrimSpace(res[2].String()),
}
}
// Collect the call_ids that are still referenced by tool-call output
// items. When several input items share the same id, the one we keep must
// preserve any call_id that has a matching output; otherwise the upstream
// rejects the request with "No tool call found for function call output".
referencedCallIDs := make(map[string]struct{}, len(items))
for i := range items {
switch meta[i].itemType {
case "function_call_output", "custom_tool_call_output":
if meta[i].callID != "" {
referencedCallIDs[meta[i].callID] = struct{}{}
}
}
}
// For each id, choose the index to keep. The default is the last
// occurrence (matching the original dedupe behavior), but we never replace
// an item whose call_id still has a matching output with one that does not.
// This keeps a single item per id while ensuring retained tool calls stay
// paired with their outputs.
keepIndexByID := make(map[string]int, len(items))
keepReferencedByID := make(map[string]bool, len(items))
for i := range items {
itemID := meta[i].id
if itemID == "" {
continue
}
_, referenced := referencedCallIDs[meta[i].callID]
referenced = referenced && meta[i].callID != ""
if _, seen := keepIndexByID[itemID]; !seen {
keepIndexByID[itemID] = i
keepReferencedByID[itemID] = referenced
continue
}
if referenced || !keepReferencedByID[itemID] {
keepIndexByID[itemID] = i
keepReferencedByID[itemID] = referenced
}
}
@@ -744,9 +793,9 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
if len(item) == 0 {
continue
}
itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
itemID := meta[i].id
if itemID != "" {
if lastIndexByID[itemID] != i {
if keepIndexByID[itemID] != i {
continue
}
}
@@ -1845,6 +1845,29 @@ func TestDedupeResponsesWebsocketInputItemsByIDAfterRepair(t *testing.T) {
}
}
func TestDedupeResponsesWebsocketInputItemsByIDKeepsReferencedToolCall(t *testing.T) {
// Two function_call items share the same id but carry different call_ids
// (e.g. the upstream reused the item id across a re-sent/repaired call).
// Only the first call_id has a matching function_call_output. Deduping by
// id must keep the referenced call so the output is not orphaned, which
// previously triggered an upstream 400 "No tool call found for function
// call output with call_id ...".
payload := []byte(`{"input":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"exec_command"},{"type":"function_call","id":"fc-1","call_id":"call-2","name":"exec_command"},{"type":"function_call_output","id":"fco-1","call_id":"call-1"}]}`)
deduped := dedupeResponsesWebsocketInputItemsByID(payload)
items := gjson.GetBytes(deduped, "input").Array()
if len(items) != 2 {
t.Fatalf("deduped input len = %d, want 2: %s", len(items), deduped)
}
if items[0].Get("id").String() != "fc-1" ||
items[0].Get("call_id").String() != "call-1" ||
items[1].Get("id").String() != "fco-1" ||
items[1].Get("call_id").String() != "call-1" {
t.Fatalf("unexpected deduped input: %s", deduped)
}
}
func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplacement(t *testing.T) {
gin.SetMode(gin.TestMode)