From b02da5dcde083e78f1007cf971d01c09d74f4eee Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 20:58:09 -0700 Subject: [PATCH 1/2] refactor: unify Gemini/Vertex error handling via handleAiSdkError() Enrich handleAiSdkError() with optional HandleAiSdkErrorOptions to support telemetry capture and i18n message formatting without adding dependencies to the shared transform layer. Migrate gemini.ts and vertex.ts outer catch blocks (createMessage and completePrompt) from bespoke ApiProviderError + i18n patterns to the shared handleAiSdkError() with onError and formatMessage callbacks. All 23 migrated AI SDK providers now use handleAiSdkError() for their outer error path. --- src/api/providers/gemini.ts | 35 ++++++++++++++++---------------- src/api/providers/vertex.ts | 35 ++++++++++++++++---------------- src/api/transform/ai-sdk.ts | 40 +++++++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 38 deletions(-) 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/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 From 265c24cf6168b0333bd25164b08fbbda5b1a44de Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 21:11:53 -0700 Subject: [PATCH 2/2] test: add coverage for HandleAiSdkErrorOptions (onError + formatMessage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback — adds 4 tests: - onError callback receives extracted message and original error - formatMessage overrides default message format - combined onError + formatMessage with status preservation - default format unchanged when no options provided --- src/api/transform/__tests__/ai-sdk.spec.ts | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) 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", () => {