Skip to content

Commit 25c6397

Browse files
committed
Implement ChromeAdapter class
1 parent c891a85 commit 25c6397

File tree

3 files changed

+251
-5
lines changed

3 files changed

+251
-5
lines changed

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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 { InferenceMode } from '../types';
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 aiProvider = {
47+
languageModel: {
48+
capabilities: () =>
49+
Promise.resolve({
50+
available: 'readily'
51+
} as AILanguageModelCapabilities)
52+
}
53+
} as AI;
54+
const adapter = new ChromeAdapter(
55+
aiProvider,
56+
InferenceMode.PREFER_ON_DEVICE
57+
);
58+
expect(
59+
await adapter.isAvailable({
60+
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
61+
})
62+
).to.be.true;
63+
});
64+
});
65+
});
Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,29 @@
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 { isChrome } from '@firebase/util';
119
import {
20+
Content,
221
EnhancedGenerateContentResponse,
322
GenerateContentRequest,
4-
InferenceMode
23+
InferenceMode,
24+
Part,
25+
Role,
26+
TextPart
527
} from '../types';
628
import { AI, AILanguageModelCreateOptionsWithSystemPrompt } from '../types/ai';
729

@@ -10,22 +32,175 @@ import { AI, AILanguageModelCreateOptionsWithSystemPrompt } from '../types/ai';
1032
* and encapsulates logic for detecting when on-device is possible.
1133
*/
1234
export class ChromeAdapter {
35+
downloadPromise: Promise<AILanguageModel> | undefined;
36+
oldSession: AILanguageModel | undefined;
1337
constructor(
1438
private aiProvider?: AI,
1539
private mode?: InferenceMode,
1640
private onDeviceParams?: AILanguageModelCreateOptionsWithSystemPrompt
1741
) {}
18-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
42+
/**
43+
* Convenience method to check if a given request can be made on-device.
44+
* Encapsulates a few concerns: 1) the mode, 2) API existence, 3) prompt formatting, and
45+
* 4) model availability, including triggering download if necessary.
46+
* Pros: caller needn't be concerned with details of on-device availability. Cons: this method
47+
* spans a few concerns and splits request validation from usage. If instance variables weren't
48+
* already part of the API, we could consider a better separation of concerns.
49+
*/
1950
async isAvailable(request: GenerateContentRequest): Promise<boolean> {
20-
return false;
51+
// Returns false if we should only use in-cloud inference.
52+
if (this.mode === InferenceMode.ONLY_ON_CLOUD) {
53+
return false;
54+
}
55+
// Returns false if the on-device inference API is undefined.
56+
const isLanguageModelAvailable =
57+
isChrome() && this.aiProvider && this.aiProvider.languageModel;
58+
if (!isLanguageModelAvailable) {
59+
return false;
60+
}
61+
// Returns false if the request can't be run on-device.
62+
if (!ChromeAdapter._isOnDeviceRequest(request)) {
63+
return false;
64+
}
65+
switch (await this.availability()) {
66+
case 'readily':
67+
// Returns true only if a model is immediately available.
68+
return true;
69+
case 'after-download':
70+
// Triggers async model download.
71+
this.download();
72+
case 'no':
73+
default:
74+
return false;
75+
}
2176
}
2277
async generateContentOnDevice(
23-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2478
request: GenerateContentRequest
2579
): Promise<EnhancedGenerateContentResponse> {
80+
const initialPrompts = ChromeAdapter.toInitialPrompts(request.contents);
81+
// Assumes validation asserted there is at least one initial prompt.
82+
const prompt = initialPrompts.pop()!;
83+
const systemPrompt = ChromeAdapter.toSystemPrompt(
84+
request.systemInstruction
85+
);
86+
const session = await this.session({
87+
initialPrompts,
88+
systemPrompt
89+
});
90+
const result = await session.prompt(prompt.content);
2691
return {
27-
text: () => '',
92+
text: () => result,
2893
functionCalls: () => undefined
2994
};
3095
}
96+
// Visible for testing
97+
static _isOnDeviceRequest(request: GenerateContentRequest): boolean {
98+
if (request.systemInstruction) {
99+
const systemContent = request.systemInstruction as Content;
100+
// Returns false if the role can't be represented on-device.
101+
if (systemContent.role && systemContent.role === 'function') {
102+
return false;
103+
}
104+
105+
// Returns false if the system prompt is multi-part.
106+
if (systemContent.parts && systemContent.parts.length > 1) {
107+
return false;
108+
}
109+
110+
// Returns false if the system prompt isn't text.
111+
const systemText = request.systemInstruction as TextPart;
112+
if (!systemText.text) {
113+
return false;
114+
}
115+
}
116+
117+
// Returns false if the prompt is empty.
118+
if (request.contents.length === 0) {
119+
return false;
120+
}
121+
122+
// Applies the same checks as above, but for each content item.
123+
for (const content of request.contents) {
124+
if (content.role === 'function') {
125+
return false;
126+
}
127+
128+
if (content.parts.length > 1) {
129+
return false;
130+
}
131+
132+
if (!content.parts[0].text) {
133+
return false;
134+
}
135+
}
136+
137+
return true;
138+
}
139+
private async availability(): Promise<AICapabilityAvailability | undefined> {
140+
return this.aiProvider?.languageModel
141+
.capabilities()
142+
.then((c: AILanguageModelCapabilities) => c.available);
143+
}
144+
private download(): void {
145+
if (this.downloadPromise) {
146+
return;
147+
}
148+
this.downloadPromise = this.aiProvider?.languageModel
149+
.create(this.onDeviceParams)
150+
.then((model: AILanguageModel) => {
151+
delete this.downloadPromise;
152+
return model;
153+
});
154+
return;
155+
}
156+
private static toSystemPrompt(
157+
prompt: string | Content | Part | undefined
158+
): string | undefined {
159+
if (!prompt) {
160+
return undefined;
161+
}
162+
163+
if (typeof prompt === 'string') {
164+
return prompt;
165+
}
166+
167+
const systemContent = prompt as Content;
168+
if (
169+
systemContent.parts &&
170+
systemContent.parts[0] &&
171+
systemContent.parts[0].text
172+
) {
173+
return systemContent.parts[0].text;
174+
}
175+
176+
const systemPart = prompt as Part;
177+
if (systemPart.text) {
178+
return systemPart.text;
179+
}
180+
181+
return undefined;
182+
}
183+
private static toOnDeviceRole(role: Role): AILanguageModelPromptRole {
184+
return role === 'model' ? 'assistant' : 'user';
185+
}
186+
private static toInitialPrompts(
187+
contents: Content[]
188+
): AILanguageModelPrompt[] {
189+
return contents.map(c => ({
190+
role: ChromeAdapter.toOnDeviceRole(c.role),
191+
// Assumes contents have been verified to contain only a single TextPart.
192+
content: c.parts[0].text!
193+
}));
194+
}
195+
private async session(
196+
opts: AILanguageModelCreateOptionsWithSystemPrompt
197+
): Promise<AILanguageModel> {
198+
const newSession = await this.aiProvider!.languageModel.create(opts);
199+
if (this.oldSession) {
200+
this.oldSession.destroy();
201+
}
202+
// Holds session reference, so model isn't unloaded from memory.
203+
this.oldSession = newSession;
204+
return newSession;
205+
}
31206
}

0 commit comments

Comments
 (0)