Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
111 changes: 77 additions & 34 deletions src/lib/ChatCompletionStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,6 @@ export class ChatCompletionStream<ParsedT = null>
parsed_arguments: toolCallSnapshot.function.parsed_arguments,
arguments_delta: toolCallDelta.function?.arguments ?? '',
});
} else {
assertNever(toolCallSnapshot?.type);
}
}
}
Expand Down Expand Up @@ -386,8 +384,6 @@ export class ChatCompletionStream<ParsedT = null>
: inputTool?.function.strict ? JSON.parse(toolCallSnapshot.function.arguments)
: null,
});
} else {
assertNever(toolCallSnapshot.type);
}
}

Expand Down Expand Up @@ -639,19 +635,25 @@ export class ChatCompletionStream<ParsedT = null>
if (tool_calls) {
if (!choice.message.tool_calls) choice.message.tool_calls = [];

for (const { index, id, type, function: fn, ...rest } of tool_calls) {
const tool_call = (choice.message.tool_calls[index] ??=
{} as ChatCompletionSnapshot.Choice.Message.ToolCall);
for (const { index, id, type, function: fn, custom, ...rest } of tool_calls as any[]) {
const tool_call: any = (choice.message.tool_calls[index] ??= {} as any);
Object.assign(tool_call, rest);
if (id) tool_call.id = id;
if (type) tool_call.type = type;
if (custom) {
tool_call.custom ??= { name: custom.name ?? '', input: '' };
if (custom.name) tool_call.custom.name = custom.name;
if (custom.input) {
tool_call.custom.input += custom.input;
}
}
if (fn) tool_call.function ??= { name: fn.name ?? '', arguments: '' };
if (fn?.name) tool_call.function!.name = fn.name;
if (fn?.name) tool_call.function.name = fn.name;
if (fn?.arguments) {
tool_call.function!.arguments += fn.arguments;
tool_call.function.arguments += fn.arguments;

if (shouldParseToolCall(this.#params, tool_call)) {
tool_call.function!.parsed_arguments = partialParse(tool_call.function!.arguments);
tool_call.function.parsed_arguments = partialParse(tool_call.function.arguments);
}
}
}
Expand Down Expand Up @@ -790,12 +792,27 @@ function finalizeChatCompletion<ParsedT>(
role,
content,
refusal: message.refusal ?? null,
tool_calls: tool_calls.map((tool_call, i) => {
const { function: fn, type, id, ...toolRest } = tool_call;
const { arguments: args, name, ...fnRest } = fn || {};
tool_calls: tool_calls.map((tool_call: any, i) => {
const { function: fn, custom, type, id, ...toolRest } = tool_call;
if (type == null) {
throw new OpenAIError(`missing choices[${index}].tool_calls[${i}].type\n${str(snapshot)}`);
}
if (type === 'custom') {
const { input = '', name, ...customRest } = custom || {};
if (name == null) {
throw new OpenAIError(
`missing choices[${index}].tool_calls[${i}].custom.name\n${str(snapshot)}`,
);
}
return {
...toolRest,
id: id || `call_${uuid4()}`,
type,
custom: { ...customRest, name, input },
};
}

const { arguments: args, name, ...fnRest } = fn || {};
if (name == null) {
throw new OpenAIError(
`missing choices[${index}].tool_calls[${i}].function.name\n${str(snapshot)}`,
Expand Down Expand Up @@ -942,36 +959,62 @@ export namespace ChatCompletionSnapshot {
}

export namespace Message {
export interface ToolCall {
/**
* The ID of the tool call.
*/
id: string;
export type ToolCall = ToolCall.FunctionToolCall | ToolCall.CustomToolCall;

function: ToolCall.Function;
export namespace ToolCall {
export interface FunctionToolCall {
/**
* The ID of the tool call.
*/
id: string;

/**
* The type of the tool.
*/
type: 'function';
}
function: FunctionToolCall.Function;

export namespace ToolCall {
export interface Function {
/**
* The arguments to call the function with, as generated by the model in JSON
* format. Note that the model does not always generate valid JSON, and may
* hallucinate parameters not defined by your function schema. Validate the
* arguments in your code before calling your function.
* The type of the tool.
*/
arguments: string;
type: 'function';
}

parsed_arguments?: unknown;
export namespace FunctionToolCall {
export interface Function {
/**
* The arguments to call the function with, as generated by the model in JSON
* format. Note that the model does not always generate valid JSON, and may
* hallucinate parameters not defined by your function schema. Validate the
* arguments in your code before calling your function.
*/
arguments: string;

parsed_arguments?: unknown;

/**
* The name of the function to call.
*/
name: string;
}
}

export interface CustomToolCall {
/**
* The name of the function to call.
* The ID of the tool call.
*/
name: string;
id: string;

custom: CustomToolCall.Custom;

/**
* The type of the tool.
*/
type: 'custom';
}

export namespace CustomToolCall {
export interface Custom {
name: string;

input: string;
}
}
}

Expand Down
23 changes: 14 additions & 9 deletions src/lib/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ChatCompletionFunctionTool,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageToolCall,
ChatCompletionStreamingToolRunnerParams,
ChatCompletionStreamingToolRunnerParamsWithContext,
ChatCompletionStreamParams,
Expand Down Expand Up @@ -158,16 +159,18 @@ export function maybeParseChatCompletion<
return {
...completion,
choices: completion.choices.map((choice) => {
assertToolCallsAreChatCompletionFunctionToolCalls(choice.message.tool_calls);

return {
...choice,
message: {
...choice.message,
parsed: null,
...(choice.message.tool_calls ?
{
tool_calls: choice.message.tool_calls,
tool_calls: choice.message.tool_calls.map((toolCall) =>
toolCall.type === 'function' ?
{ ...toolCall, function: { ...toolCall.function, parsed_arguments: null } }
: toolCall,
),
}
: undefined),
},
Expand All @@ -192,8 +195,6 @@ export function parseChatCompletion<
throw new ContentFilterFinishReasonError();
}

assertToolCallsAreChatCompletionFunctionToolCalls(choice.message.tool_calls);

return {
...choice,
message: {
Expand Down Expand Up @@ -238,8 +239,12 @@ function parseResponseFormat<

function parseToolCall<Params extends ChatCompletionCreateParams>(
params: Params,
toolCall: ChatCompletionMessageFunctionToolCall,
): ParsedFunctionToolCall {
toolCall: ChatCompletionMessageToolCall,
): ParsedFunctionToolCall | ChatCompletionMessageToolCall {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include custom calls in the parsed completion type

This return type now admits a custom tool call, but the public ParsedChatCompletionMessage.tool_calls property remains Array<ParsedFunctionToolCall> in src/resources/chat/completions/completions.ts:286-288. Thus TypeScript consumers of .parse() or stream.finalChatCompletion() cannot narrow to or access .custom, while the declared type incorrectly guarantees that .function exists; the parsed tool-call type needs to be a union that preserves custom calls.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Git)

if (toolCall.type !== 'function') {
return toolCall;
Comment on lines +240 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Permit custom tools through the parse validator

When chat.completions.parse() is given the custom tool needed to produce this call, it invokes validateInputTools(body.tools) before making the request, and that validator still throws for every non-function tool (src/resources/chat/completions/completions.ts:171-185, src/lib/parser.ts:306-312). Consequently this new pass-through branch is unreachable through the advertised .parse() helper; the validator should allow custom tools through without attempting to auto-parse them while retaining strictness checks for function tools.

Useful? React with 👍 / 👎.

}

const inputTool = params.tools?.find(
(inputTool) =>
isChatCompletionFunctionTool(inputTool) && inputTool.function?.name === toolCall.function.name,
Expand All @@ -258,9 +263,9 @@ function parseToolCall<Params extends ChatCompletionCreateParams>(

export function shouldParseToolCall(
params: ChatCompletionCreateParams | null | undefined,
toolCall: ChatCompletionMessageFunctionToolCall,
toolCall: ChatCompletionMessageToolCall,
): boolean {
if (!params || !('tools' in params) || !params.tools) {
if (!params || !('tools' in params) || !params.tools || toolCall.type !== 'function') {
return false;
}

Expand Down
83 changes: 83 additions & 0 deletions tests/lib/ChatCompletionStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,4 +753,87 @@ describe('.stream()', () => {
`);
expect(capturedLogProbs?.length).toEqual(choice?.logprobs?.refusal?.length);
});

it('handles custom tool calls in stream chunks', async () => {
const chunk1: OpenAI.Chat.ChatCompletionChunk = {
id: 'chatcmpl-stream-custom',
object: 'chat.completion.chunk',
created: 100,
model: 'gpt-4o',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'call_custom_123',
type: 'custom' as any,
custom: {
name: 'my_custom_tool',
input: '{"foo":',
},
} as any,
],
},
finish_reason: null,
},
],
};

const chunk2: OpenAI.Chat.ChatCompletionChunk = {
id: 'chatcmpl-stream-custom',
object: 'chat.completion.chunk',
created: 101,
model: 'gpt-4o',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
custom: {
input: '"bar"}',
},
} as any,
],
},
finish_reason: 'stop',
},
],
};

const client = {
chat: {
completions: {
create: jest.fn(async () => ({
controller: new AbortController(),
async *[Symbol.asyncIterator]() {
yield chunk1;
yield chunk2;
},
})),
},
},
} as unknown as OpenAI;

const stream = ChatCompletionStream.createChatCompletion(client, {
model: 'gpt-4o',
messages: [{ role: 'user', content: 'test' }],
});

const completion = await stream.finalChatCompletion();
expect(completion.choices[0]?.message.tool_calls).toEqual([
{
id: 'call_custom_123',
type: 'custom',
custom: {
name: 'my_custom_tool',
input: '{"foo":"bar"}',
},
},
]);
});
});
46 changes: 46 additions & 0 deletions tests/lib/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,3 +1406,49 @@ describe.each([
});
});
});

describe('custom tool calls', () => {
it('parses chat completion with custom tool calls without error', () => {
const { maybeParseChatCompletion } = require('openai/lib/parser');
const completion = {
id: 'chatcmpl-custom-1',
object: 'chat.completion',
created: 123456789,
model: 'gpt-4o',
choices: [
{
index: 0,
finish_reason: 'stop',
logprobs: null,
message: {
role: 'assistant',
content: null,
refusal: null,
tool_calls: [
{
id: 'call_custom_1',
type: 'custom',
custom: {
name: 'my_custom_tool',
input: '{"key":"value"}',
},
},
],
},
},
],
};

const parsed = maybeParseChatCompletion(completion, null);
expect(parsed.choices[0].message.tool_calls).toEqual([
{
id: 'call_custom_1',
type: 'custom',
custom: {
name: 'my_custom_tool',
input: '{"key":"value"}',
},
},
]);
});
});