From 406a67cccc4c3ab8a92cf7c19524559f23755d06 Mon Sep 17 00:00:00 2001 From: Erik Eldridge Date: Fri, 6 Jun 2025 16:55:51 -0700 Subject: [PATCH 1/2] Guard against unstable AI API versions --- packages/ai/package.json | 3 +- packages/ai/src/api.ts | 6 +- .../ai/src/methods/chrome-adapter.test.ts | 93 ++++++++++++++++++- packages/ai/src/methods/chrome-adapter.ts | 29 +++++- yarn.lock | 5 + 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index 8382025a68e..5165c832338 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -62,7 +62,8 @@ "rollup": "2.79.2", "rollup-plugin-replace": "2.2.0", "rollup-plugin-typescript2": "0.36.0", - "typescript": "5.5.4" + "typescript": "5.5.4", + "user-agent-data-types": "0.4.2" }, "repository": { "directory": "packages/ai", diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index 4a27be8786f..8ddbfc0cbcd 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -14,7 +14,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +// Imports navigator.userAgentData types. +// The user-agent-data-types package isn't intended for modular imports. +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// import { FirebaseApp, getApp, _getProvider } from '@firebase/app'; import { Provider } from '@firebase/component'; import { getModularInstance } from '@firebase/util'; @@ -175,6 +178,7 @@ export function getGenerativeModel( inCloudParams, new ChromeAdapter( window.LanguageModel as LanguageModel, + window.navigator.userAgentData as UADataValues, hybridParams.mode, hybridParams.onDeviceParams ), diff --git a/packages/ai/src/methods/chrome-adapter.test.ts b/packages/ai/src/methods/chrome-adapter.test.ts index 5b245ac1ffb..e16ca89420d 100644 --- a/packages/ai/src/methods/chrome-adapter.test.ts +++ b/packages/ai/src/methods/chrome-adapter.test.ts @@ -14,7 +14,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +// Imports navigator.userAgentData types. +// The user-agent-data-types package isn't intended for modular imports. +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// import { AIError } from '../errors'; import { expect, use } from 'chai'; import sinonChai from 'sinon-chai'; @@ -68,6 +71,7 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions @@ -94,7 +98,7 @@ describe('ChromeAdapter', () => { ).to.be.false; }); it('returns false if mode is only cloud', async () => { - const adapter = new ChromeAdapter(undefined, 'only_in_cloud'); + const adapter = new ChromeAdapter(undefined, undefined, 'only_in_cloud'); expect( await adapter.isAvailable({ contents: [] @@ -102,7 +106,11 @@ describe('ChromeAdapter', () => { ).to.be.false; }); it('returns false if LanguageModel API is undefined', async () => { - const adapter = new ChromeAdapter(undefined, 'prefer_on_device'); + const adapter = new ChromeAdapter( + undefined, + undefined, + 'prefer_on_device' + ); expect( await adapter.isAvailable({ contents: [] @@ -114,6 +122,7 @@ describe('ChromeAdapter', () => { { availability: async () => Availability.available } as LanguageModel, + undefined, 'prefer_on_device' ); expect( @@ -122,11 +131,59 @@ describe('ChromeAdapter', () => { }) ).to.be.false; }); + it('returns false if unsupported browser', async () => { + const adapter = new ChromeAdapter( + { + availability: async () => Availability.available + } as LanguageModel, + // Defines user agent, but no supported browser. + { brands: [] } as UADataValues, + 'prefer_on_device' + ); + expect( + await adapter.isAvailable({ + contents: [] + }) + ).to.be.false; + }); + it('returns true if supported browser', async () => { + const adapter = new ChromeAdapter( + { + availability: async () => Availability.available + } as LanguageModel, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, + 'prefer_on_device' + ); + for (const mimeType of ChromeAdapter.SUPPORTED_MIME_TYPES) { + expect( + await adapter.isAvailable({ + contents: [ + { + role: 'user', + parts: [ + { + inlineData: { + mimeType, + data: '' + } + } + ] + } + ] + }) + ).to.be.true; + } + }); it('returns false if request content has "function" role', async () => { const adapter = new ChromeAdapter( { availability: async () => Availability.available } as LanguageModel, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); expect( @@ -145,6 +202,9 @@ describe('ChromeAdapter', () => { { availability: async () => Availability.available } as LanguageModel, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); for (const mimeType of ChromeAdapter.SUPPORTED_MIME_TYPES) { @@ -173,6 +233,9 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); expect( @@ -202,6 +265,7 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions } ); @@ -225,6 +289,7 @@ describe('ChromeAdapter', () => { ); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); await adapter.isAvailable({ @@ -249,6 +314,7 @@ describe('ChromeAdapter', () => { ); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); await adapter.isAvailable({ @@ -267,6 +333,9 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); expect( @@ -285,6 +354,7 @@ describe('ChromeAdapter', () => { ).resolves(Availability.available); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions: { @@ -311,7 +381,7 @@ describe('ChromeAdapter', () => { }); describe('generateContent', () => { it('throws if Chrome API is undefined', async () => { - const adapter = new ChromeAdapter(undefined, 'only_on_device'); + const adapter = new ChromeAdapter(undefined, undefined, 'only_on_device'); await expect( adapter.generateContent({ contents: [] @@ -342,6 +412,9 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device', { createOptions } ); @@ -389,6 +462,9 @@ describe('ChromeAdapter', () => { const promptStub = stub(languageModel, 'prompt').resolves(promptOutput); const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); const request = { @@ -456,6 +532,7 @@ describe('ChromeAdapter', () => { }; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { promptOptions } ); @@ -489,6 +566,7 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); const request = { @@ -525,6 +603,7 @@ describe('ChromeAdapter', () => { const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); @@ -534,6 +613,8 @@ describe('ChromeAdapter', () => { try { await adapter.countTokens(countTokenRequest); + // eslint-disable-next-line no-throw-literal + throw 'unthrown'; } catch (e) { // the call to countToken should be rejected with Error expect((e as AIError).code).to.equal(AIErrorCode.REQUEST_ERROR); @@ -569,6 +650,7 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions } ); @@ -614,6 +696,7 @@ describe('ChromeAdapter', () => { ); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); const request = { @@ -674,6 +757,7 @@ describe('ChromeAdapter', () => { }; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { promptOptions } ); @@ -709,6 +793,7 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); const request = { diff --git a/packages/ai/src/methods/chrome-adapter.ts b/packages/ai/src/methods/chrome-adapter.ts index 7f9cb2d7a75..9ef1033b6b6 100644 --- a/packages/ai/src/methods/chrome-adapter.ts +++ b/packages/ai/src/methods/chrome-adapter.ts @@ -14,7 +14,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +// Imports navigator.userAgentData types. +// The user-agent-data-types package isn't intended for modular imports. +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// import { AIError } from '../errors'; import { logger } from '../logger'; import { @@ -51,6 +54,7 @@ export class ChromeAdapter { private oldSession: LanguageModel | undefined; constructor( private languageModelProvider?: LanguageModel, + private userAgentDataProvider?: UADataValues, private mode?: InferenceMode, private onDeviceParams: OnDeviceParams = {} ) {} @@ -101,7 +105,13 @@ export class ChromeAdapter { ); return false; } - if (!ChromeAdapter.isOnDeviceRequest(request)) { + if (!this.isSupportedBrowser()) { + logger.debug( + `On-device inference unavailable because browser is unsupported.` + ); + return false; + } + if (!ChromeAdapter.isSupportedRequest(request)) { logger.debug( `On-device inference unavailable because request is incompatible.` ); @@ -206,10 +216,23 @@ export class ChromeAdapter { return deepMerge(this.onDeviceParams.createOptions || {}, requestOptions); } + /** + * Guards against unstable AI API implementations. + */ + private isSupportedBrowser(): boolean { + return !!this.userAgentDataProvider?.brands?.find(({ brand, version }) => { + const versionNumber = Number(version); + return ( + (brand === 'Google Chrome' && versionNumber > 137) || + (brand === 'Microsoft Edge' && versionNumber > 138) + ); + }); + } + /** * Asserts inference for the given request can be performed by an on-device model. */ - private static isOnDeviceRequest(request: GenerateContentRequest): boolean { + private static isSupportedRequest(request: GenerateContentRequest): boolean { // Returns false if the prompt is empty. if (request.contents.length === 0) { logger.debug('Empty prompt rejected for on-device inference.'); diff --git a/yarn.lock b/yarn.lock index 09d7a2eda0e..a92edcd15d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16597,6 +16597,11 @@ use@^3.1.0: resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +user-agent-data-types@0.4.2: + version "0.4.2" + resolved "https://registry.npmjs.org/user-agent-data-types/-/user-agent-data-types-0.4.2.tgz#3bbd3662022c3fb9d0c2f7449b6cdd412a3f9e0d" + integrity sha512-jXep3kO/dGNmDOkbDa8ccp4QArgxR4I76m3QVcJ1aOF0B9toc+YtSXtX5gLdDTZXyWlpQYQrABr6L1L2GZOghw== + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" From 90434ab686b7b92247bef3f8c02dc136ae7d0a14 Mon Sep 17 00:00:00 2001 From: Erik Eldridge Date: Fri, 6 Jun 2025 18:08:01 -0700 Subject: [PATCH 2/2] Declare module for types, to fix build error --- packages/ai/src/api.ts | 8 ++-- .../ai/src/methods/chrome-adapter.test.ts | 44 +++++++++---------- packages/ai/src/methods/chrome-adapter.ts | 6 +-- packages/ai/src/types/user-agent-data.ts | 43 ++++++++++++++++++ 4 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 packages/ai/src/types/user-agent-data.ts diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index 8ddbfc0cbcd..977ef0ef673 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -14,10 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// Imports navigator.userAgentData types. -// The user-agent-data-types package isn't intended for modular imports. -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// + import { FirebaseApp, getApp, _getProvider } from '@firebase/app'; import { Provider } from '@firebase/component'; import { getModularInstance } from '@firebase/util'; @@ -37,6 +34,7 @@ import { encodeInstanceIdentifier } from './helpers'; import { GoogleAIBackend, VertexAIBackend } from './backend'; import { ChromeAdapter } from './methods/chrome-adapter'; import { LanguageModel } from './types/language-model'; +import { NavigatorUA } from './types/user-agent-data'; export { ChatSession } from './methods/chat-session'; export * from './requests/schema-builder'; @@ -178,7 +176,7 @@ export function getGenerativeModel( inCloudParams, new ChromeAdapter( window.LanguageModel as LanguageModel, - window.navigator.userAgentData as UADataValues, + (window.navigator as NavigatorUA).userAgentData, hybridParams.mode, hybridParams.onDeviceParams ), diff --git a/packages/ai/src/methods/chrome-adapter.test.ts b/packages/ai/src/methods/chrome-adapter.test.ts index e16ca89420d..d0030687cf2 100644 --- a/packages/ai/src/methods/chrome-adapter.test.ts +++ b/packages/ai/src/methods/chrome-adapter.test.ts @@ -14,10 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// Imports navigator.userAgentData types. -// The user-agent-data-types package isn't intended for modular imports. -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// + import { AIError } from '../errors'; import { expect, use } from 'chai'; import sinonChai from 'sinon-chai'; @@ -29,6 +26,7 @@ import { LanguageModelCreateOptions, LanguageModelMessage } from '../types/language-model'; +import { UADataValues } from '../types/user-agent-data'; import { match, stub } from 'sinon'; import { GenerateContentRequest, AIErrorCode } from '../types'; import { Schema } from '../api'; @@ -137,7 +135,9 @@ describe('ChromeAdapter', () => { availability: async () => Availability.available } as LanguageModel, // Defines user agent, but no supported browser. - { brands: [] } as UADataValues, + { + brands: [] + } as UADataValues, 'prefer_on_device' ); expect( @@ -152,29 +152,25 @@ describe('ChromeAdapter', () => { availability: async () => Availability.available } as LanguageModel, { + // Defines supported browser. brands: [{ brand: 'Google Chrome', version: '138' }] } as UADataValues, 'prefer_on_device' ); - for (const mimeType of ChromeAdapter.SUPPORTED_MIME_TYPES) { - expect( - await adapter.isAvailable({ - contents: [ - { - role: 'user', - parts: [ - { - inlineData: { - mimeType, - data: '' - } - } - ] - } - ] - }) - ).to.be.true; - } + expect( + await adapter.isAvailable({ + contents: [ + { + role: 'user', + parts: [ + { + text: 'hi' + } + ] + } + ] + }) + ).to.be.true; }); it('returns false if request content has "function" role', async () => { const adapter = new ChromeAdapter( diff --git a/packages/ai/src/methods/chrome-adapter.ts b/packages/ai/src/methods/chrome-adapter.ts index 9ef1033b6b6..a49af47554e 100644 --- a/packages/ai/src/methods/chrome-adapter.ts +++ b/packages/ai/src/methods/chrome-adapter.ts @@ -14,10 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// Imports navigator.userAgentData types. -// The user-agent-data-types package isn't intended for modular imports. -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// + import { AIError } from '../errors'; import { logger } from '../logger'; import { @@ -40,6 +37,7 @@ import { LanguageModelMessageRole, LanguageModelMessageType } from '../types/language-model'; +import { UADataValues } from '../types/user-agent-data'; import deepMerge from 'deepmerge'; /** diff --git a/packages/ai/src/types/user-agent-data.ts b/packages/ai/src/types/user-agent-data.ts new file mode 100644 index 00000000000..e5f7f29132a --- /dev/null +++ b/packages/ai/src/types/user-agent-data.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 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. + */ + +/** + * Exports the minimal subset of + * https://github.com/lukewarlow/user-agent-data-types + * required by this SDK. That package is designed to be + * imported using triple slash references, which are + * prohibited in this SDK by TSLint. + */ + +export interface NavigatorUA { + readonly userAgentData?: NavigatorUAData; +} + +interface NavigatorUABrandVersion { + readonly brand: string; + readonly version: string; +} + +export interface UADataValues { + readonly brands?: NavigatorUABrandVersion[]; +} + +interface UALowEntropyJSON { + readonly brands: NavigatorUABrandVersion[]; +} + +interface NavigatorUAData extends UALowEntropyJSON {}