Skip to content
Merged
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
1 change: 0 additions & 1 deletion packages/workflow-executor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"dependencies": {
"@forestadmin/agent-client": "1.5.10",
"@forestadmin/ai-proxy": "1.10.1",
"@langchain/openai": "1.2.5",
"@forestadmin/forestadmin-client": "1.39.9",
"@koa/bodyparser": "^6.1.0",
"@koa/router": "^13.1.0",
Expand Down
3 changes: 1 addition & 2 deletions packages/workflow-executor/src/adapters/ai-client-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { AiModelPort } from '../ports/ai-model-port';
import type { AiConfiguration, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { AiConfiguration, BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';

import { AiClient } from '@forestadmin/ai-proxy';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { AiModelPort } from '../ports/ai-model-port';
import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';

import { AiModelPortError } from '../errors';

Expand Down
59 changes: 32 additions & 27 deletions packages/workflow-executor/src/adapters/server-ai-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { AiModelPort } from '../ports/ai-model-port';
import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { AiConfiguration, BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';

import { AiClient } from '@forestadmin/ai-proxy';
import { ChatOpenAI } from '@langchain/openai';

import { AiModelPortError, WorkflowExecutorError } from '../errors';

Expand All @@ -13,37 +11,17 @@ export interface ServerAiAdapterOptions {
}

export default class ServerAiAdapter implements AiModelPort {
private readonly forestServerUrl: string;
private readonly envSecret: string;
private readonly aiClient: AiClient;

constructor(options: ServerAiAdapterOptions) {
this.forestServerUrl = options.forestServerUrl;
this.envSecret = options.envSecret;
this.aiClient = new AiClient();
this.aiClient = new AiClient({
aiConfigurations: [ServerAiAdapter.buildProxyConfiguration(options)],
});
}

getModel(): BaseChatModel {
try {
const aiProxyUrl = `${this.forestServerUrl}/liana/v1/ai-proxy`;
const { envSecret } = this;

return new ChatOpenAI({
// Model has no effect — the server uses its own configured model.
// Set here only because ChatOpenAI requires it.
model: 'gpt-4.1',
maxRetries: 2,
configuration: {
apiKey: 'unused',
fetch: (_url: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.delete('authorization');
headers.set('forest-secret-key', envSecret);

return fetch(aiProxyUrl, { ...init, headers });
},
},
});
return this.aiClient.getModel();
} catch (cause) {
if (cause instanceof WorkflowExecutorError) throw cause;
throw new AiModelPortError('getModel', cause);
Expand All @@ -58,6 +36,33 @@ export default class ServerAiAdapter implements AiModelPort {
return this.callPort('closeConnections', () => this.aiClient.closeConnections());
}

// Every call is routed to the Forest server's AI proxy, which picks the real provider/model.
// The model name is therefore a placeholder, and fetch is rewritten to hit the proxy with the
// env secret instead of an OpenAI Authorization header.
private static buildProxyConfiguration({
forestServerUrl,
envSecret,
}: ServerAiAdapterOptions): AiConfiguration {
const aiProxyUrl = `${forestServerUrl}/liana/v1/ai-proxy`;

return {
name: 'forest-server',
provider: 'openai',
model: 'gpt-4.1',
maxRetries: 2,
configuration: {
apiKey: 'unused',
fetch: (_url: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.delete('authorization');
headers.set('forest-secret-key', envSecret);

return fetch(aiProxyUrl, { ...init, headers });
},
},
};
}

private async callPort<T>(operation: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
Expand Down
12 changes: 10 additions & 2 deletions packages/workflow-executor/src/executors/step-executor-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ import type {
UpdateRecordStepDefinition,
} from '../types/validated/step-definition';

import { StepStateError, causeMessage, extractErrorMessage } from '../errors';
import {
StepStateError,
WorkflowExecutorError,
causeMessage,
extractErrorMessage,
} from '../errors';
import SchemaResolver from '../schema-resolver';
import ActivityLog from './activity-log';
import AgentWithLog from './agent-with-log';
Expand Down Expand Up @@ -114,7 +119,10 @@ export default class StepExecutorFactory {
stepId: step.stepId,
stepIndex: step.stepIndex,
status: 'error',
error: 'An unexpected error occurred.',
error:
error instanceof WorkflowExecutorError
? error.userMessage
: 'An unexpected error occurred.',
},
}),
};
Expand Down
3 changes: 1 addition & 2 deletions packages/workflow-executor/src/ports/ai-model-port.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy';

export interface AiModelPort {
getModel(aiConfigName?: string): BaseChatModel;
Expand Down
81 changes: 52 additions & 29 deletions packages/workflow-executor/test/adapters/server-ai-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,76 @@
import ServerAiAdapter from '../../src/adapters/server-ai-adapter';

const mockGetModel = jest.fn().mockReturnValue({ id: 'fake-model' });
const mockLoadRemoteTools = jest.fn().mockResolvedValue([]);
const mockCloseConnections = jest.fn().mockResolvedValue(undefined);
const mockAiClientConstructor = jest.fn();

jest.mock('@forestadmin/ai-proxy', () => ({
AiClient: jest.fn().mockImplementation(() => ({
loadRemoteTools: jest.fn().mockResolvedValue([]),
closeConnections: jest.fn().mockResolvedValue(undefined),
})),
}));
AiClient: jest.fn().mockImplementation((...args: unknown[]) => {
mockAiClientConstructor(...args);

jest.mock('@langchain/openai', () => ({
ChatOpenAI: jest.fn().mockImplementation((opts: Record<string, unknown>) => ({
mockOpts: opts,
})),
return {
getModel: mockGetModel,
loadRemoteTools: mockLoadRemoteTools,
closeConnections: mockCloseConnections,
};
}),
}));

const ENV_SECRET = 'a'.repeat(64);

describe('ServerAiAdapter', () => {
const adapter = new ServerAiAdapter({
forestServerUrl: 'https://api.forestadmin.com',
envSecret: ENV_SECRET,
let adapter: ServerAiAdapter;

beforeEach(() => {
jest.clearAllMocks();
adapter = new ServerAiAdapter({
forestServerUrl: 'https://api.forestadmin.com',
envSecret: ENV_SECRET,
});
});

describe('getModel', () => {
it('returns a ChatOpenAI configured for the FA server', () => {
const model = adapter.getModel() as unknown as { mockOpts: Record<string, unknown> };
const proxyConfig = () =>
(mockAiClientConstructor.mock.calls[0][0] as { aiConfigurations: Record<string, unknown>[] })
.aiConfigurations[0];

expect(model.mockOpts).toEqual(
describe('constructor', () => {
it('configures AiClient with a single openai config targeting the FA server', () => {
expect(proxyConfig()).toEqual(
expect.objectContaining({
name: 'forest-server',
provider: 'openai',
model: 'gpt-4.1',
maxRetries: 2,
configuration: expect.objectContaining({
apiKey: 'unused',
fetch: expect.any(Function),
}),
}),
);
});
});

it('sends forest-secret-key header instead of Authorization', () => {
const model = adapter.getModel() as unknown as { mockOpts: Record<string, unknown> };
describe('getModel', () => {
it('delegates to the internal AiClient', () => {
expect(adapter.getModel()).toEqual({ id: 'fake-model' });
expect(mockGetModel).toHaveBeenCalled();
});

const { fetch: customFetch } = model.mockOpts.configuration as {
it('rewrites fetch to the proxy URL with forest-secret-key instead of Authorization', () => {
const { fetch: customFetch } = proxyConfig().configuration as {
fetch: (url: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};

const mockInit = {
method: 'POST',
body: '{}',
headers: { Authorization: 'Bearer unused' },
} as RequestInit;
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 200 }));

try {
customFetch('https://ignored.com/chat/completions', mockInit);
customFetch('https://ignored.com/chat/completions', {
method: 'POST',
body: '{}',
headers: { Authorization: 'Bearer unused' },
});

const [url, init] = (global.fetch as jest.Mock).mock.calls[0];
expect(url).toBe('https://api.forestadmin.com/liana/v1/ai-proxy');
Expand All @@ -67,16 +85,21 @@ describe('ServerAiAdapter', () => {
});

describe('loadRemoteTools', () => {
it('delegates to internal AiClient', async () => {
const result = await adapter.loadRemoteTools({});
it('delegates to internal AiClient with the given configs', async () => {
const configs = {};

const result = await adapter.loadRemoteTools(configs);

expect(mockLoadRemoteTools).toHaveBeenCalledWith(configs);
expect(result).toEqual([]);
});
});

describe('closeConnections', () => {
it('resolves without error', async () => {
await expect(adapter.closeConnections()).resolves.toBeUndefined();
it('delegates to internal AiClient', async () => {
await adapter.closeConnections();

expect(mockCloseConnections).toHaveBeenCalled();
});
});
});
27 changes: 26 additions & 1 deletion packages/workflow-executor/test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { StepDefinition } from '../src/types/validated/step-definition';
import type { BaseChatModel } from '@forestadmin/ai-proxy';

import {
AiModelPortError,
ConfigurationError,
MalformedRunError,
RunAlreadyInFlightError,
Expand Down Expand Up @@ -1665,7 +1666,7 @@ describe('StepExecutorFactory.create — factory', () => {
);
const { stepOutcome } = await executor.execute();
expect(stepOutcome.status).toBe('error');
expect(stepOutcome.error).toBe('An unexpected error occurred.');
expect(stepOutcome.error).toBe('An unexpected error occurred while processing this step.');
});

it('returns an executor with an error outcome when loadTools rejects for a McpTask step', async () => {
Expand Down Expand Up @@ -1706,6 +1707,30 @@ describe('StepExecutorFactory.create — factory', () => {
);
});

it('surfaces the userMessage when construction throws a WorkflowExecutorError', async () => {
const contextConfig: StepContextConfig = {
...makeContextConfig(),
aiModelPort: {
getModel: jest.fn().mockImplementationOnce(() => {
throw new AiModelPortError('getModel', new Error('boom'));
}),
} as unknown as AiModelPort,
};

const executor = await StepExecutorFactory.create(
makePendingStep(),
contextConfig,
makeRunLogger(),
jest.fn(),
);
const { stepOutcome } = await executor.execute();

expect(stepOutcome.status).toBe('error');
expect(stepOutcome.error).toBe(
'The AI service is unavailable. Please try again or contact your administrator.',
);
});

it('logs cause as undefined when construction error cause is not an Error instance', async () => {
const error = new Error('wrapper');
(error as Error & { cause: string }).cause = 'plain string';
Expand Down
Loading