Skip to content

Commit 406a67c

Browse files
committed
Guard against unstable AI API versions
1 parent dae5fee commit 406a67c

File tree

5 files changed

+127
-9
lines changed

5 files changed

+127
-9
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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
17+
// Imports navigator.userAgentData types.
18+
// The user-agent-data-types package isn't intended for modular imports.
19+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
20+
/// <reference types="user-agent-data-types" />
1821
import { FirebaseApp, getApp, _getProvider } from '@firebase/app';
1922
import { Provider } from '@firebase/component';
2023
import { getModularInstance } from '@firebase/util';
@@ -175,6 +178,7 @@ export function getGenerativeModel(
175178
inCloudParams,
176179
new ChromeAdapter(
177180
window.LanguageModel as LanguageModel,
181+
window.navigator.userAgentData as UADataValues,
178182
hybridParams.mode,
179183
hybridParams.onDeviceParams
180184
),

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

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
17+
// Imports navigator.userAgentData types.
18+
// The user-agent-data-types package isn't intended for modular imports.
19+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
20+
/// <reference types="user-agent-data-types" />
1821
import { AIError } from '../errors';
1922
import { expect, use } from 'chai';
2023
import sinonChai from 'sinon-chai';
@@ -68,6 +71,7 @@ describe('ChromeAdapter', () => {
6871
} as LanguageModelCreateOptions;
6972
const adapter = new ChromeAdapter(
7073
languageModelProvider,
74+
undefined,
7175
'prefer_on_device',
7276
{
7377
createOptions
@@ -94,15 +98,19 @@ describe('ChromeAdapter', () => {
9498
).to.be.false;
9599
});
96100
it('returns false if mode is only cloud', async () => {
97-
const adapter = new ChromeAdapter(undefined, 'only_in_cloud');
101+
const adapter = new ChromeAdapter(undefined, undefined, 'only_in_cloud');
98102
expect(
99103
await adapter.isAvailable({
100104
contents: []
101105
})
102106
).to.be.false;
103107
});
104108
it('returns false if LanguageModel API is undefined', async () => {
105-
const adapter = new ChromeAdapter(undefined, 'prefer_on_device');
109+
const adapter = new ChromeAdapter(
110+
undefined,
111+
undefined,
112+
'prefer_on_device'
113+
);
106114
expect(
107115
await adapter.isAvailable({
108116
contents: []
@@ -114,6 +122,7 @@ describe('ChromeAdapter', () => {
114122
{
115123
availability: async () => Availability.available
116124
} as LanguageModel,
125+
undefined,
117126
'prefer_on_device'
118127
);
119128
expect(
@@ -122,11 +131,59 @@ describe('ChromeAdapter', () => {
122131
})
123132
).to.be.false;
124133
});
134+
it('returns false if unsupported browser', async () => {
135+
const adapter = new ChromeAdapter(
136+
{
137+
availability: async () => Availability.available
138+
} as LanguageModel,
139+
// Defines user agent, but no supported browser.
140+
{ brands: [] } 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+
brands: [{ brand: 'Google Chrome', version: '138' }]
156+
} as UADataValues,
157+
'prefer_on_device'
158+
);
159+
for (const mimeType of ChromeAdapter.SUPPORTED_MIME_TYPES) {
160+
expect(
161+
await adapter.isAvailable({
162+
contents: [
163+
{
164+
role: 'user',
165+
parts: [
166+
{
167+
inlineData: {
168+
mimeType,
169+
data: ''
170+
}
171+
}
172+
]
173+
}
174+
]
175+
})
176+
).to.be.true;
177+
}
178+
});
125179
it('returns false if request content has "function" role', async () => {
126180
const adapter = new ChromeAdapter(
127181
{
128182
availability: async () => Availability.available
129183
} as LanguageModel,
184+
{
185+
brands: [{ brand: 'Google Chrome', version: '138' }]
186+
} as UADataValues,
130187
'prefer_on_device'
131188
);
132189
expect(
@@ -145,6 +202,9 @@ describe('ChromeAdapter', () => {
145202
{
146203
availability: async () => Availability.available
147204
} as LanguageModel,
205+
{
206+
brands: [{ brand: 'Google Chrome', version: '138' }]
207+
} as UADataValues,
148208
'prefer_on_device'
149209
);
150210
for (const mimeType of ChromeAdapter.SUPPORTED_MIME_TYPES) {
@@ -173,6 +233,9 @@ describe('ChromeAdapter', () => {
173233
} as LanguageModel;
174234
const adapter = new ChromeAdapter(
175235
languageModelProvider,
236+
{
237+
brands: [{ brand: 'Google Chrome', version: '138' }]
238+
} as UADataValues,
176239
'prefer_on_device'
177240
);
178241
expect(
@@ -202,6 +265,7 @@ describe('ChromeAdapter', () => {
202265
} as LanguageModelCreateOptions;
203266
const adapter = new ChromeAdapter(
204267
languageModelProvider,
268+
undefined,
205269
'prefer_on_device',
206270
{ createOptions }
207271
);
@@ -225,6 +289,7 @@ describe('ChromeAdapter', () => {
225289
);
226290
const adapter = new ChromeAdapter(
227291
languageModelProvider,
292+
undefined,
228293
'prefer_on_device'
229294
);
230295
await adapter.isAvailable({
@@ -249,6 +314,7 @@ describe('ChromeAdapter', () => {
249314
);
250315
const adapter = new ChromeAdapter(
251316
languageModelProvider,
317+
undefined,
252318
'prefer_on_device'
253319
);
254320
await adapter.isAvailable({
@@ -267,6 +333,9 @@ describe('ChromeAdapter', () => {
267333
} as LanguageModel;
268334
const adapter = new ChromeAdapter(
269335
languageModelProvider,
336+
{
337+
brands: [{ brand: 'Google Chrome', version: '138' }]
338+
} as UADataValues,
270339
'prefer_on_device'
271340
);
272341
expect(
@@ -285,6 +354,7 @@ describe('ChromeAdapter', () => {
285354
).resolves(Availability.available);
286355
const adapter = new ChromeAdapter(
287356
languageModelProvider,
357+
undefined,
288358
'prefer_on_device',
289359
{
290360
createOptions: {
@@ -311,7 +381,7 @@ describe('ChromeAdapter', () => {
311381
});
312382
describe('generateContent', () => {
313383
it('throws if Chrome API is undefined', async () => {
314-
const adapter = new ChromeAdapter(undefined, 'only_on_device');
384+
const adapter = new ChromeAdapter(undefined, undefined, 'only_on_device');
315385
await expect(
316386
adapter.generateContent({
317387
contents: []
@@ -342,6 +412,9 @@ describe('ChromeAdapter', () => {
342412
} as LanguageModelCreateOptions;
343413
const adapter = new ChromeAdapter(
344414
languageModelProvider,
415+
{
416+
brands: [{ brand: 'Google Chrome', version: '138' }]
417+
} as UADataValues,
345418
'prefer_on_device',
346419
{ createOptions }
347420
);
@@ -389,6 +462,9 @@ describe('ChromeAdapter', () => {
389462
const promptStub = stub(languageModel, 'prompt').resolves(promptOutput);
390463
const adapter = new ChromeAdapter(
391464
languageModelProvider,
465+
{
466+
brands: [{ brand: 'Google Chrome', version: '138' }]
467+
} as UADataValues,
392468
'prefer_on_device'
393469
);
394470
const request = {
@@ -456,6 +532,7 @@ describe('ChromeAdapter', () => {
456532
};
457533
const adapter = new ChromeAdapter(
458534
languageModelProvider,
535+
undefined,
459536
'prefer_on_device',
460537
{ promptOptions }
461538
);
@@ -489,6 +566,7 @@ describe('ChromeAdapter', () => {
489566
} as LanguageModel;
490567
const adapter = new ChromeAdapter(
491568
languageModelProvider,
569+
undefined,
492570
'prefer_on_device'
493571
);
494572
const request = {
@@ -525,6 +603,7 @@ describe('ChromeAdapter', () => {
525603

526604
const adapter = new ChromeAdapter(
527605
languageModelProvider,
606+
undefined,
528607
'prefer_on_device'
529608
);
530609

@@ -534,6 +613,8 @@ describe('ChromeAdapter', () => {
534613

535614
try {
536615
await adapter.countTokens(countTokenRequest);
616+
// eslint-disable-next-line no-throw-literal
617+
throw 'unthrown';
537618
} catch (e) {
538619
// the call to countToken should be rejected with Error
539620
expect((e as AIError).code).to.equal(AIErrorCode.REQUEST_ERROR);
@@ -569,6 +650,7 @@ describe('ChromeAdapter', () => {
569650
} as LanguageModelCreateOptions;
570651
const adapter = new ChromeAdapter(
571652
languageModelProvider,
653+
undefined,
572654
'prefer_on_device',
573655
{ createOptions }
574656
);
@@ -614,6 +696,7 @@ describe('ChromeAdapter', () => {
614696
);
615697
const adapter = new ChromeAdapter(
616698
languageModelProvider,
699+
undefined,
617700
'prefer_on_device'
618701
);
619702
const request = {
@@ -674,6 +757,7 @@ describe('ChromeAdapter', () => {
674757
};
675758
const adapter = new ChromeAdapter(
676759
languageModelProvider,
760+
undefined,
677761
'prefer_on_device',
678762
{ promptOptions }
679763
);
@@ -709,6 +793,7 @@ describe('ChromeAdapter', () => {
709793
} as LanguageModel;
710794
const adapter = new ChromeAdapter(
711795
languageModelProvider,
796+
undefined,
712797
'prefer_on_device'
713798
);
714799
const request = {

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
17+
// Imports navigator.userAgentData types.
18+
// The user-agent-data-types package isn't intended for modular imports.
19+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
20+
/// <reference types="user-agent-data-types" />
1821
import { AIError } from '../errors';
1922
import { logger } from '../logger';
2023
import {
@@ -51,6 +54,7 @@ export class ChromeAdapter {
5154
private oldSession: LanguageModel | undefined;
5255
constructor(
5356
private languageModelProvider?: LanguageModel,
57+
private userAgentDataProvider?: UADataValues,
5458
private mode?: InferenceMode,
5559
private onDeviceParams: OnDeviceParams = {}
5660
) {}
@@ -101,7 +105,13 @@ export class ChromeAdapter {
101105
);
102106
return false;
103107
}
104-
if (!ChromeAdapter.isOnDeviceRequest(request)) {
108+
if (!this.isSupportedBrowser()) {
109+
logger.debug(
110+
`On-device inference unavailable because browser is unsupported.`
111+
);
112+
return false;
113+
}
114+
if (!ChromeAdapter.isSupportedRequest(request)) {
105115
logger.debug(
106116
`On-device inference unavailable because request is incompatible.`
107117
);
@@ -206,10 +216,23 @@ export class ChromeAdapter {
206216
return deepMerge(this.onDeviceParams.createOptions || {}, requestOptions);
207217
}
208218

219+
/**
220+
* Guards against unstable AI API implementations.
221+
*/
222+
private isSupportedBrowser(): boolean {
223+
return !!this.userAgentDataProvider?.brands?.find(({ brand, version }) => {
224+
const versionNumber = Number(version);
225+
return (
226+
(brand === 'Google Chrome' && versionNumber > 137) ||
227+
(brand === 'Microsoft Edge' && versionNumber > 138)
228+
);
229+
});
230+
}
231+
209232
/**
210233
* Asserts inference for the given request can be performed by an on-device model.
211234
*/
212-
private static isOnDeviceRequest(request: GenerateContentRequest): boolean {
235+
private static isSupportedRequest(request: GenerateContentRequest): boolean {
213236
// Returns false if the prompt is empty.
214237
if (request.contents.length === 0) {
215238
logger.debug('Empty prompt rejected for on-device inference.');

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)