Skip to content

Commit cde0bbb

Browse files
authored
Merge 90434ab into dae5fee
2 parents dae5fee + 90434ab commit cde0bbb

File tree

6 files changed

+159
-6
lines changed

6 files changed

+159
-6
lines changed

packages/ai/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
"rollup": "2.79.2",
6363
"rollup-plugin-replace": "2.2.0",
6464
"rollup-plugin-typescript2": "0.36.0",
65-
"typescript": "5.5.4"
65+
"typescript": "5.5.4",
66+
"user-agent-data-types": "0.4.2"
6667
},
6768
"repository": {
6869
"directory": "packages/ai",

packages/ai/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { encodeInstanceIdentifier } from './helpers';
3434
import { GoogleAIBackend, VertexAIBackend } from './backend';
3535
import { ChromeAdapter } from './methods/chrome-adapter';
3636
import { LanguageModel } from './types/language-model';
37+
import { NavigatorUA } from './types/user-agent-data';
3738

3839
export { ChatSession } from './methods/chat-session';
3940
export * from './requests/schema-builder';
@@ -175,6 +176,7 @@ export function getGenerativeModel(
175176
inCloudParams,
176177
new ChromeAdapter(
177178
window.LanguageModel as LanguageModel,
179+
(window.navigator as NavigatorUA).userAgentData,
178180
hybridParams.mode,
179181
hybridParams.onDeviceParams
180182
),

packages/ai/src/methods/chrome-adapter.test.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
LanguageModelCreateOptions,
2727
LanguageModelMessage
2828
} from '../types/language-model';
29+
import { UADataValues } from '../types/user-agent-data';
2930
import { match, stub } from 'sinon';
3031
import { GenerateContentRequest, AIErrorCode } from '../types';
3132
import { Schema } from '../api';
@@ -68,6 +69,7 @@ describe('ChromeAdapter', () => {
6869
} as LanguageModelCreateOptions;
6970
const adapter = new ChromeAdapter(
7071
languageModelProvider,
72+
undefined,
7173
'prefer_on_device',
7274
{
7375
createOptions
@@ -94,15 +96,19 @@ describe('ChromeAdapter', () => {
9496
).to.be.false;
9597
});
9698
it('returns false if mode is only cloud', async () => {
97-
const adapter = new ChromeAdapter(undefined, 'only_in_cloud');
99+
const adapter = new ChromeAdapter(undefined, undefined, 'only_in_cloud');
98100
expect(
99101
await adapter.isAvailable({
100102
contents: []
101103
})
102104
).to.be.false;
103105
});
104106
it('returns false if LanguageModel API is undefined', async () => {
105-
const adapter = new ChromeAdapter(undefined, 'prefer_on_device');
107+
const adapter = new ChromeAdapter(
108+
undefined,
109+
undefined,
110+
'prefer_on_device'
111+
);
106112
expect(
107113
await adapter.isAvailable({
108114
contents: []
@@ -114,6 +120,7 @@ describe('ChromeAdapter', () => {
114120
{
115121
availability: async () => Availability.available
116122
} as LanguageModel,
123+
undefined,
117124
'prefer_on_device'
118125
);
119126
expect(
@@ -122,11 +129,57 @@ describe('ChromeAdapter', () => {
122129
})
123130
).to.be.false;
124131
});
132+
it('returns false if unsupported browser', async () => {
133+
const adapter = new ChromeAdapter(
134+
{
135+
availability: async () => Availability.available
136+
} as LanguageModel,
137+
// Defines user agent, but no supported browser.
138+
{
139+
brands: []
140+
} as UADataValues,
141+
'prefer_on_device'
142+
);
143+
expect(
144+
await adapter.isAvailable({
145+
contents: []
146+
})
147+
).to.be.false;
148+
});
149+
it('returns true if supported browser', async () => {
150+
const adapter = new ChromeAdapter(
151+
{
152+
availability: async () => Availability.available
153+
} as LanguageModel,
154+
{
155+
// Defines supported browser.
156+
brands: [{ brand: 'Google Chrome', version: '138' }]
157+
} as UADataValues,
158+
'prefer_on_device'
159+
);
160+
expect(
161+
await adapter.isAvailable({
162+
contents: [
163+
{
164+
role: 'user',
165+
parts: [
166+
{
167+
text: 'hi'
168+
}
169+
]
170+
}
171+
]
172+
})
173+
).to.be.true;
174+
});
125175
it('returns false if request content has "function" role', async () => {
126176
const adapter = new ChromeAdapter(
127177
{
128178
availability: async () => Availability.available
129179
} as LanguageModel,
180+
{
181+
brands: [{ brand: 'Google Chrome', version: '138' }]
182+
} as UADataValues,
130183
'prefer_on_device'
131184
);
132185
expect(
@@ -145,6 +198,9 @@ describe('ChromeAdapter', () => {
145198
{
146199
availability: async () => Availability.available
147200
} as LanguageModel,
201+
{
202+
brands: [{ brand: 'Google Chrome', version: '138' }]
203+
} as UADataValues,
148204
'prefer_on_device'
149205
);
150206
for (const mimeType of ChromeAdapter.SUPPORTED_MIME_TYPES) {
@@ -173,6 +229,9 @@ describe('ChromeAdapter', () => {
173229
} as LanguageModel;
174230
const adapter = new ChromeAdapter(
175231
languageModelProvider,
232+
{
233+
brands: [{ brand: 'Google Chrome', version: '138' }]
234+
} as UADataValues,
176235
'prefer_on_device'
177236
);
178237
expect(
@@ -202,6 +261,7 @@ describe('ChromeAdapter', () => {
202261
} as LanguageModelCreateOptions;
203262
const adapter = new ChromeAdapter(
204263
languageModelProvider,
264+
undefined,
205265
'prefer_on_device',
206266
{ createOptions }
207267
);
@@ -225,6 +285,7 @@ describe('ChromeAdapter', () => {
225285
);
226286
const adapter = new ChromeAdapter(
227287
languageModelProvider,
288+
undefined,
228289
'prefer_on_device'
229290
);
230291
await adapter.isAvailable({
@@ -249,6 +310,7 @@ describe('ChromeAdapter', () => {
249310
);
250311
const adapter = new ChromeAdapter(
251312
languageModelProvider,
313+
undefined,
252314
'prefer_on_device'
253315
);
254316
await adapter.isAvailable({
@@ -267,6 +329,9 @@ describe('ChromeAdapter', () => {
267329
} as LanguageModel;
268330
const adapter = new ChromeAdapter(
269331
languageModelProvider,
332+
{
333+
brands: [{ brand: 'Google Chrome', version: '138' }]
334+
} as UADataValues,
270335
'prefer_on_device'
271336
);
272337
expect(
@@ -285,6 +350,7 @@ describe('ChromeAdapter', () => {
285350
).resolves(Availability.available);
286351
const adapter = new ChromeAdapter(
287352
languageModelProvider,
353+
undefined,
288354
'prefer_on_device',
289355
{
290356
createOptions: {
@@ -311,7 +377,7 @@ describe('ChromeAdapter', () => {
311377
});
312378
describe('generateContent', () => {
313379
it('throws if Chrome API is undefined', async () => {
314-
const adapter = new ChromeAdapter(undefined, 'only_on_device');
380+
const adapter = new ChromeAdapter(undefined, undefined, 'only_on_device');
315381
await expect(
316382
adapter.generateContent({
317383
contents: []
@@ -342,6 +408,9 @@ describe('ChromeAdapter', () => {
342408
} as LanguageModelCreateOptions;
343409
const adapter = new ChromeAdapter(
344410
languageModelProvider,
411+
{
412+
brands: [{ brand: 'Google Chrome', version: '138' }]
413+
} as UADataValues,
345414
'prefer_on_device',
346415
{ createOptions }
347416
);
@@ -389,6 +458,9 @@ describe('ChromeAdapter', () => {
389458
const promptStub = stub(languageModel, 'prompt').resolves(promptOutput);
390459
const adapter = new ChromeAdapter(
391460
languageModelProvider,
461+
{
462+
brands: [{ brand: 'Google Chrome', version: '138' }]
463+
} as UADataValues,
392464
'prefer_on_device'
393465
);
394466
const request = {
@@ -456,6 +528,7 @@ describe('ChromeAdapter', () => {
456528
};
457529
const adapter = new ChromeAdapter(
458530
languageModelProvider,
531+
undefined,
459532
'prefer_on_device',
460533
{ promptOptions }
461534
);
@@ -489,6 +562,7 @@ describe('ChromeAdapter', () => {
489562
} as LanguageModel;
490563
const adapter = new ChromeAdapter(
491564
languageModelProvider,
565+
undefined,
492566
'prefer_on_device'
493567
);
494568
const request = {
@@ -525,6 +599,7 @@ describe('ChromeAdapter', () => {
525599

526600
const adapter = new ChromeAdapter(
527601
languageModelProvider,
602+
undefined,
528603
'prefer_on_device'
529604
);
530605

@@ -534,6 +609,8 @@ describe('ChromeAdapter', () => {
534609

535610
try {
536611
await adapter.countTokens(countTokenRequest);
612+
// eslint-disable-next-line no-throw-literal
613+
throw 'unthrown';
537614
} catch (e) {
538615
// the call to countToken should be rejected with Error
539616
expect((e as AIError).code).to.equal(AIErrorCode.REQUEST_ERROR);
@@ -569,6 +646,7 @@ describe('ChromeAdapter', () => {
569646
} as LanguageModelCreateOptions;
570647
const adapter = new ChromeAdapter(
571648
languageModelProvider,
649+
undefined,
572650
'prefer_on_device',
573651
{ createOptions }
574652
);
@@ -614,6 +692,7 @@ describe('ChromeAdapter', () => {
614692
);
615693
const adapter = new ChromeAdapter(
616694
languageModelProvider,
695+
undefined,
617696
'prefer_on_device'
618697
);
619698
const request = {
@@ -674,6 +753,7 @@ describe('ChromeAdapter', () => {
674753
};
675754
const adapter = new ChromeAdapter(
676755
languageModelProvider,
756+
undefined,
677757
'prefer_on_device',
678758
{ promptOptions }
679759
);
@@ -709,6 +789,7 @@ describe('ChromeAdapter', () => {
709789
} as LanguageModel;
710790
const adapter = new ChromeAdapter(
711791
languageModelProvider,
792+
undefined,
712793
'prefer_on_device'
713794
);
714795
const request = {

packages/ai/src/methods/chrome-adapter.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
LanguageModelMessageRole,
3838
LanguageModelMessageType
3939
} from '../types/language-model';
40+
import { UADataValues } from '../types/user-agent-data';
4041
import deepMerge from 'deepmerge';
4142

4243
/**
@@ -51,6 +52,7 @@ export class ChromeAdapter {
5152
private oldSession: LanguageModel | undefined;
5253
constructor(
5354
private languageModelProvider?: LanguageModel,
55+
private userAgentDataProvider?: UADataValues,
5456
private mode?: InferenceMode,
5557
private onDeviceParams: OnDeviceParams = {}
5658
) {}
@@ -101,7 +103,13 @@ export class ChromeAdapter {
101103
);
102104
return false;
103105
}
104-
if (!ChromeAdapter.isOnDeviceRequest(request)) {
106+
if (!this.isSupportedBrowser()) {
107+
logger.debug(
108+
`On-device inference unavailable because browser is unsupported.`
109+
);
110+
return false;
111+
}
112+
if (!ChromeAdapter.isSupportedRequest(request)) {
105113
logger.debug(
106114
`On-device inference unavailable because request is incompatible.`
107115
);
@@ -206,10 +214,23 @@ export class ChromeAdapter {
206214
return deepMerge(this.onDeviceParams.createOptions || {}, requestOptions);
207215
}
208216

217+
/**
218+
* Guards against unstable AI API implementations.
219+
*/
220+
private isSupportedBrowser(): boolean {
221+
return !!this.userAgentDataProvider?.brands?.find(({ brand, version }) => {
222+
const versionNumber = Number(version);
223+
return (
224+
(brand === 'Google Chrome' && versionNumber > 137) ||
225+
(brand === 'Microsoft Edge' && versionNumber > 138)
226+
);
227+
});
228+
}
229+
209230
/**
210231
* Asserts inference for the given request can be performed by an on-device model.
211232
*/
212-
private static isOnDeviceRequest(request: GenerateContentRequest): boolean {
233+
private static isSupportedRequest(request: GenerateContentRequest): boolean {
213234
// Returns false if the prompt is empty.
214235
if (request.contents.length === 0) {
215236
logger.debug('Empty prompt rejected for on-device inference.');
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* Exports the minimal subset of
20+
* https://github.com/lukewarlow/user-agent-data-types
21+
* required by this SDK. That package is designed to be
22+
* imported using triple slash references, which are
23+
* prohibited in this SDK by TSLint.
24+
*/
25+
26+
export interface NavigatorUA {
27+
readonly userAgentData?: NavigatorUAData;
28+
}
29+
30+
interface NavigatorUABrandVersion {
31+
readonly brand: string;
32+
readonly version: string;
33+
}
34+
35+
export interface UADataValues {
36+
readonly brands?: NavigatorUABrandVersion[];
37+
}
38+
39+
interface UALowEntropyJSON {
40+
readonly brands: NavigatorUABrandVersion[];
41+
}
42+
43+
interface NavigatorUAData extends UALowEntropyJSON {}

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16597,6 +16597,11 @@ use@^3.1.0:
1659716597
resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
1659816598
integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
1659916599

16600+
user-agent-data-types@0.4.2:
16601+
version "0.4.2"
16602+
resolved "https://registry.npmjs.org/user-agent-data-types/-/user-agent-data-types-0.4.2.tgz#3bbd3662022c3fb9d0c2f7449b6cdd412a3f9e0d"
16603+
integrity sha512-jXep3kO/dGNmDOkbDa8ccp4QArgxR4I76m3QVcJ1aOF0B9toc+YtSXtX5gLdDTZXyWlpQYQrABr6L1L2GZOghw==
16604+
1660016605
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
1660116606
version "1.0.2"
1660216607
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"

0 commit comments

Comments
 (0)