prompt-api-polyfill 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,548 +1,586 @@
1
1
  /**
2
2
  * Polyfill for the Prompt API (`LanguageModel`)
3
- * Backend: Firebase AI Logic
3
+ * Backends:
4
+ * - Firebase AI Logic (via `firebase/ai`)
5
+ * - Google Gemini API (via `@google/generative-ai`)
6
+ * - OpenAI API (via `openai`)
7
+ *
4
8
  * Spec: https://github.com/webmachinelearning/prompt-api/blob/main/README.md
5
9
  *
6
- * * Instructions:
10
+ * Instructions:
7
11
  * 1. Include this script in your HTML type="module".
8
- * 2. Define window.FIREBASE_CONFIG with your Firebase configuration object BEFORE importing this.
12
+ * 2. Configure the backend:
13
+ * - For Firebase: Define `window.FIREBASE_CONFIG`.
14
+ * - For Gemini: Define `window.GEMINI_CONFIG`.
15
+ * - For OpenAI: Define `window.OPENAI_CONFIG`.
9
16
  */
10
17
 
11
- import { initializeApp } from 'https://esm.run/firebase/app';
12
- import {
13
- getAI,
14
- getGenerativeModel,
15
- GoogleAIBackend,
16
- InferenceMode,
17
- } from 'https://esm.run/firebase/ai';
18
-
19
- import './async-iterator-polyfill.js'; // Still needed for Safari 26.2.
18
+ import './async-iterator-polyfill.js';
20
19
  import MultimodalConverter from './multimodal-converter.js';
21
20
  import { convertJsonSchemaToVertexSchema } from './json-schema-converter.js';
22
21
 
23
- (() => {
24
- if ('LanguageModel' in window) {
25
- return;
22
+ // --- Helper to convert initial History ---
23
+ async function convertToHistory(prompts) {
24
+ const history = [];
25
+ for (const p of prompts) {
26
+ const role = p.role === 'assistant' ? 'model' : 'user';
27
+ let parts = [];
28
+
29
+ if (Array.isArray(p.content)) {
30
+ // Mixed content
31
+ for (const item of p.content) {
32
+ if (item.type === 'text') {
33
+ parts.push({ text: item.value || item.text || '' });
34
+ } else {
35
+ const part = await MultimodalConverter.convert(item.type, item.value);
36
+ parts.push(part);
37
+ }
38
+ }
39
+ } else {
40
+ // Simple string
41
+ parts.push({ text: p.content });
42
+ }
43
+ history.push({ role, parts });
26
44
  }
45
+ return history;
46
+ }
27
47
 
28
- const firebaseConfig = window.FIREBASE_CONFIG;
29
- if (!firebaseConfig) {
30
- console.error(
31
- 'Firebase Prompt API Polyfill: Missing configuration. Please set window.FIREBASE_CONFIG.'
32
- );
33
- return;
48
+ /**
49
+ * Main LanguageModel Class
50
+ */
51
+ export class LanguageModel extends EventTarget {
52
+ #backend;
53
+ #model;
54
+ #history;
55
+ #options;
56
+ #inCloudParams;
57
+ #destroyed;
58
+ #inputUsage;
59
+ #topK;
60
+ #temperature;
61
+ #onquotaoverflow;
62
+
63
+ constructor(backend, model, initialHistory, options = {}, inCloudParams) {
64
+ super();
65
+ this.#backend = backend;
66
+ this.#model = model;
67
+ this.#history = initialHistory || [];
68
+ this.#options = options;
69
+ this.#inCloudParams = inCloudParams;
70
+ this.#destroyed = false;
71
+ this.#inputUsage = 0;
72
+
73
+ this.#topK = options.topK;
74
+ this.#temperature = options.temperature;
34
75
  }
35
76
 
36
- // Initialize Firebase
37
- const app = initializeApp(firebaseConfig);
38
- const ai = getAI(app, { backend: new GoogleAIBackend() });
39
- const MODEL_NAME = firebaseConfig.modelName || 'gemini-2.5-flash-lite';
40
-
41
- // Helper to convert initial History
42
- async function convertToFirebaseHistory(prompts) {
43
- const history = [];
44
- for (const p of prompts) {
45
- const role = p.role === 'assistant' ? 'model' : 'user';
46
- let parts = [];
47
-
48
- if (Array.isArray(p.content)) {
49
- // Mixed content
50
- for (const item of p.content) {
51
- if (item.type === 'text') {
52
- parts.push({ text: item.value || item.text || '' });
53
- } else {
54
- const part = await MultimodalConverter.convert(
55
- item.type,
56
- item.value
57
- );
58
- parts.push(part);
59
- }
60
- }
61
- } else {
62
- // Simple string
63
- parts.push({ text: p.content });
64
- }
65
- history.push({ role, parts });
66
- }
67
- return history;
77
+ get inputUsage() {
78
+ return this.#inputUsage;
79
+ }
80
+ get inputQuota() {
81
+ return 1000000;
82
+ }
83
+ get topK() {
84
+ return this.#topK;
85
+ }
86
+ get temperature() {
87
+ return this.#temperature;
68
88
  }
69
89
 
70
- /**
71
- * Main LanguageModel Class
72
- */
73
- class LanguageModel extends EventTarget {
74
- #model;
75
- #history;
76
- #options;
77
- #inCloudParams;
78
- #destroyed;
79
- #inputUsage;
80
- #topK;
81
- #temperature;
82
- #onquotaoverflow;
83
-
84
- constructor(model, initialHistory, options = {}, inCloudParams) {
85
- super();
86
- this.#model = model;
87
- this.#history = initialHistory || [];
88
- this.#options = options;
89
- this.#inCloudParams = inCloudParams;
90
- this.#destroyed = false;
91
- this.#inputUsage = 0;
92
-
93
- this.#topK = options.topK;
94
- this.#temperature = options.temperature;
95
- }
90
+ get onquotaoverflow() {
91
+ return this.#onquotaoverflow;
92
+ }
96
93
 
97
- get inputUsage() {
98
- return this.#inputUsage;
94
+ set onquotaoverflow(handler) {
95
+ if (this.#onquotaoverflow) {
96
+ this.removeEventListener('quotaoverflow', this.#onquotaoverflow);
99
97
  }
100
- get inputQuota() {
101
- return 1000000;
98
+ this.#onquotaoverflow = handler;
99
+ if (typeof handler === 'function') {
100
+ this.addEventListener('quotaoverflow', handler);
102
101
  }
103
- get topK() {
104
- return this.#topK;
102
+ }
103
+
104
+ static async availability(options = {}) {
105
+ await LanguageModel.#validateOptions(options);
106
+ const backendClass = await LanguageModel.#getBackendClass();
107
+ return backendClass.availability(options);
108
+ }
109
+
110
+ static #backends = [
111
+ {
112
+ config: 'FIREBASE_CONFIG',
113
+ path: './backends/firebase.js',
114
+ },
115
+ {
116
+ config: 'GEMINI_CONFIG',
117
+ path: './backends/gemini.js',
118
+ },
119
+ {
120
+ config: 'OPENAI_CONFIG',
121
+ path: './backends/openai.js',
122
+ },
123
+ {
124
+ config: 'TRANSFORMERS_CONFIG',
125
+ path: './backends/transformers.js',
126
+ },
127
+ ];
128
+
129
+ static #getBackendInfo() {
130
+ for (const b of LanguageModel.#backends) {
131
+ const config = window[b.config];
132
+ if (config && config.apiKey) {
133
+ return { ...b, configValue: config };
134
+ }
135
+ }
136
+ throw new DOMException(
137
+ 'Prompt API Polyfill: No backend configuration found. Please set window.FIREBASE_CONFIG, window.GEMINI_CONFIG, or window.OPENAI_CONFIG.',
138
+ 'NotSupportedError'
139
+ );
140
+ }
141
+
142
+ static async #getBackendClass() {
143
+ const info = LanguageModel.#getBackendInfo();
144
+ return (await import(/* @vite-ignore */ info.path)).default;
145
+ }
146
+
147
+ static async #validateOptions(options = {}) {
148
+ const { maxTemperature, maxTopK } = await LanguageModel.params();
149
+
150
+ const hasTemperature = Object.prototype.hasOwnProperty.call(
151
+ options,
152
+ 'temperature'
153
+ );
154
+ const hasTopK = Object.prototype.hasOwnProperty.call(options, 'topK');
155
+
156
+ if (hasTemperature !== hasTopK) {
157
+ throw new DOMException(
158
+ 'Initializing a new session must either specify both topK and temperature, or neither of them.',
159
+ 'NotSupportedError'
160
+ );
105
161
  }
106
- get temperature() {
107
- return this.#temperature;
162
+
163
+ // If neither temperature nor topK are provided, nothing to validate.
164
+ if (!hasTemperature && !hasTopK) {
165
+ return;
108
166
  }
109
167
 
110
- get onquotaoverflow() {
111
- return this.#onquotaoverflow;
168
+ const { temperature, topK } = options;
169
+
170
+ if (
171
+ typeof temperature !== 'number' ||
172
+ Number.isNaN(temperature) ||
173
+ typeof topK !== 'number' ||
174
+ Number.isNaN(topK)
175
+ ) {
176
+ throw new DOMException(
177
+ 'The provided temperature and topK must be numbers.',
178
+ 'NotSupportedError'
179
+ );
112
180
  }
113
181
 
114
- set onquotaoverflow(handler) {
115
- if (this.#onquotaoverflow)
116
- this.removeEventListener('quotaoverflow', this.#onquotaoverflow);
117
- this.#onquotaoverflow = handler;
118
- if (typeof handler === 'function')
119
- this.addEventListener('quotaoverflow', handler);
182
+ if (temperature < 0 || temperature > maxTemperature || topK > maxTopK) {
183
+ throw new DOMException(
184
+ 'The provided temperature or topK is outside the supported range.',
185
+ 'NotSupportedError'
186
+ );
120
187
  }
188
+ }
121
189
 
122
- static async availability(options = {}) {
123
- await LanguageModel.#validateOptions(options);
124
- return 'available';
190
+ static async params() {
191
+ return {
192
+ // Values from https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash-lite#:~:text=%2C%20audio/webm-,Parameter%20defaults,-tune.
193
+ defaultTemperature: 1.0,
194
+ defaultTopK: 64,
195
+ maxTemperature: 2.0,
196
+ maxTopK: 64, // Fixed
197
+ };
198
+ }
199
+
200
+ static async create(options = {}) {
201
+ const availability = await LanguageModel.availability(options);
202
+ if (availability === 'downloadable' || availability === 'downloading') {
203
+ throw new DOMException(
204
+ 'Requires a user gesture when availability is "downloading" or "downloadable".',
205
+ 'NotAllowedError'
206
+ );
125
207
  }
126
208
 
127
- static async #validateOptions(options = {}) {
128
- const { maxTemperature, maxTopK } = await LanguageModel.params();
209
+ // --- Backend Selection Logic ---
210
+ const info = LanguageModel.#getBackendInfo();
211
+
212
+ const BackendClass = await LanguageModel.#getBackendClass();
213
+ const backend = new BackendClass(info.configValue);
214
+
215
+ const defaults = {
216
+ temperature: 1.0,
217
+ topK: 3,
218
+ };
129
219
 
130
- const hasTemperature = Object.prototype.hasOwnProperty.call(
131
- options,
132
- 'temperature'
220
+ const resolvedOptions = { ...defaults, ...options };
221
+
222
+ const inCloudParams = {
223
+ model: backend.modelName,
224
+ generationConfig: {
225
+ temperature: resolvedOptions.temperature,
226
+ topK: resolvedOptions.topK,
227
+ },
228
+ };
229
+
230
+ let initialHistory = [];
231
+
232
+ if (
233
+ resolvedOptions.initialPrompts &&
234
+ Array.isArray(resolvedOptions.initialPrompts)
235
+ ) {
236
+ const systemPrompts = resolvedOptions.initialPrompts.filter(
237
+ (p) => p.role === 'system'
238
+ );
239
+ const conversationPrompts = resolvedOptions.initialPrompts.filter(
240
+ (p) => p.role !== 'system'
133
241
  );
134
- const hasTopK = Object.prototype.hasOwnProperty.call(options, 'topK');
135
242
 
136
- if (hasTemperature !== hasTopK) {
137
- throw new DOMException(
138
- 'Initializing a new session must either specify both topK and temperature, or neither of them.',
139
- 'NotSupportedError'
140
- );
243
+ if (systemPrompts.length > 0) {
244
+ inCloudParams.systemInstruction = systemPrompts
245
+ .map((p) => p.content)
246
+ .join('\n');
141
247
  }
248
+ // Await the conversion of history items (in case of images in history)
249
+ initialHistory = await convertToHistory(conversationPrompts);
250
+ }
142
251
 
143
- // If neither temperature nor topK are provided, nothing to validate.
144
- if (!hasTemperature && !hasTopK) {
145
- return;
146
- }
252
+ const model = backend.createSession(resolvedOptions, inCloudParams);
253
+
254
+ // If a monitor callback is provided, simulate simple downloadprogress events
255
+ if (typeof resolvedOptions.monitor === 'function') {
256
+ const monitorTarget = new EventTarget();
147
257
 
148
- const { temperature, topK } = options;
149
-
150
- if (
151
- typeof temperature !== 'number' ||
152
- Number.isNaN(temperature) ||
153
- typeof topK !== 'number' ||
154
- Number.isNaN(topK)
155
- ) {
156
- throw new DOMException(
157
- 'The provided temperature and topK must be numbers.',
158
- 'NotSupportedError'
159
- );
258
+ try {
259
+ resolvedOptions.monitor(monitorTarget);
260
+ } catch (e) {
261
+ console.error('Error in monitor callback:', e);
160
262
  }
161
263
 
162
- if (temperature < 0 || temperature > maxTemperature || topK > maxTopK) {
163
- throw new DOMException(
164
- 'The provided temperature or topK is outside the supported range.',
165
- 'NotSupportedError'
166
- );
264
+ try {
265
+ const startEvent = new ProgressEvent('downloadprogress', {
266
+ loaded: 0,
267
+ total: 1,
268
+ });
269
+ const endEvent = new ProgressEvent('downloadprogress', {
270
+ loaded: 1,
271
+ total: 1,
272
+ });
273
+ monitorTarget.dispatchEvent(startEvent);
274
+ monitorTarget.dispatchEvent(endEvent);
275
+ } catch (e) {
276
+ console.error('Error dispatching downloadprogress events:', e);
167
277
  }
168
278
  }
169
279
 
170
- static async params() {
171
- return {
172
- // Values from https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash-lite#:~:text=%2C%20audio/webm-,Parameter%20defaults,-tune.
173
- defaultTemperature: 1.0,
174
- defaultTopK: 64,
175
- maxTemperature: 2.0,
176
- maxTopK: 64, // Fixed
177
- };
280
+ return new LanguageModel(
281
+ backend,
282
+ model,
283
+ initialHistory,
284
+ resolvedOptions,
285
+ inCloudParams
286
+ );
287
+ }
288
+
289
+ // Instance Methods
290
+
291
+ async clone(options = {}) {
292
+ if (this.#destroyed) {
293
+ throw new DOMException('Session is destroyed', 'InvalidStateError');
178
294
  }
179
295
 
180
- static async create(options = {}) {
181
- const availability = await LanguageModel.availability(options);
182
- // This will be relevant when the implementation is backed by a local
183
- // model that needs downloading and simulates the Prompt API's behavior.
184
- if (availability === 'downloadable' || availability === 'downloading') {
185
- throw new DOMException(
186
- 'Requires a user gesture when availability is "downloading" or "downloadable".',
187
- 'NotAllowedError'
188
- );
189
- }
190
- const defaults = {
191
- temperature: 1.0,
192
- topK: 3,
193
- };
194
-
195
- const resolvedOptions = { ...defaults, ...options };
196
-
197
- const inCloudParams = {
198
- model: MODEL_NAME,
199
- generationConfig: {
200
- temperature: resolvedOptions.temperature,
201
- topK: resolvedOptions.topK,
202
- },
203
- };
204
-
205
- let initialHistory = [];
206
- let systemInstruction = undefined;
207
-
208
- if (
209
- resolvedOptions.initialPrompts &&
210
- Array.isArray(resolvedOptions.initialPrompts)
211
- ) {
212
- const systemPrompts = resolvedOptions.initialPrompts.filter(
213
- (p) => p.role === 'system'
214
- );
215
- const conversationPrompts = resolvedOptions.initialPrompts.filter(
216
- (p) => p.role !== 'system'
217
- );
218
-
219
- if (systemPrompts.length > 0) {
220
- inCloudParams.systemInstruction = systemPrompts
221
- .map((p) => p.content)
222
- .join('\n');
223
- }
224
- // Await the conversion of history items (in case of images in history)
225
- initialHistory = await convertToFirebaseHistory(conversationPrompts);
226
- }
296
+ const historyCopy = JSON.parse(JSON.stringify(this.#history));
297
+ const mergedOptions = { ...this.#options, ...options };
298
+ const mergedInCloudParams = { ...this.#inCloudParams };
227
299
 
228
- const model = getGenerativeModel(ai, {
229
- mode: InferenceMode.ONLY_IN_CLOUD,
230
- inCloudParams,
231
- });
300
+ if (options.temperature !== undefined) {
301
+ mergedInCloudParams.generationConfig.temperature = options.temperature;
302
+ }
303
+ if (options.topK !== undefined) {
304
+ mergedInCloudParams.generationConfig.topK = options.topK;
305
+ }
232
306
 
233
- // If a monitor callback is provided, simulate simple downloadprogress events
234
- if (typeof resolvedOptions.monitor === 'function') {
235
- const monitorTarget = new EventTarget();
307
+ // Re-create the backend for the clone since it now holds state (#model)
308
+ const BackendClass = await LanguageModel.#getBackendClass();
309
+ const info = LanguageModel.#getBackendInfo();
310
+ const newBackend = new BackendClass(info.configValue);
311
+ const newModel = newBackend.createSession(
312
+ mergedOptions,
313
+ mergedInCloudParams
314
+ );
236
315
 
237
- // Let the caller attach listeners
238
- try {
239
- resolvedOptions.monitor(monitorTarget);
240
- } catch (e) {
241
- console.error('Error in monitor callback:', e);
242
- }
316
+ return new LanguageModel(
317
+ newBackend,
318
+ newModel,
319
+ historyCopy,
320
+ mergedOptions,
321
+ mergedInCloudParams
322
+ );
323
+ }
243
324
 
244
- // Fire two fake downloadprogress events: first with loaded = 0, then loaded = 1
245
- try {
246
- const startEvent = new ProgressEvent('downloadprogress', {
247
- loaded: 0,
248
- total: 1,
249
- });
250
- const endEvent = new ProgressEvent('downloadprogress', {
251
- loaded: 1,
252
- total: 1,
253
- });
254
- // The `ProgressEvent`'s `currentTarget`, `srcElement` and `target`
255
- // properties are `EventTarget`, not `CreateMonitor`, when using the
256
- // polyfill. Hopefully developers won't rely on these properties.
257
- monitorTarget.dispatchEvent(startEvent);
258
- monitorTarget.dispatchEvent(endEvent);
259
- } catch (e) {
260
- console.error('Error dispatching downloadprogress events:', e);
261
- }
262
- }
325
+ destroy() {
326
+ this.#destroyed = true;
327
+ this.#history = null;
328
+ }
263
329
 
264
- return new LanguageModel(
265
- model,
266
- initialHistory,
267
- resolvedOptions,
268
- inCloudParams
269
- );
330
+ async prompt(input, options = {}) {
331
+ if (this.#destroyed) {
332
+ throw new DOMException('Session is destroyed', 'InvalidStateError');
333
+ }
334
+ if (options.signal?.aborted) {
335
+ throw new DOMException('Aborted', 'AbortError');
270
336
  }
271
337
 
272
- // Instance Methods
273
-
274
- async clone(options = {}) {
275
- if (this.#destroyed)
276
- throw new DOMException('Session is destroyed', 'InvalidStateError');
277
- // Clone private history
278
- const historyCopy = JSON.parse(JSON.stringify(this.#history));
279
- return new LanguageModel(
280
- this.#model,
281
- historyCopy,
282
- {
283
- ...this.#options,
284
- ...options,
285
- },
286
- this.#inCloudParams
338
+ if (options.responseConstraint) {
339
+ // Update Schema
340
+ const schema = convertJsonSchemaToVertexSchema(
341
+ options.responseConstraint
287
342
  );
288
- }
343
+ this.#inCloudParams.generationConfig.responseMimeType =
344
+ 'application/json';
345
+ this.#inCloudParams.generationConfig.responseSchema = schema;
289
346
 
290
- destroy() {
291
- this.#destroyed = true;
292
- this.#history = null;
347
+ // Re-create model with new config/schema (stored in backend)
348
+ this.#model = this.#backend.createSession(
349
+ this.#options,
350
+ this.#inCloudParams
351
+ );
293
352
  }
294
353
 
295
- async prompt(input, options = {}) {
296
- if (this.#destroyed)
297
- throw new DOMException('Session is destroyed', 'InvalidStateError');
298
- if (options.signal?.aborted)
299
- throw new DOMException('Aborted', 'AbortError');
300
-
301
- if (options.responseConstraint) {
302
- const vertexSchema = convertJsonSchemaToVertexSchema(
303
- options.responseConstraint
304
- );
305
- this.#inCloudParams.generationConfig.responseMimeType =
306
- 'application/json';
307
- this.#inCloudParams.generationConfig.responseSchema = vertexSchema;
308
- this.#model = getGenerativeModel(ai, {
309
- mode: InferenceMode.ONLY_IN_CLOUD,
310
- inCloudParams: this.#inCloudParams,
311
- });
312
- }
354
+ // Process Input (Async conversion of Blob/Canvas/AudioBuffer)
355
+ const parts = await this.#processInput(input);
356
+ const userContent = { role: 'user', parts: parts };
313
357
 
314
- // Process Input (Async conversion of Blob/Canvas/AudioBuffer)
315
- const parts = await this.#processInput(input);
316
- const userContent = { role: 'user', parts: parts };
358
+ try {
359
+ // Estimate usage
360
+ const totalTokens = await this.#backend.countTokens([
361
+ { role: 'user', parts },
362
+ ]);
317
363
 
318
- try {
319
- // Estimate usage before request to fire quota events if needed
320
- const { totalTokens } = await this.#model.countTokens({
321
- contents: [{ role: 'user', parts }],
322
- });
323
- if (this.#inputUsage + totalTokens > this.inputQuota)
324
- this.dispatchEvent(new Event('quotaoverflow'));
364
+ if (this.#inputUsage + totalTokens > this.inputQuota) {
365
+ this.dispatchEvent(new Event('quotaoverflow'));
366
+ }
325
367
 
326
- const requestContents = [...this.#history, userContent];
368
+ const requestContents = [...this.#history, userContent];
327
369
 
328
- const result = await this.#model.generateContent({
329
- contents: requestContents,
330
- });
370
+ const { text, usage } =
371
+ await this.#backend.generateContent(requestContents);
331
372
 
332
- // Exact usage update from Backend response
333
- if (result.response.usageMetadata?.totalTokenCount) {
334
- this.#inputUsage = result.response.usageMetadata.totalTokenCount;
335
- }
373
+ if (usage) {
374
+ this.#inputUsage = usage;
375
+ }
336
376
 
337
- const responseText = result.response.text();
377
+ this.#history.push(userContent);
378
+ this.#history.push({ role: 'model', parts: [{ text }] });
338
379
 
339
- this.#history.push(userContent);
340
- this.#history.push({ role: 'model', parts: [{ text: responseText }] });
380
+ return text;
381
+ } catch (error) {
382
+ console.error('Prompt API Polyfill Error:', error);
383
+ throw error;
384
+ }
385
+ }
341
386
 
342
- return responseText;
343
- } catch (error) {
344
- console.error('Firebase AI Logic Error:', error);
345
- throw error;
346
- }
387
+ promptStreaming(input, options = {}) {
388
+ if (this.#destroyed) {
389
+ throw new DOMException('Session is destroyed', 'InvalidStateError');
390
+ }
391
+ if (options.signal?.aborted) {
392
+ throw new DOMException('Aborted', 'AbortError');
347
393
  }
348
394
 
349
- promptStreaming(input, options = {}) {
350
- if (this.#destroyed)
351
- throw new DOMException('Session is destroyed', 'InvalidStateError');
352
- if (options.signal?.aborted)
353
- throw new DOMException('Aborted', 'AbortError');
354
-
355
- const _this = this; // Capture 'this' to access private fields in callback
356
-
357
- if (options.responseConstraint) {
358
- const vertexSchema = convertJsonSchemaToVertexSchema(
359
- options.responseConstraint
360
- );
361
- this.#inCloudParams.generationConfig.responseMimeType =
362
- 'application/json';
363
- this.#inCloudParams.generationConfig.responseSchema = vertexSchema;
364
- this.#model = getGenerativeModel(ai, {
365
- mode: InferenceMode.ONLY_IN_CLOUD,
366
- inCloudParams: this.#inCloudParams,
367
- });
368
- }
395
+ const _this = this; // Capture 'this' to access private fields in callback
369
396
 
370
- const signal = options.signal;
397
+ const signal = options.signal;
371
398
 
372
- return new ReadableStream({
373
- async start(controller) {
374
- const abortError = new DOMException('Aborted', 'AbortError');
399
+ return new ReadableStream({
400
+ async start(controller) {
401
+ const abortError = new DOMException('Aborted', 'AbortError');
375
402
 
376
- // If already aborted before the stream starts, error the stream.
377
- if (signal?.aborted) {
403
+ if (signal?.aborted) {
404
+ controller.error(abortError);
405
+ return;
406
+ }
407
+
408
+ let aborted = false;
409
+ const onAbort = () => {
410
+ aborted = true;
411
+ try {
378
412
  controller.error(abortError);
379
- return;
413
+ } catch {
414
+ // Ignore
380
415
  }
416
+ };
381
417
 
382
- let aborted = false;
383
- const onAbort = () => {
384
- aborted = true;
385
- try {
386
- controller.error(abortError);
387
- } catch {
388
- // Controller might already be closed/errored; ignore.
389
- }
390
- };
418
+ if (signal) {
419
+ signal.addEventListener('abort', onAbort);
420
+ }
391
421
 
392
- if (signal) {
393
- signal.addEventListener('abort', onAbort);
422
+ try {
423
+ if (options.responseConstraint) {
424
+ const schema = convertJsonSchemaToVertexSchema(
425
+ options.responseConstraint
426
+ );
427
+ _this.#inCloudParams.generationConfig.responseMimeType =
428
+ 'application/json';
429
+ _this.#inCloudParams.generationConfig.responseSchema = schema;
430
+ _this.#model = _this.#backend.createSession(
431
+ _this.#options,
432
+ _this.#inCloudParams
433
+ );
394
434
  }
395
435
 
396
- try {
397
- // Access private methods/fields via captured _this
398
- const parts = await _this.#processInput(input);
399
- const userContent = { role: 'user', parts: parts };
436
+ const parts = await _this.#processInput(input);
437
+ const userContent = { role: 'user', parts: parts };
400
438
 
401
- // Estimate usage before request to fire quota events if needed
402
- const { totalTokens } = await _this.#model.countTokens({
403
- contents: [{ role: 'user', parts }],
404
- });
405
- if (_this.#inputUsage + totalTokens > this.inputQuota)
406
- this.dispatchEvent(new Event('quotaoverflow'));
439
+ const totalTokens = await _this.#backend.countTokens([
440
+ { role: 'user', parts },
441
+ ]);
407
442
 
408
- const requestContents = [..._this.#history, userContent];
443
+ if (_this.#inputUsage + totalTokens > _this.inputQuota) {
444
+ _this.dispatchEvent(new Event('quotaoverflow'));
445
+ }
409
446
 
410
- const result = await _this.#model.generateContentStream({
411
- contents: requestContents,
412
- });
447
+ const requestContents = [..._this.#history, userContent];
413
448
 
414
- let fullResponseText = '';
415
-
416
- for await (const chunk of result.stream) {
417
- if (aborted) {
418
- // Try to cancel the underlying iterator; ignore any abort-related errors.
419
- if (typeof result.stream.return === 'function') {
420
- try {
421
- await result.stream.return();
422
- } catch (e) {
423
- // Ignore cancellation errors (including AbortError).
424
- }
425
- }
426
- return;
427
- }
428
- if (chunk.usageMetadata?.totalTokenCount) {
429
- _this.#inputUsage += chunk.usageMetadata.totalTokenCount;
449
+ const stream =
450
+ await _this.#backend.generateContentStream(requestContents);
451
+
452
+ let fullResponseText = '';
453
+
454
+ for await (const chunk of stream) {
455
+ if (aborted) {
456
+ // Try to cancel if supported
457
+ if (typeof stream.return === 'function') {
458
+ await stream.return();
430
459
  }
431
- const chunkText = chunk.text();
432
- fullResponseText += chunkText;
433
- controller.enqueue(chunkText);
460
+ return;
434
461
  }
435
462
 
436
- if (!aborted) {
437
- _this.#history.push(userContent);
438
- _this.#history.push({
439
- role: 'model',
440
- parts: [{ text: fullResponseText }],
441
- });
463
+ const chunkText = chunk.text();
464
+ fullResponseText += chunkText;
442
465
 
443
- controller.close();
444
- }
445
- } catch (error) {
446
- // If we aborted, we've already signaled an AbortError; otherwise surface the error.
447
- if (!aborted) {
448
- controller.error(error);
449
- }
450
- } finally {
451
- if (signal) {
452
- signal.removeEventListener('abort', onAbort);
466
+ if (chunk.usageMetadata?.totalTokenCount) {
467
+ _this.#inputUsage = chunk.usageMetadata.totalTokenCount;
453
468
  }
469
+
470
+ controller.enqueue(chunkText);
454
471
  }
455
- },
456
- });
457
- }
458
472
 
459
- async append(input, options = {}) {
460
- if (this.#destroyed)
461
- throw new DOMException('Session is destroyed', 'InvalidStateError');
462
- if (options.signal?.aborted)
463
- throw new DOMException('Aborted', 'AbortError');
473
+ if (!aborted) {
474
+ _this.#history.push(userContent);
475
+ _this.#history.push({
476
+ role: 'model',
477
+ parts: [{ text: fullResponseText }],
478
+ });
464
479
 
465
- const parts = await this.#processInput(input);
466
- const content = { role: 'user', parts: parts };
480
+ controller.close();
481
+ }
482
+ } catch (error) {
483
+ if (!aborted) {
484
+ controller.error(error);
485
+ }
486
+ } finally {
487
+ if (signal) {
488
+ signal.removeEventListener('abort', onAbort);
489
+ }
490
+ }
491
+ },
492
+ });
493
+ }
467
494
 
468
- try {
469
- // Try to get accurate count first
470
- const { totalTokens } = await this.#model.countTokens({
471
- contents: [...this.#history, content],
472
- });
473
- this.#inputUsage = totalTokens;
474
- } catch {
475
- // Do nothing.
476
- }
495
+ async append(input, options = {}) {
496
+ if (this.#destroyed) {
497
+ throw new DOMException('Session is destroyed', 'InvalidStateError');
498
+ }
499
+ if (options.signal?.aborted) {
500
+ throw new DOMException('Aborted', 'AbortError');
501
+ }
502
+
503
+ const parts = await this.#processInput(input);
504
+ const content = { role: 'user', parts: parts };
505
+
506
+ try {
507
+ const totalTokens = await this.#backend.countTokens([
508
+ ...this.#history,
509
+ content,
510
+ ]);
511
+ this.#inputUsage = totalTokens;
512
+ } catch {
513
+ // Do nothing.
514
+ }
477
515
 
478
- this.#history.push(content);
516
+ this.#history.push(content);
479
517
 
480
- if (this.#inputUsage > this.inputQuota) {
481
- this.dispatchEvent(new Event('quotaoverflow'));
482
- }
518
+ if (this.#inputUsage > this.inputQuota) {
519
+ this.dispatchEvent(new Event('quotaoverflow'));
483
520
  }
521
+ }
484
522
 
485
- async measureInputUsage(input) {
486
- if (this.#destroyed)
487
- throw new DOMException('Session is destroyed', 'InvalidStateError');
523
+ async measureInputUsage(input) {
524
+ if (this.#destroyed) {
525
+ throw new DOMException('Session is destroyed', 'InvalidStateError');
526
+ }
488
527
 
489
- try {
490
- const parts = await this.#processInput(input);
491
- const { totalTokens } = await this.#model.countTokens({
492
- contents: [{ role: 'user', parts }],
493
- });
494
- return totalTokens;
495
- } catch (e) {
496
- // The API can't reject, so just return 0 if we don't know.
497
- console.warn(
498
- 'The underlying API call failed, quota usage (0) is not reported accurately.'
499
- );
500
- return 0;
501
- }
528
+ try {
529
+ const parts = await this.#processInput(input);
530
+ const totalTokens = await this.#backend.countTokens([
531
+ { role: 'user', parts },
532
+ ]);
533
+ return totalTokens || 0;
534
+ } catch (e) {
535
+ console.warn(
536
+ 'The underlying API call failed, quota usage (0) is not reported accurately.'
537
+ );
538
+ return 0;
502
539
  }
540
+ }
503
541
 
504
- // Private Helper to process diverse input types
505
- async #processInput(input) {
506
- if (typeof input === 'string') {
507
- return [{ text: input }];
508
- }
542
+ // Private Helper to process diverse input types
543
+ async #processInput(input) {
544
+ if (typeof input === 'string') {
545
+ return [{ text: input }];
546
+ }
509
547
 
510
- if (Array.isArray(input)) {
511
- if (input.length > 0 && input[0].role) {
512
- let combinedParts = [];
513
- for (const msg of input) {
514
- if (typeof msg.content === 'string') {
515
- combinedParts.push({ text: msg.content });
516
- if (msg.prefix) {
517
- console.warn(
518
- "The `prefix` flag isn't supported and was ignored."
519
- );
520
- }
521
- } else if (Array.isArray(msg.content)) {
522
- for (const c of msg.content) {
523
- if (c.type === 'text') combinedParts.push({ text: c.value });
524
- else {
525
- const part = await MultimodalConverter.convert(
526
- c.type,
527
- c.value
528
- );
529
- combinedParts.push(part);
530
- }
548
+ if (Array.isArray(input)) {
549
+ if (input.length > 0 && input[0].role) {
550
+ let combinedParts = [];
551
+ for (const msg of input) {
552
+ if (typeof msg.content === 'string') {
553
+ combinedParts.push({ text: msg.content });
554
+ if (msg.prefix) {
555
+ console.warn(
556
+ "The `prefix` flag isn't supported and was ignored."
557
+ );
558
+ }
559
+ } else if (Array.isArray(msg.content)) {
560
+ for (const c of msg.content) {
561
+ if (c.type === 'text') {
562
+ combinedParts.push({ text: c.value });
563
+ } else {
564
+ const part = await MultimodalConverter.convert(c.type, c.value);
565
+ combinedParts.push(part);
531
566
  }
532
567
  }
533
568
  }
534
- return combinedParts;
535
569
  }
536
- return input.map((s) => ({ text: String(s) }));
570
+ return combinedParts;
537
571
  }
538
-
539
- return [{ text: JSON.stringify(input) }];
572
+ return input.map((s) => ({ text: String(s) }));
540
573
  }
574
+
575
+ return [{ text: JSON.stringify(input) }];
541
576
  }
577
+ }
542
578
 
579
+ if (!('LanguageModel' in window) || window.__FORCE_PROMPT_API_POLYFILL__) {
543
580
  // Attach to window
544
581
  window.LanguageModel = LanguageModel;
582
+ LanguageModel.__isPolyfill = true;
545
583
  console.log(
546
- 'Polyfill: window.LanguageModel is now backed by Firebase AI Logic.'
584
+ 'Polyfill: window.LanguageModel is now backed by the Prompt API polyfill.'
547
585
  );
548
- })();
586
+ }