diff --git a/vertexai/src/functions/post_fetch_processing.ts b/vertexai/src/functions/post_fetch_processing.ts index 3d3fda83..a5f7d3ef 100644 --- a/vertexai/src/functions/post_fetch_processing.ts +++ b/vertexai/src/functions/post_fetch_processing.ts @@ -39,7 +39,16 @@ export async function throwErrorIfNotOK(response: Response | undefined) { if (!response.ok) { const status: number = response.status; const statusText: string = response.statusText; - const errorBody = await response.json(); + const responseText = await response.text(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let errorBody: any; + try { + errorBody = JSON.parse(responseText); + } catch { + throw new GoogleGenerativeAIError( + `got status: ${status} ${statusText}. Response body is not valid JSON: ${responseText.slice(0, 500)}` + ); + } const errorMessage = `got status: ${status} ${statusText}. ${JSON.stringify( errorBody )}`; diff --git a/vertexai/src/functions/post_request.ts b/vertexai/src/functions/post_request.ts index aabf3e32..738d1aa3 100644 --- a/vertexai/src/functions/post_request.ts +++ b/vertexai/src/functions/post_request.ts @@ -55,7 +55,9 @@ export async function postRequest({ requestOptions?: RequestOptions; apiVersion?: string; }): Promise { - const vertexBaseEndpoint = apiEndpoint ?? `${region}-${API_BASE_PATH}`; + const vertexBaseEndpoint = + apiEndpoint ?? + (region === 'global' ? API_BASE_PATH : `${region}-${API_BASE_PATH}`); let vertexEndpoint = `https://${vertexBaseEndpoint}/${apiVersion}/${resourcePath}:${resourceMethod}`; diff --git a/vertexai/src/functions/test/post_fetch_processing_test.ts b/vertexai/src/functions/test/post_fetch_processing_test.ts index 0ba9929a..edb801c7 100644 --- a/vertexai/src/functions/test/post_fetch_processing_test.ts +++ b/vertexai/src/functions/test/post_fetch_processing_test.ts @@ -33,7 +33,10 @@ import { UNARY_RESPONSE_MISSING_ROLE_INDEX, } from './test_data'; import * as PostFetchFunctions from '../post_fetch_processing'; -import {aggregateResponses} from '../post_fetch_processing'; +import { + aggregateResponses, + throwErrorIfNotOK, +} from '../post_fetch_processing'; import {generateContent, generateContentStream} from '../generate_content'; import {countTokens} from '../count_tokens'; @@ -180,6 +183,47 @@ describe('processStream', () => { }); }); +describe('throwErrorIfNotOK', () => { + it('non-JSON response body should throw descriptive error instead of JSON parse error', async () => { + const htmlBody = 'Not Found'; + const response = new Response(htmlBody, { + status: 404, + statusText: 'Not Found', + headers: {'Content-Type': 'text/html'}, + }); + await expectAsync(throwErrorIfNotOK(response)).toBeRejectedWithError( + /Response body is not valid JSON/ + ); + }); + + it('valid JSON 4xx response body should throw ClientError', async () => { + const errorBody = { + error: { + message: 'Resource not found', + code: 404, + status: 'NOT_FOUND', + details: [], + }, + }; + const response = new Response(JSON.stringify(errorBody), { + status: 404, + statusText: 'Not Found', + headers: {'Content-Type': 'application/json'}, + }); + await expectAsync(throwErrorIfNotOK(response)).toBeRejectedWithError( + /got status: 404/ + ); + }); + + it('OK response should not throw', async () => { + const response = new Response('{}', { + status: 200, + statusText: 'OK', + }); + await expectAsync(throwErrorIfNotOK(response)).toBeResolved(); + }); +}); + describe('processCountTokenResponse', () => { it('multiple contents, processCountTokenResponse should return faithful response', async () => { const fetchResult = new Response( diff --git a/vertexai/src/functions/test/post_request_test.ts b/vertexai/src/functions/test/post_request_test.ts index 080df448..47df4026 100644 --- a/vertexai/src/functions/test/post_request_test.ts +++ b/vertexai/src/functions/test/post_request_test.ts @@ -166,6 +166,31 @@ describe('postRequest', () => { expect(actualHeaders.get('Content-Type')).toEqual('application/json'); }); + it('global region without apiEndpoint should use base endpoint without region prefix', async () => { + await postRequest({ + region: 'global', + resourcePath: RESOURCE_PATH, + resourceMethod: RESOURCE_METHOD, + token: TOKEN, + data: data, + }); + const actualUrl: string = fetchSpy.calls.mostRecent().args[0]; + expect(actualUrl).toContain('aiplatform.googleapis.com'); + expect(actualUrl).not.toContain('global-aiplatform.googleapis.com'); + }); + + it('regional endpoint should still prepend region prefix', async () => { + await postRequest({ + region: 'us-central1', + resourcePath: RESOURCE_PATH, + resourceMethod: RESOURCE_METHOD, + token: TOKEN, + data: data, + }); + const actualUrl: string = fetchSpy.calls.mostRecent().args[0]; + expect(actualUrl).toContain('us-central1-aiplatform.googleapis.com'); + }); + it('set both custom header and apiClient, should prioritize custom headers if sent to external endpoint', async () => { const requestOptions: RequestOptions = { customHeaders: new Headers({ diff --git a/vertexai/src/resources/shared/api_client.ts b/vertexai/src/resources/shared/api_client.ts index 328884fc..35fdebb1 100644 --- a/vertexai/src/resources/shared/api_client.ts +++ b/vertexai/src/resources/shared/api_client.ts @@ -49,7 +49,9 @@ export class ApiClient { } getBaseUrl() { - return `https://${this.location}-aiplatform.googleapis.com/${this.apiVersion}`; + const locationPrefix = + this.location === 'global' ? '' : `${this.location}-`; + return `https://${locationPrefix}aiplatform.googleapis.com/${this.apiVersion}`; } getBaseResourePath() { diff --git a/vertexai/src/resources/shared/test/api_client_test.ts b/vertexai/src/resources/shared/test/api_client_test.ts new file mode 100644 index 00000000..ca4f26e6 --- /dev/null +++ b/vertexai/src/resources/shared/test/api_client_test.ts @@ -0,0 +1,50 @@ +/** + * @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 + * + * https://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 {GoogleAuth} from 'google-auth-library'; +import {ApiClient} from '../api_client'; + +function makeClient(location: string): ApiClient { + return new ApiClient( + 'test-project', + location, + 'v1', + {} as unknown as GoogleAuth + ); +} + +describe('ApiClient.getBaseUrl', () => { + it('global location should return endpoint without region prefix', () => { + const client = makeClient('global'); + expect(client.getBaseUrl()).toBe('https://aiplatform.googleapis.com/v1'); + expect(client.getBaseUrl()).not.toContain('global-'); + }); + + it('regional location should return endpoint with region prefix', () => { + const client = makeClient('us-central1'); + expect(client.getBaseUrl()).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1' + ); + }); + + it('eu-west4 location should return endpoint with correct region prefix', () => { + const client = makeClient('eu-west4'); + expect(client.getBaseUrl()).toBe( + 'https://eu-west4-aiplatform.googleapis.com/v1' + ); + }); +});