Skip to content

Commit 4272a69

Browse files
refactor: unify Gemini/Vertex error handling via handleAiSdkError() (#11364)
1 parent ff89965 commit 4272a69

File tree

4 files changed

+121
-38
lines changed

4 files changed

+121
-38
lines changed

src/api/providers/gemini.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
convertToolsForAiSdk,
1919
processAiSdkStreamPart,
2020
mapToolChoice,
21+
handleAiSdkError,
2122
} from "../transform/ai-sdk"
2223
import { t } from "i18next"
2324
import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream"
@@ -216,15 +217,14 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
216217
}
217218
}
218219
} catch (error) {
219-
const errorMessage = error instanceof Error ? error.message : String(error)
220-
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
221-
TelemetryService.instance.captureException(apiError)
222-
223-
if (error instanceof Error) {
224-
throw new Error(t("common:errors.gemini.generate_stream", { error: error.message }))
225-
}
226-
227-
throw error
220+
throw handleAiSdkError(error, this.providerName, {
221+
onError: (msg) => {
222+
TelemetryService.instance.captureException(
223+
new ApiProviderError(msg, this.providerName, modelId, "createMessage"),
224+
)
225+
},
226+
formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }),
227+
})
228228
}
229229
}
230230

@@ -364,15 +364,14 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
364364

365365
return text
366366
} catch (error) {
367-
const errorMessage = error instanceof Error ? error.message : String(error)
368-
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
369-
TelemetryService.instance.captureException(apiError)
370-
371-
if (error instanceof Error) {
372-
throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message }))
373-
}
374-
375-
throw error
367+
throw handleAiSdkError(error, this.providerName, {
368+
onError: (msg) => {
369+
TelemetryService.instance.captureException(
370+
new ApiProviderError(msg, this.providerName, modelId, "completePrompt"),
371+
)
372+
},
373+
formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }),
374+
})
376375
}
377376
}
378377

src/api/providers/vertex.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
convertToolsForAiSdk,
1919
processAiSdkStreamPart,
2020
mapToolChoice,
21+
handleAiSdkError,
2122
} from "../transform/ai-sdk"
2223
import { t } from "i18next"
2324
import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream"
@@ -200,15 +201,14 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
200201
throw usageError
201202
}
202203
} catch (error) {
203-
const errorMessage = error instanceof Error ? error.message : String(error)
204-
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
205-
TelemetryService.instance.captureException(apiError)
206-
207-
if (error instanceof Error) {
208-
throw new Error(t("common:errors.gemini.generate_stream", { error: error.message }))
209-
}
210-
211-
throw error
204+
throw handleAiSdkError(error, this.providerName, {
205+
onError: (msg) => {
206+
TelemetryService.instance.captureException(
207+
new ApiProviderError(msg, this.providerName, modelId, "createMessage"),
208+
)
209+
},
210+
formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }),
211+
})
212212
}
213213
}
214214

@@ -348,15 +348,14 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
348348

349349
return text
350350
} catch (error) {
351-
const errorMessage = error instanceof Error ? error.message : String(error)
352-
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
353-
TelemetryService.instance.captureException(apiError)
354-
355-
if (error instanceof Error) {
356-
throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message }))
357-
}
358-
359-
throw error
351+
throw handleAiSdkError(error, this.providerName, {
352+
onError: (msg) => {
353+
TelemetryService.instance.captureException(
354+
new ApiProviderError(msg, this.providerName, modelId, "completePrompt"),
355+
)
356+
},
357+
formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }),
358+
})
360359
}
361360
}
362361

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,55 @@ describe("AI SDK conversion utilities", () => {
10551055

10561056
expect((result as any).cause).toBe(originalError)
10571057
})
1058+
1059+
it("should call onError with extracted message and original error", () => {
1060+
const originalError = new Error("Quota exceeded")
1061+
const onError = vi.fn()
1062+
1063+
handleAiSdkError(originalError, "Gemini", { onError })
1064+
1065+
expect(onError).toHaveBeenCalledOnce()
1066+
expect(onError).toHaveBeenCalledWith("Quota exceeded", originalError)
1067+
})
1068+
1069+
it("should use formatMessage to override default message format", () => {
1070+
const error = new Error("Rate limit hit")
1071+
const formatMessage = (msg: string) => `Custom: ${msg}`
1072+
1073+
const result = handleAiSdkError(error, "Vertex", { formatMessage })
1074+
1075+
expect(result.message).toBe("Custom: Rate limit hit")
1076+
})
1077+
1078+
it("should call onError and use formatMessage together", () => {
1079+
const originalError = {
1080+
name: "AI_APICallError",
1081+
message: "Forbidden",
1082+
status: 403,
1083+
}
1084+
const onError = vi.fn()
1085+
const formatMessage = (msg: string) => `Translated: ${msg}`
1086+
1087+
const result = handleAiSdkError(originalError, "Gemini", { onError, formatMessage })
1088+
1089+
// onError receives the extracted message
1090+
expect(onError).toHaveBeenCalledOnce()
1091+
expect(onError.mock.calls[0][0]).toContain("403")
1092+
expect(onError.mock.calls[0][1]).toBe(originalError)
1093+
1094+
// formatMessage overrides the thrown message
1095+
expect(result.message).toMatch(/^Translated:/)
1096+
1097+
// Status code is still preserved
1098+
expect((result as any).status).toBe(403)
1099+
})
1100+
1101+
it("should use default format when no options are provided", () => {
1102+
const error = new Error("Something broke")
1103+
const result = handleAiSdkError(error, "TestProvider")
1104+
1105+
expect(result.message).toBe("TestProvider: Something broke")
1106+
})
10581107
})
10591108

10601109
describe("extractMessageFromResponseBody", () => {

src/api/transform/ai-sdk.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -769,17 +769,53 @@ function getStatusCode(obj: unknown): number | undefined {
769769
return undefined
770770
}
771771

772+
/**
773+
* Optional configuration for `handleAiSdkError()` to support telemetry
774+
* capture and custom (e.g. i18n) message formatting without adding
775+
* direct dependencies to the shared transform layer.
776+
*/
777+
export interface HandleAiSdkErrorOptions {
778+
/**
779+
* Called with the extracted error message and the original error before
780+
* throwing. Use this to report to telemetry or structured logging.
781+
*
782+
* @example
783+
* onError: (msg) => {
784+
* TelemetryService.instance.captureException(
785+
* new ApiProviderError(msg, providerName, modelId, "createMessage"),
786+
* )
787+
* }
788+
*/
789+
onError?: (message: string, originalError: unknown) => void
790+
791+
/**
792+
* Custom message formatter. When provided, the returned string is used
793+
* as the thrown Error's message instead of the default
794+
* `${providerName}: ${extractedMessage}` format.
795+
*
796+
* @example
797+
* formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg })
798+
*/
799+
formatMessage?: (message: string) => string
800+
}
801+
772802
/**
773803
* Handle AI SDK errors by extracting the message and preserving status codes.
774804
* Returns an Error object with proper status preserved for retry logic.
775805
*
776806
* @param error - The AI SDK error to handle
777807
* @param providerName - The name of the provider for context
808+
* @param options - Optional telemetry / i18n hooks (see {@link HandleAiSdkErrorOptions})
778809
* @returns An Error with preserved status code
779810
*/
780-
export function handleAiSdkError(error: unknown, providerName: string): Error {
811+
export function handleAiSdkError(error: unknown, providerName: string, options?: HandleAiSdkErrorOptions): Error {
781812
const message = extractAiSdkErrorMessage(error)
782-
const wrappedError = new Error(`${providerName}: ${message}`)
813+
814+
// Fire telemetry / logging callback before constructing the thrown error
815+
options?.onError?.(message, error)
816+
817+
const displayMessage = options?.formatMessage ? options.formatMessage(message) : `${providerName}: ${message}`
818+
const wrappedError = new Error(displayMessage)
783819

784820
// Preserve status code for retry logic
785821
const anyError = error as any

0 commit comments

Comments
 (0)