Skip to content

AI Hybrid Inference: guard against unstable browser versions #9084

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: erikeldridge-vertex-expectedinputs
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/ai/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -175,6 +176,7 @@ export function getGenerativeModel(
inCloudParams,
new ChromeAdapter(
window.LanguageModel as LanguageModel,
(window.navigator as NavigatorUA).userAgentData,
hybridParams.mode,
hybridParams.onDeviceParams
),
Expand Down
87 changes: 84 additions & 3 deletions packages/ai/src/methods/chrome-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +69,7 @@ describe('ChromeAdapter', () => {
} as LanguageModelCreateOptions;
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device',
{
createOptions
Expand All @@ -94,15 +96,19 @@ 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: []
})
).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: []
Expand All @@ -114,6 +120,7 @@ describe('ChromeAdapter', () => {
{
availability: async () => Availability.available
} as LanguageModel,
undefined,
'prefer_on_device'
);
expect(
Expand All @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -202,6 +261,7 @@ describe('ChromeAdapter', () => {
} as LanguageModelCreateOptions;
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device',
{ createOptions }
);
Expand All @@ -225,6 +285,7 @@ describe('ChromeAdapter', () => {
);
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device'
);
await adapter.isAvailable({
Expand All @@ -249,6 +310,7 @@ describe('ChromeAdapter', () => {
);
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device'
);
await adapter.isAvailable({
Expand All @@ -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(
Expand All @@ -285,6 +350,7 @@ describe('ChromeAdapter', () => {
).resolves(Availability.available);
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device',
{
createOptions: {
Expand All @@ -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: []
Expand Down Expand Up @@ -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 }
);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -456,6 +528,7 @@ describe('ChromeAdapter', () => {
};
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device',
{ promptOptions }
);
Expand Down Expand Up @@ -489,6 +562,7 @@ describe('ChromeAdapter', () => {
} as LanguageModel;
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device'
);
const request = {
Expand Down Expand Up @@ -525,6 +599,7 @@ describe('ChromeAdapter', () => {

const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device'
);

Expand All @@ -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);
Expand Down Expand Up @@ -569,6 +646,7 @@ describe('ChromeAdapter', () => {
} as LanguageModelCreateOptions;
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device',
{ createOptions }
);
Expand Down Expand Up @@ -614,6 +692,7 @@ describe('ChromeAdapter', () => {
);
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device'
);
const request = {
Expand Down Expand Up @@ -674,6 +753,7 @@ describe('ChromeAdapter', () => {
};
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device',
{ promptOptions }
);
Expand Down Expand Up @@ -709,6 +789,7 @@ describe('ChromeAdapter', () => {
} as LanguageModel;
const adapter = new ChromeAdapter(
languageModelProvider,
undefined,
'prefer_on_device'
);
const request = {
Expand Down
25 changes: 23 additions & 2 deletions packages/ai/src/methods/chrome-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
LanguageModelMessageRole,
LanguageModelMessageType
} from '../types/language-model';
import { UADataValues } from '../types/user-agent-data';
import deepMerge from 'deepmerge';

/**
Expand All @@ -51,6 +52,7 @@ export class ChromeAdapter {
private oldSession: LanguageModel | undefined;
constructor(
private languageModelProvider?: LanguageModel,
private userAgentDataProvider?: UADataValues,
private mode?: InferenceMode,
private onDeviceParams: OnDeviceParams = {}
) {}
Expand Down Expand Up @@ -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.`
);
Expand Down Expand Up @@ -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.');
Expand Down
43 changes: 43 additions & 0 deletions packages/ai/src/types/user-agent-data.ts
Original file line number Diff line number Diff line change
@@ -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 {}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading