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/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..01a01b931a 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -1,10 +1,15 @@ 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'; -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 +56,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({ @@ -90,12 +98,15 @@ export default class ProviderDispatcher { } = body; const enrichedTools = this.enrichToolDefinitions(tools); - const model = enrichedTools?.length - ? this.openaiModel.bindTools(enrichedTools, { - tool_choice: toolChoice, - 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; @@ -105,16 +116,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 { @@ -147,6 +151,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/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/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/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..653fba6bf2 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 }) }, + }, + ]); }); }); @@ -330,6 +347,101 @@ 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, + }); + }); + + 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(); + }); + }); }); describe('anthropic', () => { 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', 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); }); 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'); }); });