diff --git a/.changeset/light-cheetahs-arrive.md b/.changeset/light-cheetahs-arrive.md new file mode 100644 index 00000000000..a5636bd5a97 --- /dev/null +++ b/.changeset/light-cheetahs-arrive.md @@ -0,0 +1,5 @@ +--- +'@firebase/vertexai-preview': patch +--- + +Add a new VertexAI error type diff --git a/common/api-review/vertexai-preview.api.md b/common/api-review/vertexai-preview.api.md index b3ae09e8dc7..38de1d80ae3 100644 --- a/common/api-review/vertexai-preview.api.md +++ b/common/api-review/vertexai-preview.api.md @@ -7,6 +7,7 @@ import { AppCheckTokenResult } from '@firebase/app-check-interop-types'; import { FirebaseApp } from '@firebase/app'; import { FirebaseAuthTokenData } from '@firebase/auth-interop-types'; +import { FirebaseError } from '@firebase/util'; // @public export interface BaseParams { @@ -102,6 +103,16 @@ export interface EnhancedGenerateContentResponse extends GenerateContentResponse text: () => string; } +// @public +export interface ErrorDetails { + // (undocumented) + '@type'?: string; + [key: string]: unknown; + domain?: string; + metadata?: Record; + reason?: string; +} + // @public export interface FileData { // (undocumented) @@ -590,6 +601,35 @@ export interface VertexAI { location: string; } +// @public +export class VertexAIError extends FirebaseError { + constructor(code: VertexAIErrorCode, message: string, status?: number | undefined, statusText?: string | undefined, errorDetails?: ErrorDetails[] | undefined); + // (undocumented) + readonly code: VertexAIErrorCode; + // (undocumented) + readonly errorDetails?: ErrorDetails[] | undefined; + // (undocumented) + readonly message: string; + readonly stack?: string; + // (undocumented) + readonly status?: number | undefined; + // (undocumented) + readonly statusText?: string | undefined; +} + +// @public +export const enum VertexAIErrorCode { + ERROR = "error", + 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", + REQUEST_ERROR = "request-error", + RESPONSE_ERROR = "response-error" +} + // @public export interface VertexAIOptions { // (undocumented) diff --git a/docs-devsite/vertexai-preview.errordetails.md b/docs-devsite/vertexai-preview.errordetails.md new file mode 100644 index 00000000000..ad080a09595 --- /dev/null +++ b/docs-devsite/vertexai-preview.errordetails.md @@ -0,0 +1,66 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# ErrorDetails interface +Details object that may be included in an error response. + +Signature: + +```typescript +export interface ErrorDetails +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| ["@type"](./vertexai-preview.errordetails.md#errordetails"@type") | string | | +| [domain](./vertexai-preview.errordetails.md#errordetailsdomain) | string | The domain where the error occured. | +| [metadata](./vertexai-preview.errordetails.md#errordetailsmetadata) | Record<string, unknown> | Additonal metadata about the error. | +| [reason](./vertexai-preview.errordetails.md#errordetailsreason) | string | The reason for the error. | + +## ErrorDetails."@type" + +Signature: + +```typescript +'@type'?: string; +``` + +## ErrorDetails.domain + +The domain where the error occured. + +Signature: + +```typescript +domain?: string; +``` + +## ErrorDetails.metadata + +Additonal metadata about the error. + +Signature: + +```typescript +metadata?: Record; +``` + +## ErrorDetails.reason + +The reason for the error. + +Signature: + +```typescript +reason?: string; +``` diff --git a/docs-devsite/vertexai-preview.md b/docs-devsite/vertexai-preview.md index 1aba07d3719..078f57fd63d 100644 --- a/docs-devsite/vertexai-preview.md +++ b/docs-devsite/vertexai-preview.md @@ -27,6 +27,7 @@ The Vertex AI For Firebase Web SDK. | --- | --- | | [ChatSession](./vertexai-preview.chatsession.md#chatsession_class) | ChatSession class that enables sending chat messages and stores history of sent and received messages so far. | | [GenerativeModel](./vertexai-preview.generativemodel.md#generativemodel_class) | Class for generative model APIs. | +| [VertexAIError](./vertexai-preview.vertexaierror.md#vertexaierror_class) | Error class for the Firebase VertexAI SDK. | ## Enumerations @@ -41,6 +42,7 @@ The Vertex AI For Firebase Web SDK. | [HarmCategory](./vertexai-preview.md#harmcategory) | Harm categories that would cause prompts or candidates to be blocked. | | [HarmProbability](./vertexai-preview.md#harmprobability) | Probability that a prompt or candidate matches a harm category. | | [HarmSeverity](./vertexai-preview.md#harmseverity) | Harm severity levels. | +| [VertexAIErrorCode](./vertexai-preview.md#vertexaierrorcode) | Standardized error codes that [VertexAIError](./vertexai-preview.vertexaierror.md#vertexaierror_class) can have. | ## Interfaces @@ -54,6 +56,7 @@ The Vertex AI For Firebase Web SDK. | [CountTokensResponse](./vertexai-preview.counttokensresponse.md#counttokensresponse_interface) | Response from calling [GenerativeModel.countTokens()](./vertexai-preview.generativemodel.md#generativemodelcounttokens). | | [Date\_2](./vertexai-preview.date_2.md#date_2_interface) | Protobuf google.type.Date | | [EnhancedGenerateContentResponse](./vertexai-preview.enhancedgeneratecontentresponse.md#enhancedgeneratecontentresponse_interface) | Response object wrapped with helper methods. | +| [ErrorDetails](./vertexai-preview.errordetails.md#errordetails_interface) | Details object that may be included in an error response. | | [FileData](./vertexai-preview.filedata.md#filedata_interface) | Data pointing to a file uploaded on Google Cloud Storage. | | [FileDataPart](./vertexai-preview.filedatapart.md#filedatapart_interface) | Content part interface if the part represents [FileData](./vertexai-preview.filedata.md#filedata_interface) | | [FunctionCall](./vertexai-preview.functioncall.md#functioncall_interface) | A predicted [FunctionCall](./vertexai-preview.functioncall.md#functioncall_interface) returned from the model that contains a string representing the [FunctionDeclaration.name](./vertexai-preview.functiondeclaration.md#functiondeclarationname) and a structured JSON object containing the parameters and their values. | @@ -367,3 +370,27 @@ export declare enum HarmSeverity | HARM\_SEVERITY\_NEGLIGIBLE | "HARM_SEVERITY_NEGLIGIBLE" | | | HARM\_SEVERITY\_UNSPECIFIED | "HARM_SEVERITY_UNSPECIFIED" | | +## VertexAIErrorCode + +Standardized error codes that [VertexAIError](./vertexai-preview.vertexaierror.md#vertexaierror_class) can have. + +Signature: + +```typescript +export declare const enum VertexAIErrorCode +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| ERROR | "error" | A generic error occured. | +| FETCH\_ERROR | "fetch-error" | An error occurred while performing a fetch. | +| INVALID\_CONTENT | "invalid-content" | An error associated with a Content object. | +| NO\_API\_KEY | "no-api-key" | An error occured due to a missing api key. | +| NO\_MODEL | "no-model" | An error occurred due to a missing model. | +| NO\_PROJECT\_ID | "no-project-id" | An error occured due to a missing project id. | +| PARSE\_FAILED | "parse-failed" | An error occured while parsing. | +| REQUEST\_ERROR | "request-error" | An error occurred in a request. | +| RESPONSE\_ERROR | "response-error" | An error occured in a response. | + diff --git a/docs-devsite/vertexai-preview.vertexaierror.md b/docs-devsite/vertexai-preview.vertexaierror.md new file mode 100644 index 00000000000..9d8c5376728 --- /dev/null +++ b/docs-devsite/vertexai-preview.vertexaierror.md @@ -0,0 +1,107 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# VertexAIError class +Error class for the Firebase VertexAI SDK. + +Signature: + +```typescript +export declare class VertexAIError extends FirebaseError +``` +Extends: [FirebaseError](./util.firebaseerror.md#firebaseerror_class) + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(code, message, status, statusText, errorDetails)](./vertexai-preview.vertexaierror.md#vertexaierrorconstructor) | | Creates a new VertexAIError instance. | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [code](./vertexai-preview.vertexaierror.md#vertexaierrorcode) | | [VertexAIErrorCode](./vertexai-preview.md#vertexaierrorcode) | | +| [errorDetails](./vertexai-preview.vertexaierror.md#vertexaierrorerrordetails) | | [ErrorDetails](./vertexai-preview.errordetails.md#errordetails_interface)\[\] \| undefined | | +| [message](./vertexai-preview.vertexaierror.md#vertexaierrormessage) | | string | | +| [stack](./vertexai-preview.vertexaierror.md#vertexaierrorstack) | | string | Stack trace of the error. | +| [status](./vertexai-preview.vertexaierror.md#vertexaierrorstatus) | | number \| undefined | | +| [statusText](./vertexai-preview.vertexaierror.md#vertexaierrorstatustext) | | string \| undefined | | + +## VertexAIError.(constructor) + +Creates a new VertexAIError instance. + +Signature: + +```typescript +constructor(code: VertexAIErrorCode, message: string, status?: number | undefined, statusText?: string | undefined, errorDetails?: ErrorDetails[] | undefined); +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| code | [VertexAIErrorCode](./vertexai-preview.md#vertexaierrorcode) | The error code from [VertexAIErrorCode](./vertexai-preview.md#vertexaierrorcode). | +| message | string | A human-readable message describing the error. | +| status | number \| undefined | Optional HTTP status code of the error response. | +| statusText | string \| undefined | Optional HTTP status text of the error response. | +| errorDetails | [ErrorDetails](./vertexai-preview.errordetails.md#errordetails_interface)\[\] \| undefined | Optional additional details about the error. | + +## VertexAIError.code + +Signature: + +```typescript +readonly code: VertexAIErrorCode; +``` + +## VertexAIError.errorDetails + +Signature: + +```typescript +readonly errorDetails?: ErrorDetails[] | undefined; +``` + +## VertexAIError.message + +Signature: + +```typescript +readonly message: string; +``` + +## VertexAIError.stack + +Stack trace of the error. + +Signature: + +```typescript +readonly stack?: string; +``` + +## VertexAIError.status + +Signature: + +```typescript +readonly status?: number | undefined; +``` + +## VertexAIError.statusText + +Signature: + +```typescript +readonly statusText?: string | undefined; +``` diff --git a/packages/vertexai/src/api.test.ts b/packages/vertexai/src/api.test.ts index 5c25cce7ef9..2a06c867174 100644 --- a/packages/vertexai/src/api.test.ts +++ b/packages/vertexai/src/api.test.ts @@ -14,12 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ModelParams } from './types'; +import { ModelParams, VertexAIError, VertexAIErrorCode } from './types'; import { getGenerativeModel } from './api'; import { expect } from 'chai'; import { VertexAI } from './public-types'; import { GenerativeModel } from './models/generative-model'; -import { VertexError } from './errors'; const fakeVertexAI: VertexAI = { app: { @@ -35,27 +34,42 @@ 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 - ); + try { + getGenerativeModel(fakeVertexAI, {} as ModelParams); + } catch (e) { + expect((e as VertexAIError).code).includes(VertexAIErrorCode.NO_MODEL); + expect((e as VertexAIError).message).equals('Missing model parameter'); + } }); it('getGenerativeModel throws if no apiKey is provided', () => { const fakeVertexNoApiKey = { ...fakeVertexAI, app: { options: { projectId: 'my-project' } } } as VertexAI; - expect(() => - getGenerativeModel(fakeVertexNoApiKey, { model: 'my-model' }) - ).to.throw(VertexError.NO_API_KEY); + try { + getGenerativeModel(fakeVertexNoApiKey, { model: 'my-model' }); + } catch (e) { + expect((e as VertexAIError).code).includes(VertexAIErrorCode.NO_API_KEY); + expect((e as VertexAIError).message).equals( + 'Missing Firebase app API key' + ); + } }); it('getGenerativeModel throws if no projectId is provided', () => { const fakeVertexNoProject = { ...fakeVertexAI, app: { options: { apiKey: 'my-key' } } } as VertexAI; - expect(() => - getGenerativeModel(fakeVertexNoProject, { model: 'my-model' }) - ).to.throw(VertexError.NO_PROJECT_ID); + try { + getGenerativeModel(fakeVertexNoProject, { model: 'my-model' }); + } catch (e) { + expect((e as VertexAIError).code).includes( + VertexAIErrorCode.NO_PROJECT_ID + ); + expect((e as VertexAIError).message).equals( + 'Missing Firebase app 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 5b9620969b8..c9d62680dc1 100644 --- a/packages/vertexai/src/api.ts +++ b/packages/vertexai/src/api.ts @@ -21,8 +21,12 @@ 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 { ModelParams, RequestOptions } from './types'; +import { + ModelParams, + RequestOptions, + VertexAIError, + VertexAIErrorCode +} from './types'; import { GenerativeModel } from './models/generative-model'; export { ChatSession } from './methods/chat-session'; @@ -67,7 +71,10 @@ export function getGenerativeModel( requestOptions?: RequestOptions ): GenerativeModel { if (!modelParams.model) { - throw ERROR_FACTORY.create(VertexError.NO_MODEL); + throw new VertexAIError( + VertexAIErrorCode.NO_MODEL, + 'Missing model parameter' + ); } return new GenerativeModel(vertexAI, modelParams, requestOptions); } diff --git a/packages/vertexai/src/errors.ts b/packages/vertexai/src/errors.ts deleted file mode 100644 index c0b9d83aaeb..00000000000 --- a/packages/vertexai/src/errors.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @license - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ErrorFactory, ErrorMap } from '@firebase/util'; -import { GenerateContentResponse } from './types'; - -export const enum VertexError { - 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', - 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]: - `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]: - `The "projectId" field is empty in the local Firebase config. Firebase VertexAI requires this field to` + - `contain a valid project ID.`, - [VertexError.NO_MODEL]: - `Must provide a model name. ` + - `Example: getGenerativeModel({ model: 'my-model-name' })`, - [VertexError.PARSE_FAILED]: `Parsing failed: {$message}`, - [VertexError.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]: { - message: string; - response: GenerateContentResponse; - }; -} - -export const ERROR_FACTORY = new ErrorFactory( - 'vertexAI', - 'VertexAI', - ERRORS -); diff --git a/packages/vertexai/src/methods/chat-session-helpers.ts b/packages/vertexai/src/methods/chat-session-helpers.ts index 0ac00ad0a1c..c558d8685e6 100644 --- a/packages/vertexai/src/methods/chat-session-helpers.ts +++ b/packages/vertexai/src/methods/chat-session-helpers.ts @@ -15,8 +15,14 @@ * limitations under the License. */ -import { Content, POSSIBLE_ROLES, Part, Role } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { + Content, + POSSIBLE_ROLES, + Part, + Role, + VertexAIError, + VertexAIErrorCode +} from '../types'; // https://ai.google.dev/api/rest/v1beta/Content#part @@ -48,28 +54,32 @@ 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, { - message: `First content should be with role 'user', got ${role}` - }); + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + `First content should be with role 'user', got ${role}` + ); } if (!POSSIBLE_ROLES.includes(role)) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { - message: `Each item should include role field. Got ${role} but valid roles are: ${JSON.stringify( + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + `Each item should include role field. Got ${role} but valid roles are: ${JSON.stringify( POSSIBLE_ROLES )}` - }); + ); } if (!Array.isArray(parts)) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { - message: "Content should have 'parts' property with an array of Parts" - }); + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + `Content should have 'parts' but property with an array of Parts` + ); } if (parts.length === 0) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { - message: 'Each Content should have at least one part' - }); + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + `Each content should have at least one part` + ); } const countFields: Record = { @@ -89,22 +99,24 @@ 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, { - message: `Content with role '${role}' can't contain '${key}' part` - }); + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + `Content with role '${role}' can't contain '${key}' part` + ); } } if (prevContent) { const validPreviousContentRoles = VALID_PREVIOUS_CONTENT_ROLES[role]; if (!validPreviousContentRoles.includes(prevContent.role)) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { - message: `Content with role '${role}' can't follow '${ + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + `Content with role '${role} can't follow '${ prevContent.role }'. Valid previous roles: ${JSON.stringify( VALID_PREVIOUS_CONTENT_ROLES )}` - }); + ); } } prevContent = currContent; diff --git a/packages/vertexai/src/models/generative-model.ts b/packages/vertexai/src/models/generative-model.ts index efd6719661b..1deb8e2ae85 100644 --- a/packages/vertexai/src/models/generative-model.ts +++ b/packages/vertexai/src/models/generative-model.ts @@ -33,7 +33,9 @@ import { SafetySetting, StartChatParams, Tool, - ToolConfig + ToolConfig, + VertexAIError, + VertexAIErrorCode } from '../types'; import { ChatSession } from '../methods/chat-session'; import { countTokens } from '../methods/count-tokens'; @@ -42,7 +44,6 @@ import { formatSystemInstruction } from '../requests/request-helpers'; import { VertexAI } from '../public-types'; -import { ERROR_FACTORY, VertexError } from '../errors'; import { ApiSettings } from '../types/internal'; import { VertexAIService } from '../service'; @@ -66,9 +67,15 @@ export class GenerativeModel { requestOptions?: RequestOptions ) { if (!vertexAI.app?.options?.apiKey) { - throw ERROR_FACTORY.create(VertexError.NO_API_KEY); + throw new VertexAIError( + VertexAIErrorCode.NO_API_KEY, + 'Missing Firebase app API key' + ); } else if (!vertexAI.app?.options?.projectId) { - throw ERROR_FACTORY.create(VertexError.NO_PROJECT_ID); + throw new VertexAIError( + VertexAIErrorCode.NO_PROJECT_ID, + 'Missing Firebase app 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..412ce26429b 100644 --- a/packages/vertexai/src/requests/request-helpers.ts +++ b/packages/vertexai/src/requests/request-helpers.ts @@ -15,8 +15,13 @@ * limitations under the License. */ -import { Content, GenerateContentRequest, Part } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { + Content, + GenerateContentRequest, + Part, + VertexAIError, + VertexAIErrorCode +} from '../types'; export function formatSystemInstruction( input?: string | Part | Content @@ -81,16 +86,17 @@ function assignRoleToPartsAndValidateSendMessageRequest( } if (hasUserContent && hasFunctionContent) { - throw ERROR_FACTORY.create(VertexError.INVALID_CONTENT, { - message: - 'Within a single message, FunctionResponse cannot be mixed with other type of part in the request for sending chat message.' - }); + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + '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, { - message: 'No content is provided for sending chat message.' - }); + throw new VertexAIError( + VertexAIErrorCode.INVALID_CONTENT, + 'No content is provided for sending chat message.' + ); } if (hasUserContent) { diff --git a/packages/vertexai/src/requests/request.test.ts b/packages/vertexai/src/requests/request.test.ts index d27c4e41252..b8ae9d12c25 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 '../types'; 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,16 @@ describe('request methods', () => { { timeout: 0 } - ) - ).to.be.rejectedWith('500 AbortError'); + ); + } catch (e) { + expect((e as VertexAIError).code).to.equal( + VertexAIErrorCode.FETCH_ERROR + ); + expect((e as VertexAIError).status).to.equal(500); + expect((e as VertexAIError).statusText).to.equal('AbortError'); + expect((e as VertexAIError).message).to.include('500 AbortError'); + } + expect(fetchStub).to.be.calledOnce; }); it('Network error, no response.json()', async () => { @@ -253,15 +262,22 @@ describe('request methods', () => { status: 500, statusText: 'Server Error' } as Response); - await expect( - makeRequest( + try { + await makeRequest( 'models/model-name', Task.GENERATE_CONTENT, fakeApiSettings, false, '' - ) - ).to.be.rejectedWith(/500 Server Error/); + ); + } catch (e) { + expect((e as VertexAIError).code).to.equal( + VertexAIErrorCode.FETCH_ERROR + ); + expect((e as VertexAIError).status).to.equal(500); + expect((e as VertexAIError).statusText).to.equal('Server Error'); + expect((e as VertexAIError).message).to.include('500 Server Error'); + } expect(fetchStub).to.be.calledOnce; }); it('Network error, includes response.json()', async () => { @@ -271,15 +287,23 @@ describe('request methods', () => { statusText: 'Server Error', json: () => Promise.resolve({ error: { message: 'extra info' } }) } as Response); - await expect( - makeRequest( + try { + await makeRequest( 'models/model-name', Task.GENERATE_CONTENT, fakeApiSettings, false, '' - ) - ).to.be.rejectedWith(/500 Server Error.*extra info/); + ); + } catch (e) { + expect((e as VertexAIError).code).to.equal( + VertexAIErrorCode.FETCH_ERROR + ); + expect((e as VertexAIError).status).to.equal(500); + expect((e as VertexAIError).statusText).to.equal('Server Error'); + expect((e as VertexAIError).message).to.include('500 Server Error'); + expect((e as VertexAIError).message).to.include('extra info'); + } expect(fetchStub).to.be.calledOnce; }); it('Network error, includes response.json() and details', async () => { @@ -301,17 +325,26 @@ describe('request methods', () => { } }) } as Response); - await expect( - makeRequest( + try { + await makeRequest( 'models/model-name', Task.GENERATE_CONTENT, fakeApiSettings, false, '' - ) - ).to.be.rejectedWith( - /500 Server Error.*extra info.*generic::invalid_argument/ - ); + ); + } catch (e) { + expect((e as VertexAIError).code).to.equal( + VertexAIErrorCode.FETCH_ERROR + ); + expect((e as VertexAIError).status).to.equal(500); + expect((e as VertexAIError).statusText).to.equal('Server Error'); + expect((e as VertexAIError).message).to.include('500 Server Error'); + expect((e as VertexAIError).message).to.include('extra info'); + expect((e as VertexAIError).message).to.include( + 'generic::invalid_argument' + ); + } expect(fetchStub).to.be.calledOnce; }); }); diff --git a/packages/vertexai/src/requests/request.ts b/packages/vertexai/src/requests/request.ts index ca78c16a383..3649e95756a 100644 --- a/packages/vertexai/src/requests/request.ts +++ b/packages/vertexai/src/requests/request.ts @@ -15,8 +15,7 @@ * limitations under the License. */ -import { RequestOptions } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { RequestOptions, VertexAIError, VertexAIErrorCode } from '../types'; import { ApiSettings } from '../types/internal'; import { DEFAULT_API_VERSION, @@ -24,6 +23,7 @@ import { LANGUAGE_TAG, PACKAGE_VERSION } from '../constants'; +import { FirebaseError } from '@firebase/util'; export enum Task { GENERATE_CONTENT = 'generateContent', @@ -140,24 +140,38 @@ 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 new VertexAIError( + VertexAIErrorCode.FETCH_ERROR, + `Error fetching from ${url}: [${response.status} ${response.statusText}] ${message}`, + response.status, + response.statusText, + errorDetails + ); } - } 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; + } catch (e) { + let err = e as Error; + if ( + (e as FirebaseError).code !== VertexAIErrorCode.FETCH_ERROR && + e instanceof Error + ) { + err = new VertexAIError( + VertexAIErrorCode.ERROR, + `Error fetching from ${url.toString()}: ${e.message}` + ); + err.stack = e.stack; + } + throw err; } return response; diff --git a/packages/vertexai/src/requests/response-helpers.ts b/packages/vertexai/src/requests/response-helpers.ts index dc49123420f..2327222f1a5 100644 --- a/packages/vertexai/src/requests/response-helpers.ts +++ b/packages/vertexai/src/requests/response-helpers.ts @@ -20,9 +20,10 @@ import { FinishReason, FunctionCall, GenerateContentCandidate, - GenerateContentResponse + GenerateContentResponse, + VertexAIError, + VertexAIErrorCode } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; /** * Adds convenience helper methods to a response object, including stream @@ -41,17 +42,19 @@ export function addHelpers( ); } if (hadBadFinishReason(response.candidates[0])) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { - message: `${formatBlockErrorMessage(response)}`, - response - }); + throw new VertexAIError( + VertexAIErrorCode.REQUEST_ERROR, + `Response error: ${formatBlockErrorMessage( + response + )}. Response body stored in error.customData.response` + ); } return getText(response); } else if (response.promptFeedback) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { - message: `Text not available. ${formatBlockErrorMessage(response)}`, - response - }); + throw new VertexAIError( + VertexAIErrorCode.RESPONSE_ERROR, + `Text not available. ${formatBlockErrorMessage(response)}` + ); } return ''; }; @@ -65,19 +68,17 @@ export function addHelpers( ); } if (hadBadFinishReason(response.candidates[0])) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { - message: `${formatBlockErrorMessage(response)}`, - response - }); + throw new VertexAIError( + VertexAIErrorCode.RESPONSE_ERROR, + `${formatBlockErrorMessage(response)}` + ); } return getFunctionCalls(response); } else if (response.promptFeedback) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { - message: `Function call not available. ${formatBlockErrorMessage( - response - )}`, - response - }); + throw new VertexAIError( + VertexAIErrorCode.RESPONSE_ERROR, + `Function call not available. ${formatBlockErrorMessage(response)}` + ); } return undefined; }; diff --git a/packages/vertexai/src/requests/stream-reader.ts b/packages/vertexai/src/requests/stream-reader.ts index 0c070cfe0f2..77a2ccd504e 100644 --- a/packages/vertexai/src/requests/stream-reader.ts +++ b/packages/vertexai/src/requests/stream-reader.ts @@ -20,9 +20,10 @@ import { GenerateContentCandidate, GenerateContentResponse, GenerateContentStreamResult, - Part + Part, + VertexAIError, + VertexAIErrorCode } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; import { addHelpers } from './response-helpers'; const responseLineRE = /^data\: (.*)(?:\n\n|\r\r|\r\n\r\n)/; @@ -93,9 +94,10 @@ export function getResponseStream( if (done) { if (currentText.trim()) { controller.error( - ERROR_FACTORY.create(VertexError.PARSE_FAILED, { - message: 'Failed to parse stream' - }) + new VertexAIError( + VertexAIErrorCode.PARSE_FAILED, + 'Failed to parse stream' + ) ); return; } @@ -111,9 +113,10 @@ export function getResponseStream( parsedResponse = JSON.parse(match[1]); } catch (e) { controller.error( - ERROR_FACTORY.create(VertexError.PARSE_FAILED, { - message: `Error parsing JSON response: "${match[1]}"` - }) + new VertexAIError( + VertexAIErrorCode.PARSE_FAILED, + `Error parsing JSON response: "${match[1]}` + ) ); return; } diff --git a/packages/vertexai/src/types/error.ts b/packages/vertexai/src/types/error.ts new file mode 100644 index 00000000000..fc70961db8f --- /dev/null +++ b/packages/vertexai/src/types/error.ts @@ -0,0 +1,110 @@ +import { FirebaseError } from "@firebase/util"; + +/** + * Details object that may be included in an error response. + * + * @public + */ +export interface ErrorDetails { + '@type'?: string; + + /** The reason for the error. */ + reason?: string; + + /** The domain where the error occured. */ + domain?: string; + + /** Additonal metadata about the error. */ + metadata?: Record; + + /** Any other relevant information about the error. */ + [key: string]: unknown; +} + +/** + * Standardized error codes that {@link VertexAIError} can have. + * + * @public + */ +export const enum VertexAIErrorCode { + /** A generic error occured. */ + ERROR = 'error', + + /** An error occurred in a request. */ + REQUEST_ERROR = 'request-error', + + /** An error occured in a response. */ + RESPONSE_ERROR = 'response-error', + + /** An error occurred while performing a fetch. */ + FETCH_ERROR = 'fetch-error', + + /** An error associated with a Content object. */ + INVALID_CONTENT = 'invalid-content', + + /** An error occured due to a missing api key. */ + NO_API_KEY = 'no-api-key', + + /** An error occurred due to a missing model. */ + NO_MODEL = 'no-model', + + /** An error occured due to a missing project id. */ + NO_PROJECT_ID = 'no-project-id', + + /** An error occured while parsing. */ + PARSE_FAILED = 'parse-failed' +} + + +/** + * Error class for the Firebase VertexAI SDK. + * + * @public + */ +export class VertexAIError extends FirebaseError { + /** + * Stack trace of the error. + */ + readonly stack?: string; + + /** + * Creates a new VertexAIError instance. + * + * @param code - The error code from {@link VertexAIErrorCode}. + * @param message - A human-readable message describing the error. + * @param status - Optional HTTP status code of the error response. + * @param statusText - Optional HTTP status text of the error response. + * @param errorDetails - Optional additional details about the error. + */ + constructor( + readonly code: VertexAIErrorCode, + readonly message: string, + readonly status?: number, + readonly statusText?: string, + readonly errorDetails?: ErrorDetails[] + ) { + // Match error format used by FirebaseError from ErrorFactory + const service = 'vertex-ai'; + const serviceName = 'VertexAI'; + const fullCode = `${service}/${code}`; + const fullMessage = `${serviceName}: ${message} (${fullCode})`; + super(fullCode, fullMessage); + + // FirebaseError initializes a stack trace, but it assumes the error is created from the error + // factory. Since we break this assumption, we set the stack trace to be originating from this + // constructor. + // This is only supported in V8. + if (Error.captureStackTrace) { + // Allows us to initialize the stack trace without including the constructor itself at the + // top level of the stack trace. + Error.captureStackTrace(this, VertexAIError); + } + + // Allows instanceof VertexAIError in ES5/ES6 + // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, VertexAIError.prototype); + + // Since Error is an interface, we don't inherit toString and so we define it ourselves. + this.toString = () => fullMessage; + } +} \ No newline at end of file diff --git a/packages/vertexai/src/types/index.ts b/packages/vertexai/src/types/index.ts index 3782a66cc36..45365c39037 100644 --- a/packages/vertexai/src/types/index.ts +++ b/packages/vertexai/src/types/index.ts @@ -19,3 +19,4 @@ export * from './content'; export * from './enums'; export * from './requests'; export * from './responses'; +export * from './error';