Skip to content

Commit 9c02454

Browse files
authored
Merge pull request router-for-me#3657 from catoncat/fix/responses-input-id-dedupe-orphaned-output
fix(openai): keep referenced tool call when deduping websocket input IDs
2 parents 1c9601f + f05d68d commit 9c02454

2 files changed

Lines changed: 78 additions & 6 deletions

File tree

sdk/api/handlers/openai/openai_responses_websocket.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -728,14 +728,63 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
728728
return "", errUnmarshal
729729
}
730730

731-
lastIndexByID := make(map[string]int, len(items))
731+
// Parse each item's type, id and call_id once; gjson is a scan-based
732+
// parser, so reusing this metadata avoids rescanning every item in each of
733+
// the loops below as the conversation history grows.
734+
type itemMetadata struct {
735+
itemType string
736+
id string
737+
callID string
738+
}
739+
meta := make([]itemMetadata, len(items))
732740
for i, item := range items {
733741
if len(item) == 0 {
734742
continue
735743
}
736-
itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
737-
if itemID != "" {
738-
lastIndexByID[itemID] = i
744+
res := gjson.GetManyBytes(item, "type", "id", "call_id")
745+
meta[i] = itemMetadata{
746+
itemType: strings.TrimSpace(res[0].String()),
747+
id: strings.TrimSpace(res[1].String()),
748+
callID: strings.TrimSpace(res[2].String()),
749+
}
750+
}
751+
752+
// Collect the call_ids that are still referenced by tool-call output
753+
// items. When several input items share the same id, the one we keep must
754+
// preserve any call_id that has a matching output; otherwise the upstream
755+
// rejects the request with "No tool call found for function call output".
756+
referencedCallIDs := make(map[string]struct{}, len(items))
757+
for i := range items {
758+
switch meta[i].itemType {
759+
case "function_call_output", "custom_tool_call_output":
760+
if meta[i].callID != "" {
761+
referencedCallIDs[meta[i].callID] = struct{}{}
762+
}
763+
}
764+
}
765+
766+
// For each id, choose the index to keep. The default is the last
767+
// occurrence (matching the original dedupe behavior), but we never replace
768+
// an item whose call_id still has a matching output with one that does not.
769+
// This keeps a single item per id while ensuring retained tool calls stay
770+
// paired with their outputs.
771+
keepIndexByID := make(map[string]int, len(items))
772+
keepReferencedByID := make(map[string]bool, len(items))
773+
for i := range items {
774+
itemID := meta[i].id
775+
if itemID == "" {
776+
continue
777+
}
778+
_, referenced := referencedCallIDs[meta[i].callID]
779+
referenced = referenced && meta[i].callID != ""
780+
if _, seen := keepIndexByID[itemID]; !seen {
781+
keepIndexByID[itemID] = i
782+
keepReferencedByID[itemID] = referenced
783+
continue
784+
}
785+
if referenced || !keepReferencedByID[itemID] {
786+
keepIndexByID[itemID] = i
787+
keepReferencedByID[itemID] = referenced
739788
}
740789
}
741790

@@ -744,9 +793,9 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
744793
if len(item) == 0 {
745794
continue
746795
}
747-
itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
796+
itemID := meta[i].id
748797
if itemID != "" {
749-
if lastIndexByID[itemID] != i {
798+
if keepIndexByID[itemID] != i {
750799
continue
751800
}
752801
}

sdk/api/handlers/openai/openai_responses_websocket_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,6 +1845,29 @@ func TestDedupeResponsesWebsocketInputItemsByIDAfterRepair(t *testing.T) {
18451845
}
18461846
}
18471847

1848+
func TestDedupeResponsesWebsocketInputItemsByIDKeepsReferencedToolCall(t *testing.T) {
1849+
// Two function_call items share the same id but carry different call_ids
1850+
// (e.g. the upstream reused the item id across a re-sent/repaired call).
1851+
// Only the first call_id has a matching function_call_output. Deduping by
1852+
// id must keep the referenced call so the output is not orphaned, which
1853+
// previously triggered an upstream 400 "No tool call found for function
1854+
// call output with call_id ...".
1855+
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"}]}`)
1856+
1857+
deduped := dedupeResponsesWebsocketInputItemsByID(payload)
1858+
1859+
items := gjson.GetBytes(deduped, "input").Array()
1860+
if len(items) != 2 {
1861+
t.Fatalf("deduped input len = %d, want 2: %s", len(items), deduped)
1862+
}
1863+
if items[0].Get("id").String() != "fc-1" ||
1864+
items[0].Get("call_id").String() != "call-1" ||
1865+
items[1].Get("id").String() != "fco-1" ||
1866+
items[1].Get("call_id").String() != "call-1" {
1867+
t.Fatalf("unexpected deduped input: %s", deduped)
1868+
}
1869+
}
1870+
18481871
func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplacement(t *testing.T) {
18491872
gin.SetMode(gin.TestMode)
18501873

0 commit comments

Comments
 (0)