diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index caf82a47..5e741547 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -28,6 +28,7 @@ import { ToolCallStatus, } from '../types.js'; import { isAtCommand } from '../utils/commandUtils.js'; +import { parseAndFormatApiError } from '../utils/errorParsing.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; @@ -467,7 +468,9 @@ export const useGeminiStream = ( addItem( { type: MessageType.ERROR, - text: `[Stream Error: ${getErrorMessage(error) || 'Unknown error'}]`, + text: parseAndFormatApiError( + getErrorMessage(error) || 'Unknown error', + ), }, userMessageTimestamp, ); diff --git a/packages/cli/src/ui/utils/errorParsing.test.ts b/packages/cli/src/ui/utils/errorParsing.test.ts new file mode 100644 index 00000000..afee5793 --- /dev/null +++ b/packages/cli/src/ui/utils/errorParsing.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parseAndFormatApiError } from './errorParsing.js'; + +describe('parseAndFormatApiError', () => { + it('should format a valid API error JSON', () => { + const errorMessage = + 'got status: 400 Bad Request. {"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}'; + const expected = + 'API Error: API key not valid. Please pass a valid API key. (Status: INVALID_ARGUMENT)'; + expect(parseAndFormatApiError(errorMessage)).toBe(expected); + }); + + it('should return the original message if it is not a JSON error', () => { + const errorMessage = 'This is a plain old error message'; + expect(parseAndFormatApiError(errorMessage)).toBe(errorMessage); + }); + + it('should return the original message for malformed JSON', () => { + const errorMessage = '[Stream Error: {"error": "malformed}'; + expect(parseAndFormatApiError(errorMessage)).toBe(errorMessage); + }); + + it('should handle JSON that does not match the ApiError structure', () => { + const errorMessage = '[Stream Error: {"not_an_error": "some other json"}]'; + expect(parseAndFormatApiError(errorMessage)).toBe(errorMessage); + }); + + it('should format a nested API error', () => { + const nestedErrorMessage = JSON.stringify({ + error: { + code: 429, + message: + "Gemini 2.5 Pro Preview doesn't have a free quota tier. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits.", + status: 'RESOURCE_EXHAUSTED', + }, + }); + + const errorMessage = JSON.stringify({ + error: { + code: 429, + message: nestedErrorMessage, + status: 'Too Many Requests', + }, + }); + + expect(parseAndFormatApiError(errorMessage)).toBe( + "API Error: Gemini 2.5 Pro Preview doesn't have a free quota tier. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. (Status: Too Many Requests)", + ); + }); +}); diff --git a/packages/cli/src/ui/utils/errorParsing.ts b/packages/cli/src/ui/utils/errorParsing.ts new file mode 100644 index 00000000..aec337b6 --- /dev/null +++ b/packages/cli/src/ui/utils/errorParsing.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ApiError { + error: { + code: number; + message: string; + status: string; + details: unknown[]; + }; +} + +function isApiError(error: unknown): error is ApiError { + return ( + typeof error === 'object' && + error !== null && + 'error' in error && + typeof (error as ApiError).error === 'object' && + 'message' in (error as ApiError).error + ); +} + +export function parseAndFormatApiError(errorMessage: string): string { + // The error message might be prefixed with some text, like "[Stream Error: ...]". + // We want to find the start of the JSON object. + const jsonStart = errorMessage.indexOf('{'); + if (jsonStart === -1) { + return errorMessage; // Not a JSON error, return as is. + } + + const jsonString = errorMessage.substring(jsonStart); + + try { + const error = JSON.parse(jsonString) as unknown; + if (isApiError(error)) { + let finalMessage = error.error.message; + try { + // See if the message is a stringified JSON with another error + const nestedError = JSON.parse(finalMessage) as unknown; + if (isApiError(nestedError)) { + finalMessage = nestedError.error.message; + } + } catch (_e) { + // It's not a nested JSON error, so we just use the message as is. + } + return `API Error: ${finalMessage} (Status: ${error.error.status})`; + } + } catch (_e) { + // Not a valid JSON, fall through and return the original message. + } + + return errorMessage; +}