Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit abf0491

Browse files
pashankagsiddh
authored andcommittedApr 23, 2025·
Complete Hybrid inference impl
Fix languageCode parameter in action_code_url (#8912) * Fix languageCode parameter in action_code_url * Add changeset Vaihi add langmodel types. (#8927) * Adding LanguageModel types. These are based off https://github.com/webmachinelearning/prompt-api?tab=readme-ov-file#full-api-surface-in-web-idl * Adding LanguageModel types. * Remove bunch of exports * yarn formatted * after lint Define HybridParams (#8935) Co-authored-by: Erik Eldridge <[email protected]> Adding smoke test for new hybrid params (#8937) * Adding smoke test for new hybrid params * Use the existing name of the model params input --------- Co-authored-by: Erik Eldridge <[email protected]> Moving to in-cloud naming (#8938) Co-authored-by: Erik Eldridge <[email protected]> Moving to string type for the inference mode (#8941) Define ChromeAdapter class (#8942) Co-authored-by: Erik Eldridge <[email protected]> VinF Hybrid Inference: Implement ChromeAdapter (rebased) (#8943) Adding count token impl (#8950) VinF Hybrid Inference #4: ChromeAdapter in stream methods (rebased) (#8949) Define values for Availability enum (#8951) VinF Hybrid Inference: narrow Chrome input type (#8953) Add image inference support (#8954) * Adding image based input for inference * adding image as input to create language model object disable count tokens api for on-device inference (#8962) VinF Hybrid Inference: throw if only_on_device and model is unavailable (#8965)
1 parent 475c81a commit abf0491

15 files changed

+1214
-101
lines changed
 

‎e2e/sample-apps/modular.js

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import {
5858
onValue,
5959
off
6060
} from 'firebase/database';
61-
import { getGenerativeModel, getVertexAI, VertexAI } from 'firebase/vertexai';
61+
import { getGenerativeModel, getVertexAI } from 'firebase/vertexai';
6262
import { getDataConnect, DataConnect } from 'firebase/data-connect';
6363

6464
/**
@@ -313,9 +313,15 @@ function callPerformance(app) {
313313
async function callVertexAI(app) {
314314
console.log('[VERTEXAI] start');
315315
const vertexAI = getVertexAI(app);
316-
const model = getGenerativeModel(vertexAI, { model: 'gemini-1.5-flash' });
317-
const result = await model.countTokens('abcdefg');
318-
console.log(`[VERTEXAI] counted tokens: ${result.totalTokens}`);
316+
const model = getGenerativeModel(vertexAI, {
317+
mode: 'only_on_device'
318+
});
319+
const singleResult = await model.generateContent([
320+
{ text: 'describe the following:' },
321+
{ text: 'the mojave desert' }
322+
]);
323+
console.log(`Generated text: ${singleResult.response.text()}`);
324+
console.log(`[VERTEXAI] end`);
319325
}
320326

321327
/**
@@ -341,18 +347,18 @@ async function main() {
341347
const app = initializeApp(config);
342348
setLogLevel('warn');
343349

344-
callAppCheck(app);
345-
await authLogin(app);
346-
await callStorage(app);
347-
await callFirestore(app);
348-
await callDatabase(app);
349-
await callMessaging(app);
350-
callAnalytics(app);
351-
callPerformance(app);
352-
await callFunctions(app);
350+
// callAppCheck(app);
351+
// await authLogin(app);
352+
// await callStorage(app);
353+
// await callFirestore(app);
354+
// await callDatabase(app);
355+
// await callMessaging(app);
356+
// callAnalytics(app);
357+
// callPerformance(app);
358+
// await callFunctions(app);
353359
await callVertexAI(app);
354-
callDataConnect(app);
355-
await authLogout(app);
360+
// callDataConnect(app);
361+
// await authLogout(app);
356362
console.log('DONE');
357363
}
358364

‎packages/vertexai/src/api.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ describe('Top level API', () => {
101101
expect(genModel).to.be.an.instanceOf(GenerativeModel);
102102
expect(genModel.model).to.equal('publishers/google/models/my-model');
103103
});
104+
it('getGenerativeModel with HybridParams sets a default model', () => {
105+
const genModel = getGenerativeModel(fakeAI, {
106+
mode: 'only_on_device'
107+
});
108+
expect(genModel.model).to.equal(
109+
`publishers/google/models/${GenerativeModel.DEFAULT_HYBRID_IN_CLOUD_MODEL}`
110+
);
111+
});
112+
it('getGenerativeModel with HybridParams honors a model override', () => {
113+
const genModel = getGenerativeModel(fakeAI, {
114+
mode: 'prefer_on_device',
115+
inCloudParams: { model: 'my-model' }
116+
});
117+
expect(genModel.model).to.equal('publishers/google/models/my-model');
118+
});
104119
it('getImagenModel throws if no model is provided', () => {
105120
try {
106121
getImagenModel(fakeAI, {} as ImagenModelParams);

‎packages/vertexai/src/api.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AIService } from './service';
2323
import { AI, AIOptions, VertexAI, VertexAIOptions } from './public-types';
2424
import {
2525
ImagenModelParams,
26+
HybridParams,
2627
ModelParams,
2728
RequestOptions,
2829
AIErrorCode
@@ -31,6 +32,8 @@ import { AIError } from './errors';
3132
import { AIModel, GenerativeModel, ImagenModel } from './models';
3233
import { encodeInstanceIdentifier } from './helpers';
3334
import { GoogleAIBackend, VertexAIBackend } from './backend';
35+
import { ChromeAdapter } from './methods/chrome-adapter';
36+
import { LanguageModel } from './types/language-model';
3437

3538
export { ChatSession } from './methods/chat-session';
3639
export * from './requests/schema-builder';
@@ -138,16 +141,36 @@ export function getAI(
138141
*/
139142
export function getGenerativeModel(
140143
ai: AI,
141-
modelParams: ModelParams,
144+
modelParams: ModelParams | HybridParams,
142145
requestOptions?: RequestOptions
143146
): GenerativeModel {
144-
if (!modelParams.model) {
147+
// Uses the existence of HybridParams.mode to clarify the type of the modelParams input.
148+
const hybridParams = modelParams as HybridParams;
149+
let inCloudParams: ModelParams;
150+
if (hybridParams.mode) {
151+
inCloudParams = hybridParams.inCloudParams || {
152+
model: GenerativeModel.DEFAULT_HYBRID_IN_CLOUD_MODEL
153+
};
154+
} else {
155+
inCloudParams = modelParams as ModelParams;
156+
}
157+
158+
if (!inCloudParams.model) {
145159
throw new AIError(
146160
AIErrorCode.NO_MODEL,
147161
`Must provide a model name. Example: getGenerativeModel({ model: 'my-model-name' })`
148162
);
149163
}
150-
return new GenerativeModel(ai, modelParams, requestOptions);
164+
return new GenerativeModel(
165+
ai,
166+
inCloudParams,
167+
new ChromeAdapter(
168+
window.LanguageModel as LanguageModel,
169+
hybridParams.mode,
170+
hybridParams.onDeviceParams
171+
),
172+
requestOptions
173+
);
151174
}
152175

153176
/**

‎packages/vertexai/src/methods/chat-session.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { GenerateContentStreamResult } from '../types';
2424
import { ChatSession } from './chat-session';
2525
import { ApiSettings } from '../types/internal';
2626
import { VertexAIBackend } from '../backend';
27+
import { ChromeAdapter } from './chrome-adapter';
2728

2829
use(sinonChai);
2930
use(chaiAsPromised);
@@ -46,7 +47,11 @@ describe('ChatSession', () => {
4647
generateContentMethods,
4748
'generateContent'
4849
).rejects('generateContent failed');
49-
const chatSession = new ChatSession(fakeApiSettings, 'a-model');
50+
const chatSession = new ChatSession(
51+
fakeApiSettings,
52+
'a-model',
53+
new ChromeAdapter()
54+
);
5055
await expect(chatSession.sendMessage('hello')).to.be.rejected;
5156
expect(generateContentStub).to.be.calledWith(
5257
fakeApiSettings,
@@ -63,7 +68,11 @@ describe('ChatSession', () => {
6368
generateContentMethods,
6469
'generateContentStream'
6570
).rejects('generateContentStream failed');
66-
const chatSession = new ChatSession(fakeApiSettings, 'a-model');
71+
const chatSession = new ChatSession(
72+
fakeApiSettings,
73+
'a-model',
74+
new ChromeAdapter()
75+
);
6776
await expect(chatSession.sendMessageStream('hello')).to.be.rejected;
6877
expect(generateContentStreamStub).to.be.calledWith(
6978
fakeApiSettings,
@@ -82,7 +91,11 @@ describe('ChatSession', () => {
8291
generateContentMethods,
8392
'generateContentStream'
8493
).resolves({} as unknown as GenerateContentStreamResult);
85-
const chatSession = new ChatSession(fakeApiSettings, 'a-model');
94+
const chatSession = new ChatSession(
95+
fakeApiSettings,
96+
'a-model',
97+
new ChromeAdapter()
98+
);
8699
await chatSession.sendMessageStream('hello');
87100
expect(generateContentStreamStub).to.be.calledWith(
88101
fakeApiSettings,

‎packages/vertexai/src/methods/chat-session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { validateChatHistory } from './chat-session-helpers';
3030
import { generateContent, generateContentStream } from './generate-content';
3131
import { ApiSettings } from '../types/internal';
3232
import { logger } from '../logger';
33+
import { ChromeAdapter } from './chrome-adapter';
3334

3435
/**
3536
* Do not log a message for this error.
@@ -50,6 +51,7 @@ export class ChatSession {
5051
constructor(
5152
apiSettings: ApiSettings,
5253
public model: string,
54+
private chromeAdapter: ChromeAdapter,
5355
public params?: StartChatParams,
5456
public requestOptions?: RequestOptions
5557
) {
@@ -95,6 +97,7 @@ export class ChatSession {
9597
this._apiSettings,
9698
this.model,
9799
generateContentRequest,
100+
this.chromeAdapter,
98101
this.requestOptions
99102
)
100103
)
@@ -146,6 +149,7 @@ export class ChatSession {
146149
this._apiSettings,
147150
this.model,
148151
generateContentRequest,
152+
this.chromeAdapter,
149153
this.requestOptions
150154
);
151155

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

Lines changed: 473 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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 { AIError } from '../errors';
19+
import {
20+
CountTokensRequest,
21+
GenerateContentRequest,
22+
InferenceMode,
23+
Part,
24+
AIErrorCode
25+
} from '../types';
26+
import {
27+
Availability,
28+
LanguageModel,
29+
LanguageModelCreateOptions,
30+
LanguageModelMessageContent
31+
} from '../types/language-model';
32+
33+
/**
34+
* Defines an inference "backend" that uses Chrome's on-device model,
35+
* and encapsulates logic for detecting when on-device is possible.
36+
*/
37+
export class ChromeAdapter {
38+
private isDownloading = false;
39+
private downloadPromise: Promise<LanguageModel | void> | undefined;
40+
private oldSession: LanguageModel | undefined;
41+
constructor(
42+
private languageModelProvider?: LanguageModel,
43+
private mode?: InferenceMode,
44+
private onDeviceParams?: LanguageModelCreateOptions
45+
) {}
46+
47+
/**
48+
* Checks if a given request can be made on-device.
49+
*
50+
* <ol>Encapsulates a few concerns:
51+
* <li>the mode</li>
52+
* <li>API existence</li>
53+
* <li>prompt formatting</li>
54+
* <li>model availability, including triggering download if necessary</li>
55+
* </ol>
56+
*
57+
* <p>Pros: callers needn't be concerned with details of on-device availability.</p>
58+
* <p>Cons: this method spans a few concerns and splits request validation from usage.
59+
* If instance variables weren't already part of the API, we could consider a better
60+
* separation of concerns.</p>
61+
*/
62+
async isAvailable(request: GenerateContentRequest): Promise<boolean> {
63+
if (this.mode === 'only_in_cloud') {
64+
return false;
65+
}
66+
67+
const availability = await this.languageModelProvider?.availability();
68+
69+
// Triggers async model download so it'll be available next time.
70+
if (availability === Availability.downloadable) {
71+
this.download();
72+
}
73+
74+
if (this.mode === 'only_on_device') {
75+
return true;
76+
}
77+
78+
// Applies prefer_on_device logic.
79+
return (
80+
availability === Availability.available &&
81+
ChromeAdapter.isOnDeviceRequest(request)
82+
);
83+
}
84+
85+
/**
86+
* Generates content on device.
87+
*
88+
* <p>This is comparable to {@link GenerativeModel.generateContent} for generating content in
89+
* Cloud.</p>
90+
* @param request a standard Vertex {@link GenerateContentRequest}
91+
* @returns {@link Response}, so we can reuse common response formatting.
92+
*/
93+
async generateContent(request: GenerateContentRequest): Promise<Response> {
94+
const session = await this.createSession(
95+
// TODO: normalize on-device params during construction.
96+
this.onDeviceParams || {}
97+
);
98+
// TODO: support multiple content objects when Chrome supports
99+
// sequence<LanguageModelMessage>
100+
const contents = await Promise.all(
101+
request.contents[0].parts.map(ChromeAdapter.toLanguageModelMessageContent)
102+
);
103+
const text = await session.prompt(contents);
104+
return ChromeAdapter.toResponse(text);
105+
}
106+
107+
/**
108+
* Generates content stream on device.
109+
*
110+
* <p>This is comparable to {@link GenerativeModel.generateContentStream} for generating content in
111+
* Cloud.</p>
112+
* @param request a standard Vertex {@link GenerateContentRequest}
113+
* @returns {@link Response}, so we can reuse common response formatting.
114+
*/
115+
async generateContentStream(
116+
request: GenerateContentRequest
117+
): Promise<Response> {
118+
const session = await this.createSession(
119+
// TODO: normalize on-device params during construction.
120+
this.onDeviceParams || {}
121+
);
122+
// TODO: support multiple content objects when Chrome supports
123+
// sequence<LanguageModelMessage>
124+
const contents = await Promise.all(
125+
request.contents[0].parts.map(ChromeAdapter.toLanguageModelMessageContent)
126+
);
127+
const stream = await session.promptStreaming(contents);
128+
return ChromeAdapter.toStreamResponse(stream);
129+
}
130+
131+
async countTokens(_request: CountTokensRequest): Promise<Response> {
132+
throw new AIError(
133+
AIErrorCode.REQUEST_ERROR,
134+
'Count Tokens is not yet available for on-device model.'
135+
);
136+
}
137+
138+
/**
139+
* Asserts inference for the given request can be performed by an on-device model.
140+
*/
141+
private static isOnDeviceRequest(request: GenerateContentRequest): boolean {
142+
// Returns false if the prompt is empty.
143+
if (request.contents.length === 0) {
144+
return false;
145+
}
146+
147+
// Applies the same checks as above, but for each content item.
148+
for (const content of request.contents) {
149+
if (content.role === 'function') {
150+
return false;
151+
}
152+
}
153+
154+
return true;
155+
}
156+
157+
/**
158+
* Triggers the download of an on-device model.
159+
*
160+
* <p>Chrome only downloads models as needed. Chrome knows a model is needed when code calls
161+
* LanguageModel.create.</p>
162+
*
163+
* <p>Since Chrome manages the download, the SDK can only avoid redundant download requests by
164+
* tracking if a download has previously been requested.</p>
165+
*/
166+
private download(): void {
167+
if (this.isDownloading) {
168+
return;
169+
}
170+
this.isDownloading = true;
171+
const options = this.onDeviceParams || {};
172+
ChromeAdapter.addImageTypeAsExpectedInput(options);
173+
this.downloadPromise = this.languageModelProvider
174+
?.create(options)
175+
.then(() => {
176+
this.isDownloading = false;
177+
});
178+
}
179+
180+
/**
181+
* Converts a Vertex Part object to a Chrome LanguageModelMessageContent object.
182+
*/
183+
private static async toLanguageModelMessageContent(
184+
part: Part
185+
): Promise<LanguageModelMessageContent> {
186+
if (part.text) {
187+
return {
188+
type: 'text',
189+
content: part.text
190+
};
191+
} else if (part.inlineData) {
192+
const formattedImageContent = await fetch(
193+
`data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
194+
);
195+
const imageBlob = await formattedImageContent.blob();
196+
const imageBitmap = await createImageBitmap(imageBlob);
197+
return {
198+
type: 'image',
199+
content: imageBitmap
200+
};
201+
}
202+
// Assumes contents have been verified to contain only a single TextPart.
203+
// TODO: support other input types
204+
throw new Error('Not yet implemented');
205+
}
206+
207+
/**
208+
* Abstracts Chrome session creation.
209+
*
210+
* <p>Chrome uses a multi-turn session for all inference. Vertex uses single-turn for all
211+
* inference. To map the Vertex API to Chrome's API, the SDK creates a new session for all
212+
* inference.</p>
213+
*
214+
* <p>Chrome will remove a model from memory if it's no longer in use, so this method ensures a
215+
* new session is created before an old session is destroyed.</p>
216+
*/
217+
private async createSession(
218+
// TODO: define a default value, since these are optional.
219+
options: LanguageModelCreateOptions
220+
): Promise<LanguageModel> {
221+
if (!this.languageModelProvider) {
222+
throw new AIError(
223+
AIErrorCode.REQUEST_ERROR,
224+
'Chrome AI requested for unsupported browser version.'
225+
);
226+
}
227+
// TODO: could we use this.onDeviceParams instead of passing in options?
228+
ChromeAdapter.addImageTypeAsExpectedInput(options);
229+
const newSession = await this.languageModelProvider!.create(options);
230+
if (this.oldSession) {
231+
this.oldSession.destroy();
232+
}
233+
// Holds session reference, so model isn't unloaded from memory.
234+
this.oldSession = newSession;
235+
return newSession;
236+
}
237+
238+
private static addImageTypeAsExpectedInput(
239+
options: LanguageModelCreateOptions
240+
): void {
241+
options.expectedInputs = options.expectedInputs || [];
242+
options.expectedInputs.push({ type: 'image' });
243+
}
244+
245+
/**
246+
* Formats string returned by Chrome as a {@link Response} returned by Vertex.
247+
*/
248+
private static toResponse(text: string): Response {
249+
return {
250+
json: async () => ({
251+
candidates: [
252+
{
253+
content: {
254+
parts: [{ text }]
255+
}
256+
}
257+
]
258+
})
259+
} as Response;
260+
}
261+
262+
/**
263+
* Formats string stream returned by Chrome as SSE returned by Vertex.
264+
*/
265+
private static toStreamResponse(stream: ReadableStream<string>): Response {
266+
const encoder = new TextEncoder();
267+
return {
268+
body: stream.pipeThrough(
269+
new TransformStream({
270+
transform(chunk, controller) {
271+
const json = JSON.stringify({
272+
candidates: [
273+
{
274+
content: {
275+
role: 'model',
276+
parts: [{ text: chunk }]
277+
}
278+
}
279+
]
280+
});
281+
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
282+
}
283+
})
284+
)
285+
} as Response;
286+
}
287+
}

‎packages/vertexai/src/methods/count-tokens.test.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { ApiSettings } from '../types/internal';
2727
import { Task } from '../requests/request';
2828
import { mapCountTokensRequest } from '../googleai-mappers';
2929
import { GoogleAIBackend, VertexAIBackend } from '../backend';
30+
import { ChromeAdapter } from './chrome-adapter';
3031

3132
use(sinonChai);
3233
use(chaiAsPromised);
@@ -66,7 +67,8 @@ describe('countTokens()', () => {
6667
const result = await countTokens(
6768
fakeApiSettings,
6869
'model',
69-
fakeRequestParams
70+
fakeRequestParams,
71+
new ChromeAdapter()
7072
);
7173
expect(result.totalTokens).to.equal(6);
7274
expect(result.totalBillableCharacters).to.equal(16);
@@ -92,7 +94,8 @@ describe('countTokens()', () => {
9294
const result = await countTokens(
9395
fakeApiSettings,
9496
'model',
95-
fakeRequestParams
97+
fakeRequestParams,
98+
new ChromeAdapter()
9699
);
97100
expect(result.totalTokens).to.equal(1837);
98101
expect(result.totalBillableCharacters).to.equal(117);
@@ -120,7 +123,8 @@ describe('countTokens()', () => {
120123
const result = await countTokens(
121124
fakeApiSettings,
122125
'model',
123-
fakeRequestParams
126+
fakeRequestParams,
127+
new ChromeAdapter()
124128
);
125129
expect(result.totalTokens).to.equal(258);
126130
expect(result).to.not.have.property('totalBillableCharacters');
@@ -146,7 +150,12 @@ describe('countTokens()', () => {
146150
json: mockResponse.json
147151
} as Response);
148152
await expect(
149-
countTokens(fakeApiSettings, 'model', fakeRequestParams)
153+
countTokens(
154+
fakeApiSettings,
155+
'model',
156+
fakeRequestParams,
157+
new ChromeAdapter()
158+
)
150159
).to.be.rejectedWith(/404.*not found/);
151160
expect(mockFetch).to.be.called;
152161
});
@@ -164,7 +173,12 @@ describe('countTokens()', () => {
164173
it('maps request to GoogleAI format', async () => {
165174
makeRequestStub.resolves({ ok: true, json: () => {} } as Response); // Unused
166175

167-
await countTokens(fakeGoogleAIApiSettings, 'model', fakeRequestParams);
176+
await countTokens(
177+
fakeGoogleAIApiSettings,
178+
'model',
179+
fakeRequestParams,
180+
new ChromeAdapter()
181+
);
168182

169183
expect(makeRequestStub).to.be.calledWith(
170184
'model',
@@ -176,4 +190,24 @@ describe('countTokens()', () => {
176190
);
177191
});
178192
});
193+
it('on-device', async () => {
194+
const chromeAdapter = new ChromeAdapter();
195+
const isAvailableStub = stub(chromeAdapter, 'isAvailable').resolves(true);
196+
const mockResponse = getMockResponse(
197+
'vertexAI',
198+
'unary-success-total-tokens.json'
199+
);
200+
const countTokensStub = stub(chromeAdapter, 'countTokens').resolves(
201+
mockResponse as Response
202+
);
203+
const result = await countTokens(
204+
fakeApiSettings,
205+
'model',
206+
fakeRequestParams,
207+
chromeAdapter
208+
);
209+
expect(result.totalTokens).eq(6);
210+
expect(isAvailableStub).to.be.called;
211+
expect(countTokensStub).to.be.calledWith(fakeRequestParams);
212+
});
179213
});

‎packages/vertexai/src/methods/count-tokens.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ import { Task, makeRequest } from '../requests/request';
2424
import { ApiSettings } from '../types/internal';
2525
import * as GoogleAIMapper from '../googleai-mappers';
2626
import { BackendType } from '../public-types';
27+
import { ChromeAdapter } from './chrome-adapter';
2728

28-
export async function countTokens(
29+
export async function countTokensOnCloud(
2930
apiSettings: ApiSettings,
3031
model: string,
3132
params: CountTokensRequest,
@@ -48,3 +49,17 @@ export async function countTokens(
4849
);
4950
return response.json();
5051
}
52+
53+
export async function countTokens(
54+
apiSettings: ApiSettings,
55+
model: string,
56+
params: CountTokensRequest,
57+
chromeAdapter: ChromeAdapter,
58+
requestOptions?: RequestOptions
59+
): Promise<CountTokensResponse> {
60+
if (await chromeAdapter.isAvailable(params)) {
61+
return (await chromeAdapter.countTokens(params)).json();
62+
}
63+
64+
return countTokensOnCloud(apiSettings, model, params, requestOptions);
65+
}

‎packages/vertexai/src/methods/generate-content.test.ts

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { Task } from '../requests/request';
3434
import { AIError } from '../api';
3535
import { mapGenerateContentRequest } from '../googleai-mappers';
3636
import { GoogleAIBackend, VertexAIBackend } from '../backend';
37+
import { ChromeAdapter } from './chrome-adapter';
3738

3839
use(sinonChai);
3940
use(chaiAsPromised);
@@ -96,7 +97,8 @@ describe('generateContent()', () => {
9697
const result = await generateContent(
9798
fakeApiSettings,
9899
'model',
99-
fakeRequestParams
100+
fakeRequestParams,
101+
new ChromeAdapter()
100102
);
101103
expect(result.response.text()).to.include('Mountain View, California');
102104
expect(makeRequestStub).to.be.calledWith(
@@ -119,7 +121,8 @@ describe('generateContent()', () => {
119121
const result = await generateContent(
120122
fakeApiSettings,
121123
'model',
122-
fakeRequestParams
124+
fakeRequestParams,
125+
new ChromeAdapter()
123126
);
124127
expect(result.response.text()).to.include('Use Freshly Ground Coffee');
125128
expect(result.response.text()).to.include('30 minutes of brewing');
@@ -142,7 +145,8 @@ describe('generateContent()', () => {
142145
const result = await generateContent(
143146
fakeApiSettings,
144147
'model',
145-
fakeRequestParams
148+
fakeRequestParams,
149+
new ChromeAdapter()
146150
);
147151
expect(result.response.usageMetadata?.totalTokenCount).to.equal(1913);
148152
expect(result.response.usageMetadata?.candidatesTokenCount).to.equal(76);
@@ -177,7 +181,8 @@ describe('generateContent()', () => {
177181
const result = await generateContent(
178182
fakeApiSettings,
179183
'model',
180-
fakeRequestParams
184+
fakeRequestParams,
185+
new ChromeAdapter()
181186
);
182187
expect(result.response.text()).to.include(
183188
'Some information cited from an external source'
@@ -204,7 +209,8 @@ describe('generateContent()', () => {
204209
const result = await generateContent(
205210
fakeApiSettings,
206211
'model',
207-
fakeRequestParams
212+
fakeRequestParams,
213+
new ChromeAdapter()
208214
);
209215
expect(result.response.text).to.throw('SAFETY');
210216
expect(makeRequestStub).to.be.calledWith(
@@ -226,7 +232,8 @@ describe('generateContent()', () => {
226232
const result = await generateContent(
227233
fakeApiSettings,
228234
'model',
229-
fakeRequestParams
235+
fakeRequestParams,
236+
new ChromeAdapter()
230237
);
231238
expect(result.response.text).to.throw('SAFETY');
232239
expect(makeRequestStub).to.be.calledWith(
@@ -248,7 +255,8 @@ describe('generateContent()', () => {
248255
const result = await generateContent(
249256
fakeApiSettings,
250257
'model',
251-
fakeRequestParams
258+
fakeRequestParams,
259+
new ChromeAdapter()
252260
);
253261
expect(result.response.text()).to.equal('');
254262
expect(makeRequestStub).to.be.calledWith(
@@ -270,7 +278,8 @@ describe('generateContent()', () => {
270278
const result = await generateContent(
271279
fakeApiSettings,
272280
'model',
273-
fakeRequestParams
281+
fakeRequestParams,
282+
new ChromeAdapter()
274283
);
275284
expect(result.response.text()).to.include('Some text');
276285
expect(makeRequestStub).to.be.calledWith(
@@ -292,7 +301,12 @@ describe('generateContent()', () => {
292301
json: mockResponse.json
293302
} as Response);
294303
await expect(
295-
generateContent(fakeApiSettings, 'model', fakeRequestParams)
304+
generateContent(
305+
fakeApiSettings,
306+
'model',
307+
fakeRequestParams,
308+
new ChromeAdapter()
309+
)
296310
).to.be.rejectedWith(/400.*invalid argument/);
297311
expect(mockFetch).to.be.called;
298312
});
@@ -307,7 +321,12 @@ describe('generateContent()', () => {
307321
json: mockResponse.json
308322
} as Response);
309323
await expect(
310-
generateContent(fakeApiSettings, 'model', fakeRequestParams)
324+
generateContent(
325+
fakeApiSettings,
326+
'model',
327+
fakeRequestParams,
328+
new ChromeAdapter()
329+
)
311330
).to.be.rejectedWith(
312331
/firebasevertexai\.googleapis[\s\S]*my-project[\s\S]*api-not-enabled/
313332
);
@@ -347,7 +366,8 @@ describe('generateContent()', () => {
347366
generateContent(
348367
fakeGoogleAIApiSettings,
349368
'model',
350-
requestParamsWithMethod
369+
requestParamsWithMethod,
370+
new ChromeAdapter()
351371
)
352372
).to.be.rejectedWith(AIError, AIErrorCode.UNSUPPORTED);
353373
expect(makeRequestStub).to.not.be.called;
@@ -362,7 +382,8 @@ describe('generateContent()', () => {
362382
await generateContent(
363383
fakeGoogleAIApiSettings,
364384
'model',
365-
fakeGoogleAIRequestParams
385+
fakeGoogleAIRequestParams,
386+
new ChromeAdapter()
366387
);
367388

368389
expect(makeRequestStub).to.be.calledWith(
@@ -375,4 +396,25 @@ describe('generateContent()', () => {
375396
);
376397
});
377398
});
399+
// TODO: define a similar test for generateContentStream
400+
it('on-device', async () => {
401+
const chromeAdapter = new ChromeAdapter();
402+
const isAvailableStub = stub(chromeAdapter, 'isAvailable').resolves(true);
403+
const mockResponse = getMockResponse(
404+
'vertexAI',
405+
'unary-success-basic-reply-short.json'
406+
);
407+
const generateContentStub = stub(chromeAdapter, 'generateContent').resolves(
408+
mockResponse as Response
409+
);
410+
const result = await generateContent(
411+
fakeApiSettings,
412+
'model',
413+
fakeRequestParams,
414+
chromeAdapter
415+
);
416+
expect(result.response.text()).to.include('Mountain View, California');
417+
expect(isAvailableStub).to.be.called;
418+
expect(generateContentStub).to.be.calledWith(fakeRequestParams);
419+
});
378420
});

‎packages/vertexai/src/methods/generate-content.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,44 +28,85 @@ import { processStream } from '../requests/stream-reader';
2828
import { ApiSettings } from '../types/internal';
2929
import * as GoogleAIMapper from '../googleai-mappers';
3030
import { BackendType } from '../public-types';
31+
import { ChromeAdapter } from './chrome-adapter';
3132

32-
export async function generateContentStream(
33+
async function generateContentStreamOnCloud(
3334
apiSettings: ApiSettings,
3435
model: string,
3536
params: GenerateContentRequest,
3637
requestOptions?: RequestOptions
37-
): Promise<GenerateContentStreamResult> {
38+
): Promise<Response> {
3839
if (apiSettings.backend.backendType === BackendType.GOOGLE_AI) {
3940
params = GoogleAIMapper.mapGenerateContentRequest(params);
4041
}
41-
const response = await makeRequest(
42+
return makeRequest(
4243
model,
4344
Task.STREAM_GENERATE_CONTENT,
4445
apiSettings,
4546
/* stream */ true,
4647
JSON.stringify(params),
4748
requestOptions
4849
);
50+
}
51+
52+
export async function generateContentStream(
53+
apiSettings: ApiSettings,
54+
model: string,
55+
params: GenerateContentRequest,
56+
chromeAdapter: ChromeAdapter,
57+
requestOptions?: RequestOptions
58+
): Promise<GenerateContentStreamResult> {
59+
let response;
60+
if (await chromeAdapter.isAvailable(params)) {
61+
response = await chromeAdapter.generateContentStream(params);
62+
} else {
63+
response = await generateContentStreamOnCloud(
64+
apiSettings,
65+
model,
66+
params,
67+
requestOptions
68+
);
69+
}
4970
return processStream(response, apiSettings); // TODO: Map streaming responses
5071
}
5172

52-
export async function generateContent(
73+
async function generateContentOnCloud(
5374
apiSettings: ApiSettings,
5475
model: string,
5576
params: GenerateContentRequest,
5677
requestOptions?: RequestOptions
57-
): Promise<GenerateContentResult> {
78+
): Promise<Response> {
5879
if (apiSettings.backend.backendType === BackendType.GOOGLE_AI) {
5980
params = GoogleAIMapper.mapGenerateContentRequest(params);
6081
}
61-
const response = await makeRequest(
82+
return makeRequest(
6283
model,
6384
Task.GENERATE_CONTENT,
6485
apiSettings,
6586
/* stream */ false,
6687
JSON.stringify(params),
6788
requestOptions
6889
);
90+
}
91+
92+
export async function generateContent(
93+
apiSettings: ApiSettings,
94+
model: string,
95+
params: GenerateContentRequest,
96+
chromeAdapter: ChromeAdapter,
97+
requestOptions?: RequestOptions
98+
): Promise<GenerateContentResult> {
99+
let response;
100+
if (await chromeAdapter.isAvailable(params)) {
101+
response = await chromeAdapter.generateContent(params);
102+
} else {
103+
response = await generateContentOnCloud(
104+
apiSettings,
105+
model,
106+
params,
107+
requestOptions
108+
);
109+
}
69110
const generateContentResponse = await processGenerateContentResponse(
70111
response,
71112
apiSettings

‎packages/vertexai/src/models/generative-model.test.ts

Lines changed: 92 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { match, restore, stub } from 'sinon';
2222
import { getMockResponse } from '../../test-utils/mock-response';
2323
import sinonChai from 'sinon-chai';
2424
import { VertexAIBackend } from '../backend';
25+
import { ChromeAdapter } from '../methods/chrome-adapter';
2526

2627
use(sinonChai);
2728

@@ -41,21 +42,27 @@ const fakeAI: AI = {
4142

4243
describe('GenerativeModel', () => {
4344
it('passes params through to generateContent', async () => {
44-
const genModel = new GenerativeModel(fakeAI, {
45-
model: 'my-model',
46-
tools: [
47-
{
48-
functionDeclarations: [
49-
{
50-
name: 'myfunc',
51-
description: 'mydesc'
52-
}
53-
]
54-
}
55-
],
56-
toolConfig: { functionCallingConfig: { mode: FunctionCallingMode.NONE } },
57-
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
58-
});
45+
const genModel = new GenerativeModel(
46+
fakeAI,
47+
{
48+
model: 'my-model',
49+
tools: [
50+
{
51+
functionDeclarations: [
52+
{
53+
name: 'myfunc',
54+
description: 'mydesc'
55+
}
56+
]
57+
}
58+
],
59+
toolConfig: {
60+
functionCallingConfig: { mode: FunctionCallingMode.NONE }
61+
},
62+
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
63+
},
64+
new ChromeAdapter()
65+
);
5966
expect(genModel.tools?.length).to.equal(1);
6067
expect(genModel.toolConfig?.functionCallingConfig?.mode).to.equal(
6168
FunctionCallingMode.NONE
@@ -86,10 +93,14 @@ describe('GenerativeModel', () => {
8693
restore();
8794
});
8895
it('passes text-only systemInstruction through to generateContent', async () => {
89-
const genModel = new GenerativeModel(fakeAI, {
90-
model: 'my-model',
91-
systemInstruction: 'be friendly'
92-
});
96+
const genModel = new GenerativeModel(
97+
fakeAI,
98+
{
99+
model: 'my-model',
100+
systemInstruction: 'be friendly'
101+
},
102+
new ChromeAdapter()
103+
);
93104
expect(genModel.systemInstruction?.parts[0].text).to.equal('be friendly');
94105
const mockResponse = getMockResponse(
95106
'vertexAI',
@@ -112,21 +123,27 @@ describe('GenerativeModel', () => {
112123
restore();
113124
});
114125
it('generateContent overrides model values', async () => {
115-
const genModel = new GenerativeModel(fakeAI, {
116-
model: 'my-model',
117-
tools: [
118-
{
119-
functionDeclarations: [
120-
{
121-
name: 'myfunc',
122-
description: 'mydesc'
123-
}
124-
]
125-
}
126-
],
127-
toolConfig: { functionCallingConfig: { mode: FunctionCallingMode.NONE } },
128-
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
129-
});
126+
const genModel = new GenerativeModel(
127+
fakeAI,
128+
{
129+
model: 'my-model',
130+
tools: [
131+
{
132+
functionDeclarations: [
133+
{
134+
name: 'myfunc',
135+
description: 'mydesc'
136+
}
137+
]
138+
}
139+
],
140+
toolConfig: {
141+
functionCallingConfig: { mode: FunctionCallingMode.NONE }
142+
},
143+
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
144+
},
145+
new ChromeAdapter()
146+
);
130147
expect(genModel.tools?.length).to.equal(1);
131148
expect(genModel.toolConfig?.functionCallingConfig?.mode).to.equal(
132149
FunctionCallingMode.NONE
@@ -168,14 +185,20 @@ describe('GenerativeModel', () => {
168185
restore();
169186
});
170187
it('passes params through to chat.sendMessage', async () => {
171-
const genModel = new GenerativeModel(fakeAI, {
172-
model: 'my-model',
173-
tools: [
174-
{ functionDeclarations: [{ name: 'myfunc', description: 'mydesc' }] }
175-
],
176-
toolConfig: { functionCallingConfig: { mode: FunctionCallingMode.NONE } },
177-
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
178-
});
188+
const genModel = new GenerativeModel(
189+
fakeAI,
190+
{
191+
model: 'my-model',
192+
tools: [
193+
{ functionDeclarations: [{ name: 'myfunc', description: 'mydesc' }] }
194+
],
195+
toolConfig: {
196+
functionCallingConfig: { mode: FunctionCallingMode.NONE }
197+
},
198+
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
199+
},
200+
new ChromeAdapter()
201+
);
179202
expect(genModel.tools?.length).to.equal(1);
180203
expect(genModel.toolConfig?.functionCallingConfig?.mode).to.equal(
181204
FunctionCallingMode.NONE
@@ -206,10 +229,14 @@ describe('GenerativeModel', () => {
206229
restore();
207230
});
208231
it('passes text-only systemInstruction through to chat.sendMessage', async () => {
209-
const genModel = new GenerativeModel(fakeAI, {
210-
model: 'my-model',
211-
systemInstruction: 'be friendly'
212-
});
232+
const genModel = new GenerativeModel(
233+
fakeAI,
234+
{
235+
model: 'my-model',
236+
systemInstruction: 'be friendly'
237+
},
238+
new ChromeAdapter()
239+
);
213240
expect(genModel.systemInstruction?.parts[0].text).to.equal('be friendly');
214241
const mockResponse = getMockResponse(
215242
'vertexAI',
@@ -232,14 +259,20 @@ describe('GenerativeModel', () => {
232259
restore();
233260
});
234261
it('startChat overrides model values', async () => {
235-
const genModel = new GenerativeModel(fakeAI, {
236-
model: 'my-model',
237-
tools: [
238-
{ functionDeclarations: [{ name: 'myfunc', description: 'mydesc' }] }
239-
],
240-
toolConfig: { functionCallingConfig: { mode: FunctionCallingMode.NONE } },
241-
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
242-
});
262+
const genModel = new GenerativeModel(
263+
fakeAI,
264+
{
265+
model: 'my-model',
266+
tools: [
267+
{ functionDeclarations: [{ name: 'myfunc', description: 'mydesc' }] }
268+
],
269+
toolConfig: {
270+
functionCallingConfig: { mode: FunctionCallingMode.NONE }
271+
},
272+
systemInstruction: { role: 'system', parts: [{ text: 'be friendly' }] }
273+
},
274+
new ChromeAdapter()
275+
);
243276
expect(genModel.tools?.length).to.equal(1);
244277
expect(genModel.toolConfig?.functionCallingConfig?.mode).to.equal(
245278
FunctionCallingMode.NONE
@@ -284,7 +317,11 @@ describe('GenerativeModel', () => {
284317
restore();
285318
});
286319
it('calls countTokens', async () => {
287-
const genModel = new GenerativeModel(fakeAI, { model: 'my-model' });
320+
const genModel = new GenerativeModel(
321+
fakeAI,
322+
{ model: 'my-model' },
323+
new ChromeAdapter()
324+
);
288325
const mockResponse = getMockResponse(
289326
'vertexAI',
290327
'unary-success-total-tokens.json'

‎packages/vertexai/src/models/generative-model.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,17 @@ import {
4343
} from '../requests/request-helpers';
4444
import { AI } from '../public-types';
4545
import { AIModel } from './genai-model';
46+
import { ChromeAdapter } from '../methods/chrome-adapter';
4647

4748
/**
4849
* Class for generative model APIs.
4950
* @public
5051
*/
5152
export class GenerativeModel extends AIModel {
53+
/**
54+
* Defines the name of the default in-cloud model to use for hybrid inference.
55+
*/
56+
static DEFAULT_HYBRID_IN_CLOUD_MODEL = 'gemini-2.0-flash-lite';
5257
generationConfig: GenerationConfig;
5358
safetySettings: SafetySetting[];
5459
requestOptions?: RequestOptions;
@@ -59,6 +64,7 @@ export class GenerativeModel extends AIModel {
5964
constructor(
6065
ai: AI,
6166
modelParams: ModelParams,
67+
private chromeAdapter: ChromeAdapter,
6268
requestOptions?: RequestOptions
6369
) {
6470
super(ai, modelParams.model);
@@ -91,6 +97,7 @@ export class GenerativeModel extends AIModel {
9197
systemInstruction: this.systemInstruction,
9298
...formattedParams
9399
},
100+
this.chromeAdapter,
94101
this.requestOptions
95102
);
96103
}
@@ -116,6 +123,7 @@ export class GenerativeModel extends AIModel {
116123
systemInstruction: this.systemInstruction,
117124
...formattedParams
118125
},
126+
this.chromeAdapter,
119127
this.requestOptions
120128
);
121129
}
@@ -128,6 +136,7 @@ export class GenerativeModel extends AIModel {
128136
return new ChatSession(
129137
this._apiSettings,
130138
this.model,
139+
this.chromeAdapter,
131140
{
132141
tools: this.tools,
133142
toolConfig: this.toolConfig,
@@ -145,6 +154,11 @@ export class GenerativeModel extends AIModel {
145154
request: CountTokensRequest | string | Array<string | Part>
146155
): Promise<CountTokensResponse> {
147156
const formattedParams = formatGenerateContentInput(request);
148-
return countTokens(this._apiSettings, this.model, formattedParams);
157+
return countTokens(
158+
this._apiSettings,
159+
this.model,
160+
formattedParams,
161+
this.chromeAdapter
162+
);
149163
}
150164
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
export interface LanguageModel extends EventTarget {
19+
create(options?: LanguageModelCreateOptions): Promise<LanguageModel>;
20+
availability(options?: LanguageModelCreateCoreOptions): Promise<Availability>;
21+
prompt(
22+
input: LanguageModelPrompt,
23+
options?: LanguageModelPromptOptions
24+
): Promise<string>;
25+
promptStreaming(
26+
input: LanguageModelPrompt,
27+
options?: LanguageModelPromptOptions
28+
): ReadableStream;
29+
measureInputUsage(
30+
input: LanguageModelPrompt,
31+
options?: LanguageModelPromptOptions
32+
): Promise<number>;
33+
destroy(): undefined;
34+
}
35+
export enum Availability {
36+
'unavailable' = 'unavailable',
37+
'downloadable' = 'downloadable',
38+
'downloading' = 'downloading',
39+
'available' = 'available'
40+
}
41+
export interface LanguageModelCreateCoreOptions {
42+
topK?: number;
43+
temperature?: number;
44+
expectedInputs?: LanguageModelExpectedInput[];
45+
}
46+
export interface LanguageModelCreateOptions
47+
extends LanguageModelCreateCoreOptions {
48+
signal?: AbortSignal;
49+
systemPrompt?: string;
50+
initialPrompts?: LanguageModelInitialPrompts;
51+
}
52+
interface LanguageModelPromptOptions {
53+
signal?: AbortSignal;
54+
}
55+
interface LanguageModelExpectedInput {
56+
type: LanguageModelMessageType;
57+
languages?: string[];
58+
}
59+
// TODO: revert to type from Prompt API explainer once it's supported.
60+
export type LanguageModelPrompt = LanguageModelMessageContent[];
61+
type LanguageModelInitialPrompts =
62+
| LanguageModelMessage[]
63+
| LanguageModelMessageShorthand[];
64+
interface LanguageModelMessage {
65+
role: LanguageModelMessageRole;
66+
content: LanguageModelMessageContent[];
67+
}
68+
interface LanguageModelMessageShorthand {
69+
role: LanguageModelMessageRole;
70+
content: string;
71+
}
72+
export interface LanguageModelMessageContent {
73+
type: LanguageModelMessageType;
74+
content: LanguageModelMessageContentValue;
75+
}
76+
type LanguageModelMessageRole = 'system' | 'user' | 'assistant';
77+
type LanguageModelMessageType = 'text' | 'image' | 'audio';
78+
type LanguageModelMessageContentValue =
79+
| ImageBitmapSource
80+
| AudioBuffer
81+
| BufferSource
82+
| string;

‎packages/vertexai/src/types/requests.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import { TypedSchema } from '../requests/schema-builder';
1919
import { Content, Part } from './content';
20+
import { LanguageModelCreateOptions } from './language-model';
2021
import {
2122
FunctionCallingMode,
2223
HarmBlockMethod,
@@ -218,3 +219,29 @@ export interface FunctionCallingConfig {
218219
mode?: FunctionCallingMode;
219220
allowedFunctionNames?: string[];
220221
}
222+
223+
/**
224+
* Toggles hybrid inference.
225+
*/
226+
export interface HybridParams {
227+
/**
228+
* Specifies on-device or in-cloud inference. Defaults to prefer on-device.
229+
*/
230+
mode: InferenceMode;
231+
/**
232+
* Optional. Specifies advanced params for on-device inference.
233+
*/
234+
onDeviceParams?: LanguageModelCreateOptions;
235+
/**
236+
* Optional. Specifies advanced params for in-cloud inference.
237+
*/
238+
inCloudParams?: ModelParams;
239+
}
240+
241+
/**
242+
* Determines whether inference happens on-device or in-cloud.
243+
*/
244+
export type InferenceMode =
245+
| 'prefer_on_device'
246+
| 'only_on_device'
247+
| 'only_in_cloud';

0 commit comments

Comments
 (0)
Please sign in to comment.