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..977ef0ef673 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -34,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'; @@ -175,6 +176,7 @@ export function getGenerativeModel( inCloudParams, new ChromeAdapter( window.LanguageModel as LanguageModel, + (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 5b245ac1ffb..d0030687cf2 100644 --- a/packages/ai/src/methods/chrome-adapter.test.ts +++ b/packages/ai/src/methods/chrome-adapter.test.ts @@ -26,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'; @@ -68,6 +69,7 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions @@ -94,7 +96,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 +104,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 +120,7 @@ describe('ChromeAdapter', () => { { availability: async () => Availability.available } as LanguageModel, + undefined, 'prefer_on_device' ); expect( @@ -122,11 +129,57 @@ 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, + { + // Defines supported browser. + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, + 'prefer_on_device' + ); + 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( { availability: async () => Availability.available } as LanguageModel, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); expect( @@ -145,6 +198,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 +229,9 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); expect( @@ -202,6 +261,7 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions } ); @@ -225,6 +285,7 @@ describe('ChromeAdapter', () => { ); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); await adapter.isAvailable({ @@ -249,6 +310,7 @@ describe('ChromeAdapter', () => { ); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); await adapter.isAvailable({ @@ -267,6 +329,9 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device' ); expect( @@ -285,6 +350,7 @@ describe('ChromeAdapter', () => { ).resolves(Availability.available); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions: { @@ -311,7 +377,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 +408,9 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + { + brands: [{ brand: 'Google Chrome', version: '138' }] + } as UADataValues, 'prefer_on_device', { createOptions } ); @@ -389,6 +458,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 +528,7 @@ describe('ChromeAdapter', () => { }; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { promptOptions } ); @@ -489,6 +562,7 @@ describe('ChromeAdapter', () => { } as LanguageModel; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); const request = { @@ -525,6 +599,7 @@ describe('ChromeAdapter', () => { const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); @@ -534,6 +609,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 +646,7 @@ describe('ChromeAdapter', () => { } as LanguageModelCreateOptions; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { createOptions } ); @@ -614,6 +692,7 @@ describe('ChromeAdapter', () => { ); const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device' ); const request = { @@ -674,6 +753,7 @@ describe('ChromeAdapter', () => { }; const adapter = new ChromeAdapter( languageModelProvider, + undefined, 'prefer_on_device', { promptOptions } ); @@ -709,6 +789,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..a49af47554e 100644 --- a/packages/ai/src/methods/chrome-adapter.ts +++ b/packages/ai/src/methods/chrome-adapter.ts @@ -37,6 +37,7 @@ import { LanguageModelMessageRole, LanguageModelMessageType } from '../types/language-model'; +import { UADataValues } from '../types/user-agent-data'; import deepMerge from 'deepmerge'; /** @@ -51,6 +52,7 @@ export class ChromeAdapter { private oldSession: LanguageModel | undefined; constructor( private languageModelProvider?: LanguageModel, + private userAgentDataProvider?: UADataValues, private mode?: InferenceMode, private onDeviceParams: OnDeviceParams = {} ) {} @@ -101,7 +103,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 +214,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/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 {} 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"