Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }),
})
}
}

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

Expand Down
35 changes: 17 additions & 18 deletions src/api/providers/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }),
})
}
}

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

Expand Down
49 changes: 49 additions & 0 deletions src/api/transform/__tests__/ai-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
40 changes: 38 additions & 2 deletions src/api/transform/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +743 to +766
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing handleAiSdkError tests (lines 869-911) only cover the two-parameter signature. The new onError and formatMessage options have no test coverage. Worth adding a few cases to verify: (1) onError is called with the extracted message and original error, (2) formatMessage overrides the default ${providerName}: ${message} format, and (3) when neither option is provided, existing behavior is unchanged (already covered, but a combined test with both options would round things out).

Fix it with Roo Code or mention @roomote and request a fix.


/**
* 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
Expand Down
Loading