From ba9f613d9c861c056578fa139559acf2643b71f4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 16:13:22 +0100 Subject: [PATCH 01/14] feat(ai-proxy): add UnauthorizedError/TooManyRequestsError and typed AI provider errors - Add UnauthorizedError (401) and TooManyRequestsError (429) to agent-toolkit - Re-export from datasource-toolkit for backward compatibility - Add HTTP codes and cases in agent error-handling middleware - Create AIProviderError, AIRateLimitError, AIAuthenticationError in ai-proxy with baseBusinessErrorName overrides for correct HTTP status mapping - Update wrapProviderError to use typed errors instead of generic AIUnprocessableError Co-Authored-By: Claude Opus 4.6 --- packages/agent-toolkit/src/errors.ts | 12 +++++ .../agent/src/routes/system/error-handling.ts | 10 ++++ packages/agent/src/types.ts | 6 ++- .../test/routes/system/error-handling.test.ts | 30 ++++++++++++ packages/ai-proxy/src/errors.ts | 28 +++++++++++ packages/ai-proxy/src/provider-dispatcher.ts | 40 ++++++---------- .../ai-proxy/test/provider-dispatcher.test.ts | 47 ++++++++++++------- packages/datasource-toolkit/src/errors.ts | 2 + 8 files changed, 131 insertions(+), 44 deletions(-) diff --git a/packages/agent-toolkit/src/errors.ts b/packages/agent-toolkit/src/errors.ts index 49aceca12d..2caab8adb3 100644 --- a/packages/agent-toolkit/src/errors.ts +++ b/packages/agent-toolkit/src/errors.ts @@ -55,3 +55,15 @@ export class NotFoundError extends BusinessError { this.baseBusinessErrorName = 'NotFoundError'; } } +export class UnauthorizedError extends BusinessError { + constructor(message?: string, data?: Record, name?: string) { + super(message, data, name); + this.baseBusinessErrorName = 'UnauthorizedError'; + } +} +export class TooManyRequestsError extends BusinessError { + constructor(message?: string, data?: Record, name?: string) { + super(message, data, name); + this.baseBusinessErrorName = 'TooManyRequestsError'; + } +} diff --git a/packages/agent/src/routes/system/error-handling.ts b/packages/agent/src/routes/system/error-handling.ts index 1b717563ee..515de51377 100644 --- a/packages/agent/src/routes/system/error-handling.ts +++ b/packages/agent/src/routes/system/error-handling.ts @@ -6,6 +6,8 @@ import { BusinessError, ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, ValidationError, } from '@forestadmin/datasource-toolkit'; @@ -60,6 +62,10 @@ export default class ErrorHandling extends BaseRoute { case BusinessError.isOfType(error, BadRequestError): return HttpCode.BadRequest; + case error instanceof UnauthorizedError: + case BusinessError.isOfType(error, UnauthorizedError): + return HttpCode.Unauthorized; + case error instanceof ForbiddenError: case BusinessError.isOfType(error, ForbiddenError): return HttpCode.Forbidden; @@ -68,6 +74,10 @@ export default class ErrorHandling extends BaseRoute { case BusinessError.isOfType(error, NotFoundError): return HttpCode.NotFound; + case error instanceof TooManyRequestsError: + case BusinessError.isOfType(error, TooManyRequestsError): + return HttpCode.TooManyRequests; + case error instanceof UnprocessableError: case BusinessError.isOfType(error, UnprocessableError): case error instanceof BusinessError: diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index c003a86880..d90d83b084 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -52,12 +52,14 @@ export type HttpCallback = (req: IncomingMessage, res: ServerResponse, next?: () export enum HttpCode { BadRequest = 400, + Unauthorized = 401, Forbidden = 403, + NotFound = 404, + Unprocessable = 422, + TooManyRequests = 429, InternalServerError = 500, NoContent = 204, - NotFound = 404, Ok = 200, - Unprocessable = 422, } export enum RouteType { diff --git a/packages/agent/test/routes/system/error-handling.test.ts b/packages/agent/test/routes/system/error-handling.test.ts index 41f4f59423..935763671c 100644 --- a/packages/agent/test/routes/system/error-handling.test.ts +++ b/packages/agent/test/routes/system/error-handling.test.ts @@ -4,6 +4,8 @@ import { BadRequestError, ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, ValidationError, } from '@forestadmin/datasource-toolkit'; @@ -133,6 +135,32 @@ describe('ErrorHandling', () => { expect(console.error).not.toHaveBeenCalled(); }); + test('it should set the status and body for unauthorized errors', async () => { + const context = createMockContext(); + const next = jest.fn().mockRejectedValue(new UnauthorizedError('unauthorized')); + + await expect(handleError.call(route, context, next)).rejects.toThrow(); + + expect(context.response.status).toStrictEqual(HttpCode.Unauthorized); + expect(context.response.body).toStrictEqual({ + errors: [{ detail: 'unauthorized', name: 'UnauthorizedError', status: 401 }], + }); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('it should set the status and body for too many requests errors', async () => { + const context = createMockContext(); + const next = jest.fn().mockRejectedValue(new TooManyRequestsError('rate limited')); + + await expect(handleError.call(route, context, next)).rejects.toThrow(); + + expect(context.response.status).toStrictEqual(HttpCode.TooManyRequests); + expect(context.response.body).toStrictEqual({ + errors: [{ detail: 'rate limited', name: 'TooManyRequestsError', status: 429 }], + }); + expect(console.error).not.toHaveBeenCalled(); + }); + test('it should set the status and body for other errors and prevent information leak', async () => { const context = createMockContext(); const next = jest @@ -258,6 +286,8 @@ describe('ErrorHandling', () => { { Error: UnprocessableError, errorName: 'UnprocessableError' }, { Error: ForbiddenError, errorName: 'ForbiddenError' }, { Error: NotFoundError, errorName: 'NotFoundError' }, + { Error: UnauthorizedError, errorName: 'UnauthorizedError' }, + { Error: TooManyRequestsError, errorName: 'TooManyRequestsError' }, ])('should have the right baseBusinessErrorName', ({ Error, errorName }) => { const extendedError = new Error('message'); diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index e47feadcd2..fed0769c25 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -8,6 +8,8 @@ import { BadRequestError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, } from '@forestadmin/datasource-toolkit'; @@ -51,6 +53,32 @@ export class AIUnprocessableError extends UnprocessableError { } } +export class AIProviderError extends AIUnprocessableError { + readonly provider: string; + + constructor(message: string, provider: string, options?: { cause?: Error }) { + super(message, options); + this.name = 'AIProviderError'; + this.provider = provider; + } +} + +export class AIRateLimitError extends AIProviderError { + constructor(provider: string, options?: { cause?: Error }) { + super(`${provider} rate limit exceeded`, provider, options); + this.name = 'AIRateLimitError'; + this.baseBusinessErrorName = 'TooManyRequestsError'; + } +} + +export class AIAuthenticationError extends AIProviderError { + constructor(provider: string, options?: { cause?: Error }) { + super(`${provider} authentication failed: check your API key configuration`, provider, options); + this.name = 'AIAuthenticationError'; + this.baseBusinessErrorName = 'UnauthorizedError'; + } +} + export class AINotConfiguredError extends AIError { constructor(message = 'AI is not configured') { super(message); diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index de285bf60c..75678fda88 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -9,7 +9,14 @@ import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling' import { ChatOpenAI } from '@langchain/openai'; import AnthropicAdapter from './anthropic-adapter'; -import { AIBadRequestError, AINotConfiguredError, AIUnprocessableError } from './errors'; +import { + AIAuthenticationError, + AIBadRequestError, + AINotConfiguredError, + AIProviderError, + AIRateLimitError, + AIUnprocessableError, +} from './errors'; import { LangChainAdapter } from './langchain-adapter'; // Re-export types for consumers @@ -138,40 +145,23 @@ export default class ProviderDispatcher { return LangChainAdapter.convertResponse(response, this.modelName); } - /** - * Wraps provider errors into AI-specific error types. - * - * TODO: Currently all provider errors are wrapped as AIUnprocessableError, - * losing the original HTTP semantics (429 rate limit, 401 auth failure). - * To fix this properly we need to: - * 1. Add UnauthorizedError and TooManyRequestsError to datasource-toolkit - * 2. Add corresponding cases in the agent's error-handling middleware - * 3. Create AIProviderError, AIRateLimitError, AIAuthenticationError in ai-proxy - * with baseBusinessErrorName overrides for correct HTTP status mapping - */ private static wrapProviderError(error: unknown, providerName: string): Error { if (error instanceof AIUnprocessableError) return error; if (error instanceof AIBadRequestError) return error; if (!(error instanceof Error)) { - return new AIUnprocessableError(`Error while calling ${providerName}: ${String(error)}`); + return new AIProviderError( + `Error while calling ${providerName}: ${String(error)}`, + providerName, + ); } const { status } = error as Error & { status?: number }; - if (status === 429) { - return new AIUnprocessableError(`${providerName} rate limit exceeded: ${error.message}`, { - cause: error, - }); - } - - if (status === 401) { - return new AIUnprocessableError(`${providerName} authentication failed: ${error.message}`, { - cause: error, - }); - } + if (status === 429) return new AIRateLimitError(providerName, { cause: error }); + if (status === 401) return new AIAuthenticationError(providerName, { cause: error }); - return new AIUnprocessableError(`Error while calling ${providerName}: ${error.message}`, { + return new AIProviderError(`Error while calling ${providerName}: ${error.message}`, providerName, { cause: error, }); } diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index d32ac6d006..387ce0771e 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -5,8 +5,11 @@ import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling' import { ChatOpenAI } from '@langchain/openai'; import { + AIAuthenticationError, AIBadRequestError, AINotConfiguredError, + AIProviderError, + AIRateLimitError, AIUnprocessableError, ProviderDispatcher, RemoteTools, @@ -149,36 +152,41 @@ describe('ProviderDispatcher', () => { }); describe('error handling', () => { - it('should wrap generic errors as AIUnprocessableError with cause', async () => { + it('should wrap generic errors as AIProviderError with cause', async () => { const original = new Error('OpenAI error'); invokeMock.mockRejectedValueOnce(original); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIProviderError); expect(thrown.message).toBe('Error while calling OpenAI: OpenAI error'); + expect(thrown.provider).toBe('OpenAI'); expect(thrown.cause).toBe(original); }); - it('should wrap 429 as AIUnprocessableError with rate limit message', async () => { + it('should wrap 429 as AIRateLimitError', async () => { const error = Object.assign(new Error('Too many requests'), { status: 429 }); invokeMock.mockRejectedValueOnce(error); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); - expect(thrown.message).toBe('OpenAI rate limit exceeded: Too many requests'); + expect(thrown).toBeInstanceOf(AIRateLimitError); + expect(thrown.message).toBe('OpenAI rate limit exceeded'); + expect(thrown.provider).toBe('OpenAI'); + expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); }); - it('should wrap 401 as AIUnprocessableError with auth message', async () => { + it('should wrap 401 as AIAuthenticationError', async () => { const error = Object.assign(new Error('Invalid API key'), { status: 401 }); invokeMock.mockRejectedValueOnce(error); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); - expect(thrown.message).toBe('OpenAI authentication failed: Invalid API key'); + expect(thrown).toBeInstanceOf(AIAuthenticationError); + expect(thrown.message).toBe('OpenAI authentication failed: check your API key configuration'); + expect(thrown.provider).toBe('OpenAI'); + expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); expect(thrown.cause).toBe(error); }); @@ -388,7 +396,7 @@ describe('ProviderDispatcher', () => { }); describe('error handling', () => { - it('should wrap generic errors as AIUnprocessableError with cause', async () => { + it('should wrap generic errors as AIProviderError with cause', async () => { const original = new Error('Anthropic API error'); anthropicInvokeMock.mockRejectedValueOnce(original); @@ -396,12 +404,13 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIProviderError); expect(thrown.message).toBe('Error while calling Anthropic: Anthropic API error'); + expect(thrown.provider).toBe('Anthropic'); expect(thrown.cause).toBe(original); }); - it('should wrap 429 as AIUnprocessableError with rate limit message', async () => { + it('should wrap 429 as AIRateLimitError', async () => { const error = Object.assign(new Error('Too many requests'), { status: 429 }); anthropicInvokeMock.mockRejectedValueOnce(error); @@ -409,12 +418,14 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); - expect(thrown.message).toBe('Anthropic rate limit exceeded: Too many requests'); + expect(thrown).toBeInstanceOf(AIRateLimitError); + expect(thrown.message).toBe('Anthropic rate limit exceeded'); + expect(thrown.provider).toBe('Anthropic'); + expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); }); - it('should wrap 401 as AIUnprocessableError with auth message', async () => { + it('should wrap 401 as AIAuthenticationError', async () => { const error = Object.assign(new Error('Invalid API key'), { status: 401 }); anthropicInvokeMock.mockRejectedValueOnce(error); @@ -422,8 +433,10 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); - expect(thrown.message).toBe('Anthropic authentication failed: Invalid API key'); + expect(thrown).toBeInstanceOf(AIAuthenticationError); + expect(thrown.message).toBe('Anthropic authentication failed: check your API key configuration'); + expect(thrown.provider).toBe('Anthropic'); + expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); expect(thrown.cause).toBe(error); }); @@ -434,7 +447,7 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIProviderError); expect(thrown.message).toBe('Error while calling Anthropic: string error'); }); diff --git a/packages/datasource-toolkit/src/errors.ts b/packages/datasource-toolkit/src/errors.ts index 304a84a056..5ef14439ee 100644 --- a/packages/datasource-toolkit/src/errors.ts +++ b/packages/datasource-toolkit/src/errors.ts @@ -9,6 +9,8 @@ export { UnprocessableError, ForbiddenError, NotFoundError, + UnauthorizedError, + TooManyRequestsError, } from '@forestadmin/agent-toolkit'; export class IntrospectionFormatError extends BusinessError { From 314046270309931f4ed8b690e01c39a7dade0521 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 16:36:00 +0100 Subject: [PATCH 02/14] feat(errors): add HTTP status codes to error classes Add `status` property to BusinessError and all subclasses, simplifying the error-handling middleware from a 30-line switch/case to a direct status lookup. Co-Authored-By: Claude Opus 4.6 --- packages/agent-toolkit/src/errors.ts | 18 ++++---- .../agent/src/routes/system/error-handling.ts | 46 ++----------------- packages/ai-proxy/src/errors.ts | 4 +- packages/ai-proxy/src/provider-dispatcher.ts | 8 ++-- 4 files changed, 22 insertions(+), 54 deletions(-) diff --git a/packages/agent-toolkit/src/errors.ts b/packages/agent-toolkit/src/errors.ts index 2caab8adb3..ac24b3feb8 100644 --- a/packages/agent-toolkit/src/errors.ts +++ b/packages/agent-toolkit/src/errors.ts @@ -3,13 +3,15 @@ export class BusinessError extends Error { // INTERNAL USAGES public readonly isBusinessError = true; public baseBusinessErrorName: string; + public status: number; public readonly data: Record | undefined; - constructor(message?: string, data?: Record, name?: string) { + constructor(message?: string, data?: Record, name?: string, status = 422) { super(message); this.name = name ?? this.constructor.name; this.data = data; + this.status = status; } /** @@ -27,43 +29,43 @@ export class BusinessError extends Error { export class ValidationError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 400); this.baseBusinessErrorName = 'ValidationError'; } } export class BadRequestError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 400); this.baseBusinessErrorName = 'BadRequestError'; } } export class UnprocessableError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 422); this.baseBusinessErrorName = 'UnprocessableError'; } } export class ForbiddenError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 403); this.baseBusinessErrorName = 'ForbiddenError'; } } export class NotFoundError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 404); this.baseBusinessErrorName = 'NotFoundError'; } } export class UnauthorizedError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 401); this.baseBusinessErrorName = 'UnauthorizedError'; } } export class TooManyRequestsError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); + super(message, data, name, 429); this.baseBusinessErrorName = 'TooManyRequestsError'; } } diff --git a/packages/agent/src/routes/system/error-handling.ts b/packages/agent/src/routes/system/error-handling.ts index 515de51377..63ee572037 100644 --- a/packages/agent/src/routes/system/error-handling.ts +++ b/packages/agent/src/routes/system/error-handling.ts @@ -1,16 +1,7 @@ import type Router from '@koa/router'; import type { Context, Next } from 'koa'; -import { - BadRequestError, - BusinessError, - ForbiddenError, - NotFoundError, - TooManyRequestsError, - UnauthorizedError, - UnprocessableError, - ValidationError, -} from '@forestadmin/datasource-toolkit'; +import { BusinessError } from '@forestadmin/datasource-toolkit'; import { HttpError } from 'koa'; import { HttpCode, RouteType } from '../../types'; @@ -55,38 +46,11 @@ export default class ErrorHandling extends BaseRoute { private getErrorStatus(error: Error): number { if (error instanceof HttpError) return error.status; - switch (true) { - case error instanceof ValidationError: - case BusinessError.isOfType(error, ValidationError): - case error instanceof BadRequestError: - case BusinessError.isOfType(error, BadRequestError): - return HttpCode.BadRequest; - - case error instanceof UnauthorizedError: - case BusinessError.isOfType(error, UnauthorizedError): - return HttpCode.Unauthorized; - - case error instanceof ForbiddenError: - case BusinessError.isOfType(error, ForbiddenError): - return HttpCode.Forbidden; - - case error instanceof NotFoundError: - case BusinessError.isOfType(error, NotFoundError): - return HttpCode.NotFound; - - case error instanceof TooManyRequestsError: - case BusinessError.isOfType(error, TooManyRequestsError): - return HttpCode.TooManyRequests; - - case error instanceof UnprocessableError: - case BusinessError.isOfType(error, UnprocessableError): - case error instanceof BusinessError: - case BusinessError.isOfType(error, BusinessError): - return HttpCode.Unprocessable; - - default: - return HttpCode.InternalServerError; + if (error instanceof BusinessError || (error as BusinessError).isBusinessError) { + return (error as BusinessError).status; } + + return HttpCode.InternalServerError; } private getErrorMessage(error: Error): string { diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index fed0769c25..f4d7409253 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -8,8 +8,6 @@ import { BadRequestError, NotFoundError, - TooManyRequestsError, - UnauthorizedError, UnprocessableError, } from '@forestadmin/datasource-toolkit'; @@ -68,6 +66,7 @@ export class AIRateLimitError extends AIProviderError { super(`${provider} rate limit exceeded`, provider, options); this.name = 'AIRateLimitError'; this.baseBusinessErrorName = 'TooManyRequestsError'; + this.status = 429; } } @@ -76,6 +75,7 @@ export class AIAuthenticationError extends AIProviderError { super(`${provider} authentication failed: check your API key configuration`, provider, options); this.name = 'AIAuthenticationError'; this.baseBusinessErrorName = 'UnauthorizedError'; + this.status = 401; } } diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 75678fda88..76cd30625a 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -161,9 +161,11 @@ export default class ProviderDispatcher { if (status === 429) return new AIRateLimitError(providerName, { cause: error }); if (status === 401) return new AIAuthenticationError(providerName, { cause: error }); - return new AIProviderError(`Error while calling ${providerName}: ${error.message}`, providerName, { - cause: error, - }); + return new AIProviderError( + `Error while calling ${providerName}: ${error.message}`, + providerName, + { cause: error }, + ); } private enrichToolDefinitions(tools?: ChatCompletionTool[]): ChatCompletionTool[] | undefined { From 840bef1ad4f948431afd2eae2dc9c4a858967ec3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 16:39:26 +0100 Subject: [PATCH 03/14] refactor(ai-proxy): move error message formatting into AIProviderError class The provider name and cause detail are now assembled by the constructor, simplifying wrapProviderError callers. Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/errors.ts | 19 +++++++++++++------ packages/ai-proxy/src/provider-dispatcher.ts | 15 ++------------- .../ai-proxy/test/provider-dispatcher.test.ts | 2 +- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index f4d7409253..ab4045a9d8 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -54,16 +54,20 @@ export class AIUnprocessableError extends UnprocessableError { export class AIProviderError extends AIUnprocessableError { readonly provider: string; - constructor(message: string, provider: string, options?: { cause?: Error }) { - super(message, options); + constructor(provider: string, options?: { cause?: unknown; message?: string }) { + const cause = options?.cause; + const message = + options?.message ?? + `Error while calling ${provider}: ${cause instanceof Error ? cause.message : JSON.stringify(cause) ?? 'unknown'}`; + super(message, { cause: cause instanceof Error ? cause : undefined }); this.name = 'AIProviderError'; this.provider = provider; } } export class AIRateLimitError extends AIProviderError { - constructor(provider: string, options?: { cause?: Error }) { - super(`${provider} rate limit exceeded`, provider, options); + constructor(provider: string, options?: { cause?: unknown }) { + super(provider, { ...options, message: `${provider} rate limit exceeded` }); this.name = 'AIRateLimitError'; this.baseBusinessErrorName = 'TooManyRequestsError'; this.status = 429; @@ -71,8 +75,11 @@ export class AIRateLimitError extends AIProviderError { } export class AIAuthenticationError extends AIProviderError { - constructor(provider: string, options?: { cause?: Error }) { - super(`${provider} authentication failed: check your API key configuration`, provider, options); + constructor(provider: string, options?: { cause?: unknown }) { + super(provider, { + ...options, + message: `${provider} authentication failed: check your API key configuration`, + }); this.name = 'AIAuthenticationError'; this.baseBusinessErrorName = 'UnauthorizedError'; this.status = 401; diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 76cd30625a..b251844c4f 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -149,23 +149,12 @@ export default class ProviderDispatcher { if (error instanceof AIUnprocessableError) return error; if (error instanceof AIBadRequestError) return error; - if (!(error instanceof Error)) { - return new AIProviderError( - `Error while calling ${providerName}: ${String(error)}`, - providerName, - ); - } - - const { status } = error as Error & { status?: number }; + const status = error instanceof Error ? (error as Error & { status?: number }).status : null; if (status === 429) return new AIRateLimitError(providerName, { cause: error }); if (status === 401) return new AIAuthenticationError(providerName, { cause: error }); - return new AIProviderError( - `Error while calling ${providerName}: ${error.message}`, - providerName, - { cause: error }, - ); + return new AIProviderError(providerName, { cause: error }); } private enrichToolDefinitions(tools?: ChatCompletionTool[]): ChatCompletionTool[] | undefined { diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 387ce0771e..76805eb617 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -448,7 +448,7 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AIProviderError); - expect(thrown.message).toBe('Error while calling Anthropic: string error'); + expect(thrown.message).toBe('Error while calling Anthropic: "string error"'); }); it('should not wrap conversion errors as provider errors', async () => { From 847521749edb2022f56b649fea51f033b3a38092 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 16:45:55 +0100 Subject: [PATCH 04/14] fix(agent): restore switch/case in error middleware for version compatibility The simplified error.status lookup would break if the user upgrades agent without upgrading datasource-toolkit (old errors lack the status property). Co-Authored-By: Claude Opus 4.6 --- .../agent/src/routes/system/error-handling.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/agent/src/routes/system/error-handling.ts b/packages/agent/src/routes/system/error-handling.ts index 63ee572037..515de51377 100644 --- a/packages/agent/src/routes/system/error-handling.ts +++ b/packages/agent/src/routes/system/error-handling.ts @@ -1,7 +1,16 @@ import type Router from '@koa/router'; import type { Context, Next } from 'koa'; -import { BusinessError } from '@forestadmin/datasource-toolkit'; +import { + BadRequestError, + BusinessError, + ForbiddenError, + NotFoundError, + TooManyRequestsError, + UnauthorizedError, + UnprocessableError, + ValidationError, +} from '@forestadmin/datasource-toolkit'; import { HttpError } from 'koa'; import { HttpCode, RouteType } from '../../types'; @@ -46,11 +55,38 @@ export default class ErrorHandling extends BaseRoute { private getErrorStatus(error: Error): number { if (error instanceof HttpError) return error.status; - if (error instanceof BusinessError || (error as BusinessError).isBusinessError) { - return (error as BusinessError).status; + switch (true) { + case error instanceof ValidationError: + case BusinessError.isOfType(error, ValidationError): + case error instanceof BadRequestError: + case BusinessError.isOfType(error, BadRequestError): + return HttpCode.BadRequest; + + case error instanceof UnauthorizedError: + case BusinessError.isOfType(error, UnauthorizedError): + return HttpCode.Unauthorized; + + case error instanceof ForbiddenError: + case BusinessError.isOfType(error, ForbiddenError): + return HttpCode.Forbidden; + + case error instanceof NotFoundError: + case BusinessError.isOfType(error, NotFoundError): + return HttpCode.NotFound; + + case error instanceof TooManyRequestsError: + case BusinessError.isOfType(error, TooManyRequestsError): + return HttpCode.TooManyRequests; + + case error instanceof UnprocessableError: + case BusinessError.isOfType(error, UnprocessableError): + case error instanceof BusinessError: + case BusinessError.isOfType(error, BusinessError): + return HttpCode.Unprocessable; + + default: + return HttpCode.InternalServerError; } - - return HttpCode.InternalServerError; } private getErrorMessage(error: Error): string { From 5ca18b677a8703f158f57b967dffcdc77dfef7d5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 16:48:23 +0100 Subject: [PATCH 05/14] refactor(errors): rename status to httpCode on BusinessError Co-Authored-By: Claude Opus 4.6 --- packages/agent-toolkit/src/errors.ts | 6 +++--- packages/ai-proxy/src/errors.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-toolkit/src/errors.ts b/packages/agent-toolkit/src/errors.ts index ac24b3feb8..38ec29fa07 100644 --- a/packages/agent-toolkit/src/errors.ts +++ b/packages/agent-toolkit/src/errors.ts @@ -3,15 +3,15 @@ export class BusinessError extends Error { // INTERNAL USAGES public readonly isBusinessError = true; public baseBusinessErrorName: string; - public status: number; + public httpCode: number; public readonly data: Record | undefined; - constructor(message?: string, data?: Record, name?: string, status = 422) { + constructor(message?: string, data?: Record, name?: string, httpCode = 422) { super(message); this.name = name ?? this.constructor.name; this.data = data; - this.status = status; + this.httpCode = httpCode; } /** diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index ab4045a9d8..fea1535c6f 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -70,7 +70,7 @@ export class AIRateLimitError extends AIProviderError { super(provider, { ...options, message: `${provider} rate limit exceeded` }); this.name = 'AIRateLimitError'; this.baseBusinessErrorName = 'TooManyRequestsError'; - this.status = 429; + this.httpCode = 429; } } @@ -82,7 +82,7 @@ export class AIAuthenticationError extends AIProviderError { }); this.name = 'AIAuthenticationError'; this.baseBusinessErrorName = 'UnauthorizedError'; - this.status = 401; + this.httpCode = 401; } } From 280dfca6974295434fa617b8aa7fdbe62ef48c04 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 16:54:48 +0100 Subject: [PATCH 06/14] test(ai-proxy): add hierarchy tests for AIProviderError, AIRateLimitError, AIAuthenticationError Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/test/errors.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index f3a1569ec9..97cc5e075f 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -5,11 +5,14 @@ import { } from '@forestadmin/datasource-toolkit'; import { + AIAuthenticationError, AIBadRequestError, AIError, AIModelNotSupportedError, AINotConfiguredError, AINotFoundError, + AIProviderError, + AIRateLimitError, AIToolNotFoundError, AIToolUnprocessableError, AIUnprocessableError, @@ -67,6 +70,29 @@ describe('AI Error Hierarchy', () => { expect(error).toBeInstanceOf(UnprocessableError); expect(error.name).toBe('AIToolUnprocessableError'); }); + + test('AIProviderError extends UnprocessableError via AIUnprocessableError', () => { + const error = new AIProviderError('OpenAI', { cause: new Error('test') }); + expect(error).toBeInstanceOf(AIUnprocessableError); + expect(error).toBeInstanceOf(UnprocessableError); + expect(error.provider).toBe('OpenAI'); + }); + + test('AIRateLimitError extends AIProviderError with TooManyRequestsError semantics', () => { + const error = new AIRateLimitError('OpenAI'); + expect(error).toBeInstanceOf(AIProviderError); + expect(error).toBeInstanceOf(UnprocessableError); + expect(error.baseBusinessErrorName).toBe('TooManyRequestsError'); + expect(error.httpCode).toBe(429); + }); + + test('AIAuthenticationError extends AIProviderError with UnauthorizedError semantics', () => { + const error = new AIAuthenticationError('OpenAI'); + expect(error).toBeInstanceOf(AIProviderError); + expect(error).toBeInstanceOf(UnprocessableError); + expect(error.baseBusinessErrorName).toBe('UnauthorizedError'); + expect(error.httpCode).toBe(401); + }); }); describe('BadRequestError branch (400)', () => { From d189c0174395b6e3386d904f6d3e5c448130818b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 17 Feb 2026 17:00:52 +0100 Subject: [PATCH 07/14] fix(ai-proxy): fix prettier line length in AIProviderError constructor Co-Authored-By: Claude Opus 4.6 --- packages/agent-toolkit/test/errors.test.ts | 4 ++++ packages/ai-proxy/src/errors.ts | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/agent-toolkit/test/errors.test.ts b/packages/agent-toolkit/test/errors.test.ts index 1188e224bc..a384ccfc31 100644 --- a/packages/agent-toolkit/test/errors.test.ts +++ b/packages/agent-toolkit/test/errors.test.ts @@ -4,6 +4,8 @@ import { BusinessError, ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, ValidationError, } from '../src/errors'; @@ -67,6 +69,8 @@ describe('errors', () => { { ErrorClass: UnprocessableError, errorName: 'UnprocessableError' }, { ErrorClass: ForbiddenError, errorName: 'ForbiddenError' }, { ErrorClass: NotFoundError, errorName: 'NotFoundError' }, + { ErrorClass: UnauthorizedError, errorName: 'UnauthorizedError' }, + { ErrorClass: TooManyRequestsError, errorName: 'TooManyRequestsError' }, ])('$errorName should have the correct baseBusinessErrorName', ({ ErrorClass, errorName }) => { const error = new ErrorClass('test'); expect(error.baseBusinessErrorName).toEqual(errorName); diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index fea1535c6f..b794a72795 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -56,9 +56,8 @@ export class AIProviderError extends AIUnprocessableError { constructor(provider: string, options?: { cause?: unknown; message?: string }) { const cause = options?.cause; - const message = - options?.message ?? - `Error while calling ${provider}: ${cause instanceof Error ? cause.message : JSON.stringify(cause) ?? 'unknown'}`; + const detail = cause instanceof Error ? cause.message : JSON.stringify(cause) ?? 'unknown'; + const message = options?.message ?? `Error while calling ${provider}: ${detail}`; super(message, { cause: cause instanceof Error ? cause : undefined }); this.name = 'AIProviderError'; this.provider = provider; From 988618f50547d985d7d36898447813a0ff6c2f79 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Feb 2026 12:18:07 +0100 Subject: [PATCH 08/14] refactor(ai-proxy): type cause as Error instead of unknown in AI error classes Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/errors.ts | 14 +++++++------- packages/ai-proxy/src/provider-dispatcher.ts | 8 +++++++- packages/ai-proxy/test/provider-dispatcher.test.ts | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index b794a72795..1cd13ac5e9 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -54,18 +54,18 @@ export class AIUnprocessableError extends UnprocessableError { export class AIProviderError extends AIUnprocessableError { readonly provider: string; - constructor(provider: string, options?: { cause?: unknown; message?: string }) { - const cause = options?.cause; - const detail = cause instanceof Error ? cause.message : JSON.stringify(cause) ?? 'unknown'; - const message = options?.message ?? `Error while calling ${provider}: ${detail}`; - super(message, { cause: cause instanceof Error ? cause : undefined }); + constructor(provider: string, options?: { cause?: Error; message?: string }) { + const message = + options?.message ?? + `Error while calling ${provider}: ${options?.cause?.message ?? 'unknown'}`; + super(message, options); this.name = 'AIProviderError'; this.provider = provider; } } export class AIRateLimitError extends AIProviderError { - constructor(provider: string, options?: { cause?: unknown }) { + constructor(provider: string, options?: { cause?: Error }) { super(provider, { ...options, message: `${provider} rate limit exceeded` }); this.name = 'AIRateLimitError'; this.baseBusinessErrorName = 'TooManyRequestsError'; @@ -74,7 +74,7 @@ export class AIRateLimitError extends AIProviderError { } export class AIAuthenticationError extends AIProviderError { - constructor(provider: string, options?: { cause?: unknown }) { + constructor(provider: string, options?: { cause?: Error }) { super(provider, { ...options, message: `${provider} authentication failed: check your API key configuration`, diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index b251844c4f..695049576d 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -149,7 +149,13 @@ export default class ProviderDispatcher { if (error instanceof AIUnprocessableError) return error; if (error instanceof AIBadRequestError) return error; - const status = error instanceof Error ? (error as Error & { status?: number }).status : null; + if (!(error instanceof Error)) { + return new AIProviderError(providerName, { + message: `Error while calling ${providerName}: ${String(error)}`, + }); + } + + const { status } = error as Error & { status?: number }; if (status === 429) return new AIRateLimitError(providerName, { cause: error }); if (status === 401) return new AIAuthenticationError(providerName, { cause: error }); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 76805eb617..387ce0771e 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -448,7 +448,7 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AIProviderError); - expect(thrown.message).toBe('Error while calling Anthropic: "string error"'); + expect(thrown.message).toBe('Error while calling Anthropic: string error'); }); it('should not wrap conversion errors as provider errors', async () => { From 3cf3703c0b1fb9c315b5c80246b8505ecdb2262d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Feb 2026 19:03:12 +0100 Subject: [PATCH 09/14] refactor(ai-proxy): rename AI error classes for consistency and simplify constructor - AIRateLimitError -> AITooManyRequestsError (matches TooManyRequestsError) - AIAuthenticationError -> AIUnauthorizedError (matches UnauthorizedError) - Remove message from AIProviderError options, build it from cause - Type cause as Error instead of unknown Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/errors.ts | 24 ++++++++----------- packages/ai-proxy/src/provider-dispatcher.ts | 12 ++++------ packages/ai-proxy/test/errors.test.ts | 12 +++++----- .../ai-proxy/test/provider-dispatcher.test.ts | 21 ++++++++-------- 4 files changed, 31 insertions(+), 38 deletions(-) diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index 1cd13ac5e9..0595b15c2b 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -54,32 +54,28 @@ export class AIUnprocessableError extends UnprocessableError { export class AIProviderError extends AIUnprocessableError { readonly provider: string; - constructor(provider: string, options?: { cause?: Error; message?: string }) { - const message = - options?.message ?? - `Error while calling ${provider}: ${options?.cause?.message ?? 'unknown'}`; - super(message, options); + constructor(provider: string, options?: { cause?: Error }) { + super(`Error while calling ${provider}: ${options?.cause?.message ?? 'unknown'}`, options); this.name = 'AIProviderError'; this.provider = provider; } } -export class AIRateLimitError extends AIProviderError { +export class AITooManyRequestsError extends AIProviderError { constructor(provider: string, options?: { cause?: Error }) { - super(provider, { ...options, message: `${provider} rate limit exceeded` }); - this.name = 'AIRateLimitError'; + super(provider, options); + this.message = `${provider} rate limit exceeded`; + this.name = 'AITooManyRequestsError'; this.baseBusinessErrorName = 'TooManyRequestsError'; this.httpCode = 429; } } -export class AIAuthenticationError extends AIProviderError { +export class AIUnauthorizedError extends AIProviderError { constructor(provider: string, options?: { cause?: Error }) { - super(provider, { - ...options, - message: `${provider} authentication failed: check your API key configuration`, - }); - this.name = 'AIAuthenticationError'; + super(provider, options); + this.message = `${provider} authentication failed: check your API key configuration`; + this.name = 'AIUnauthorizedError'; this.baseBusinessErrorName = 'UnauthorizedError'; this.httpCode = 401; } diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 695049576d..3d0237522f 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -10,11 +10,11 @@ import { ChatOpenAI } from '@langchain/openai'; import AnthropicAdapter from './anthropic-adapter'; import { - AIAuthenticationError, AIBadRequestError, AINotConfiguredError, AIProviderError, - AIRateLimitError, + AITooManyRequestsError, + AIUnauthorizedError, AIUnprocessableError, } from './errors'; import { LangChainAdapter } from './langchain-adapter'; @@ -150,15 +150,13 @@ export default class ProviderDispatcher { if (error instanceof AIBadRequestError) return error; if (!(error instanceof Error)) { - return new AIProviderError(providerName, { - message: `Error while calling ${providerName}: ${String(error)}`, - }); + return new AIProviderError(providerName, { cause: new Error(String(error)) }); } const { status } = error as Error & { status?: number }; - if (status === 429) return new AIRateLimitError(providerName, { cause: error }); - if (status === 401) return new AIAuthenticationError(providerName, { cause: error }); + if (status === 429) return new AITooManyRequestsError(providerName, { cause: error }); + if (status === 401) return new AIUnauthorizedError(providerName, { cause: error }); return new AIProviderError(providerName, { cause: error }); } diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index 97cc5e075f..c2ec82f847 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -5,14 +5,14 @@ import { } from '@forestadmin/datasource-toolkit'; import { - AIAuthenticationError, AIBadRequestError, AIError, AIModelNotSupportedError, AINotConfiguredError, AINotFoundError, AIProviderError, - AIRateLimitError, + AITooManyRequestsError, + AIUnauthorizedError, AIToolNotFoundError, AIToolUnprocessableError, AIUnprocessableError, @@ -78,16 +78,16 @@ describe('AI Error Hierarchy', () => { expect(error.provider).toBe('OpenAI'); }); - test('AIRateLimitError extends AIProviderError with TooManyRequestsError semantics', () => { - const error = new AIRateLimitError('OpenAI'); + test('AITooManyRequestsError extends AIProviderError with TooManyRequestsError semantics', () => { + const error = new AITooManyRequestsError('OpenAI'); expect(error).toBeInstanceOf(AIProviderError); expect(error).toBeInstanceOf(UnprocessableError); expect(error.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(error.httpCode).toBe(429); }); - test('AIAuthenticationError extends AIProviderError with UnauthorizedError semantics', () => { - const error = new AIAuthenticationError('OpenAI'); + test('AIUnauthorizedError extends AIProviderError with UnauthorizedError semantics', () => { + const error = new AIUnauthorizedError('OpenAI'); expect(error).toBeInstanceOf(AIProviderError); expect(error).toBeInstanceOf(UnprocessableError); expect(error.baseBusinessErrorName).toBe('UnauthorizedError'); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 387ce0771e..ad75f374b1 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -5,12 +5,11 @@ import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling' import { ChatOpenAI } from '@langchain/openai'; import { - AIAuthenticationError, AIBadRequestError, AINotConfiguredError, AIProviderError, - AIRateLimitError, - AIUnprocessableError, + AITooManyRequestsError, + AIUnauthorizedError, ProviderDispatcher, RemoteTools, } from '../src'; @@ -164,26 +163,26 @@ describe('ProviderDispatcher', () => { expect(thrown.cause).toBe(original); }); - it('should wrap 429 as AIRateLimitError', async () => { + it('should wrap 429 as AITooManyRequestsError', async () => { const error = Object.assign(new Error('Too many requests'), { status: 429 }); invokeMock.mockRejectedValueOnce(error); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIRateLimitError); + expect(thrown).toBeInstanceOf(AITooManyRequestsError); expect(thrown.message).toBe('OpenAI rate limit exceeded'); expect(thrown.provider).toBe('OpenAI'); expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); }); - it('should wrap 401 as AIAuthenticationError', async () => { + it('should wrap 401 as AIUnauthorizedError', async () => { const error = Object.assign(new Error('Invalid API key'), { status: 401 }); invokeMock.mockRejectedValueOnce(error); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIAuthenticationError); + expect(thrown).toBeInstanceOf(AIUnauthorizedError); expect(thrown.message).toBe('OpenAI authentication failed: check your API key configuration'); expect(thrown.provider).toBe('OpenAI'); expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); @@ -410,7 +409,7 @@ describe('ProviderDispatcher', () => { expect(thrown.cause).toBe(original); }); - it('should wrap 429 as AIRateLimitError', async () => { + it('should wrap 429 as AITooManyRequestsError', async () => { const error = Object.assign(new Error('Too many requests'), { status: 429 }); anthropicInvokeMock.mockRejectedValueOnce(error); @@ -418,14 +417,14 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIRateLimitError); + expect(thrown).toBeInstanceOf(AITooManyRequestsError); expect(thrown.message).toBe('Anthropic rate limit exceeded'); expect(thrown.provider).toBe('Anthropic'); expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); }); - it('should wrap 401 as AIAuthenticationError', async () => { + it('should wrap 401 as AIUnauthorizedError', async () => { const error = Object.assign(new Error('Invalid API key'), { status: 401 }); anthropicInvokeMock.mockRejectedValueOnce(error); @@ -433,7 +432,7 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIAuthenticationError); + expect(thrown).toBeInstanceOf(AIUnauthorizedError); expect(thrown.message).toBe('Anthropic authentication failed: check your API key configuration'); expect(thrown.provider).toBe('Anthropic'); expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); From 93137000b9fbbed82ce3e84389bba1144c95900a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Feb 2026 22:50:31 +0100 Subject: [PATCH 10/14] =?UTF-8?q?refactor(ai-proxy):=20flatten=20error=20h?= =?UTF-8?q?ierarchy=20=E2=80=94=20each=20AI=20error=20extends=20its=20matc?= =?UTF-8?q?hing=20base=20HTTP=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove intermediate classes AIError and AIUnprocessableError. Each AI error now directly extends its matching base class (e.g. AITooManyRequestsError extends TooManyRequestsError, AIUnauthorizedError extends UnauthorizedError). This removes manual baseBusinessErrorName/httpCode overrides and simplifies wrapProviderError to a single instanceof BusinessError check. Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/errors.ts | 58 ++++++++---------- packages/ai-proxy/src/provider-dispatcher.ts | 8 +-- packages/ai-proxy/test/errors.test.ts | 59 ++++++++----------- .../ai-proxy/test/provider-dispatcher.test.ts | 9 +++ 4 files changed, 60 insertions(+), 74 deletions(-) diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index 0595b15c2b..c689867fed 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -1,23 +1,17 @@ /** - * All custom AI errors extend HTTP-status error classes (BadRequestError, NotFoundError, - * UnprocessableError) from datasource-toolkit. This allows the agent's error middleware - * to map them to their natural HTTP status codes automatically. + * All custom AI errors extend their matching HTTP-status error class from datasource-toolkit. + * This allows the agent's error middleware to map them to their natural HTTP status codes. */ // eslint-disable-next-line max-classes-per-file import { BadRequestError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, } from '@forestadmin/datasource-toolkit'; -export class AIError extends UnprocessableError { - constructor(message: string) { - super(message); - this.name = 'AIError'; - } -} - export class AIBadRequestError extends BadRequestError { constructor(message: string) { super(message); @@ -41,54 +35,50 @@ export class AINotFoundError extends NotFoundError { } } -export class AIUnprocessableError extends UnprocessableError { +export class AIProviderError extends UnprocessableError { + readonly provider: string; readonly cause?: Error; - constructor(message: string, options?: { cause?: Error }) { - super(message); - this.name = 'AIUnprocessableError'; + constructor(provider: string, options?: { cause?: Error }) { + super(`Error while calling ${provider}: ${options?.cause?.message ?? 'unknown'}`); + this.name = 'AIProviderError'; + this.provider = provider; if (options?.cause) this.cause = options.cause; } } -export class AIProviderError extends AIUnprocessableError { +export class AITooManyRequestsError extends TooManyRequestsError { readonly provider: string; + readonly cause?: Error; constructor(provider: string, options?: { cause?: Error }) { - super(`Error while calling ${provider}: ${options?.cause?.message ?? 'unknown'}`, options); - this.name = 'AIProviderError'; + super(`${provider} rate limit exceeded`); + this.name = 'AITooManyRequestsError'; this.provider = provider; + if (options?.cause) this.cause = options.cause; } } -export class AITooManyRequestsError extends AIProviderError { - constructor(provider: string, options?: { cause?: Error }) { - super(provider, options); - this.message = `${provider} rate limit exceeded`; - this.name = 'AITooManyRequestsError'; - this.baseBusinessErrorName = 'TooManyRequestsError'; - this.httpCode = 429; - } -} +export class AIUnauthorizedError extends UnauthorizedError { + readonly provider: string; + readonly cause?: Error; -export class AIUnauthorizedError extends AIProviderError { constructor(provider: string, options?: { cause?: Error }) { - super(provider, options); - this.message = `${provider} authentication failed: check your API key configuration`; + super(`${provider} authentication failed: check your API key configuration`); this.name = 'AIUnauthorizedError'; - this.baseBusinessErrorName = 'UnauthorizedError'; - this.httpCode = 401; + this.provider = provider; + if (options?.cause) this.cause = options.cause; } } -export class AINotConfiguredError extends AIError { +export class AINotConfiguredError extends UnprocessableError { constructor(message = 'AI is not configured') { super(message); this.name = 'AINotConfiguredError'; } } -export class AIToolUnprocessableError extends AIUnprocessableError { +export class AIToolUnprocessableError extends UnprocessableError { constructor(message: string) { super(message); this.name = 'AIToolUnprocessableError'; @@ -102,7 +92,7 @@ export class AIToolNotFoundError extends AINotFoundError { } } -export class McpError extends AIError { +export class McpError extends UnprocessableError { constructor(message: string) { super(message); this.name = 'McpError'; diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 3d0237522f..1dbc7ebb60 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -8,6 +8,8 @@ import { ChatAnthropic } from '@langchain/anthropic'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; import { ChatOpenAI } from '@langchain/openai'; +import { BusinessError, UnprocessableError } from '@forestadmin/datasource-toolkit'; + import AnthropicAdapter from './anthropic-adapter'; import { AIBadRequestError, @@ -15,7 +17,6 @@ import { AIProviderError, AITooManyRequestsError, AIUnauthorizedError, - AIUnprocessableError, } from './errors'; import { LangChainAdapter } from './langchain-adapter'; @@ -107,7 +108,7 @@ export default class ProviderDispatcher { const rawResponse = response.additional_kwargs.__raw_response as ChatCompletionResponse; if (!rawResponse) { - throw new AIUnprocessableError( + throw new UnprocessableError( 'OpenAI response missing raw response data. This may indicate an API change.', ); } @@ -146,8 +147,7 @@ export default class ProviderDispatcher { } private static wrapProviderError(error: unknown, providerName: string): Error { - if (error instanceof AIUnprocessableError) return error; - if (error instanceof AIBadRequestError) return error; + if (error instanceof BusinessError) return error; if (!(error instanceof Error)) { return new AIProviderError(providerName, { cause: new Error(String(error)) }); diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index c2ec82f847..7316308a87 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -1,12 +1,13 @@ import { BadRequestError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, } from '@forestadmin/datasource-toolkit'; import { AIBadRequestError, - AIError, AIModelNotSupportedError, AINotConfiguredError, AINotFoundError, @@ -15,7 +16,6 @@ import { AIUnauthorizedError, AIToolNotFoundError, AIToolUnprocessableError, - AIUnprocessableError, McpConfigError, McpConflictError, McpConnectionError, @@ -24,20 +24,24 @@ import { describe('AI Error Hierarchy', () => { describe('UnprocessableError branch (422)', () => { - test('AIError extends UnprocessableError', () => { - const error = new AIError('test'); + test('AINotConfiguredError extends UnprocessableError', () => { + const error = new AINotConfiguredError(); expect(error).toBeInstanceOf(UnprocessableError); }); - test('AINotConfiguredError extends UnprocessableError via AIError', () => { - const error = new AINotConfiguredError(); - expect(error).toBeInstanceOf(AIError); + test('AIToolUnprocessableError extends UnprocessableError', () => { + const error = new AIToolUnprocessableError('test'); expect(error).toBeInstanceOf(UnprocessableError); }); - test('McpError extends UnprocessableError via AIError', () => { + test('AIProviderError extends UnprocessableError', () => { + const error = new AIProviderError('OpenAI', { cause: new Error('test') }); + expect(error).toBeInstanceOf(UnprocessableError); + expect(error.provider).toBe('OpenAI'); + }); + + test('McpError extends UnprocessableError', () => { const error = new McpError('test'); - expect(error).toBeInstanceOf(AIError); expect(error).toBeInstanceOf(UnprocessableError); }); @@ -58,39 +62,22 @@ describe('AI Error Hierarchy', () => { expect(error).toBeInstanceOf(McpError); expect(error).toBeInstanceOf(UnprocessableError); }); + }); - test('AIUnprocessableError extends UnprocessableError', () => { - const error = new AIUnprocessableError('test'); - expect(error).toBeInstanceOf(UnprocessableError); - }); - - test('AIToolUnprocessableError extends UnprocessableError via AIUnprocessableError', () => { - const error = new AIToolUnprocessableError('test'); - expect(error).toBeInstanceOf(AIUnprocessableError); - expect(error).toBeInstanceOf(UnprocessableError); - expect(error.name).toBe('AIToolUnprocessableError'); - }); - - test('AIProviderError extends UnprocessableError via AIUnprocessableError', () => { - const error = new AIProviderError('OpenAI', { cause: new Error('test') }); - expect(error).toBeInstanceOf(AIUnprocessableError); - expect(error).toBeInstanceOf(UnprocessableError); - expect(error.provider).toBe('OpenAI'); - }); - - test('AITooManyRequestsError extends AIProviderError with TooManyRequestsError semantics', () => { + describe('TooManyRequestsError branch (429)', () => { + test('AITooManyRequestsError extends TooManyRequestsError', () => { const error = new AITooManyRequestsError('OpenAI'); - expect(error).toBeInstanceOf(AIProviderError); - expect(error).toBeInstanceOf(UnprocessableError); - expect(error.baseBusinessErrorName).toBe('TooManyRequestsError'); + expect(error).toBeInstanceOf(TooManyRequestsError); + expect(error.provider).toBe('OpenAI'); expect(error.httpCode).toBe(429); }); + }); - test('AIUnauthorizedError extends AIProviderError with UnauthorizedError semantics', () => { + describe('UnauthorizedError branch (401)', () => { + test('AIUnauthorizedError extends UnauthorizedError', () => { const error = new AIUnauthorizedError('OpenAI'); - expect(error).toBeInstanceOf(AIProviderError); - expect(error).toBeInstanceOf(UnprocessableError); - expect(error.baseBusinessErrorName).toBe('UnauthorizedError'); + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.provider).toBe('OpenAI'); expect(error.httpCode).toBe(401); }); }); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index ad75f374b1..845e98e15d 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -151,6 +151,15 @@ describe('ProviderDispatcher', () => { }); describe('error handling', () => { + it('should not wrap BusinessError thrown during invocation', async () => { + const error = new AINotConfiguredError(); + invokeMock.mockRejectedValueOnce(error); + + const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); + + expect(thrown).toBe(error); + }); + it('should wrap generic errors as AIProviderError with cause', async () => { const original = new Error('OpenAI error'); invokeMock.mockRejectedValueOnce(original); From 20bfe169570aea85930f4e82b5a430c5c2ca9046 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Feb 2026 22:54:22 +0100 Subject: [PATCH 11/14] fix(ai-proxy): fix import order for eslint import/order rule Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/provider-dispatcher.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 1dbc7ebb60..22db9f177f 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -4,12 +4,11 @@ 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 { ChatAnthropic } from '@langchain/anthropic'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; import { ChatOpenAI } from '@langchain/openai'; -import { BusinessError, UnprocessableError } from '@forestadmin/datasource-toolkit'; - import AnthropicAdapter from './anthropic-adapter'; import { AIBadRequestError, From 67d1d94bd3a4706db574618745cb414b647a44e4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Feb 2026 08:24:28 +0100 Subject: [PATCH 12/14] fix(ai-proxy): add AIForbiddenError, surface provider messages, make fields readonly - Mark httpCode and baseBusinessErrorName as readonly on BusinessError - Add AIForbiddenError (403) for provider permission-denied responses - Include cause.message in AITooManyRequestsError and AIUnauthorizedError instead of hardcoded messages, with fallbacks - Use JSON.stringify for non-Error throws in wrapProviderError Co-Authored-By: Claude Opus 4.6 --- packages/agent-toolkit/src/errors.ts | 34 ++++++++-------- packages/ai-proxy/src/errors.ts | 17 +++++++- packages/ai-proxy/src/provider-dispatcher.ts | 4 +- packages/ai-proxy/test/errors.test.ts | 12 ++++++ .../ai-proxy/test/provider-dispatcher.test.ts | 39 ++++++++++++++++--- 5 files changed, 81 insertions(+), 25 deletions(-) diff --git a/packages/agent-toolkit/src/errors.ts b/packages/agent-toolkit/src/errors.ts index 38ec29fa07..063351f44a 100644 --- a/packages/agent-toolkit/src/errors.ts +++ b/packages/agent-toolkit/src/errors.ts @@ -2,16 +2,23 @@ export class BusinessError extends Error { // INTERNAL USAGES public readonly isBusinessError = true; - public baseBusinessErrorName: string; - public httpCode: number; + public readonly baseBusinessErrorName: string; + public readonly httpCode: number; public readonly data: Record | undefined; - constructor(message?: string, data?: Record, name?: string, httpCode = 422) { + constructor( + message?: string, + data?: Record, + name?: string, + httpCode = 422, + baseBusinessErrorName?: string, + ) { super(message); this.name = name ?? this.constructor.name; this.data = data; this.httpCode = httpCode; + this.baseBusinessErrorName = baseBusinessErrorName ?? this.constructor.name; } /** @@ -29,43 +36,36 @@ export class BusinessError extends Error { export class ValidationError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 400); - this.baseBusinessErrorName = 'ValidationError'; + super(message, data, name, 400, 'ValidationError'); } } export class BadRequestError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 400); - this.baseBusinessErrorName = 'BadRequestError'; + super(message, data, name, 400, 'BadRequestError'); } } export class UnprocessableError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 422); - this.baseBusinessErrorName = 'UnprocessableError'; + super(message, data, name, 422, 'UnprocessableError'); } } export class ForbiddenError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 403); - this.baseBusinessErrorName = 'ForbiddenError'; + super(message, data, name, 403, 'ForbiddenError'); } } export class NotFoundError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 404); - this.baseBusinessErrorName = 'NotFoundError'; + super(message, data, name, 404, 'NotFoundError'); } } export class UnauthorizedError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 401); - this.baseBusinessErrorName = 'UnauthorizedError'; + super(message, data, name, 401, 'UnauthorizedError'); } } export class TooManyRequestsError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name, 429); - this.baseBusinessErrorName = 'TooManyRequestsError'; + super(message, data, name, 429, 'TooManyRequestsError'); } } diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index c689867fed..2cf9be7916 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -6,6 +6,7 @@ // eslint-disable-next-line max-classes-per-file import { BadRequestError, + ForbiddenError, NotFoundError, TooManyRequestsError, UnauthorizedError, @@ -52,7 +53,7 @@ export class AITooManyRequestsError extends TooManyRequestsError { readonly cause?: Error; constructor(provider: string, options?: { cause?: Error }) { - super(`${provider} rate limit exceeded`); + super(`${provider} rate limit exceeded: ${options?.cause?.message ?? 'unknown reason'}`); this.name = 'AITooManyRequestsError'; this.provider = provider; if (options?.cause) this.cause = options.cause; @@ -64,13 +65,25 @@ export class AIUnauthorizedError extends UnauthorizedError { readonly cause?: Error; constructor(provider: string, options?: { cause?: Error }) { - super(`${provider} authentication failed: check your API key configuration`); + super(`${provider} authentication failed: ${options?.cause?.message ?? 'check your API key configuration'}`); this.name = 'AIUnauthorizedError'; this.provider = provider; if (options?.cause) this.cause = options.cause; } } +export class AIForbiddenError extends ForbiddenError { + readonly provider: string; + readonly cause?: Error; + + constructor(provider: string, options?: { cause?: Error }) { + super(`${provider} access denied: ${options?.cause?.message ?? 'permission denied'}`); + this.name = 'AIForbiddenError'; + this.provider = provider; + if (options?.cause) this.cause = options.cause; + } +} + export class AINotConfiguredError extends UnprocessableError { constructor(message = 'AI is not configured') { super(message); diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 22db9f177f..4d17d29e8e 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -12,6 +12,7 @@ import { ChatOpenAI } from '@langchain/openai'; import AnthropicAdapter from './anthropic-adapter'; import { AIBadRequestError, + AIForbiddenError, AINotConfiguredError, AIProviderError, AITooManyRequestsError, @@ -149,13 +150,14 @@ export default class ProviderDispatcher { if (error instanceof BusinessError) return error; if (!(error instanceof Error)) { - return new AIProviderError(providerName, { cause: new Error(String(error)) }); + return new AIProviderError(providerName, { cause: new Error(JSON.stringify(error)) }); } const { status } = error as Error & { status?: number }; if (status === 429) return new AITooManyRequestsError(providerName, { cause: error }); if (status === 401) return new AIUnauthorizedError(providerName, { cause: error }); + if (status === 403) return new AIForbiddenError(providerName, { cause: error }); return new AIProviderError(providerName, { cause: error }); } diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index 7316308a87..441574f27c 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -1,5 +1,6 @@ import { BadRequestError, + ForbiddenError, NotFoundError, TooManyRequestsError, UnauthorizedError, @@ -8,6 +9,7 @@ import { import { AIBadRequestError, + AIForbiddenError, AIModelNotSupportedError, AINotConfiguredError, AINotFoundError, @@ -82,6 +84,16 @@ describe('AI Error Hierarchy', () => { }); }); + describe('ForbiddenError branch (403)', () => { + test('AIForbiddenError extends ForbiddenError', () => { + const error = new AIForbiddenError('OpenAI', { cause: new Error('model access denied') }); + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.provider).toBe('OpenAI'); + expect(error.httpCode).toBe(403); + expect(error.message).toBe('OpenAI access denied: model access denied'); + }); + }); + describe('BadRequestError branch (400)', () => { test('AIBadRequestError extends BadRequestError', () => { const error = new AIBadRequestError('test'); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 845e98e15d..ce90a2fe7a 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -6,6 +6,7 @@ import { ChatOpenAI } from '@langchain/openai'; import { AIBadRequestError, + AIForbiddenError, AINotConfiguredError, AIProviderError, AITooManyRequestsError, @@ -179,7 +180,7 @@ describe('ProviderDispatcher', () => { const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); expect(thrown).toBeInstanceOf(AITooManyRequestsError); - expect(thrown.message).toBe('OpenAI rate limit exceeded'); + expect(thrown.message).toBe('OpenAI rate limit exceeded: Too many requests'); expect(thrown.provider).toBe('OpenAI'); expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); @@ -192,12 +193,25 @@ describe('ProviderDispatcher', () => { const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); expect(thrown).toBeInstanceOf(AIUnauthorizedError); - expect(thrown.message).toBe('OpenAI authentication failed: check your API key configuration'); + expect(thrown.message).toBe('OpenAI authentication failed: Invalid API key'); expect(thrown.provider).toBe('OpenAI'); expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); expect(thrown.cause).toBe(error); }); + it('should wrap 403 as AIForbiddenError', async () => { + const error = Object.assign(new Error('Access denied'), { status: 403 }); + invokeMock.mockRejectedValueOnce(error); + + const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); + + expect(thrown).toBeInstanceOf(AIForbiddenError); + expect(thrown.message).toBe('OpenAI access denied: Access denied'); + expect(thrown.provider).toBe('OpenAI'); + expect(thrown.baseBusinessErrorName).toBe('ForbiddenError'); + expect(thrown.cause).toBe(error); + }); + it('should throw when rawResponse is missing', async () => { invokeMock.mockResolvedValueOnce({ content: 'response', @@ -427,7 +441,7 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AITooManyRequestsError); - expect(thrown.message).toBe('Anthropic rate limit exceeded'); + expect(thrown.message).toBe('Anthropic rate limit exceeded: Too many requests'); expect(thrown.provider).toBe('Anthropic'); expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); @@ -442,12 +456,27 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AIUnauthorizedError); - expect(thrown.message).toBe('Anthropic authentication failed: check your API key configuration'); + expect(thrown.message).toBe('Anthropic authentication failed: Invalid API key'); expect(thrown.provider).toBe('Anthropic'); expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); expect(thrown.cause).toBe(error); }); + it('should wrap 403 as AIForbiddenError', async () => { + const error = Object.assign(new Error('Access denied'), { status: 403 }); + anthropicInvokeMock.mockRejectedValueOnce(error); + + const thrown = await dispatcher + .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) + .catch(e => e); + + expect(thrown).toBeInstanceOf(AIForbiddenError); + expect(thrown.message).toBe('Anthropic access denied: Access denied'); + expect(thrown.provider).toBe('Anthropic'); + expect(thrown.baseBusinessErrorName).toBe('ForbiddenError'); + expect(thrown.cause).toBe(error); + }); + it('should handle non-Error throws gracefully', async () => { anthropicInvokeMock.mockRejectedValueOnce('string error'); @@ -456,7 +485,7 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AIProviderError); - expect(thrown.message).toBe('Error while calling Anthropic: string error'); + expect(thrown.message).toBe('Error while calling Anthropic: "string error"'); }); it('should not wrap conversion errors as provider errors', async () => { From 46041ae48be6351d05b36130260b72c150aa3a50 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Feb 2026 08:30:36 +0100 Subject: [PATCH 13/14] fix(datasource-toolkit): pass baseBusinessErrorName via super() for readonly compatibility IntrospectionFormatError was assigning baseBusinessErrorName after super(), which fails now that the field is readonly on BusinessError. Co-Authored-By: Claude Opus 4.6 --- packages/datasource-toolkit/src/errors.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/datasource-toolkit/src/errors.ts b/packages/datasource-toolkit/src/errors.ts index 5ef14439ee..90ac0709d5 100644 --- a/packages/datasource-toolkit/src/errors.ts +++ b/packages/datasource-toolkit/src/errors.ts @@ -18,8 +18,7 @@ export class IntrospectionFormatError extends BusinessError { const message = `This version of introspection is newer than this package version. ` + `Please update ${sourcePackageName}`; - super(message); - this.baseBusinessErrorName = 'IntrospectionFormatError'; + super(message, undefined, undefined, 422, 'IntrospectionFormatError'); } /** @deprecated use name instead */ From de2330b3ae77c335c4d963b8ffce61753573ca32 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Feb 2026 08:41:28 +0100 Subject: [PATCH 14/14] fix(ai-proxy): fix prettier line length in AIUnauthorizedError constructor Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/errors.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index 2cf9be7916..6ab2f0149f 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -65,7 +65,11 @@ export class AIUnauthorizedError extends UnauthorizedError { readonly cause?: Error; constructor(provider: string, options?: { cause?: Error }) { - super(`${provider} authentication failed: ${options?.cause?.message ?? 'check your API key configuration'}`); + super( + `${provider} authentication failed: ${ + options?.cause?.message ?? 'check your API key configuration' + }`, + ); this.name = 'AIUnauthorizedError'; this.provider = provider; if (options?.cause) this.cause = options.cause;