From e7260e23d186787d44c145829af245534db4d054 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Tue, 25 Jun 2024 10:23:49 -0400 Subject: [PATCH] Add a new VertexAI error type (#8240) --- .changeset/light-cheetahs-arrive.md | 5 + common/api-review/vertexai-preview.api.md | 43 +++++++++ docs-devsite/_toc.yaml | 6 ++ .../vertexai-preview.customerrordata.md | 68 ++++++++++++++ docs-devsite/vertexai-preview.errordetails.md | 66 +++++++++++++ docs-devsite/vertexai-preview.md | 28 ++++++ .../vertexai-preview.vertexaierror.md | 76 +++++++++++++++ packages/vertexai/src/api.test.ts | 39 +++++--- packages/vertexai/src/api.ts | 11 ++- packages/vertexai/src/errors.ts | 85 ++++++++--------- .../src/methods/chat-session-helpers.ts | 52 +++++++---- .../vertexai/src/models/generative-model.ts | 15 ++- .../vertexai/src/requests/request-helpers.ts | 24 +++-- .../vertexai/src/requests/request.test.ts | 78 ++++++++++++---- packages/vertexai/src/requests/request.ts | 36 ++++++-- .../vertexai/src/requests/response-helpers.ts | 53 +++++++---- .../vertexai/src/requests/stream-reader.ts | 19 ++-- packages/vertexai/src/types/error.ts | 92 +++++++++++++++++++ packages/vertexai/src/types/index.ts | 1 + 19 files changed, 653 insertions(+), 144 deletions(-) create mode 100644 .changeset/light-cheetahs-arrive.md create mode 100644 docs-devsite/vertexai-preview.customerrordata.md create mode 100644 docs-devsite/vertexai-preview.errordetails.md create mode 100644 docs-devsite/vertexai-preview.vertexaierror.md create mode 100644 packages/vertexai/src/types/error.ts diff --git a/.changeset/light-cheetahs-arrive.md b/.changeset/light-cheetahs-arrive.md new file mode 100644 index 00000000000..d395a78db05 --- /dev/null +++ b/.changeset/light-cheetahs-arrive.md @@ -0,0 +1,5 @@ +--- +'@firebase/vertexai-preview': patch +--- + +Add a publicly exported `VertexAIError` class. \ No newline at end of file diff --git a/common/api-review/vertexai-preview.api.md b/common/api-review/vertexai-preview.api.md index b3ae09e8dc7..c965bdde432 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 { @@ -83,6 +84,14 @@ export interface CountTokensResponse { totalTokens: number; } +// @public +export interface CustomErrorData { + errorDetails?: ErrorDetails[]; + response?: GenerateContentResponse; + status?: number; + statusText?: string; +} + // @public interface Date_2 { // (undocumented) @@ -102,6 +111,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 +609,30 @@ export interface VertexAI { location: string; } +// @public +export class VertexAIError extends FirebaseError { + constructor(code: VertexAIErrorCode, message: string, customErrorData?: CustomErrorData | undefined); + // (undocumented) + readonly code: VertexAIErrorCode; + // (undocumented) + readonly customErrorData?: CustomErrorData | undefined; + // (undocumented) + readonly message: string; +} + +// @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/_toc.yaml b/docs-devsite/_toc.yaml index 43ec97cda56..7412d572013 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -468,10 +468,14 @@ toc: path: /docs/reference/js/vertexai-preview.counttokensrequest.md - title: CountTokensResponse path: /docs/reference/js/vertexai-preview.counttokensresponse.md + - title: CustomErrorData + path: /docs/reference/js/vertexai-preview.customerrordata.md - title: Date_2 path: /docs/reference/js/vertexai-preview.date_2.md - title: EnhancedGenerateContentResponse path: /docs/reference/js/vertexai-preview.enhancedgeneratecontentresponse.md + - title: ErrorDetails + path: /docs/reference/js/vertexai-preview.errordetails.md - title: FileData path: /docs/reference/js/vertexai-preview.filedata.md - title: FileDataPart @@ -541,6 +545,8 @@ toc: path: /docs/reference/js/vertexai-preview.usagemetadata.md - title: VertexAI path: /docs/reference/js/vertexai-preview.vertexai.md + - title: VertexAIError + path: /docs/reference/js/vertexai-preview.vertexaierror.md - title: VertexAIOptions path: /docs/reference/js/vertexai-preview.vertexaioptions.md - title: VideoMetadata diff --git a/docs-devsite/vertexai-preview.customerrordata.md b/docs-devsite/vertexai-preview.customerrordata.md new file mode 100644 index 00000000000..f0af0574161 --- /dev/null +++ b/docs-devsite/vertexai-preview.customerrordata.md @@ -0,0 +1,68 @@ +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 %} + +# CustomErrorData interface +Details object that contains data originating from a bad HTTP response. + +Signature: + +```typescript +export interface CustomErrorData +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [errorDetails](./vertexai-preview.customerrordata.md#customerrordataerrordetails) | [ErrorDetails](./vertexai-preview.errordetails.md#errordetails_interface)\[\] | Optional additional details about the error. | +| [response](./vertexai-preview.customerrordata.md#customerrordataresponse) | [GenerateContentResponse](./vertexai-preview.generatecontentresponse.md#generatecontentresponse_interface) | Response from a [GenerateContentRequest](./vertexai-preview.generatecontentrequest.md#generatecontentrequest_interface) | +| [status](./vertexai-preview.customerrordata.md#customerrordatastatus) | number | HTTP status code of the error response. | +| [statusText](./vertexai-preview.customerrordata.md#customerrordatastatustext) | string | HTTP status text of the error response. | + +## CustomErrorData.errorDetails + +Optional additional details about the error. + +Signature: + +```typescript +errorDetails?: ErrorDetails[]; +``` + +## CustomErrorData.response + +Response from a [GenerateContentRequest](./vertexai-preview.generatecontentrequest.md#generatecontentrequest_interface) + +Signature: + +```typescript +response?: GenerateContentResponse; +``` + +## CustomErrorData.status + +HTTP status code of the error response. + +Signature: + +```typescript +status?: number; +``` + +## CustomErrorData.statusText + +HTTP status text of the error response. + +Signature: + +```typescript +statusText?: string; +``` 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..57fbab35909 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 Vertex AI for Firebase 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 @@ -52,8 +54,10 @@ The Vertex AI For Firebase Web SDK. | [Content](./vertexai-preview.content.md#content_interface) | Content type for both prompts and response candidates. | | [CountTokensRequest](./vertexai-preview.counttokensrequest.md#counttokensrequest_interface) | Params for calling [GenerativeModel.countTokens()](./vertexai-preview.generativemodel.md#generativemodelcounttokens) | | [CountTokensResponse](./vertexai-preview.counttokensresponse.md#counttokensresponse_interface) | Response from calling [GenerativeModel.countTokens()](./vertexai-preview.generativemodel.md#generativemodelcounttokens). | +| [CustomErrorData](./vertexai-preview.customerrordata.md#customerrordata_interface) | Details object that contains data originating from a bad HTTP response. | | [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 +371,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 occurred. | +| 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 occurred due to a missing Firebase API key. | +| NO\_MODEL | "no-model" | An error occurred due to a model name not being specified during initialization. | +| NO\_PROJECT\_ID | "no-project-id" | An error occurred due to a missing project ID. | +| PARSE\_FAILED | "parse-failed" | An error occurred while parsing. | +| REQUEST\_ERROR | "request-error" | An error occurred in a request. | +| RESPONSE\_ERROR | "response-error" | An error occurred 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..05a12c62b8e --- /dev/null +++ b/docs-devsite/vertexai-preview.vertexaierror.md @@ -0,0 +1,76 @@ +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 Vertex AI for Firebase SDK. + +Signature: + +```typescript +export declare class VertexAIError extends FirebaseError +``` +Extends: [FirebaseError](./util.firebaseerror.md#firebaseerror_class) + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(code, message, customErrorData)](./vertexai-preview.vertexaierror.md#vertexaierrorconstructor) | | Constructs a new instance of the VertexAIError class. | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [code](./vertexai-preview.vertexaierror.md#vertexaierrorcode) | | [VertexAIErrorCode](./vertexai-preview.md#vertexaierrorcode) | | +| [customErrorData](./vertexai-preview.vertexaierror.md#vertexaierrorcustomerrordata) | | [CustomErrorData](./vertexai-preview.customerrordata.md#customerrordata_interface) \| undefined | | +| [message](./vertexai-preview.vertexaierror.md#vertexaierrormessage) | | string | | + +## VertexAIError.(constructor) + +Constructs a new instance of the `VertexAIError` class. + +Signature: + +```typescript +constructor(code: VertexAIErrorCode, message: string, customErrorData?: CustomErrorData | 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. | +| customErrorData | [CustomErrorData](./vertexai-preview.customerrordata.md#customerrordata_interface) \| undefined | Optional error data. | + +## VertexAIError.code + +Signature: + +```typescript +readonly code: VertexAIErrorCode; +``` + +## VertexAIError.customErrorData + +Signature: + +```typescript +readonly customErrorData?: CustomErrorData | undefined; +``` + +## VertexAIError.message + +Signature: + +```typescript +readonly message: string; +``` diff --git a/packages/vertexai/src/api.test.ts b/packages/vertexai/src/api.test.ts index 5c25cce7ef9..794c5045012 100644 --- a/packages/vertexai/src/api.test.ts +++ b/packages/vertexai/src/api.test.ts @@ -14,12 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ModelParams } from './types'; +import { ModelParams, VertexAIErrorCode } from './types'; +import { VertexAIError } from './errors'; 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 +35,44 @@ 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( + `Must provide a model name. Example: getGenerativeModel({ model: 'my-model-name' })` + ); + } }); 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( + `The "apiKey" field is empty in the local Firebase config. Firebase VertexAI requires this field to contain a valid 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( + `The "projectId" field is empty in the local Firebase config. Firebase VertexAI requires this field to contain a valid 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..92d5aac7144 100644 --- a/packages/vertexai/src/api.ts +++ b/packages/vertexai/src/api.ts @@ -21,14 +21,16 @@ 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, VertexAIErrorCode } from './types'; +import { VertexAIError } from './errors'; import { GenerativeModel } from './models/generative-model'; export { ChatSession } from './methods/chat-session'; export { GenerativeModel }; +export { VertexAIError }; + declare module '@firebase/component' { interface NameServiceMapping { [VERTEX_TYPE]: VertexAIService; @@ -67,7 +69,10 @@ export function getGenerativeModel( requestOptions?: RequestOptions ): GenerativeModel { if (!modelParams.model) { - throw ERROR_FACTORY.create(VertexError.NO_MODEL); + throw new VertexAIError( + VertexAIErrorCode.NO_MODEL, + `Must provide a model name. Example: getGenerativeModel({ model: 'my-model-name' })` + ); } return new GenerativeModel(vertexAI, modelParams, requestOptions); } diff --git a/packages/vertexai/src/errors.ts b/packages/vertexai/src/errors.ts index c0b9d83aaeb..7cb7a30020b 100644 --- a/packages/vertexai/src/errors.ts +++ b/packages/vertexai/src/errors.ts @@ -15,49 +15,50 @@ * limitations under the License. */ -import { ErrorFactory, ErrorMap } from '@firebase/util'; -import { GenerateContentResponse } from './types'; +import { FirebaseError } from '@firebase/util'; +import { VertexAIErrorCode, CustomErrorData } from './types'; +import { VERTEX_TYPE } from './constants'; -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' -} +/** + * Error class for the Vertex AI for Firebase SDK. + * + * @public + */ +export class VertexAIError extends FirebaseError { + /** + * Constructs a new instance of the `VertexAIError` class. + * + * @param code - The error code from {@link VertexAIErrorCode}. + * @param message - A human-readable message describing the error. + * @param customErrorData - Optional error data. + */ + constructor( + readonly code: VertexAIErrorCode, + readonly message: string, + readonly customErrorData?: CustomErrorData + ) { + // Match error format used by FirebaseError from ErrorFactory + const service = VERTEX_TYPE; + const serviceName = 'VertexAI'; + const fullCode = `${service}/${code}`; + const fullMessage = `${serviceName}: ${message} (${fullCode}).`; + super(fullCode, fullMessage); -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` -}; + // 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); + } -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; - }; -} + // 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); -export const ERROR_FACTORY = new ErrorFactory( - 'vertexAI', - 'VertexAI', - ERRORS -); + // Since Error is an interface, we don't inherit toString and so we define it ourselves. + this.toString = () => fullMessage; + } +} diff --git a/packages/vertexai/src/methods/chat-session-helpers.ts b/packages/vertexai/src/methods/chat-session-helpers.ts index 0ac00ad0a1c..899db4f626a 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, + VertexAIErrorCode +} from '../types'; +import { VertexAIError } from '../errors'; // 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..88be3ee6436 100644 --- a/packages/vertexai/src/models/generative-model.ts +++ b/packages/vertexai/src/models/generative-model.ts @@ -33,8 +33,10 @@ import { SafetySetting, StartChatParams, Tool, - ToolConfig + ToolConfig, + VertexAIErrorCode } from '../types'; +import { VertexAIError } from '../errors'; import { ChatSession } from '../methods/chat-session'; import { countTokens } from '../methods/count-tokens'; import { @@ -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, + `The "apiKey" field is empty in the local Firebase config. Firebase VertexAI requires this field to contain a valid API key.` + ); } else if (!vertexAI.app?.options?.projectId) { - throw ERROR_FACTORY.create(VertexError.NO_PROJECT_ID); + throw new VertexAIError( + 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.` + ); } 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..9e525b2a875 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, + VertexAIErrorCode +} from '../types'; +import { VertexAIError } from '../errors'; 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..16a1ece2c7e 100644 --- a/packages/vertexai/src/requests/request.test.ts +++ b/packages/vertexai/src/requests/request.test.ts @@ -22,6 +22,8 @@ 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 { VertexAIErrorCode } from '../types'; +import { VertexAIError } from '../errors'; use(sinonChai); use(chaiAsPromised); @@ -233,8 +235,8 @@ describe('request methods', () => { statusText: 'AbortError' } as Response); - await expect( - makeRequest( + try { + await makeRequest( 'models/model-name', Task.GENERATE_CONTENT, fakeApiSettings, @@ -243,8 +245,18 @@ 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).customErrorData?.status).to.equal(500); + expect((e as VertexAIError).customErrorData?.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 +265,24 @@ 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).customErrorData?.status).to.equal(500); + expect((e as VertexAIError).customErrorData?.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 +292,25 @@ 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).customErrorData?.status).to.equal(500); + expect((e as VertexAIError).customErrorData?.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 +332,28 @@ 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).customErrorData?.status).to.equal(500); + expect((e as VertexAIError).customErrorData?.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..eac99a23038 100644 --- a/packages/vertexai/src/requests/request.ts +++ b/packages/vertexai/src/requests/request.ts @@ -15,8 +15,8 @@ * limitations under the License. */ -import { RequestOptions } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { RequestOptions, VertexAIErrorCode } from '../types'; +import { VertexAIError } from '../errors'; import { ApiSettings } from '../types/internal'; import { DEFAULT_API_VERSION, @@ -140,24 +140,40 @@ 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}`, + { + status: response.status, + statusText: response.statusText, + errorDetails + } + ); + } + } catch (e) { + let err = e as Error; + if ( + (e as VertexAIError).code !== VertexAIErrorCode.FETCH_ERROR && + e instanceof Error + ) { + err = new VertexAIError( + VertexAIErrorCode.ERROR, + `Error fetching from ${url.toString()}: ${e.message}` + ); + err.stack = e.stack; } - } 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; + throw err; } return response; diff --git a/packages/vertexai/src/requests/response-helpers.ts b/packages/vertexai/src/requests/response-helpers.ts index dc49123420f..1b43602b0cb 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, + VertexAIErrorCode } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { VertexAIError } from '../errors'; /** * Adds convenience helper methods to a response object, including stream @@ -41,17 +42,25 @@ 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, + `Response error: ${formatBlockErrorMessage( + response + )}. Response body stored in error.response`, + { + 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)}`, + { + response + } + ); } return ''; }; @@ -65,19 +74,25 @@ 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, + `Response error: ${formatBlockErrorMessage( + response + )}. Response body stored in error.response`, + { + response + } + ); } return getFunctionCalls(response); } else if (response.promptFeedback) { - throw ERROR_FACTORY.create(VertexError.RESPONSE_ERROR, { - message: `Function call not available. ${formatBlockErrorMessage( + throw new VertexAIError( + VertexAIErrorCode.RESPONSE_ERROR, + `Function call not available. ${formatBlockErrorMessage(response)}`, + { response - )}`, - response - }); + } + ); } return undefined; }; diff --git a/packages/vertexai/src/requests/stream-reader.ts b/packages/vertexai/src/requests/stream-reader.ts index 0c070cfe0f2..c4163d26b60 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, + VertexAIErrorCode } from '../types'; -import { ERROR_FACTORY, VertexError } from '../errors'; +import { VertexAIError } 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..ecc30de5a3e --- /dev/null +++ b/packages/vertexai/src/types/error.ts @@ -0,0 +1,92 @@ +/** + * @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 { GenerateContentResponse } from './responses'; + +/** + * 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; +} + +/** + * Details object that contains data originating from a bad HTTP response. + * + * @public + */ +export interface CustomErrorData { + /** HTTP status code of the error response. */ + status?: number; + + /** HTTP status text of the error response. */ + statusText?: string; + + /** Response from a {@link GenerateContentRequest} */ + response?: GenerateContentResponse; + + /** Optional additional details about the error. */ + errorDetails?: ErrorDetails[]; +} + +/** + * Standardized error codes that {@link VertexAIError} can have. + * + * @public + */ +export const enum VertexAIErrorCode { + /** A generic error occurred. */ + ERROR = 'error', + + /** An error occurred in a request. */ + REQUEST_ERROR = 'request-error', + + /** An error occurred 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 occurred due to a missing Firebase API key. */ + NO_API_KEY = 'no-api-key', + + /** An error occurred due to a model name not being specified during initialization. */ + NO_MODEL = 'no-model', + + /** An error occurred due to a missing project ID. */ + NO_PROJECT_ID = 'no-project-id', + + /** An error occurred while parsing. */ + PARSE_FAILED = 'parse-failed' +} 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';