From 57dd8c1d4cdc00c410bdfdab92e003e5b3efbfd6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 10 Jul 2026 11:15:01 +0200 Subject: [PATCH 1/5] fix(ai-proxy): use the OpenAI Responses API for tool calling OpenAI reasoning models (gpt-5.x) reject function tools combined with reasoning_effort on /v1/chat/completions (400). Set useResponsesApi: true on both ChatOpenAI construction sites (ProviderDispatcher + createBaseChatModel), so tools + reasoning work together. A user-supplied useResponsesApi still wins. The OpenAI proxy path no longer reads additional_kwargs.__raw_response (the Responses API returns a different raw shape); it now builds the chat.completion wire shape from the normalized AIMessage via LangChainAdapter.convertResponse, the same path already used for Anthropic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-proxy/src/create-base-chat-model.ts | 4 +- packages/ai-proxy/src/provider-dispatcher.ts | 20 ++--- .../test/create-base-chat-model.test.ts | 2 + .../ai-proxy/test/provider-dispatcher.test.ts | 81 +++++++++++-------- 4 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/ai-proxy/src/create-base-chat-model.ts b/packages/ai-proxy/src/create-base-chat-model.ts index 78d97ea5d1..ca8c2e3db3 100644 --- a/packages/ai-proxy/src/create-base-chat-model.ts +++ b/packages/ai-proxy/src/create-base-chat-model.ts @@ -11,7 +11,9 @@ export function createBaseChatModel(config: AiConfiguration): BaseChatModel { if (config.provider === 'openai') { const { provider, name, ...opts } = config; - return new ChatOpenAI({ maxRetries: 0, ...opts }); + // Use the Responses API: reasoning models (gpt-5.x) reject tools + reasoning_effort on + // /v1/chat/completions. A user-supplied useResponsesApi still wins (spread after). + return new ChatOpenAI({ maxRetries: 0, useResponsesApi: true, ...opts }); } if (config.provider === 'anthropic') { diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 7019ea932e..e147e72f0e 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -4,7 +4,7 @@ import type { RemoteTools } from './remote-tools'; import type { DispatchBody } from './schemas/route'; import type { AIMessage, BaseMessageLike } from '@langchain/core/messages'; -import { BusinessError, UnprocessableError } from '@forestadmin/datasource-toolkit'; +import { BusinessError } from '@forestadmin/datasource-toolkit'; import { ChatAnthropic } from '@langchain/anthropic'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; import { ChatOpenAI } from '@langchain/openai'; @@ -51,9 +51,12 @@ export default class ProviderDispatcher { const { provider, name, ...chatOpenAIOptions } = configuration; this.openaiModel = new ChatOpenAI({ maxRetries: 0, // No retries by default - this lib is a passthrough + // Use the Responses API: reasoning models (gpt-5.x) reject tools + reasoning_effort on + // /v1/chat/completions. A user-supplied useResponsesApi still wins (spread after). + useResponsesApi: true, ...chatOpenAIOptions, - __includeRawResponse: true, }); + this.modelName = configuration.model ?? null; } else if (configuration?.provider === 'anthropic') { const { provider, name, model, ...clientOptions } = configuration; this.anthropicModel = new ChatAnthropic({ @@ -105,16 +108,9 @@ export default class ProviderDispatcher { throw ProviderDispatcher.wrapProviderError(error, 'OpenAI'); } - // eslint-disable-next-line no-underscore-dangle - const rawResponse = response.additional_kwargs.__raw_response as ChatCompletionResponse; - - if (!rawResponse) { - throw new UnprocessableError( - 'OpenAI response missing raw response data. This may indicate an API change.', - ); - } - - return rawResponse; + // Build the OpenAI wire shape from LangChain's normalized AIMessage (same path as Anthropic). + // The Responses API returns a different raw object, so we no longer read __raw_response. + return LangChainAdapter.convertResponse(response, this.modelName); } private async dispatchAnthropic(body: DispatchBody): Promise { diff --git a/packages/ai-proxy/test/create-base-chat-model.test.ts b/packages/ai-proxy/test/create-base-chat-model.test.ts index 6da3e41af3..8814433588 100644 --- a/packages/ai-proxy/test/create-base-chat-model.test.ts +++ b/packages/ai-proxy/test/create-base-chat-model.test.ts @@ -29,6 +29,7 @@ describe('createBaseChatModel', () => { expect(ChatOpenAI).toHaveBeenCalledWith({ maxRetries: 0, + useResponsesApi: true, apiKey: 'test-key', model: 'gpt-4o', }); @@ -47,6 +48,7 @@ describe('createBaseChatModel', () => { expect(ChatOpenAI).toHaveBeenCalledWith({ maxRetries: 0, + useResponsesApi: true, apiKey: 'test-key', model: 'gpt-4o', temperature: 0.7, diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 8bfdd12c1b..e853fd0935 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -18,36 +18,13 @@ import { } from '../src'; import ServerRemoteTool from '../src/server-remote-tool'; -// Mock raw OpenAI response (returned via __includeRawResponse: true) -const mockOpenAIResponse = { - id: 'chatcmpl-123', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4o', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'response', - tool_calls: [], - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 20, - total_tokens: 30, - }, -}; - +// Normalized LangChain AIMessage — the dispatcher converts it to the OpenAI wire shape +// via LangChainAdapter.convertResponse (the Responses API no longer exposes __raw_response). const invokeMock = jest.fn().mockResolvedValue({ content: 'response', tool_calls: [], response_metadata: { finish_reason: 'stop' }, usage_metadata: { input_tokens: 10, output_tokens: 20, total_tokens: 30 }, - additional_kwargs: { __raw_response: mockOpenAIResponse }, }); const bindToolsMock = jest.fn().mockReturnValue({ invoke: invokeMock }); @@ -129,10 +106,36 @@ describe('ProviderDispatcher', () => { dispatcher = new ProviderDispatcher(openaiConfig, new RemoteTools()); }); - it('should return the raw OpenAI response', async () => { + it('converts the OpenAI response to the chat.completion wire shape', async () => { const response = await dispatcher.dispatch(buildBody()); - expect(response).toEqual(mockOpenAIResponse); + expect(response).toMatchObject({ + object: 'chat.completion', + model: 'gpt-4o', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'response' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }); + }); + + it('builds the OpenAI model with the Responses API enabled', async () => { + expect(ChatOpenAI).toHaveBeenCalledWith(expect.objectContaining({ useResponsesApi: true })); + }); + + it('lets an explicit useResponsesApi config win over the default', async () => { + new ProviderDispatcher( + { ...openaiConfig, useResponsesApi: false } as never, + new RemoteTools(), + ); + + expect(ChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ useResponsesApi: false }), + ); }); it('should not forward user-supplied model or arbitrary properties to the LLM', async () => { @@ -237,15 +240,29 @@ describe('ProviderDispatcher', () => { expect(thrown.cause).toBe(error); }); - it('should throw when rawResponse is missing', async () => { + it('converts tool calls into the response (finish_reason: tool_calls)', async () => { invokeMock.mockResolvedValueOnce({ - content: 'response', - additional_kwargs: { __raw_response: null }, + content: '', + tool_calls: [{ id: 'call_1', name: 'calculate', args: { a: 1 } }], + usage_metadata: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, }); - await expect(dispatcher.dispatch(buildBody())).rejects.toThrow( - 'OpenAI response missing raw response data. This may indicate an API change.', + const response = await dispatcher.dispatch( + buildBody({ + tools: [{ type: 'function', function: { name: 'calculate', parameters: {} } }], + messages: [{ role: 'user', content: 'calc' }], + tool_choice: 'required', + }), ); + + expect(response.choices[0].finish_reason).toBe('tool_calls'); + expect(response.choices[0].message.tool_calls).toEqual([ + { + id: 'call_1', + type: 'function', + function: { name: 'calculate', arguments: JSON.stringify({ a: 1 }) }, + }, + ]); }); }); From f66815a105807d5a3569da7b377bc630b3d2d8c7 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 10 Jul 2026 11:39:00 +0200 Subject: [PATCH 2/5] fix(ai-proxy): force single-tool tool_choice under the Responses API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LangChain's Responses API path silently drops string tool_choice values ('required'/'any') — its param builder forwards only object-form choices. Under the global Responses switch this made ~20 OpenAI models return text instead of a tool call (finish_reason !== 'tool_calls'), failing the LLM integration test. Re-express "must call a tool" as forcing the single available function, the only forcing shape the Responses path forwards. Also deny-list gpt-3.5-turbo-16k (removed from the API, 404) and fix a date-fragile assertion in the load-related-record executor test (-1 matched dates like 2026-07-10; anchor on 'return -1'). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ai-proxy/src/provider-dispatcher.ts | 26 ++++++++++++-- packages/ai-proxy/src/supported-models.ts | 5 +-- .../ai-proxy/test/provider-dispatcher.test.ts | 35 +++++++++++++++++++ .../load-related-record-step-executor.test.ts | 2 +- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index e147e72f0e..898c90272f 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -1,5 +1,10 @@ import type { OpenAIMessage } from './langchain-adapter'; -import type { AiConfiguration, ChatCompletionResponse, ChatCompletionTool } from './provider'; +import type { + AiConfiguration, + ChatCompletionResponse, + ChatCompletionTool, + ChatCompletionToolChoice, +} from './provider'; import type { RemoteTools } from './remote-tools'; import type { DispatchBody } from './schemas/route'; import type { AIMessage, BaseMessageLike } from '@langchain/core/messages'; @@ -95,7 +100,7 @@ export default class ProviderDispatcher { const enrichedTools = this.enrichToolDefinitions(tools); const model = enrichedTools?.length ? this.openaiModel.bindTools(enrichedTools, { - tool_choice: toolChoice, + tool_choice: ProviderDispatcher.forceToolChoiceForResponses(toolChoice, enrichedTools), parallel_tool_calls: parallelToolCalls, }) : this.openaiModel; @@ -143,6 +148,23 @@ export default class ProviderDispatcher { return LangChainAdapter.convertResponse(response, this.modelName); } + // LangChain's Responses API path silently drops string tool_choice ('required'/'any'): its + // param builder forwards only object-form choices. Re-express "must call a tool" as forcing the + // single available function — the only forcing shape it forwards. Multi-tool 'required' has no + // such shape under the Responses API and falls back to the model default. + private static forceToolChoiceForResponses( + toolChoice: unknown, + tools: ChatCompletionTool[], + ): ChatCompletionToolChoice { + const functionTools = tools.filter(tool => tool.type === 'function'); + + if ((toolChoice === 'required' || toolChoice === 'any') && functionTools.length === 1) { + return { type: 'function', function: { name: functionTools[0].function.name } }; + } + + return toolChoice as ChatCompletionToolChoice; + } + private static wrapProviderError(error: unknown, providerName: string): Error { if (error instanceof BusinessError) return error; diff --git a/packages/ai-proxy/src/supported-models.ts b/packages/ai-proxy/src/supported-models.ts index d0abf01f72..250b24dc45 100644 --- a/packages/ai-proxy/src/supported-models.ts +++ b/packages/ai-proxy/src/supported-models.ts @@ -46,10 +46,7 @@ const OPENAI_UNSUPPORTED_PATTERNS = [ const OPENAI_UNSUPPORTED_MODELS = [ 'us-40-51r-vm-ev3', // Not a chat model (v1/completions only) - // Reject reasoning_effort with function tools on v1/chat/completions (v1/responses only) - 'gpt-5.6-luna', - 'gpt-5.6-sol', - 'gpt-5.6-terra', + 'gpt-3.5-turbo-16k', // 404 Model not found (removed from the API) ]; const OPENAI_SUPPORTED_OVERRIDES = ['gpt-4-turbo', 'gpt-4o', 'gpt-4.1']; diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index e853fd0935..1de0604271 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -347,6 +347,41 @@ describe('ProviderDispatcher', () => { }); }); }); + + describe('tool_choice: required (Responses API)', () => { + it('forces the single available function so the Responses API honors the request', async () => { + await dispatcher.dispatch( + buildBody({ + tools: [{ type: 'function', function: { name: 'calculate', parameters: {} } }], + messages: [{ role: 'user', content: 'test' }], + tool_choice: 'required', + }), + ); + + expect(bindToolsMock).toHaveBeenCalledWith(expect.any(Array), { + tool_choice: { type: 'function', function: { name: 'calculate' } }, + parallel_tool_calls: undefined, + }); + }); + + it('leaves required untouched when several tools are available', async () => { + await dispatcher.dispatch( + buildBody({ + tools: [ + { type: 'function', function: { name: 'a', parameters: {} } }, + { type: 'function', function: { name: 'b', parameters: {} } }, + ], + messages: [{ role: 'user', content: 'test' }], + tool_choice: 'required', + }), + ); + + expect(bindToolsMock).toHaveBeenCalledWith(expect.any(Array), { + tool_choice: 'required', + parallel_tool_calls: undefined, + }); + }); + }); }); describe('anthropic', () => { diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index 4f1c21d3e8..9a53573e56 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -1368,7 +1368,7 @@ describe('LoadRelatedRecordStepExecutor', () => { // an impossible request returns a weak match instead of -1. const recordSelectionMessages = invoke.mock.calls[1][0] as Array<{ content: unknown }>; const content = recordSelectionMessages.map(m => String(m.content)).join('\n'); - expect(content).toContain('-1'); + expect(content).toContain('return -1'); }); }); From 4201e920278e8aca3156b5ac2f5579fa4588f144 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 10 Jul 2026 11:53:16 +0200 Subject: [PATCH 3/5] fix(ai-proxy): reject empty messages and never bind tools for tool_choice none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Responses API rejects an empty input with its own wording, breaking the proxy's error contract — validate messages at the schema boundary instead. tool_choice 'none' is another string value LangChain's Responses path drops; not binding the tools is the only way to guarantee the model cannot call them. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ai-proxy/CLAUDE.md | 2 +- packages/ai-proxy/src/provider-dispatcher.ts | 15 +++-- packages/ai-proxy/src/schemas/route.ts | 8 ++- .../ai-proxy/test/provider-dispatcher.test.ts | 60 +++++++++++++++++++ packages/ai-proxy/test/router.test.ts | 30 +++++++--- packages/ai-proxy/test/schemas/route.test.ts | 10 ++++ 6 files changed, 108 insertions(+), 17 deletions(-) diff --git a/packages/ai-proxy/CLAUDE.md b/packages/ai-proxy/CLAUDE.md index 2f5a3016ea..f0b91f94df 100644 --- a/packages/ai-proxy/CLAUDE.md +++ b/packages/ai-proxy/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **`invoke-remote-tool`** — execute one tool by sanitized name (+ optional `source-id` to disambiguate). - **`remote-tools`** — return tool definitions (JSON-schema) for the frontend. -**Provider dispatch** (`provider-dispatcher.ts`): constructs a `ChatOpenAI` or `ChatAnthropic` (LangChain) from the config. OpenAI's raw response is plucked from `additional_kwargs.__raw_response` (requires `__includeRawResponse: true`); Anthropic responses are converted back to OpenAI shape via `LangChainAdapter`/`AnthropicAdapter`. The OpenAI Chat Completions format is the canonical wire format on both ends. Provider SDK errors are normalized to the AI error classes by `wrapProviderError` (maps HTTP 400/401/403/429/5xx). +**Provider dispatch** (`provider-dispatcher.ts`): constructs a `ChatOpenAI` or `ChatAnthropic` (LangChain) from the config. OpenAI runs on the **Responses API** (`useResponsesApi: true` — reasoning models reject tools + reasoning_effort on `/v1/chat/completions`); both providers' responses are converted back to OpenAI chat.completion shape via `LangChainAdapter.convertResponse` (Anthropic messages additionally via `AnthropicAdapter`). The OpenAI Chat Completions format is the canonical wire format on both ends. Caveat: LangChain's Responses path silently drops string `tool_choice` values — the dispatcher compensates (`'required'`/`'any'` with a single tool → forced function; `'none'` → tools not bound). Provider SDK errors are normalized to the AI error classes by `wrapProviderError` (maps HTTP 400/401/403/429/5xx). **Tool providers** (`tool-provider.ts` interface: `loadTools` / `checkConnection` / `dispose`): `createToolProviders` (`tool-provider-factory.ts`) splits the per-request `toolConfigs` map into two `ToolProvider`s — `McpClient` (`mcp-client.ts`, wraps `@langchain/mcp-adapters`, one `MultiServerMCPClient` per server for fault isolation) and `ForestIntegrationClient` (`forest-integration-client.ts`, dispatches `integrationName` → Zendesk / Kolar / Snowflake tool sets under `integrations/`). The two are distinguished by the `isForestConnector: true` flag. Loaded tools are wrapped as `RemoteTool` subclasses and collected into `RemoteTools`. Providers are created per-request and `dispose()`d in a `finally`. diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 898c90272f..01a01b931a 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -98,12 +98,15 @@ export default class ProviderDispatcher { } = body; const enrichedTools = this.enrichToolDefinitions(tools); - const model = enrichedTools?.length - ? this.openaiModel.bindTools(enrichedTools, { - tool_choice: ProviderDispatcher.forceToolChoiceForResponses(toolChoice, enrichedTools), - parallel_tool_calls: parallelToolCalls, - }) - : this.openaiModel; + // 'none' is also dropped by LangChain's Responses path — not binding the tools is the only + // way to guarantee the model cannot call them. + const model = + enrichedTools?.length && toolChoice !== 'none' + ? this.openaiModel.bindTools(enrichedTools, { + tool_choice: ProviderDispatcher.forceToolChoiceForResponses(toolChoice, enrichedTools), + parallel_tool_calls: parallelToolCalls, + }) + : this.openaiModel; let response: AIMessage; diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index 0b0c742df8..21961be2c1 100644 --- a/packages/ai-proxy/src/schemas/route.ts +++ b/packages/ai-proxy/src/schemas/route.ts @@ -17,9 +17,11 @@ const baseQuerySchema = z.object({ */ const aiQueryBodySchema = z.object( { - messages: z.array(z.any(), { - message: 'Missing required body parameter: messages', - }), + messages: z + .array(z.any(), { + message: 'Missing required body parameter: messages', + }) + .min(1, { message: 'messages must contain at least one message' }), tools: z.array(z.any()).optional(), tool_choice: z.any().optional(), parallel_tool_calls: z.boolean().optional(), diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 1de0604271..653fba6bf2 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -381,6 +381,66 @@ describe('ProviderDispatcher', () => { parallel_tool_calls: undefined, }); }); + + it('treats the any alias like required', async () => { + await dispatcher.dispatch( + buildBody({ + tools: [{ type: 'function', function: { name: 'calculate', parameters: {} } }], + messages: [{ role: 'user', content: 'test' }], + tool_choice: 'any', + }), + ); + + expect(bindToolsMock).toHaveBeenCalledWith(expect.any(Array), { + tool_choice: { type: 'function', function: { name: 'calculate' } }, + parallel_tool_calls: undefined, + }); + }); + + it('counts only function tools when deciding to force', async () => { + await dispatcher.dispatch( + buildBody({ + tools: [ + { type: 'function', function: { name: 'calculate', parameters: {} } }, + { type: 'custom', custom: { name: 'other' } }, + ], + messages: [{ role: 'user', content: 'test' }], + tool_choice: 'required', + }), + ); + + expect(bindToolsMock).toHaveBeenCalledWith(expect.any(Array), { + tool_choice: { type: 'function', function: { name: 'calculate' } }, + parallel_tool_calls: undefined, + }); + }); + + it('does not force tool_choice auto', async () => { + await dispatcher.dispatch( + buildBody({ + tools: [{ type: 'function', function: { name: 'calculate', parameters: {} } }], + messages: [{ role: 'user', content: 'test' }], + tool_choice: 'auto', + }), + ); + + expect(bindToolsMock).toHaveBeenCalledWith(expect.any(Array), { + tool_choice: 'auto', + parallel_tool_calls: undefined, + }); + }); + + it('does not bind tools at all for tool_choice none', async () => { + await dispatcher.dispatch( + buildBody({ + tools: [{ type: 'function', function: { name: 'calculate', parameters: {} } }], + messages: [{ role: 'user', content: 'test' }], + tool_choice: 'none', + }), + ); + + expect(bindToolsMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 98578993ad..7dfad79b4b 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -62,13 +62,17 @@ describe('route', () => { await router.route({ route: 'ai-query', - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { + tools: [], + tool_choice: 'required', + messages: [{ role: 'user', content: 'hi' }], + } as unknown as DispatchBody, }); expect(dispatchMock).toHaveBeenCalledWith({ tools: [], tool_choice: 'required', - messages: [], + messages: [{ role: 'user', content: 'hi' }], }); }); @@ -92,7 +96,11 @@ describe('route', () => { await router.route({ route: 'ai-query', query: { 'ai-name': 'gpt4mini' }, - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { + tools: [], + tool_choice: 'required', + messages: [{ role: 'user', content: 'hi' }], + } as unknown as DispatchBody, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4MiniConfig, expect.anything()); @@ -117,7 +125,11 @@ describe('route', () => { await router.route({ route: 'ai-query', - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { + tools: [], + tool_choice: 'required', + messages: [{ role: 'user', content: 'hi' }], + } as unknown as DispatchBody, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4Config, expect.anything()); @@ -139,7 +151,11 @@ describe('route', () => { await router.route({ route: 'ai-query', query: { 'ai-name': 'non-existent' }, - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { + tools: [], + tool_choice: 'required', + messages: [{ role: 'user', content: 'hi' }], + } as unknown as DispatchBody, }); expect(mockLogger).toHaveBeenCalledWith( @@ -272,7 +288,7 @@ describe('route', () => { await expect( router.route({ route: 'ai-query', - body: { messages: [] }, + body: { messages: [{ role: 'user', content: 'hi' }] }, toolConfigs: dummyMcpServerConfigs, }), ).rejects.toThrow(); @@ -301,7 +317,7 @@ describe('route', () => { await expect( router.route({ route: 'ai-query', - body: { messages: [] }, + body: { messages: [{ role: 'user', content: 'hi' }] }, toolConfigs: dummyMcpServerConfigs, }), ).rejects.toThrow(dispatchError); diff --git a/packages/ai-proxy/test/schemas/route.test.ts b/packages/ai-proxy/test/schemas/route.test.ts index e0d1464b1a..1021e9d008 100644 --- a/packages/ai-proxy/test/schemas/route.test.ts +++ b/packages/ai-proxy/test/schemas/route.test.ts @@ -59,6 +59,16 @@ describe('routeArgsSchema', () => { expect(result.error?.issues[0].message).toBe('Missing required body parameter: messages'); }); + it('rejects ai-query with an empty messages array', () => { + const result = routeArgsSchema.safeParse({ + route: 'ai-query', + body: { messages: [] }, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].message).toBe('messages must contain at least one message'); + }); + it('rejects ai-query with non-boolean parallel_tool_calls', () => { const result = routeArgsSchema.safeParse({ route: 'ai-query', From 8c8879b5ce05714202eb5e8fdc0f209d753bc6ac Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 10 Jul 2026 11:57:01 +0200 Subject: [PATCH 4/5] chore(ai-proxy): retrigger ci From 7d9a0efc97956e23d041016f338bf866148922b7 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 10 Jul 2026 12:17:18 +0200 Subject: [PATCH 5/5] fix(ai-proxy): re-support gpt-5.6 reasoning models under the Responses API The deny-list from #1750 worked around the chat/completions 400; the Responses switch makes these models pass tool calling again. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ai-proxy/test/supported-models.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ai-proxy/test/supported-models.test.ts b/packages/ai-proxy/test/supported-models.test.ts index eed6e7b41e..322bb7125a 100644 --- a/packages/ai-proxy/test/supported-models.test.ts +++ b/packages/ai-proxy/test/supported-models.test.ts @@ -14,12 +14,16 @@ describe('isModelSupportingTools', () => { }); it.each(['gpt-5.6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra'])( - 'should return false for %s (v1/responses only)', + 'should return true for %s (supported since the proxy uses v1/responses)', model => { - expect(isModelSupportingTools(model)).toBe(false); + expect(isModelSupportingTools(model)).toBe(true); }, ); + it('should return false for gpt-3.5-turbo-16k (removed from the API)', () => { + expect(isModelSupportingTools('gpt-3.5-turbo-16k')).toBe(false); + }); + it('should return false for claude-fable-5 (always-on thinking incompatible with proxy)', () => { expect(isModelSupportingTools('claude-fable-5', 'anthropic')).toBe(false); });