Skip to content

Commit bf9de16

Browse files
committed
Implement ChromeAdapter class
1 parent 9763167 commit bf9de16

File tree

5 files changed

+241
-9
lines changed

5 files changed

+241
-9
lines changed

common/api-review/util.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,11 @@ export function isBrowser(): boolean;
264264
// @public (undocumented)
265265
export function isBrowserExtension(): boolean;
266266

267+
// Warning: (ae-missing-release-tag) "isChrome" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
268+
//
269+
// @public (undocumented)
270+
export function isChrome(): boolean;
271+
267272
// Warning: (ae-missing-release-tag) "isCloudflareWorker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
268273
//
269274
// @public

packages/util/src/environment.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@ export function isSafari(): boolean {
173173
);
174174
}
175175

176+
export function isChrome(): boolean {
177+
return (
178+
!isNode() && !!navigator.userAgent && navigator.userAgent.includes('Chrome')
179+
);
180+
}
181+
176182
/**
177183
* This method checks if indexedDB is supported by current browser/service worker context
178184
* @return true if indexedDB is supported by current browser/service worker context
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
import { expect, use } from 'chai';
19+
import sinonChai from 'sinon-chai';
20+
import chaiAsPromised from 'chai-as-promised';
21+
import { ChromeAdapter } from './chrome-adapter';
22+
import { Availability, LanguageModel } from '../types/language-model';
23+
24+
use(sinonChai);
25+
use(chaiAsPromised);
26+
27+
describe('ChromeAdapter', () => {
28+
describe('isOnDeviceRequest', () => {
29+
it('returns true for simple text part', async () => {
30+
expect(
31+
ChromeAdapter._isOnDeviceRequest({
32+
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
33+
})
34+
).to.be.true;
35+
});
36+
it('returns false if contents empty', async () => {
37+
expect(
38+
ChromeAdapter._isOnDeviceRequest({
39+
contents: []
40+
})
41+
).to.be.false;
42+
});
43+
});
44+
describe('isAvailable', () => {
45+
it('returns true if a model is available', async () => {
46+
const languageModelProvider = {
47+
availability: () => Promise.resolve(Availability.available)
48+
} as LanguageModel;
49+
const adapter = new ChromeAdapter(
50+
languageModelProvider,
51+
'prefer_on_device'
52+
);
53+
expect(
54+
await adapter.isAvailable({
55+
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
56+
})
57+
).to.be.true;
58+
});
59+
});
60+
});

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

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,41 +15,202 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { GenerateContentRequest, InferenceMode } from '../types';
1918
import {
19+
Content,
20+
GenerateContentRequest,
21+
InferenceMode,
22+
Part,
23+
Role,
24+
TextPart
25+
} from '../types';
26+
import {
27+
Availability,
2028
LanguageModel,
21-
LanguageModelCreateOptions
29+
LanguageModelCreateOptions,
30+
LanguageModelMessageRole,
31+
LanguageModelMessageShorthand
2232
} from '../types/language-model';
33+
import { isChrome } from '@firebase/util';
2334

2435
/**
2536
* Defines an inference "backend" that uses Chrome's on-device model,
2637
* and encapsulates logic for detecting when on-device is possible.
2738
*/
2839
export class ChromeAdapter {
40+
downloadPromise: Promise<LanguageModel> | undefined;
41+
oldSession: LanguageModel | undefined;
2942
constructor(
3043
private languageModelProvider?: LanguageModel,
3144
private mode?: InferenceMode,
3245
private onDeviceParams?: LanguageModelCreateOptions
3346
) {}
34-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
47+
/**
48+
* Convenience method to check if a given request can be made on-device.
49+
* Encapsulates a few concerns: 1) the mode, 2) API existence, 3) prompt formatting, and
50+
* 4) model availability, including triggering download if necessary.
51+
* Pros: caller needn't be concerned with details of on-device availability. Cons: this method
52+
* spans a few concerns and splits request validation from usage. If instance variables weren't
53+
* already part of the API, we could consider a better separation of concerns.
54+
*/
3555
async isAvailable(request: GenerateContentRequest): Promise<boolean> {
36-
return false;
56+
// Returns false if we should only use in-cloud inference.
57+
if (this.mode === 'only_in_cloud') {
58+
return false;
59+
}
60+
// Returns false because only Chrome's experimental Prompt API is supported.
61+
if (!isChrome()) {
62+
return false;
63+
}
64+
// Returns false if the on-device inference API is undefined.;
65+
if (!this.languageModelProvider) {
66+
return false;
67+
}
68+
// Returns false if the request can't be run on-device.
69+
if (!ChromeAdapter._isOnDeviceRequest(request)) {
70+
return false;
71+
}
72+
const availability = await this.languageModelProvider.availability();
73+
switch (availability) {
74+
case Availability.available:
75+
// Returns true only if a model is immediately available.
76+
return true;
77+
case Availability.downloadable:
78+
// Triggers async download if model is downloadable.
79+
this.download();
80+
default:
81+
return false;
82+
}
3783
}
3884
async generateContentOnDevice(
39-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4085
request: GenerateContentRequest
4186
): Promise<Response> {
87+
const initialPrompts = ChromeAdapter.toInitialPrompts(request.contents);
88+
// Assumes validation asserted there is at least one initial prompt.
89+
const prompt = initialPrompts.pop()!;
90+
const systemPrompt = ChromeAdapter.toSystemPrompt(
91+
request.systemInstruction
92+
);
93+
const session = await this.session({
94+
initialPrompts,
95+
systemPrompt
96+
});
97+
const text = await session.prompt(prompt.content);
4298
return {
4399
json: () =>
44100
Promise.resolve({
45101
candidates: [
46102
{
47103
content: {
48-
parts: [{ text: '' }]
104+
parts: [{ text }]
49105
}
50106
}
51107
]
52108
})
53109
} as Response;
54110
}
111+
// Visible for testing
112+
static _isOnDeviceRequest(request: GenerateContentRequest): boolean {
113+
if (request.systemInstruction) {
114+
const systemContent = request.systemInstruction as Content;
115+
// Returns false if the role can't be represented on-device.
116+
if (systemContent.role && systemContent.role === 'function') {
117+
return false;
118+
}
119+
120+
// Returns false if the system prompt is multi-part.
121+
if (systemContent.parts && systemContent.parts.length > 1) {
122+
return false;
123+
}
124+
125+
// Returns false if the system prompt isn't text.
126+
const systemText = request.systemInstruction as TextPart;
127+
if (!systemText.text) {
128+
return false;
129+
}
130+
}
131+
132+
// Returns false if the prompt is empty.
133+
if (request.contents.length === 0) {
134+
return false;
135+
}
136+
137+
// Applies the same checks as above, but for each content item.
138+
for (const content of request.contents) {
139+
if (content.role === 'function') {
140+
return false;
141+
}
142+
143+
if (content.parts.length > 1) {
144+
return false;
145+
}
146+
147+
if (!content.parts[0].text) {
148+
return false;
149+
}
150+
}
151+
152+
return true;
153+
}
154+
private download(): void {
155+
if (this.downloadPromise) {
156+
return;
157+
}
158+
this.downloadPromise = this.languageModelProvider
159+
?.create(this.onDeviceParams)
160+
.then((model: LanguageModel) => {
161+
delete this.downloadPromise;
162+
return model;
163+
});
164+
return;
165+
}
166+
private static toSystemPrompt(
167+
prompt: string | Content | Part | undefined
168+
): string | undefined {
169+
if (!prompt) {
170+
return undefined;
171+
}
172+
173+
if (typeof prompt === 'string') {
174+
return prompt;
175+
}
176+
177+
const systemContent = prompt as Content;
178+
if (
179+
systemContent.parts &&
180+
systemContent.parts[0] &&
181+
systemContent.parts[0].text
182+
) {
183+
return systemContent.parts[0].text;
184+
}
185+
186+
const systemPart = prompt as Part;
187+
if (systemPart.text) {
188+
return systemPart.text;
189+
}
190+
191+
return undefined;
192+
}
193+
private static toOnDeviceRole(role: Role): LanguageModelMessageRole {
194+
return role === 'model' ? 'assistant' : 'user';
195+
}
196+
private static toInitialPrompts(
197+
contents: Content[]
198+
): LanguageModelMessageShorthand[] {
199+
return contents.map(c => ({
200+
role: ChromeAdapter.toOnDeviceRole(c.role),
201+
// Assumes contents have been verified to contain only a single TextPart.
202+
content: c.parts[0].text!
203+
}));
204+
}
205+
private async session(
206+
opts: LanguageModelCreateOptions
207+
): Promise<LanguageModel> {
208+
const newSession = await this.languageModelProvider!.create(opts);
209+
if (this.oldSession) {
210+
this.oldSession.destroy();
211+
}
212+
// Holds session reference, so model isn't unloaded from memory.
213+
this.oldSession = newSession;
214+
return newSession;
215+
}
55216
}

packages/vertexai/src/types/language-model.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface LanguageModel extends EventTarget {
3232
): Promise<number>;
3333
destroy(): undefined;
3434
}
35-
enum Availability {
35+
export enum Availability {
3636
'unavailable',
3737
'downloadable',
3838
'downloading',
@@ -67,15 +67,15 @@ interface LanguageModelMessage {
6767
role: LanguageModelMessageRole;
6868
content: LanguageModelMessageContent[];
6969
}
70-
interface LanguageModelMessageShorthand {
70+
export interface LanguageModelMessageShorthand {
7171
role: LanguageModelMessageRole;
7272
content: string;
7373
}
7474
interface LanguageModelMessageContent {
7575
type: LanguageModelMessageType;
7676
content: LanguageModelMessageContentValue;
7777
}
78-
type LanguageModelMessageRole = 'system' | 'user' | 'assistant';
78+
export type LanguageModelMessageRole = 'system' | 'user' | 'assistant';
7979
type LanguageModelMessageType = 'text' | 'image' | 'audio';
8080
type LanguageModelMessageContentValue =
8181
| ImageBitmapSource

0 commit comments

Comments
 (0)