Skip to content

Commit 6371623

Browse files
authored
fix(runner): normalize missing tool call IDs (#1958)
Normalize missing or empty tool call IDs before completion messages are stored and emitted, including streaming responses. Add coverage for parallel same-function calls and generated IDs.\n\nCo-authored-by: anish <anishesg@users.noreply.github.com>
1 parent 25ab4c6 commit 6371623

3 files changed

Lines changed: 172 additions & 4 deletions

File tree

src/lib/AbstractChatCompletionRunner.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OpenAIError } from '../error';
22
import type OpenAI from '../index';
33
import type { RequestOptions } from '../internal/request-options';
4+
import { uuid4 } from '../internal/utils/uuid';
45
import { isAutoParsableTool, parseChatCompletion } from '../lib/parser';
56
import type {
67
ChatCompletion,
@@ -30,6 +31,19 @@ import {
3031

3132
const DEFAULT_MAX_CHAT_COMPLETIONS = 10;
3233

34+
function normalizeToolCallIds(chatCompletion: ChatCompletion): void {
35+
for (const choice of chatCompletion.choices) {
36+
for (const toolCall of choice.message.tool_calls ?? []) {
37+
// Some OpenAI-compatible providers omit tool call IDs or return an empty string.
38+
// Generate a unique ID before the completion is stored or emitted so the assistant
39+
// tool call and its result message always reference the same value.
40+
if (!toolCall.id) {
41+
toolCall.id = `call_${uuid4()}`;
42+
}
43+
}
44+
}
45+
}
46+
3347
export interface ChatCompletionRunnerContext {
3448
messages: ChatCompletionMessageParam[];
3549
abort(): void;
@@ -58,6 +72,7 @@ export class AbstractChatCompletionRunner<
5872
this: AbstractChatCompletionRunner<AbstractChatCompletionRunnerEvents, ParsedT>,
5973
chatCompletion: ParsedChatCompletion<ParsedT>,
6074
): ParsedChatCompletion<ParsedT> {
75+
normalizeToolCallIds(chatCompletion);
6176
this._chatCompletions.push(chatCompletion);
6277
this._emit('chatCompletion', chatCompletion);
6378
const message = chatCompletion.choices[0]?.message;

src/lib/ChatCompletionStream.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import OpenAI from '../index';
99
import { RequestOptions } from '../internal/request-options';
1010
import { type ReadableStream } from '../internal/shim-types';
11+
import { uuid4 } from '../internal/utils/uuid';
1112
import {
1213
AutoParseableResponseFormat,
1314
hasAutoParseableInput,
@@ -657,9 +658,6 @@ function finalizeChatCompletion<ParsedT>(
657658
tool_calls: tool_calls.map((tool_call, i) => {
658659
const { function: fn, type, id, ...toolRest } = tool_call;
659660
const { arguments: args, name, ...fnRest } = fn || {};
660-
if (id == null) {
661-
throw new OpenAIError(`missing choices[${index}].tool_calls[${i}].id\n${str(snapshot)}`);
662-
}
663661
if (type == null) {
664662
throw new OpenAIError(`missing choices[${index}].tool_calls[${i}].type\n${str(snapshot)}`);
665663
}
@@ -674,7 +672,12 @@ function finalizeChatCompletion<ParsedT>(
674672
);
675673
}
676674

677-
return { ...toolRest, id, type, function: { ...fnRest, name, arguments: args } };
675+
return {
676+
...toolRest,
677+
id: id || `call_${uuid4()}`,
678+
type,
679+
function: { ...fnRest, name, arguments: args },
680+
};
678681
}),
679682
},
680683
};

tests/lib/ChatCompletionRunFunctions.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,93 @@ describe('resource completions', () => {
662662
expect(listener.functionCallResults).toEqual([`it's raining`]);
663663
await listener.sanityCheck({ ignoredMessages: new Set([injectedMessage]) });
664664
});
665+
test('generates unique IDs for parallel tool calls with empty IDs', async () => {
666+
const { fetch, handleRequest } = mockChatCompletionFetch();
667+
668+
const openai = new OpenAI({ apiKey: 'something1234', baseURL: 'http://127.0.0.1:4010', fetch });
669+
const runner = openai.chat.completions.runTools({
670+
messages: [{ role: 'user', content: 'echo both values' }],
671+
model: 'gpt-3.5-turbo',
672+
tools: [
673+
{
674+
type: 'function',
675+
function: {
676+
function: function echo(value: string) {
677+
return value;
678+
},
679+
parameters: {},
680+
description: 'echoes a value',
681+
},
682+
},
683+
],
684+
});
685+
686+
await handleRequest(async () => ({
687+
id: '1',
688+
choices: [
689+
{
690+
index: 0,
691+
finish_reason: 'tool_calls',
692+
logprobs: null,
693+
message: {
694+
role: 'assistant',
695+
content: null,
696+
refusal: null,
697+
tool_calls: [
698+
{
699+
type: 'function',
700+
id: '',
701+
function: { arguments: 'first', name: 'echo' },
702+
},
703+
{
704+
type: 'function',
705+
id: '',
706+
function: { arguments: 'second', name: 'echo' },
707+
},
708+
],
709+
},
710+
},
711+
],
712+
created: Math.floor(Date.now() / 1000),
713+
model: 'gpt-3.5-turbo',
714+
object: 'chat.completion',
715+
}));
716+
717+
await handleRequest(async (request) => {
718+
const assistantMessage = request.messages[1];
719+
if (assistantMessage?.role !== 'assistant' || !assistantMessage.tool_calls) {
720+
throw new Error('expected an assistant message with tool calls');
721+
}
722+
723+
const generatedIDs = assistantMessage.tool_calls.map((toolCall) => toolCall.id);
724+
expect(generatedIDs).toHaveLength(2);
725+
expect(generatedIDs.every((id) => id.startsWith('call_'))).toBe(true);
726+
expect(new Set(generatedIDs).size).toBe(2);
727+
728+
const toolMessages = request.messages.slice(2);
729+
expect(
730+
toolMessages.map((message) => (message.role === 'tool' ? message.tool_call_id : undefined)),
731+
).toEqual(generatedIDs);
732+
733+
return {
734+
id: '2',
735+
choices: [
736+
{
737+
index: 0,
738+
finish_reason: 'stop',
739+
logprobs: null,
740+
message: { role: 'assistant', content: 'done', refusal: null },
741+
},
742+
],
743+
created: Math.floor(Date.now() / 1000),
744+
model: 'gpt-3.5-turbo',
745+
object: 'chat.completion',
746+
};
747+
});
748+
749+
await runner.done();
750+
await expect(runner.finalFunctionToolCallResult()).resolves.toBe('second');
751+
});
665752
test('runs tool calls concurrently and preserves their result order', async () => {
666753
const { fetch, handleRequest } = mockChatCompletionFetch();
667754

@@ -1827,6 +1914,69 @@ describe('resource completions', () => {
18271914
expect(listener.eventFunctionCallResults).toEqual([`it's raining`]);
18281915
await listener.sanityCheck({ ignoredMessages: new Set([injectedMessage]) });
18291916
});
1917+
test('generates an ID for streamed tool calls with an empty ID', async () => {
1918+
const { fetch, handleRequest } = mockStreamingChatCompletionFetch();
1919+
1920+
const openai = new OpenAI({ apiKey: 'something1234', baseURL: 'http://127.0.0.1:4010', fetch });
1921+
const runner = openai.chat.completions.runTools({
1922+
stream: true,
1923+
messages: [{ role: 'user', content: 'tell me what the weather is like' }],
1924+
model: 'gpt-3.5-turbo',
1925+
tools: [
1926+
{
1927+
type: 'function',
1928+
function: {
1929+
function: function getWeather() {
1930+
return `it's raining`;
1931+
},
1932+
parameters: {},
1933+
description: 'gets the weather',
1934+
},
1935+
},
1936+
],
1937+
});
1938+
1939+
await Promise.all([
1940+
handleRequest(async function* (): AsyncIterable<OpenAI.Chat.ChatCompletionChunk> {
1941+
for (const choice of functionCallDeltas('', { id: '', name: 'getWeather' })) {
1942+
yield {
1943+
id: '1',
1944+
choices: [choice],
1945+
created: Math.floor(Date.now() / 1000),
1946+
model: 'gpt-3.5-turbo',
1947+
object: 'chat.completion.chunk',
1948+
};
1949+
}
1950+
}),
1951+
handleRequest(async function* (request): AsyncIterable<OpenAI.Chat.ChatCompletionChunk> {
1952+
const assistantMessage = request.messages[1];
1953+
const toolMessage = request.messages[2];
1954+
if (assistantMessage?.role !== 'assistant' || !assistantMessage.tool_calls?.[0]) {
1955+
throw new Error('expected an assistant message with a tool call');
1956+
}
1957+
if (toolMessage?.role !== 'tool') {
1958+
throw new Error('expected a tool result message');
1959+
}
1960+
1961+
const generatedID = assistantMessage.tool_calls[0].id;
1962+
expect(generatedID).toMatch(/^call_/);
1963+
expect(toolMessage.tool_call_id).toBe(generatedID);
1964+
1965+
for (const choice of contentChoiceDeltas(`it's raining`)) {
1966+
yield {
1967+
id: '2',
1968+
choices: [choice],
1969+
created: Math.floor(Date.now() / 1000),
1970+
model: 'gpt-3.5-turbo',
1971+
object: 'chat.completion.chunk',
1972+
};
1973+
}
1974+
}),
1975+
runner.done(),
1976+
]);
1977+
1978+
await expect(runner.finalFunctionToolCallResult()).resolves.toBe(`it's raining`);
1979+
});
18301980
test('flow with abort', async () => {
18311981
const { fetch, handleRequest } = mockStreamingChatCompletionFetch();
18321982

0 commit comments

Comments
 (0)