From a048747853f65a7fb809637d391a189cd5cdc45b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 21:54:56 +0100 Subject: [PATCH 1/2] feat(ai-proxy): add parallel_tool_calls support Add parallel_tool_calls parameter to DispatchBody to match OpenAI API. When set to false, the model will call at most one tool per request. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/provider-dispatcher.ts | 17 +++++++- .../ai-proxy/test/provider-dispatcher.test.ts | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 5f04f3288..d511794fa 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -77,6 +77,7 @@ export type DispatchBody = { messages: ChatCompletionMessage[]; tools?: ChatCompletionTool[]; tool_choice?: ChatCompletionToolChoice; + parallel_tool_calls?: boolean; }; export class ProviderDispatcher { @@ -101,10 +102,20 @@ export class ProviderDispatcher { throw new AINotConfiguredError(); } - const { tools, messages, tool_choice: toolChoice } = body; + const { + tools, + messages, + tool_choice: toolChoice, + parallel_tool_calls: parallelToolCalls, + } = body; const enrichedTools = this.enrichToolDefinitions(tools); - const model = this.bindToolsIfNeeded(this.chatModel, enrichedTools, toolChoice); + const model = this.bindToolsIfNeeded( + this.chatModel, + enrichedTools, + toolChoice, + parallelToolCalls, + ); try { const response = await model.invoke(messages as BaseMessageLike[]); @@ -140,6 +151,7 @@ export class ProviderDispatcher { chatModel: ChatOpenAI, tools: ChatCompletionTool[] | undefined, toolChoice?: ChatCompletionToolChoice, + parallelToolCalls?: boolean, ) { if (!tools || tools.length === 0) { return chatModel; @@ -147,6 +159,7 @@ export class ProviderDispatcher { return chatModel.bindTools(tools, { tool_choice: toolChoice as 'auto' | 'none' | 'required' | undefined, + parallel_tool_calls: parallelToolCalls, }); } diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index c819fb1bc..cbe761325 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -218,6 +218,45 @@ describe('ProviderDispatcher', () => { }); }); + describe('when parallel_tool_calls is provided', () => { + it('should pass parallel_tool_calls to bindTools', async () => { + const dispatcher = new ProviderDispatcher( + { name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, + new RemoteTools(apiKeys), + ); + + await dispatcher.dispatch({ + messages: [{ role: 'user', content: 'test' }], + tools: [{ type: 'function', function: { name: 'test', parameters: {} } }], + tool_choice: 'auto', + parallel_tool_calls: false, + } as unknown as DispatchBody); + + expect(bindToolsMock).toHaveBeenCalledWith( + [{ type: 'function', function: { name: 'test', parameters: {} } }], + { tool_choice: 'auto', parallel_tool_calls: false }, + ); + }); + + it('should pass parallel_tool_calls: true when explicitly set', async () => { + const dispatcher = new ProviderDispatcher( + { name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, + new RemoteTools(apiKeys), + ); + + await dispatcher.dispatch({ + messages: [{ role: 'user', content: 'test' }], + tools: [{ type: 'function', function: { name: 'test', parameters: {} } }], + parallel_tool_calls: true, + } as unknown as DispatchBody); + + expect(bindToolsMock).toHaveBeenCalledWith( + expect.any(Array), + { tool_choice: undefined, parallel_tool_calls: true }, + ); + }); + }); + describe('when there is not remote tool', () => { it('should not enhance the remote tools definition', async () => { const remoteTools = new RemoteTools(apiKeys); From 5a99e1767bedf1ec323d87cae7cf304e24e737ba Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 22:22:50 +0100 Subject: [PATCH 2/2] refactor(ai-proxy): inline bindToolsIfNeeded method Simplify dispatch() by inlining the tools binding logic directly, removing unnecessary method indirection for simple null check. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/provider-dispatcher.ts | 28 +++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index d511794fa..ca0df1096 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -110,12 +110,12 @@ export class ProviderDispatcher { } = body; const enrichedTools = this.enrichToolDefinitions(tools); - const model = this.bindToolsIfNeeded( - this.chatModel, - enrichedTools, - toolChoice, - parallelToolCalls, - ); + const model = enrichedTools?.length + ? this.chatModel.bindTools(enrichedTools, { + tool_choice: toolChoice, + parallel_tool_calls: parallelToolCalls, + }) + : this.chatModel; try { const response = await model.invoke(messages as BaseMessageLike[]); @@ -147,22 +147,6 @@ export class ProviderDispatcher { } } - private bindToolsIfNeeded( - chatModel: ChatOpenAI, - tools: ChatCompletionTool[] | undefined, - toolChoice?: ChatCompletionToolChoice, - parallelToolCalls?: boolean, - ) { - if (!tools || tools.length === 0) { - return chatModel; - } - - return chatModel.bindTools(tools, { - tool_choice: toolChoice as 'auto' | 'none' | 'required' | undefined, - parallel_tool_calls: parallelToolCalls, - }); - } - private enrichToolDefinitions(tools?: ChatCompletionTool[]) { if (!tools || !Array.isArray(tools)) return tools;