Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/lib/AbstractChatCompletionRunner.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +72,7 @@ export class AbstractChatCompletionRunner<
this: AbstractChatCompletionRunner<AbstractChatCompletionRunnerEvents, ParsedT>,
chatCompletion: ParsedChatCompletion<ParsedT>,
): ParsedChatCompletion<ParsedT> {
normalizeToolCallIds(chatCompletion);
this._chatCompletions.push(chatCompletion);
this._emit('chatCompletion', chatCompletion);
const message = chatCompletion.choices[0]?.message;
Expand Down
11 changes: 7 additions & 4 deletions src/lib/ChatCompletionStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -657,9 +658,6 @@ function finalizeChatCompletion<ParsedT>(
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)}`);
}
Expand All @@ -674,7 +672,12 @@ function finalizeChatCompletion<ParsedT>(
);
}

return { ...toolRest, id, type, function: { ...fnRest, name, arguments: args } };
return {
...toolRest,
id: id || `call_${uuid4()}`,
type,
function: { ...fnRest, name, arguments: args },
};
}),
},
};
Expand Down
150 changes: 150 additions & 0 deletions tests/lib/ChatCompletionRunFunctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<OpenAI.Chat.ChatCompletionChunk> {
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<OpenAI.Chat.ChatCompletionChunk> {
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();

Expand Down
Loading