diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 786222c39c..5a596eea68 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -18,6 +18,7 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, + handleAiSdkError, } from "../transform/ai-sdk" import { t } from "i18next" import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" @@ -216,15 +217,14 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(t("common:errors.gemini.generate_stream", { error: error.message })) - } - - throw error + throw handleAiSdkError(error, this.providerName, { + onError: (msg) => { + TelemetryService.instance.captureException( + new ApiProviderError(msg, this.providerName, modelId, "createMessage"), + ) + }, + formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), + }) } } @@ -364,15 +364,14 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message })) - } - - throw error + throw handleAiSdkError(error, this.providerName, { + onError: (msg) => { + TelemetryService.instance.captureException( + new ApiProviderError(msg, this.providerName, modelId, "completePrompt"), + ) + }, + formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), + }) } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 7f1b551485..390060ae1e 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -18,6 +18,7 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, + handleAiSdkError, } from "../transform/ai-sdk" import { t } from "i18next" import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" @@ -200,15 +201,14 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl throw usageError } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(t("common:errors.gemini.generate_stream", { error: error.message })) - } - - throw error + throw handleAiSdkError(error, this.providerName, { + onError: (msg) => { + TelemetryService.instance.captureException( + new ApiProviderError(msg, this.providerName, modelId, "createMessage"), + ) + }, + formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), + }) } } @@ -348,15 +348,14 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message })) - } - - throw error + throw handleAiSdkError(error, this.providerName, { + onError: (msg) => { + TelemetryService.instance.captureException( + new ApiProviderError(msg, this.providerName, modelId, "completePrompt"), + ) + }, + formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), + }) } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 8559f01ed2..8a5230c0ab 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -908,6 +908,55 @@ describe("AI SDK conversion utilities", () => { expect((result as any).cause).toBe(originalError) }) + + it("should call onError with extracted message and original error", () => { + const originalError = new Error("Quota exceeded") + const onError = vi.fn() + + handleAiSdkError(originalError, "Gemini", { onError }) + + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith("Quota exceeded", originalError) + }) + + it("should use formatMessage to override default message format", () => { + const error = new Error("Rate limit hit") + const formatMessage = (msg: string) => `Custom: ${msg}` + + const result = handleAiSdkError(error, "Vertex", { formatMessage }) + + expect(result.message).toBe("Custom: Rate limit hit") + }) + + it("should call onError and use formatMessage together", () => { + const originalError = { + name: "AI_APICallError", + message: "Forbidden", + status: 403, + } + const onError = vi.fn() + const formatMessage = (msg: string) => `Translated: ${msg}` + + const result = handleAiSdkError(originalError, "Gemini", { onError, formatMessage }) + + // onError receives the extracted message + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0][0]).toContain("403") + expect(onError.mock.calls[0][1]).toBe(originalError) + + // formatMessage overrides the thrown message + expect(result.message).toMatch(/^Translated:/) + + // Status code is still preserved + expect((result as any).status).toBe(403) + }) + + it("should use default format when no options are provided", () => { + const error = new Error("Something broke") + const result = handleAiSdkError(error, "TestProvider") + + expect(result.message).toBe("TestProvider: Something broke") + }) }) describe("extractMessageFromResponseBody", () => { diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 14b3ad6dbc..e7a61091c5 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -735,17 +735,53 @@ function getStatusCode(obj: unknown): number | undefined { return undefined } +/** + * Optional configuration for `handleAiSdkError()` to support telemetry + * capture and custom (e.g. i18n) message formatting without adding + * direct dependencies to the shared transform layer. + */ +export interface HandleAiSdkErrorOptions { + /** + * Called with the extracted error message and the original error before + * throwing. Use this to report to telemetry or structured logging. + * + * @example + * onError: (msg) => { + * TelemetryService.instance.captureException( + * new ApiProviderError(msg, providerName, modelId, "createMessage"), + * ) + * } + */ + onError?: (message: string, originalError: unknown) => void + + /** + * Custom message formatter. When provided, the returned string is used + * as the thrown Error's message instead of the default + * `${providerName}: ${extractedMessage}` format. + * + * @example + * formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }) + */ + formatMessage?: (message: string) => string +} + /** * Handle AI SDK errors by extracting the message and preserving status codes. * Returns an Error object with proper status preserved for retry logic. * * @param error - The AI SDK error to handle * @param providerName - The name of the provider for context + * @param options - Optional telemetry / i18n hooks (see {@link HandleAiSdkErrorOptions}) * @returns An Error with preserved status code */ -export function handleAiSdkError(error: unknown, providerName: string): Error { +export function handleAiSdkError(error: unknown, providerName: string, options?: HandleAiSdkErrorOptions): Error { const message = extractAiSdkErrorMessage(error) - const wrappedError = new Error(`${providerName}: ${message}`) + + // Fire telemetry / logging callback before constructing the thrown error + options?.onError?.(message, error) + + const displayMessage = options?.formatMessage ? options.formatMessage(message) : `${providerName}: ${message}` + const wrappedError = new Error(displayMessage) // Preserve status code for retry logic const anyError = error as any