mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
fix(sse): remap non-contiguous upstream tool_calls index to a gap-free output_index (#12445)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark.
This commit is contained in:
@@ -23,12 +23,12 @@ import {
|
||||
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
|
||||
import { buildResponsesToolCallItem } from "./responsesToolItem.ts";
|
||||
import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts";
|
||||
import { resolveLocalToolCallIndex } from "./openai-responses/toolCallLocalIndex.ts";
|
||||
import {
|
||||
synthesizeCompletedToolCalls,
|
||||
computeFinishReason,
|
||||
withAssistantRoleOnFirstDelta,
|
||||
} from "./openai-responses/synthesizeCompletedToolCalls.ts";
|
||||
|
||||
// normalizeUpstreamFailure is re-exported for external importers (tests).
|
||||
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
|
||||
|
||||
@@ -506,7 +506,7 @@ function toolCallOutputIndexBase(state) {
|
||||
|
||||
function emitToolCall(state, emit, tc) {
|
||||
const tcIdx = tc.index ?? 0;
|
||||
const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx);
|
||||
const outputIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, tcIdx);
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
@@ -609,7 +609,7 @@ function emitToolCall(state, emit, tc) {
|
||||
function closeToolCall(state, emit, idx, recordAsCompleted = true) {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx);
|
||||
const normalizedIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, idx);
|
||||
const args = state.funcArgsBuf[idx] || "{}";
|
||||
const toolName = state.funcNames[idx] || "";
|
||||
// See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Remap a turn's raw upstream tool_calls delta `index` onto a local,
|
||||
* contiguous, 0-based sequence in first-seen order.
|
||||
*
|
||||
* Live incident (2026-09-02, minimax-m3:free via OpenRouter/GMICloud): the
|
||||
* upstream's own `index` doesn't reliably start at 0 or stay contiguous per
|
||||
* turn — this turn's two calls arrived with raw index 1 and 2 (never 0).
|
||||
* Adding that raw index straight onto toolCallOutputIndexBase() left a GAP
|
||||
* in the emitted output_index sequence (0 for the message, then 2 and 3 for
|
||||
* the calls — index 1 never used). A client that reads response.completed's
|
||||
* final `output[]` array by ARRAY POSITION and expects position to equal
|
||||
* output_index (the Responses API's own contract) reads output[1] (this
|
||||
* turn's first call, real output_index 2) while looking it up under
|
||||
* output_index 1, misses it, then reads output[2] (the second call, real
|
||||
* output_index 3) under output_index 2 — landing on the FIRST call's tracked
|
||||
* slot with a different call_id, which a spec-following client correctly
|
||||
* treats as "stream changed output item identity" and aborts.
|
||||
*/
|
||||
|
||||
export type ToolCallLocalIndexState = {
|
||||
toolCallLocalIndex?: Record<string, number>;
|
||||
toolCallLocalIndexNext?: number;
|
||||
};
|
||||
|
||||
export function resolveLocalToolCallIndex(
|
||||
state: ToolCallLocalIndexState,
|
||||
tcIdx: string | number
|
||||
): number {
|
||||
if (!state.toolCallLocalIndex) state.toolCallLocalIndex = {};
|
||||
if (state.toolCallLocalIndex[tcIdx] === undefined) {
|
||||
state.toolCallLocalIndex[tcIdx] = state.toolCallLocalIndexNext ?? 0;
|
||||
state.toolCallLocalIndexNext = state.toolCallLocalIndex[tcIdx] + 1;
|
||||
}
|
||||
return state.toolCallLocalIndex[tcIdx];
|
||||
}
|
||||
@@ -994,3 +994,143 @@ test("OpenAI -> Responses: a text message and a following tool call in the same
|
||||
"completed output must include the tool call"
|
||||
);
|
||||
});
|
||||
|
||||
// Live incident (2026-09-02): a free-tier streaming model, after a short text
|
||||
// preamble, opened two tool calls whose upstream `tool_calls[].index` was 1
|
||||
// and 2 -- never 0. toolCallOutputIndexBase()+index therefore emitted
|
||||
// output_index 0 (message), 2, 3 -- skipping 1 entirely. A spec-following
|
||||
// Responses-API client reads response.completed's final `output[]` array by
|
||||
// ARRAY POSITION and expects position === output_index (the API's own
|
||||
// contract): output[1] (this turn's first call, real output_index 2) gets
|
||||
// looked up under output_index 1 and missed, then output[2] (the second
|
||||
// call, real output_index 3) gets looked up under output_index 2 and
|
||||
// collides with the FIRST call's tracked slot -- two different call_ids on
|
||||
// what the client thinks is one identity, which it correctly refuses to
|
||||
// treat as anything but a broken stream. Reproduced verbatim (anonymized
|
||||
// content, same index/id shape) against OpenClaw's own
|
||||
// createResponsesOutputTracker before this fix; content and tool/model names
|
||||
// below are placeholders, not the real incident's.
|
||||
test("OpenAI -> Responses: tool-call output_index stays gap-free when the upstream's own index doesn't start at 0", () => {
|
||||
const events = collectEvents([
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{ index: 0, delta: { content: "Status:", role: "assistant" }, finish_reason: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{ index: 0, delta: { content: " all clear.", role: "assistant" }, finish_reason: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: null,
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 1,
|
||||
id: "call_stub_1",
|
||||
type: "function",
|
||||
function: { name: "notify", arguments: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: null,
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 1, function: { arguments: '{"a":1}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: null,
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 2,
|
||||
id: "call_stub_2",
|
||||
type: "function",
|
||||
function: { name: "notify", arguments: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: null,
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 2, function: { arguments: '{"a":2}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-gap1",
|
||||
model: "stub-model",
|
||||
choices: [
|
||||
{ index: 0, delta: { content: "", role: "assistant" }, finish_reason: "tool_calls" },
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
null,
|
||||
]);
|
||||
|
||||
const addedEvents = events.filter((e) => e.event === "response.output_item.added");
|
||||
const indexes = addedEvents.map((e) => e.data.output_index).sort((a, b) => a - b);
|
||||
const sequential = indexes.map((_, i) => i);
|
||||
assert.deepEqual(
|
||||
indexes,
|
||||
sequential,
|
||||
`output_index values must be a gap-free 0..n-1 sequence (position === output_index is the Responses API's own contract); got ${JSON.stringify(indexes)}`
|
||||
);
|
||||
|
||||
// The exact client-observable symptom: response.completed's output[]
|
||||
// array, read by array position, must match each item's own tracked
|
||||
// output_index -- otherwise a client keying by array position resolves
|
||||
// the wrong item.
|
||||
const completedGap = events.find((e) => e.event === "response.completed");
|
||||
completedGap.data.response.output.forEach((item, position) => {
|
||||
const addedEvent = addedEvents.find((e) => e.data.item?.id === item.id);
|
||||
assert.equal(
|
||||
addedEvent?.data.output_index,
|
||||
position,
|
||||
`item ${item.id} (type ${item.type}) streamed at output_index ${addedEvent?.data.output_index} but sits at array position ${position} in the completed output`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user