Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/ai-proxy/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
4 changes: 3 additions & 1 deletion packages/ai-proxy/src/create-base-chat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
59 changes: 40 additions & 19 deletions packages/ai-proxy/src/provider-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;

Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/provider-dispatcher.ts:113

dispatchOpenAI now rebuilds every response through LangChainAdapter.convertResponse, which hard-codes choices[0].finish_reason to 'stop' (or 'tool_calls' when tool calls exist). The real finish reason from response.response_metadata.finish_reason — such as 'length' or 'content_filter' — is silently dropped, so callers receive 'stop' even when generation was truncated or filtered. Consider reading finish_reason from response.response_metadata in convertResponse instead of always defaulting to 'stop'.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/ai-proxy/src/provider-dispatcher.ts around line 113:

`dispatchOpenAI` now rebuilds every response through `LangChainAdapter.convertResponse`, which hard-codes `choices[0].finish_reason` to `'stop'` (or `'tool_calls'` when tool calls exist). The real finish reason from `response.response_metadata.finish_reason` — such as `'length'` or `'content_filter'` — is silently dropped, so callers receive `'stop'` even when generation was truncated or filtered. Consider reading `finish_reason` from `response.response_metadata` in `convertResponse` instead of always defaulting to `'stop'`.

}

private async dispatchAnthropic(body: DispatchBody): Promise<ChatCompletionResponse> {
Expand Down Expand Up @@ -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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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;

Expand Down
8 changes: 5 additions & 3 deletions packages/ai-proxy/src/schemas/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
5 changes: 1 addition & 4 deletions packages/ai-proxy/src/supported-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-proxy/test/create-base-chat-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('createBaseChatModel', () => {

expect(ChatOpenAI).toHaveBeenCalledWith({
maxRetries: 0,
useResponsesApi: true,
apiKey: 'test-key',
model: 'gpt-4o',
});
Expand All @@ -47,6 +48,7 @@ describe('createBaseChatModel', () => {

expect(ChatOpenAI).toHaveBeenCalledWith({
maxRetries: 0,
useResponsesApi: true,
apiKey: 'test-key',
model: 'gpt-4o',
temperature: 0.7,
Expand Down
176 changes: 144 additions & 32 deletions packages/ai-proxy/test/provider-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 }) },
},
]);
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading