From a59cf9ab14419b76091b648473ea3aee3de56ae8 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 8 May 2024 12:03:53 -0400 Subject: [PATCH] WIP using ErrorFactory --- packages/vertexai/src/api.test.ts | 8 +- packages/vertexai/src/api.ts | 4 +- packages/vertexai/src/errors.ts | 110 +++++++++++++++--- .../src/methods/chat-session-helpers.ts | 14 +-- .../src/methods/generate-content.test.ts | 11 +- .../vertexai/src/models/generative-model.ts | 6 +- .../vertexai/src/requests/request-helpers.ts | 6 +- .../vertexai/src/requests/request.test.ts | 15 ++- packages/vertexai/src/requests/request.ts | 30 +++-- .../vertexai/src/requests/response-helpers.ts | 10 +- .../vertexai/src/requests/stream-reader.ts | 6 +- 11 files changed, 160 insertions(+), 60 deletions(-) diff --git a/packages/vertexai/src/api.test.ts b/packages/vertexai/src/api.test.ts index 5c25cce7ef9..49205dfeaa6 100644 --- a/packages/vertexai/src/api.test.ts +++ b/packages/vertexai/src/api.test.ts @@ -19,7 +19,7 @@ import { getGenerativeModel } from './api'; import { expect } from 'chai'; import { VertexAI } from './public-types'; import { GenerativeModel } from './models/generative-model'; -import { VertexError } from './errors'; +import { VertexAIErrorCode } from './errors'; const fakeVertexAI: VertexAI = { app: { @@ -36,7 +36,7 @@ const fakeVertexAI: VertexAI = { describe('Top level API', () => { it('getGenerativeModel throws if no model is provided', () => { expect(() => getGenerativeModel(fakeVertexAI, {} as ModelParams)).to.throw( - VertexError.NO_MODEL + VertexAIErrorCode.NO_MODEL ); }); it('getGenerativeModel throws if no apiKey is provided', () => { @@ -46,7 +46,7 @@ describe('Top level API', () => { } as VertexAI; expect(() => getGenerativeModel(fakeVertexNoApiKey, { model: 'my-model' }) - ).to.throw(VertexError.NO_API_KEY); + ).to.throw(VertexAIErrorCode.NO_API_KEY); }); it('getGenerativeModel throws if no projectId is provided', () => { const fakeVertexNoProject = { @@ -55,7 +55,7 @@ describe('Top level API', () => { } as VertexAI; expect(() => getGenerativeModel(fakeVertexNoProject, { model: 'my-model' }) - ).to.throw(VertexError.NO_PROJECT_ID); + ).to.throw(VertexAIErrorCode.NO_PROJECT_ID); }); it('getGenerativeModel gets a GenerativeModel', () => { const genModel = getGenerativeModel(fakeVertexAI, { model: 'my-model' }); diff --git a/packages/vertexai/src/api.ts b/packages/vertexai/src/api.ts index 38409d598ba..0069bd63775 100644 --- a/packages/vertexai/src/api.ts +++ b/packages/vertexai/src/api.ts @@ -21,7 +21,7 @@ import { getModularInstance } from '@firebase/util'; import { DEFAULT_LOCATION, VERTEX_TYPE } from './constants'; import { VertexAIService } from './service'; import { VertexAI, VertexAIOptions } from './public-types'; -import { ERROR_FACTORY, VertexError } from './errors'; +import { createVertexError, VertexAIErrorCode } from './errors'; import { ModelParams, RequestOptions } from './types'; import { GenerativeModel } from './models/generative-model'; @@ -67,7 +67,7 @@ export function getGenerativeModel( requestOptions?: RequestOptions ): GenerativeModel { if (!modelParams.model) { - throw ERROR_FACTORY.create(VertexError.NO_MODEL); + throw createVertexError(VertexAIErrorCode.NO_MODEL); } return new GenerativeModel(vertexAI, modelParams, requestOptions); } diff --git a/packages/vertexai/src/errors.ts b/packages/vertexai/src/errors.ts index c0b9d83aaeb..0022ce3de0f 100644 --- a/packages/vertexai/src/errors.ts +++ b/packages/vertexai/src/errors.ts @@ -15,49 +15,125 @@ * limitations under the License. */ -import { ErrorFactory, ErrorMap } from '@firebase/util'; +import { ErrorFactory, ErrorMap, FirebaseError } from '@firebase/util'; import { GenerateContentResponse } from './types'; -export const enum VertexError { +export const enum VertexAIErrorCode { FETCH_ERROR = 'fetch-error', INVALID_CONTENT = 'invalid-content', NO_API_KEY = 'no-api-key', NO_MODEL = 'no-model', NO_PROJECT_ID = 'no-project-id', PARSE_FAILED = 'parse-failed', + BAD_RESPONSE = 'bad-response', RESPONSE_ERROR = 'response-error' } -const ERRORS: ErrorMap = { - [VertexError.FETCH_ERROR]: `Error fetching from {$url}: {$message}`, - [VertexError.INVALID_CONTENT]: `Content formatting error: {$message}`, - [VertexError.NO_API_KEY]: +const VertexAIErrorMessages: ErrorMap = { + [VertexAIErrorCode.FETCH_ERROR]: `Error fetching from {$url}: {$message}`, + [VertexAIErrorCode.INVALID_CONTENT]: `Content formatting error: {$message}`, + [VertexAIErrorCode.NO_API_KEY]: `The "apiKey" field is empty in the local Firebase config. Firebase VertexAI requires this field to` + `contain a valid API key.`, - [VertexError.NO_PROJECT_ID]: + [VertexAIErrorCode.NO_PROJECT_ID]: `The "projectId" field is empty in the local Firebase config. Firebase VertexAI requires this field to` + `contain a valid project ID.`, - [VertexError.NO_MODEL]: + [VertexAIErrorCode.NO_MODEL]: `Must provide a model name. ` + `Example: getGenerativeModel({ model: 'my-model-name' })`, - [VertexError.PARSE_FAILED]: `Parsing failed: {$message}`, - [VertexError.RESPONSE_ERROR]: + [VertexAIErrorCode.PARSE_FAILED]: `Parsing failed: {$message}`, + [VertexAIErrorCode.BAD_RESPONSE]: `Bad response from {$url}: [{$status} {$statusText}] {$message}`, + [VertexAIErrorCode.RESPONSE_ERROR]: `Response error: {$message}. Response body stored in ` + `error.customData.response` }; -interface ErrorParams { - [VertexError.FETCH_ERROR]: { url: string; message: string }; - [VertexError.INVALID_CONTENT]: { message: string }; - [VertexError.PARSE_FAILED]: { message: string }; - [VertexError.RESPONSE_ERROR]: { +/** + * Details object that may be included in an error response. + * @public + */ +interface ErrorDetails { + '@type'?: string; + reason?: string; + domain?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface VertexAIErrorParams { + [VertexAIErrorCode.FETCH_ERROR]: { url: string; message: string }; + [VertexAIErrorCode.INVALID_CONTENT]: { message: string }; + [VertexAIErrorCode.PARSE_FAILED]: { message: string }; + [VertexAIErrorCode.BAD_RESPONSE]: { + url: string; + status: number; + statusText: string; + message: string; + errorDetails?: ErrorDetails[]; + }; + [VertexAIErrorCode.RESPONSE_ERROR]: { message: string; response: GenerateContentResponse; }; } -export const ERROR_FACTORY = new ErrorFactory( +const ERROR_FACTORY = new ErrorFactory( 'vertexAI', 'VertexAI', - ERRORS + VertexAIErrorMessages ); + +/** + * An error returned by VertexAI. + * @public + */ +export class VertexAIError extends FirebaseError { + /** + * Error data specific that can be included in a VertexAIError + */ + customData: { + /** + * + */ + url?: string; + /** + * HTTP status code + */ + status?: string; + /** + * HTTP status text associated with an error + */ + statusText?: string; + /** + * Addtional error details originating from an HTTP response. + */ + errorDetails?: ErrorDetails[]; + /** + * Additonal context in the form of {@link GenerateContentResponse} + */ + response?: GenerateContentResponse; + } = {}; + + constructor( + code: K, + ...data: K extends keyof ErrorParams ? [ErrorParams[K]] : [] + ) { + super(firebaseError.code, firebaseError.message, firebaseError.customData); + this.customData = { ...firebaseError.customData } || {}; + } +} + +/** + * Create a VertexAIError. + * + * @param code A {@link VertexAIErrorCode} + * @param data Error data specific to the {@link VertexAIErrorParams} + * @returns VertexAIError + */ +export function createVertexError( + code: K, + ...data: K extends keyof VertexAIErrorParams ? [VertexAIErrorParams[K]] : [] +): VertexAIError { + const firebaseError = ERROR_FACTORY.create(code, ...data); + return new VertexAIError(code, ...data); +} diff --git a/packages/vertexai/src/methods/chat-session-helpers.ts b/packages/vertexai/src/methods/chat-session-helpers.ts index 0ac00ad0a1c..19c3506fcfd 100644 --- a/packages/vertexai/src/methods/chat-session-helpers.ts +++ b/packages/vertexai/src/methods/chat-session-helpers.ts @@ -16,7 +16,7 @@ */ import { Content, POSSIBLE_ROLES, Part, Role } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { createVertexError, VertexAIErrorCode } from '../errors'; // https://ai.google.dev/api/rest/v1beta/Content#part @@ -48,12 +48,12 @@ export function validateChatHistory(history: Content[]): void { for (const currContent of history) { const { role, parts } = currContent; if (!prevContent && role !== 'user') { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: `First content should be with role 'user', got ${role}` }); } if (!POSSIBLE_ROLES.includes(role)) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: `Each item should include role field. Got ${role} but valid roles are: ${JSON.stringify( POSSIBLE_ROLES )}` @@ -61,13 +61,13 @@ export function validateChatHistory(history: Content[]): void { } if (!Array.isArray(parts)) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: "Content should have 'parts' property with an array of Parts" }); } if (parts.length === 0) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: 'Each Content should have at least one part' }); } @@ -89,7 +89,7 @@ export function validateChatHistory(history: Content[]): void { const validParts = VALID_PARTS_PER_ROLE[role]; for (const key of VALID_PART_FIELDS) { if (!validParts.includes(key) && countFields[key] > 0) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: `Content with role '${role}' can't contain '${key}' part` }); } @@ -98,7 +98,7 @@ export function validateChatHistory(history: Content[]): void { if (prevContent) { const validPreviousContentRoles = VALID_PREVIOUS_CONTENT_ROLES[role]; if (!validPreviousContentRoles.includes(prevContent.role)) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: `Content with role '${role}' can't follow '${ prevContent.role }'. Valid previous roles: ${JSON.stringify( diff --git a/packages/vertexai/src/methods/generate-content.test.ts b/packages/vertexai/src/methods/generate-content.test.ts index 5503c172c96..589f0d030c5 100644 --- a/packages/vertexai/src/methods/generate-content.test.ts +++ b/packages/vertexai/src/methods/generate-content.test.ts @@ -30,6 +30,7 @@ import { } from '../types'; import { ApiSettings } from '../types/internal'; import { Task } from '../requests/request'; +import { VertexAIError, VertexAIErrorCode } from '../errors'; use(sinonChai); use(chaiAsPromised); @@ -211,9 +212,13 @@ describe('generateContent()', () => { status: 400, json: mockResponse.json } as Response); - await expect( - generateContent(fakeApiSettings, 'model', fakeRequestParams) - ).to.be.rejectedWith(/400.*invalid argument/); + try { + await generateContent(fakeApiSettings, 'model', fakeRequestParams); + } catch (e) { + expect((e as VertexAIError).code).to.include( + VertexAIErrorCode.BAD_RESPONSE + ); + } expect(mockFetch).to.be.called; }); }); diff --git a/packages/vertexai/src/models/generative-model.ts b/packages/vertexai/src/models/generative-model.ts index efd6719661b..f9efe753abe 100644 --- a/packages/vertexai/src/models/generative-model.ts +++ b/packages/vertexai/src/models/generative-model.ts @@ -42,7 +42,7 @@ import { formatSystemInstruction } from '../requests/request-helpers'; import { VertexAI } from '../public-types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { createVertexError, VertexAIErrorCode } from '../errors'; import { ApiSettings } from '../types/internal'; import { VertexAIService } from '../service'; @@ -66,9 +66,9 @@ export class GenerativeModel { requestOptions?: RequestOptions ) { if (!vertexAI.app?.options?.apiKey) { - throw ERROR_FACTORY.create(VertexError.NO_API_KEY); + throw createVertexError(VertexAIErrorCode.NO_API_KEY); } else if (!vertexAI.app?.options?.projectId) { - throw ERROR_FACTORY.create(VertexError.NO_PROJECT_ID); + throw createVertexError(VertexAIErrorCode.NO_PROJECT_ID); } else { this._apiSettings = { apiKey: vertexAI.app.options.apiKey, diff --git a/packages/vertexai/src/requests/request-helpers.ts b/packages/vertexai/src/requests/request-helpers.ts index 0b7ce4ed4d2..c9e0fae34f0 100644 --- a/packages/vertexai/src/requests/request-helpers.ts +++ b/packages/vertexai/src/requests/request-helpers.ts @@ -16,7 +16,7 @@ */ import { Content, GenerateContentRequest, Part } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { createVertexError, VertexAIErrorCode } from '../errors'; export function formatSystemInstruction( input?: string | Part | Content @@ -81,14 +81,14 @@ function assignRoleToPartsAndValidateSendMessageRequest( } if (hasUserContent && hasFunctionContent) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: 'Within a single message, FunctionResponse cannot be mixed with other type of part in the request for sending chat message.' }); } if (!hasUserContent && !hasFunctionContent) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { + throw createVertexError(VertexAIErrorCode.INVALID_CONTENT, { message: 'No content is provided for sending chat message.' }); } diff --git a/packages/vertexai/src/requests/request.test.ts b/packages/vertexai/src/requests/request.test.ts index d27c4e41252..f3ea0b9888d 100644 --- a/packages/vertexai/src/requests/request.test.ts +++ b/packages/vertexai/src/requests/request.test.ts @@ -22,6 +22,7 @@ import chaiAsPromised from 'chai-as-promised'; import { RequestUrl, Task, getHeaders, makeRequest } from './request'; import { ApiSettings } from '../types/internal'; import { DEFAULT_API_VERSION } from '../constants'; +import { VertexAIError, VertexAIErrorCode } from '../errors'; use(sinonChai); use(chaiAsPromised); @@ -233,8 +234,8 @@ describe('request methods', () => { statusText: 'AbortError' } as Response); - await expect( - makeRequest( + try { + await makeRequest( 'models/model-name', Task.GENERATE_CONTENT, fakeApiSettings, @@ -243,8 +244,14 @@ describe('request methods', () => { { timeout: 0 } - ) - ).to.be.rejectedWith('500 AbortError'); + ); + } catch (e) { + expect((e as VertexAIError).code).to.equal( + 'vertexAI/' + VertexAIErrorCode.BAD_RESPONSE + ); + expect((e as VertexAIError).message).to.include('AbortError'); + expect((e as VertexAIError).customData?.status).to.equal(500); + } expect(fetchStub).to.be.calledOnce; }); it('Network error, no response.json()', async () => { diff --git a/packages/vertexai/src/requests/request.ts b/packages/vertexai/src/requests/request.ts index ca78c16a383..a0b183d897d 100644 --- a/packages/vertexai/src/requests/request.ts +++ b/packages/vertexai/src/requests/request.ts @@ -16,7 +16,7 @@ */ import { RequestOptions } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { createVertexError, VertexAIErrorCode } from '../errors'; import { ApiSettings } from '../types/internal'; import { DEFAULT_API_VERSION, @@ -24,6 +24,7 @@ import { LANGUAGE_TAG, PACKAGE_VERSION } from '../constants'; +import { FirebaseError } from '@firebase/util'; export enum Task { GENERATE_CONTENT = 'generateContent', @@ -140,24 +141,35 @@ export async function makeRequest( response = await fetch(request.url, request.fetchOptions); if (!response.ok) { let message = ''; + let errorDetails; try { const json = await response.json(); message = json.error.message; if (json.error.details) { message += ` ${JSON.stringify(json.error.details)}`; + errorDetails = json.error.details; } } catch (e) { // ignored } - throw new Error(`[${response.status} ${response.statusText}] ${message}`); + + throw createVertexError(VertexAIErrorCode.BAD_RESPONSE, { + url: url.toString(), + status: response.status, + statusText: response.statusText, + message, + errorDetails + }); + } + } catch (e) { + let err = e as Error; + if (!(e as FirebaseError).code.includes(VertexAIErrorCode.BAD_RESPONSE)) { + err = createVertexError(VertexAIErrorCode.FETCH_ERROR, { + url: url.toString(), + message: (e as Error).message + }); } - } catch (caughtError) { - const e = caughtError as Error; - const err = ERROR_FACTORY.create(VertexError.FETCH_ERROR, { - url: url.toString(), - message: e.message - }); - err.stack = e.stack; + err.stack = (e as Error).stack; throw err; } return response; diff --git a/packages/vertexai/src/requests/response-helpers.ts b/packages/vertexai/src/requests/response-helpers.ts index 17a0071b008..8a2aff051be 100644 --- a/packages/vertexai/src/requests/response-helpers.ts +++ b/packages/vertexai/src/requests/response-helpers.ts @@ -22,7 +22,7 @@ import { GenerateContentCandidate, GenerateContentResponse } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { createVertexError, VertexAIErrorCode } from '../errors'; /** * Adds convenience helper methods to a response object, including stream @@ -41,14 +41,14 @@ export function addHelpers( ); } if (hadBadFinishReason(response.candidates[0])) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { + throw createVertexError(VertexAIErrorCode.RESPONSE_ERROR, { message: `${formatBlockErrorMessage(response)}`, response }); } return getText(response); } else if (response.promptFeedback) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { + throw createVertexError(VertexAIErrorCode.RESPONSE_ERROR, { message: `Text not available. ${formatBlockErrorMessage(response)}`, response }); @@ -65,14 +65,14 @@ export function addHelpers( ); } if (hadBadFinishReason(response.candidates[0])) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { + throw createVertexError(VertexAIErrorCode.RESPONSE_ERROR, { message: `${formatBlockErrorMessage(response)}`, response }); } return getFunctionCalls(response); } else if (response.promptFeedback) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { + throw createVertexError(VertexAIErrorCode.RESPONSE_ERROR, { message: `Function call not available. ${formatBlockErrorMessage( response )}`, diff --git a/packages/vertexai/src/requests/stream-reader.ts b/packages/vertexai/src/requests/stream-reader.ts index 0c070cfe0f2..1117936a27c 100644 --- a/packages/vertexai/src/requests/stream-reader.ts +++ b/packages/vertexai/src/requests/stream-reader.ts @@ -22,7 +22,7 @@ import { GenerateContentStreamResult, Part } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { createVertexError, VertexAIErrorCode } from '../errors'; import { addHelpers } from './response-helpers'; const responseLineRE = /^data\: (.*)(?:\n\n|\r\r|\r\n\r\n)/; @@ -93,7 +93,7 @@ export function getResponseStream( if (done) { if (currentText.trim()) { controller.error( - ERROR_FACTORY.create(VertexError.PARSE_FAILED, { + createVertexError(VertexAIErrorCode.PARSE_FAILED, { message: 'Failed to parse stream' }) ); @@ -111,7 +111,7 @@ export function getResponseStream( parsedResponse = JSON.parse(match[1]); } catch (e) { controller.error( - ERROR_FACTORY.create(VertexError.PARSE_FAILED, { + createVertexError(VertexAIErrorCode.PARSE_FAILED, { message: `Error parsing JSON response: "${match[1]}"` }) );