Skip to content

translate/responses: assembleOutput uses len-bounded loop over map[int], silently drops tool calls with non-contiguous upstream indices #505

Description

@rohith500

ResponsesWriter.assembleOutput() iterates tool items with a len-bounded loop over a map[int]*responsesToolItem keyed by the upstream's raw tool_calls[].index value:

// internal/translate/responses.go:952
for i := 0; i < len(t.toolItems); i++ {
    item, ok := t.toolItems[i]
    if !ok { continue }   // dead code in all current tests
    ...
}

This is safe only when upstream indices are dense and zero-based (0, 1, 2…). If any upstream emits a gap — e.g. index=0 then index=2len(toolItems)=2 and the loop visits i=0 (hit), i=1 (miss → continue), terminates. The entry at key 2 is never reached and that tool call is silently dropped from the response.completed output array.

Expected vs Actual

Given these upstream SSE chunks (two parallel tool calls with a gap at index=1):

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"q\":\"x\"}"}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":2,"id":"call_b","function":{"name":"lookup","arguments":""}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":2,"function":{"arguments":"{\"id\":1}"}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}

data: [DONE]

Expectedresponse.completed.response.output contains both tool calls:

"output": [
  {"id": "call_a", "type": "function_call", "status": "completed",
   "call_id": "call_a", "name": "search", "arguments": "{\"q\":\"x\"}"},
  {"id": "call_b", "type": "function_call", "status": "completed",
   "call_id": "call_b", "name": "lookup", "arguments": "{\"id\":1}"}
]

Actual — only the first tool call appears; call_b is silently dropped:

"output": [
  {"id": "call_a", "type": "function_call", "status": "completed",
   "call_id": "call_a", "name": "search", "arguments": "{\"q\":\"x\"}"}
]

Why: toolItems = {0: call_a, 2: call_b}, len(toolItems) = 2.
Loop: i=0 → appends call_a. i=1toolItems[1] absent → continue.
Loop ends at i=2 — never reached. call_b is dropped.

Root cause

appendToolCall stores entries at the raw upstream index with no remapping:

// responses.go:601, 658
idx := int(tc.Get("index").Int())
t.toolItems[idx] = item

The SSETranslator (Anthropic upstream) re-indexes sequentially via its own t.toolIdx counter so the Anthropic path is not affected. OpenAI-compat upstreams (OpenAI, OpenRouter, Fireworks, DeepInfra, Bedrock) forward raw SSE bytes verbatim — their tool_calls[].index values key toolItems directly.

The OpenAI spec does not strictly enforce index contiguity. The codebase already documents that vLLM/SGLang-backed providers (GLM, Qwen, Kimi) emit malformed tool_call deltas in adjacent ways (stream.go:940 — nameless first delta guard). A gap in index sequence is the same class of deviation.

The !ok { continue } guard was written defensively but is unreachable in all current tests, with responses_test.go exercising only index=0 for all tool-call fixtures.

Note: closeOpenItems() uses range t.toolItems and is not affected.

Fix

Replace the len-bounded loop with sort-and-iterate over actual map keys:

import "sort"

indices := make([]int, 0, len(t.toolItems))
for idx := range t.toolItems {
    indices = append(indices, idx)
}
sort.Ints(indices)
for _, idx := range indices {
    item := t.toolItems[idx]
    out = append(out, map[string]any{
        "id":        item.itemID,
        "type":      "function_call",
        "status":    "completed",
        "call_id":   item.callID,
        "name":      item.name,
        "arguments": item.arguments.String(),
    })
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions