refactor: unify Gemini/Vertex error handling via handleAiSdkError() (#11364)

This commit is contained in:
Hannes Rudolph 2026-02-10 09:19:37 -07:00 committed by GitHub
parent ff89965f59
commit 4272a69726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 121 additions and 38 deletions

View file

@ -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 }),
})
}
}

View file

@ -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 }),
})
}
}

View file

@ -1055,6 +1055,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", () => {

View file

@ -769,17 +769,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