diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 142719aa..08017c3a 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -728,14 +728,50 @@ func dedupeInputItemsByID(rawArray string) (string, error) { return "", errUnmarshal } - lastIndexByID := make(map[string]int, len(items)) + // 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 _, item := range items { + if len(item) == 0 { + continue + } + switch strings.TrimSpace(gjson.GetBytes(item, "type").String()) { + case "function_call_output", "custom_tool_call_output": + callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) + if callID != "" { + referencedCallIDs[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, item := range items { if len(item) == 0 { continue } itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String()) - if itemID != "" { - lastIndexByID[itemID] = i + if itemID == "" { + continue + } + callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) + _, referenced := referencedCallIDs[callID] + referenced = referenced && callID != "" + if _, seen := keepIndexByID[itemID]; !seen { + keepIndexByID[itemID] = i + keepReferencedByID[itemID] = referenced + continue + } + if referenced || !keepReferencedByID[itemID] { + keepIndexByID[itemID] = i + keepReferencedByID[itemID] = referenced } } @@ -746,7 +782,7 @@ func dedupeInputItemsByID(rawArray string) (string, error) { } itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String()) if itemID != "" { - if lastIndexByID[itemID] != i { + if keepIndexByID[itemID] != i { continue } } diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 6502ae0c..6796023e 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -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) -- 2.51.2 From f05d68d4ec434e60f7e7c153efc7393bd20682a8 Mon Sep 17 00:00:00 2001 From: cat Date: Mon, 1 Jun 2026 15:01:31 +0800 Subject: [PATCH 2/2] refactor(openai): parse dedupe input item metadata in a single pass Address review feedback: parse each item's type/id/call_id once with gjson.GetManyBytes and reuse it across the dedupe loops instead of rescanning every item up to five times. Behavior is unchanged. --- .../openai/openai_responses_websocket.go | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 08017c3a..0e6cfce4 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -728,20 +728,37 @@ func dedupeInputItemsByID(rawArray string) (string, error) { return "", errUnmarshal } + // 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 + } + 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 _, item := range items { - if len(item) == 0 { - continue - } - switch strings.TrimSpace(gjson.GetBytes(item, "type").String()) { + for i := range items { + switch meta[i].itemType { case "function_call_output", "custom_tool_call_output": - callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) - if callID != "" { - referencedCallIDs[callID] = struct{}{} + if meta[i].callID != "" { + referencedCallIDs[meta[i].callID] = struct{}{} } } } @@ -753,17 +770,13 @@ func dedupeInputItemsByID(rawArray string) (string, error) { // paired with their outputs. keepIndexByID := make(map[string]int, len(items)) keepReferencedByID := make(map[string]bool, len(items)) - for i, item := range items { - if len(item) == 0 { - continue - } - itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String()) + for i := range items { + itemID := meta[i].id if itemID == "" { continue } - callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) - _, referenced := referencedCallIDs[callID] - referenced = referenced && callID != "" + _, referenced := referencedCallIDs[meta[i].callID] + referenced = referenced && meta[i].callID != "" if _, seen := keepIndexByID[itemID]; !seen { keepIndexByID[itemID] = i keepReferencedByID[itemID] = referenced @@ -780,7 +793,7 @@ 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 keepIndexByID[itemID] != i { continue