diff --git a/src/lib/AbstractChatCompletionRunner.ts b/src/lib/AbstractChatCompletionRunner.ts index 65777356f..c96e19d91 100644 --- a/src/lib/AbstractChatCompletionRunner.ts +++ b/src/lib/AbstractChatCompletionRunner.ts @@ -1,6 +1,7 @@ import { OpenAIError } from '../error'; import type OpenAI from '../index'; import type { RequestOptions } from '../internal/request-options'; +import { uuid4 } from '../internal/utils/uuid'; import { isAutoParsableTool, parseChatCompletion } from '../lib/parser'; import type { ChatCompletion, @@ -30,6 +31,19 @@ import { const DEFAULT_MAX_CHAT_COMPLETIONS = 10; +function normalizeToolCallIds(chatCompletion: ChatCompletion): void { + for (const choice of chatCompletion.choices) { + for (const toolCall of choice.message.tool_calls ?? []) { + // Some OpenAI-compatible providers omit tool call IDs or return an empty string. + // Generate a unique ID before the completion is stored or emitted so the assistant + // tool call and its result message always reference the same value. + if (!toolCall.id) { + toolCall.id = `call_${uuid4()}`; + } + } + } +} + export interface ChatCompletionRunnerContext { messages: ChatCompletionMessageParam[]; abort(): void; @@ -58,6 +72,7 @@ export class AbstractChatCompletionRunner< this: AbstractChatCompletionRunner, chatCompletion: ParsedChatCompletion, ): ParsedChatCompletion { + normalizeToolCallIds(chatCompletion); this._chatCompletions.push(chatCompletion); this._emit('chatCompletion', chatCompletion); const message = chatCompletion.choices[0]?.message; diff --git a/src/lib/ChatCompletionStream.ts b/src/lib/ChatCompletionStream.ts index e37c87467..f63862b84 100644 --- a/src/lib/ChatCompletionStream.ts +++ b/src/lib/ChatCompletionStream.ts @@ -8,6 +8,7 @@ import { import OpenAI from '../index'; import { RequestOptions } from '../internal/request-options'; import { type ReadableStream } from '../internal/shim-types'; +import { uuid4 } from '../internal/utils/uuid'; import { AutoParseableResponseFormat, hasAutoParseableInput, @@ -657,9 +658,6 @@ function finalizeChatCompletion( tool_calls: tool_calls.map((tool_call, i) => { const { function: fn, type, id, ...toolRest } = tool_call; const { arguments: args, name, ...fnRest } = fn || {}; - if (id == null) { - throw new OpenAIError(`missing choices[${index}].tool_calls[${i}].id\n${str(snapshot)}`); - } if (type == null) { throw new OpenAIError(`missing choices[${index}].tool_calls[${i}].type\n${str(snapshot)}`); } @@ -674,7 +672,12 @@ function finalizeChatCompletion( ); } - return { ...toolRest, id, type, function: { ...fnRest, name, arguments: args } }; + return { + ...toolRest, + id: id || `call_${uuid4()}`, + type, + function: { ...fnRest, name, arguments: args }, + }; }), }, }; diff --git a/tests/lib/ChatCompletionRunFunctions.test.ts b/tests/lib/ChatCompletionRunFunctions.test.ts index 09774c3de..888ffdd88 100644 --- a/tests/lib/ChatCompletionRunFunctions.test.ts +++ b/tests/lib/ChatCompletionRunFunctions.test.ts @@ -662,6 +662,93 @@ describe('resource completions', () => { expect(listener.functionCallResults).toEqual([`it's raining`]); await listener.sanityCheck({ ignoredMessages: new Set([injectedMessage]) }); }); + test('generates unique IDs for parallel tool calls with empty IDs', async () => { + const { fetch, handleRequest } = mockChatCompletionFetch(); + + const openai = new OpenAI({ apiKey: 'something1234', baseURL: 'http://127.0.0.1:4010', fetch }); + const runner = openai.chat.completions.runTools({ + messages: [{ role: 'user', content: 'echo both values' }], + model: 'gpt-3.5-turbo', + tools: [ + { + type: 'function', + function: { + function: function echo(value: string) { + return value; + }, + parameters: {}, + description: 'echoes a value', + }, + }, + ], + }); + + await handleRequest(async () => ({ + id: '1', + choices: [ + { + index: 0, + finish_reason: 'tool_calls', + logprobs: null, + message: { + role: 'assistant', + content: null, + refusal: null, + tool_calls: [ + { + type: 'function', + id: '', + function: { arguments: 'first', name: 'echo' }, + }, + { + type: 'function', + id: '', + function: { arguments: 'second', name: 'echo' }, + }, + ], + }, + }, + ], + created: Math.floor(Date.now() / 1000), + model: 'gpt-3.5-turbo', + object: 'chat.completion', + })); + + await handleRequest(async (request) => { + const assistantMessage = request.messages[1]; + if (assistantMessage?.role !== 'assistant' || !assistantMessage.tool_calls) { + throw new Error('expected an assistant message with tool calls'); + } + + const generatedIDs = assistantMessage.tool_calls.map((toolCall) => toolCall.id); + expect(generatedIDs).toHaveLength(2); + expect(generatedIDs.every((id) => id.startsWith('call_'))).toBe(true); + expect(new Set(generatedIDs).size).toBe(2); + + const toolMessages = request.messages.slice(2); + expect( + toolMessages.map((message) => (message.role === 'tool' ? message.tool_call_id : undefined)), + ).toEqual(generatedIDs); + + return { + id: '2', + choices: [ + { + index: 0, + finish_reason: 'stop', + logprobs: null, + message: { role: 'assistant', content: 'done', refusal: null }, + }, + ], + created: Math.floor(Date.now() / 1000), + model: 'gpt-3.5-turbo', + object: 'chat.completion', + }; + }); + + await runner.done(); + await expect(runner.finalFunctionToolCallResult()).resolves.toBe('second'); + }); test('runs tool calls concurrently and preserves their result order', async () => { const { fetch, handleRequest } = mockChatCompletionFetch(); @@ -1827,6 +1914,69 @@ describe('resource completions', () => { expect(listener.eventFunctionCallResults).toEqual([`it's raining`]); await listener.sanityCheck({ ignoredMessages: new Set([injectedMessage]) }); }); + test('generates an ID for streamed tool calls with an empty ID', async () => { + const { fetch, handleRequest } = mockStreamingChatCompletionFetch(); + + const openai = new OpenAI({ apiKey: 'something1234', baseURL: 'http://127.0.0.1:4010', fetch }); + const runner = openai.chat.completions.runTools({ + stream: true, + messages: [{ role: 'user', content: 'tell me what the weather is like' }], + model: 'gpt-3.5-turbo', + tools: [ + { + type: 'function', + function: { + function: function getWeather() { + return `it's raining`; + }, + parameters: {}, + description: 'gets the weather', + }, + }, + ], + }); + + await Promise.all([ + handleRequest(async function* (): AsyncIterable { + for (const choice of functionCallDeltas('', { id: '', name: 'getWeather' })) { + yield { + id: '1', + choices: [choice], + created: Math.floor(Date.now() / 1000), + model: 'gpt-3.5-turbo', + object: 'chat.completion.chunk', + }; + } + }), + handleRequest(async function* (request): AsyncIterable { + const assistantMessage = request.messages[1]; + const toolMessage = request.messages[2]; + if (assistantMessage?.role !== 'assistant' || !assistantMessage.tool_calls?.[0]) { + throw new Error('expected an assistant message with a tool call'); + } + if (toolMessage?.role !== 'tool') { + throw new Error('expected a tool result message'); + } + + const generatedID = assistantMessage.tool_calls[0].id; + expect(generatedID).toMatch(/^call_/); + expect(toolMessage.tool_call_id).toBe(generatedID); + + for (const choice of contentChoiceDeltas(`it's raining`)) { + yield { + id: '2', + choices: [choice], + created: Math.floor(Date.now() / 1000), + model: 'gpt-3.5-turbo', + object: 'chat.completion.chunk', + }; + } + }), + runner.done(), + ]); + + await expect(runner.finalFunctionToolCallResult()).resolves.toBe(`it's raining`); + }); test('flow with abort', async () => { const { fetch, handleRequest } = mockStreamingChatCompletionFetch();