Skip to content

Commit

Permalink
WIP using ErrorFactory
Browse files Browse the repository at this point in the history
  • Loading branch information
dlarocque committed May 9, 2024
1 parent f688228 commit a59cf9a
Show file tree
Hide file tree
Showing 11 changed files with 160 additions and 60 deletions.
8 changes: 4 additions & 4 deletions packages/vertexai/src/api.test.ts
Expand Up @@ -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: {
Expand All @@ -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', () => {
Expand All @@ -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 = {
Expand All @@ -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' });
Expand Down
4 changes: 2 additions & 2 deletions packages/vertexai/src/api.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
110 changes: 93 additions & 17 deletions packages/vertexai/src/errors.ts
Expand Up @@ -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> = {
[VertexError.FETCH_ERROR]: `Error fetching from {$url}: {$message}`,
[VertexError.INVALID_CONTENT]: `Content formatting error: {$message}`,
[VertexError.NO_API_KEY]:
const VertexAIErrorMessages: ErrorMap<VertexAIErrorCode> = {
[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<string, unknown>;
[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<VertexError, ErrorParams>(
const ERROR_FACTORY = new ErrorFactory<VertexAIErrorCode, VertexAIErrorParams>(
'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<K extends VertexAIErrorCode>(
code: K,
...data: K extends keyof VertexAIErrorParams ? [VertexAIErrorParams[K]] : []
): VertexAIError {
const firebaseError = ERROR_FACTORY.create(code, ...data);
return new VertexAIError(code, ...data);
}
14 changes: 7 additions & 7 deletions packages/vertexai/src/methods/chat-session-helpers.ts
Expand Up @@ -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

Expand Down Expand Up @@ -48,26 +48,26 @@ 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
)}`
});
}

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'
});
}
Expand All @@ -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`
});
}
Expand All @@ -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(
Expand Down
11 changes: 8 additions & 3 deletions packages/vertexai/src/methods/generate-content.test.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
});
});
6 changes: 3 additions & 3 deletions packages/vertexai/src/models/generative-model.ts
Expand Up @@ -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';

Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/vertexai/src/requests/request-helpers.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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.'
});
}
Expand Down
15 changes: 11 additions & 4 deletions packages/vertexai/src/requests/request.test.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -233,8 +234,8 @@ describe('request methods', () => {
statusText: 'AbortError'
} as Response);

await expect(
makeRequest(
try {
await makeRequest(
'models/model-name',
Task.GENERATE_CONTENT,
fakeApiSettings,
Expand All @@ -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 () => {
Expand Down

0 comments on commit a59cf9a

Please sign in to comment.