mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 09:56:49 +08:00
fix(openai): keep referenced tool call when deduping websocket input IDs
The input item ID dedupe added in #3620 keeps only the last occurrence of each item id. When an upstream reuses the same item id across a re-sent or repaired tool call (so two function_call items share an id but carry different call_ids), the last-wins rule can drop the function_call whose call_id still has a matching function_call_output. The upstream then rejects the request with HTTP 400 "No tool call found for function call output with call_id ...", breaking every subsequent turn over the Codex WebSocket path. Make the dedupe orphan-aware: when several input items share an id, never replace an item whose call_id is still referenced by a tool-call output with one that is not. This keeps a single item per id (preserving the original intent) while ensuring retained tool calls stay paired with their outputs. Adds a regression test covering two function_call items that share an id where only the earlier call_id has a surviving output.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user