/** * @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 { AIErrorCode, ErrorDetails, SingleRequestOptions } from '../types'; import { AIError } from '../errors'; import { ApiSettings } from '../types/internal'; import { DEFAULT_DOMAIN, DEFAULT_FETCH_TIMEOUT_MS, LANGUAGE_TAG, PACKAGE_VERSION, } from '../constants'; import { logger } from '../logger'; import { BackendType } from '../public-types'; import { Platform } from 'react-native'; const DEFAULT_FUNCTIONS_EMULATOR_PORT = 5001; function parseEmulatorPort(value: string | undefined): number | null { if (!value) { return null; } const n = parseInt(value, 10); return Number.isFinite(n) ? n : null; } // Intentional duplicate of getE2eEmulatorHost() in packages/app/e2e/helpers.js. // @react-native-firebase/ai cannot import app e2e/helpers (not published to npm; // Metro static resolution breaks consumer builds). Inline duplicate is an accepted // tradeoff vs exporting test helpers on the public API. function getRnfbTestEmulatorHost(): string { if (Platform.OS === 'android') { return '10.0.2.2'; } return '127.0.0.1'; } function getRnfbTestFunctionsEmulatorPort(): number { let raw: string | undefined; if (Platform.OS === 'android') { raw = process.env.RNFB_ANDROID_EMULATOR_FUNCTIONS_PORT; } else if (Platform.OS === 'ios') { raw = process.env.RNFB_IOS_EMULATOR_FUNCTIONS_PORT; } else if (Platform.OS === 'macos') { raw = process.env.RNFB_MACOS_EMULATOR_FUNCTIONS_PORT; } else { throw new Error( `Unknown Platform.OS for e2e emulator routing (got ${Platform.OS}; expected android|ios|macos)`, ); } return parseEmulatorPort(raw) ?? DEFAULT_FUNCTIONS_EMULATOR_PORT; } export enum Task { GENERATE_CONTENT = 'generateContent', STREAM_GENERATE_CONTENT = 'streamGenerateContent', COUNT_TOKENS = 'countTokens', PREDICT = 'predict', } export const enum ServerPromptTemplateTask { TEMPLATE_GENERATE_CONTENT = 'templateGenerateContent', TEMPLATE_STREAM_GENERATE_CONTENT = 'templateStreamGenerateContent', TEMPLATE_PREDICT = 'templatePredict', } export class RequestUrl { constructor( public model: string, public task: Task, public apiSettings: ApiSettings, public stream: boolean, public requestOptions?: SingleRequestOptions, ) {} toString(): string { // @ts-ignore const isTestEnvironment = globalThis.RNFB_VERTEXAI_EMULATOR_URL; if (isTestEnvironment) { let emulatorUrl; logger.info( 'Running VertexAI in test environment, pointing to Firebase Functions emulator URL', ); const fnHost = getRnfbTestEmulatorHost(); const fnPort = getRnfbTestFunctionsEmulatorPort(); if (this.stream) { emulatorUrl = `http://${fnHost}:${fnPort}/react-native-firebase-testing/us-central1/testFetchStream`; } else { emulatorUrl = `http://${fnHost}:${fnPort}/react-native-firebase-testing/us-central1/testFetch`; } return emulatorUrl; } // Manually construct URL to avoid React Native URL API issues let baseUrl = this.baseUrl; // Remove trailing slash if present if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1); } const pathname = `${this.apiSettings.backend._getModelPath(this.apiSettings.project, this.model)}:${this.task}`; const queryString = this.queryParams; return `${baseUrl}${pathname}${queryString ? `?${queryString}` : ''}`; } private get baseUrl(): string { return this.requestOptions?.baseUrl || `https://${DEFAULT_DOMAIN}`; } private get queryParams(): string { let params = ''; if (this.stream) { params += 'alt=sse'; } return params; } } function createAbortError(reason?: unknown): Error { if (typeof DOMException !== 'undefined') { return new DOMException( reason == null ? 'Aborted' : String(reason), 'AbortError', ) as unknown as Error; } const error = new Error(reason == null ? 'Aborted' : String(reason)); error.name = 'AbortError'; return error; } function getAbortSignalReason(signal?: AbortSignal): unknown { return (signal as (AbortSignal & { reason?: unknown }) | undefined)?.reason; } export class TemplateRequestUrl { constructor( public templateId: string, public task: ServerPromptTemplateTask, public apiSettings: ApiSettings, public stream: boolean, public requestOptions?: SingleRequestOptions, ) {} toString(): string { // Manually construct URL to avoid React Native URL API issues let baseUrl = this.baseUrl; // Remove trailing slash if present if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1); } const pathname = `${this.apiSettings.backend._getTemplatePath(this.apiSettings.project, this.templateId)}:${this.task}`; const queryString = this.queryParams; return `${baseUrl}${pathname}${queryString ? `?${queryString}` : ''}`; } private get baseUrl(): string { return this.requestOptions?.baseUrl || `https://${DEFAULT_DOMAIN}`; } private get queryParams(): string { let params = ''; if (this.stream) { params += 'alt=sse'; } return params; } } /** * Log language and "fire/version" to x-goog-api-client */ function getClientHeaders(): string { const loggingTags = []; loggingTags.push(`${LANGUAGE_TAG}/${PACKAGE_VERSION}`); loggingTags.push(`fire/${PACKAGE_VERSION}`); return loggingTags.join(' '); } export async function getHeaders(url: RequestUrl): Promise { const headers = new Headers(); headers.append('Content-Type', 'application/json'); headers.append('x-goog-api-client', getClientHeaders()); headers.append('x-goog-api-key', url.apiSettings.apiKey); if (url.apiSettings.automaticDataCollectionEnabled) { headers.append('X-Firebase-Appid', url.apiSettings.appId); if (url.apiSettings.appVersion) { headers.append('X-Firebase-AppVersion', url.apiSettings.appVersion); } } if (url.apiSettings.getAppCheckToken) { let appCheckToken; try { appCheckToken = await url.apiSettings.getAppCheckToken(); } catch (e) { logger.warn(`Unable to obtain a valid App Check token: ${e}`); } if (appCheckToken) { headers.append('X-Firebase-AppCheck', appCheckToken.token); } } if (url.apiSettings.getAuthToken) { const authToken = await url.apiSettings.getAuthToken(); if (authToken) { headers.append('Authorization', `Firebase ${authToken}`); } } return headers; } export async function getTemplateHeaders(url: TemplateRequestUrl): Promise { const headers = new Headers(); headers.append('Content-Type', 'application/json'); headers.append('x-goog-api-client', getClientHeaders()); headers.append('x-goog-api-key', url.apiSettings.apiKey); if (url.apiSettings.automaticDataCollectionEnabled) { headers.append('X-Firebase-Appid', url.apiSettings.appId); if (url.apiSettings.appVersion) { headers.append('X-Firebase-AppVersion', url.apiSettings.appVersion); } } if (url.apiSettings.getAppCheckToken) { let appCheckToken; try { appCheckToken = await url.apiSettings.getAppCheckToken(); } catch (e) { logger.warn(`Unable to obtain a valid App Check token: ${e}`); } if (appCheckToken) { headers.append('X-Firebase-AppCheck', appCheckToken.token); } } if (url.apiSettings.getAuthToken) { const authToken = await url.apiSettings.getAuthToken(); if (authToken) { headers.append('Authorization', `Firebase ${authToken}`); } } return headers; } export async function constructRequest( model: string, task: Task, apiSettings: ApiSettings, stream: boolean, body: string, requestOptions?: SingleRequestOptions, ): Promise<{ url: string; fetchOptions: RequestInit }> { const url = new RequestUrl(model, task, apiSettings, stream, requestOptions); return { url: url.toString(), fetchOptions: { method: 'POST', headers: await getHeaders(url), body, }, }; } export async function constructTemplateRequest( templateId: string, task: ServerPromptTemplateTask, apiSettings: ApiSettings, stream: boolean, body: string, requestOptions?: SingleRequestOptions, ): Promise<{ url: string; fetchOptions: RequestInit }> { const url = new TemplateRequestUrl(templateId, task, apiSettings, stream, requestOptions); return { url: url.toString(), fetchOptions: { method: 'POST', headers: await getTemplateHeaders(url), body, }, }; } // Overload for model requests export async function makeRequest( params: { model: string; task: Task; apiSettings: ApiSettings; stream: boolean; requestOptions?: SingleRequestOptions; }, body: string, ): Promise; // Overload for template requests export async function makeRequest( params: { templateId: string; task: ServerPromptTemplateTask; apiSettings: ApiSettings; stream: boolean; requestOptions?: SingleRequestOptions; }, body: string, ): Promise; // Implementation export async function makeRequest( params: | { model: string; task: Task; apiSettings: ApiSettings; stream: boolean; requestOptions?: SingleRequestOptions; } | { templateId: string; task: ServerPromptTemplateTask; apiSettings: ApiSettings; stream: boolean; requestOptions?: SingleRequestOptions; }, body: string, ): Promise { // Determine if this is a template request or model request const isTemplateRequest = 'templateId' in params; const url = isTemplateRequest ? new TemplateRequestUrl( params.templateId, params.task, params.apiSettings, params.stream, params.requestOptions, ) : new RequestUrl( (params as { model: string }).model, params.task as Task, params.apiSettings, params.stream, params.requestOptions, ); let response; let fetchTimeoutId: string | number | NodeJS.Timeout | undefined; const externalSignal = params.requestOptions?.signal; let externalAbortReason: unknown; if (externalSignal?.aborted) { throw createAbortError(getAbortSignalReason(externalSignal)); } const abortController = new AbortController(); const abortFromExternalSignal = (): void => { externalAbortReason = getAbortSignalReason(externalSignal); abortController.abort(); }; externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true }); try { const request = isTemplateRequest ? await constructTemplateRequest( params.templateId, params.task, params.apiSettings, params.stream, body, params.requestOptions, ) : await constructRequest( (params as { model: string }).model, params.task as Task, params.apiSettings, params.stream, body, params.requestOptions, ); const timeoutMillis = params.requestOptions?.timeout != null && params.requestOptions.timeout >= 0 ? params.requestOptions.timeout : DEFAULT_FETCH_TIMEOUT_MS; fetchTimeoutId = setTimeout(() => abortController.abort(), timeoutMillis); request.fetchOptions.signal = abortController.signal; const fetchOptions = params.stream ? { ...request.fetchOptions, reactNative: { textStreaming: true, }, } : request.fetchOptions; response = await fetch(request.url, 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 (_) { // ignored } if ( response.status === 403 && errorDetails && errorDetails.some((detail: ErrorDetails) => detail.reason === 'SERVICE_DISABLED') && errorDetails.some((detail: ErrorDetails) => (detail.links as Array>)?.[0]?.description?.includes( 'Google developers console API activation', ), ) ) { throw new AIError( AIErrorCode.API_NOT_ENABLED, `The Firebase AI SDK requires the Firebase AI ` + `API ('firebasevertexai.googleapis.com') to be enabled in your ` + `Firebase project. Enable this API by visiting the Firebase Console ` + `at https://console.firebase.google.com/project/${url.apiSettings.project}/genai/ ` + `and clicking "Get started". If you enabled this API recently, ` + `wait a few minutes for the action to propagate to our systems and ` + `then retry.`, { status: response.status, statusText: response.statusText, errorDetails, }, ); } throw new AIError( AIErrorCode.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 instanceof Error && e.name === 'AbortError') { throw createAbortError(externalAbortReason); } if ( (e as AIError).code !== AIErrorCode.FETCH_ERROR && (e as AIError).code !== AIErrorCode.API_NOT_ENABLED && e instanceof Error ) { err = new AIError(AIErrorCode.ERROR, `Error fetching from ${url.toString()}: ${e.message}`); err.stack = e.stack; } throw err; } finally { if (fetchTimeoutId) { clearTimeout(fetchTimeoutId); } externalSignal?.removeEventListener('abort', abortFromExternalSignal); } return response; } export class WebSocketUrl { constructor(public apiSettings: ApiSettings) {} toString(): string { // Manually construct URL to avoid React Native URL API issues const baseUrl = `wss://${DEFAULT_DOMAIN}`; const pathname = this.pathname; const queryString = `key=${encodeURIComponent(this.apiSettings.apiKey)}`; return `${baseUrl}${pathname}?${queryString}`; } private get pathname(): string { if (this.apiSettings.backend.backendType === BackendType.GOOGLE_AI) { return '/ws/google.firebase.vertexai.v1beta.GenerativeService/BidiGenerateContent'; } else { return `/ws/google.firebase.vertexai.v1beta.LlmBidiService/BidiGenerateContent/locations/${this.apiSettings.location}`; } } }