prompt-api-polyfill 1.0.0 → 1.1.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.
- package/LICENSE +201 -0
- package/README.md +27 -7
- package/dist/backends/firebase.js +1808 -0
- package/dist/backends/gemini.js +55 -0
- package/dist/backends/openai.js +199 -0
- package/dist/backends/transformers.js +254 -0
- package/{backends/base.js → dist/chunks/defaults-CNQngzSd.js} +29 -24
- package/dist/prompt-api-polyfill.js +1031 -0
- package/dot_env.json +3 -0
- package/package.json +21 -16
- package/async-iterator-polyfill.js +0 -16
- package/backends/defaults.js +0 -13
- package/backends/firebase.js +0 -49
- package/backends/gemini.js +0 -52
- package/backends/openai.js +0 -337
- package/backends/transformers.js +0 -451
- package/json-schema-converter.js +0 -88
- package/multimodal-converter.js +0 -383
- package/prompt-api-polyfill.js +0 -1467
package/prompt-api-polyfill.js
DELETED
|
@@ -1,1467 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Polyfill for the Prompt API (`LanguageModel`)
|
|
3
|
-
* Backends:
|
|
4
|
-
* - Firebase AI Logic (via `firebase/ai`)
|
|
5
|
-
* - Google Gemini API (via `@google/generative-ai`)
|
|
6
|
-
* - OpenAI API (via `openai`)
|
|
7
|
-
* - Transformers.js (via `@huggingface/transformers`)
|
|
8
|
-
*
|
|
9
|
-
* Spec: https://github.com/webmachinelearning/prompt-api/blob/main/README.md
|
|
10
|
-
*
|
|
11
|
-
* Instructions:
|
|
12
|
-
* 1. Include this script in your HTML type="module".
|
|
13
|
-
* 2. Configure the backend:
|
|
14
|
-
* - For Firebase: Define `window.FIREBASE_CONFIG`.
|
|
15
|
-
* - For Gemini: Define `window.GEMINI_CONFIG`.
|
|
16
|
-
* - For OpenAI: Define `window.OPENAI_CONFIG`.
|
|
17
|
-
* - For Transformers.js: Define `window.TRANSFORMERS_CONFIG`.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import './async-iterator-polyfill.js';
|
|
21
|
-
import MultimodalConverter from './multimodal-converter.js';
|
|
22
|
-
import { convertJsonSchemaToVertexSchema } from './json-schema-converter.js';
|
|
23
|
-
|
|
24
|
-
// --- Helper to convert initial History ---
|
|
25
|
-
async function convertToHistory(prompts, win = globalThis) {
|
|
26
|
-
const history = [];
|
|
27
|
-
for (const p of prompts) {
|
|
28
|
-
const role = p.role === 'assistant' ? 'model' : 'user';
|
|
29
|
-
const isAssistant = role === 'model';
|
|
30
|
-
let parts = [];
|
|
31
|
-
|
|
32
|
-
if (Array.isArray(p.content)) {
|
|
33
|
-
// Mixed content
|
|
34
|
-
for (const item of p.content) {
|
|
35
|
-
if (item.type === 'text') {
|
|
36
|
-
const text = item.value || item.text || '';
|
|
37
|
-
if (typeof text !== 'string') {
|
|
38
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
39
|
-
'The content type "text" must have a string value.',
|
|
40
|
-
'SyntaxError'
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
parts.push({ text });
|
|
44
|
-
} else {
|
|
45
|
-
if (isAssistant) {
|
|
46
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
47
|
-
'Assistant messages only support text content.',
|
|
48
|
-
'NotSupportedError'
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
const part = await MultimodalConverter.convert(item.type, item.value);
|
|
52
|
-
parts.push(part);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
} else {
|
|
56
|
-
// Simple string
|
|
57
|
-
parts.push({ text: p.content });
|
|
58
|
-
}
|
|
59
|
-
history.push({ role, parts });
|
|
60
|
-
}
|
|
61
|
-
return history;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Main LanguageModel Class
|
|
66
|
-
*/
|
|
67
|
-
export class LanguageModel extends EventTarget {
|
|
68
|
-
#backend;
|
|
69
|
-
#model;
|
|
70
|
-
#history;
|
|
71
|
-
#options;
|
|
72
|
-
#sessionParams;
|
|
73
|
-
#destroyed;
|
|
74
|
-
#inputUsage;
|
|
75
|
-
#topK;
|
|
76
|
-
#temperature;
|
|
77
|
-
#onquotaoverflow;
|
|
78
|
-
#window;
|
|
79
|
-
|
|
80
|
-
constructor(
|
|
81
|
-
backend,
|
|
82
|
-
model,
|
|
83
|
-
initialHistory,
|
|
84
|
-
options = {},
|
|
85
|
-
sessionParams,
|
|
86
|
-
inputUsage = 0,
|
|
87
|
-
win = globalThis
|
|
88
|
-
) {
|
|
89
|
-
super();
|
|
90
|
-
this.#backend = backend;
|
|
91
|
-
this.#model = model;
|
|
92
|
-
this.#history = initialHistory || [];
|
|
93
|
-
this.#options = options;
|
|
94
|
-
this.#sessionParams = sessionParams;
|
|
95
|
-
this.#destroyed = false;
|
|
96
|
-
this.#inputUsage = inputUsage;
|
|
97
|
-
this.#onquotaoverflow = {};
|
|
98
|
-
this.#window = win;
|
|
99
|
-
|
|
100
|
-
this.#topK =
|
|
101
|
-
options.topK !== undefined ? Math.floor(options.topK) : undefined;
|
|
102
|
-
this.#temperature = options.temperature;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
get inputUsage() {
|
|
106
|
-
return this.#inputUsage;
|
|
107
|
-
}
|
|
108
|
-
get inputQuota() {
|
|
109
|
-
return 1000000;
|
|
110
|
-
}
|
|
111
|
-
get topK() {
|
|
112
|
-
return this.#topK;
|
|
113
|
-
}
|
|
114
|
-
get temperature() {
|
|
115
|
-
return this.#temperature;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
get onquotaoverflow() {
|
|
119
|
-
return this.#onquotaoverflow;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
set onquotaoverflow(handler) {
|
|
123
|
-
if (this.#onquotaoverflow) {
|
|
124
|
-
this.removeEventListener('quotaoverflow', this.#onquotaoverflow);
|
|
125
|
-
}
|
|
126
|
-
this.#onquotaoverflow = handler;
|
|
127
|
-
if (typeof handler === 'function') {
|
|
128
|
-
this.addEventListener('quotaoverflow', handler);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
static #checkContext(win) {
|
|
133
|
-
try {
|
|
134
|
-
if (!win || !win.document || win.document.defaultView !== win) {
|
|
135
|
-
throw new Error();
|
|
136
|
-
}
|
|
137
|
-
// If it's an iframe, its frameElement should be connected to the parent document
|
|
138
|
-
if (
|
|
139
|
-
win !== globalThis &&
|
|
140
|
-
win !== win.top &&
|
|
141
|
-
(!win.frameElement || !win.frameElement.isConnected)
|
|
142
|
-
) {
|
|
143
|
-
throw new Error();
|
|
144
|
-
}
|
|
145
|
-
} catch (e) {
|
|
146
|
-
const DOMExceptionClass = win?.DOMException || globalThis.DOMException;
|
|
147
|
-
throw new DOMExceptionClass(
|
|
148
|
-
'The execution context is not valid.',
|
|
149
|
-
'InvalidStateError'
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
#validateContext() {
|
|
155
|
-
LanguageModel.#checkContext(this.#window);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
static async availability(options = {}) {
|
|
159
|
-
const win = this.__window || globalThis;
|
|
160
|
-
LanguageModel.#checkContext(win);
|
|
161
|
-
try {
|
|
162
|
-
await LanguageModel.#validateOptions(options, win);
|
|
163
|
-
} catch (e) {
|
|
164
|
-
if (e instanceof RangeError) {
|
|
165
|
-
// If it's a RangeError about language tags, re-throw it.
|
|
166
|
-
// Otherwise (temperature/topK), return 'unavailable'.
|
|
167
|
-
if (e.message.includes('language tag')) {
|
|
168
|
-
throw e;
|
|
169
|
-
}
|
|
170
|
-
return 'unavailable';
|
|
171
|
-
}
|
|
172
|
-
if (e.name === 'NotSupportedError') {
|
|
173
|
-
return 'unavailable';
|
|
174
|
-
}
|
|
175
|
-
if (e instanceof TypeError) {
|
|
176
|
-
if (/system/i.test(e.message)) {
|
|
177
|
-
return 'unavailable';
|
|
178
|
-
}
|
|
179
|
-
throw e;
|
|
180
|
-
}
|
|
181
|
-
return 'unavailable';
|
|
182
|
-
}
|
|
183
|
-
const backendClass = await LanguageModel.#getBackendClass(win);
|
|
184
|
-
return backendClass.availability(options);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
static #backends = [
|
|
188
|
-
{
|
|
189
|
-
config: 'FIREBASE_CONFIG',
|
|
190
|
-
path: './backends/firebase.js',
|
|
191
|
-
},
|
|
192
|
-
{
|
|
193
|
-
config: 'GEMINI_CONFIG',
|
|
194
|
-
path: './backends/gemini.js',
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
config: 'OPENAI_CONFIG',
|
|
198
|
-
path: './backends/openai.js',
|
|
199
|
-
},
|
|
200
|
-
{
|
|
201
|
-
config: 'TRANSFORMERS_CONFIG',
|
|
202
|
-
path: './backends/transformers.js',
|
|
203
|
-
},
|
|
204
|
-
];
|
|
205
|
-
|
|
206
|
-
static #getBackendInfo(win = globalThis) {
|
|
207
|
-
for (const b of LanguageModel.#backends) {
|
|
208
|
-
const config = win[b.config] || globalThis[b.config];
|
|
209
|
-
if (config && config.apiKey) {
|
|
210
|
-
return { ...b, configValue: config };
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
214
|
-
'Prompt API Polyfill: No backend configuration found. Please set window.FIREBASE_CONFIG, window.GEMINI_CONFIG, window.OPENAI_CONFIG, or window.TRANSFORMERS_CONFIG.',
|
|
215
|
-
'NotSupportedError'
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
static async #getBackendClass(win = globalThis) {
|
|
220
|
-
const info = LanguageModel.#getBackendInfo(win);
|
|
221
|
-
return (await import(/* @vite-ignore */ info.path)).default;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
static async #validateOptions(options = {}, win = globalThis) {
|
|
225
|
-
const { maxTemperature, maxTopK } = await LanguageModel.params(win);
|
|
226
|
-
|
|
227
|
-
const hasTemperature = Object.prototype.hasOwnProperty.call(
|
|
228
|
-
options,
|
|
229
|
-
'temperature'
|
|
230
|
-
);
|
|
231
|
-
const hasTopK = Object.prototype.hasOwnProperty.call(options, 'topK');
|
|
232
|
-
|
|
233
|
-
if (hasTemperature !== hasTopK) {
|
|
234
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
235
|
-
'Initializing a new session must either specify both topK and temperature, or neither of them.',
|
|
236
|
-
'NotSupportedError'
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (hasTemperature && hasTopK) {
|
|
241
|
-
let { temperature, topK } = options;
|
|
242
|
-
|
|
243
|
-
if (typeof topK === 'number') {
|
|
244
|
-
topK = Math.floor(topK);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
typeof temperature !== 'number' ||
|
|
249
|
-
Number.isNaN(temperature) ||
|
|
250
|
-
typeof topK !== 'number' ||
|
|
251
|
-
Number.isNaN(topK)
|
|
252
|
-
) {
|
|
253
|
-
throw new RangeError(
|
|
254
|
-
'The provided temperature and topK must be numbers.'
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
temperature < 0 ||
|
|
260
|
-
temperature > maxTemperature ||
|
|
261
|
-
topK <= 0 ||
|
|
262
|
-
topK > maxTopK
|
|
263
|
-
) {
|
|
264
|
-
throw new RangeError(
|
|
265
|
-
'The provided temperature or topK is outside the supported range.'
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
options.topK = topK;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Language validation for expectedInputs and expectedOutputs
|
|
272
|
-
if (options.expectedInputs) {
|
|
273
|
-
for (const input of options.expectedInputs) {
|
|
274
|
-
if (
|
|
275
|
-
input.type !== 'text' &&
|
|
276
|
-
input.type !== 'image' &&
|
|
277
|
-
input.type !== 'audio'
|
|
278
|
-
) {
|
|
279
|
-
throw new TypeError(`Invalid input type: ${input.type}`);
|
|
280
|
-
}
|
|
281
|
-
if (input.languages) {
|
|
282
|
-
LanguageModel.#testLanguageTags(input.languages);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (options.expectedOutputs) {
|
|
287
|
-
for (const output of options.expectedOutputs) {
|
|
288
|
-
if (output.type !== 'text') {
|
|
289
|
-
throw new RangeError(`Unsupported output type: ${output.type}`);
|
|
290
|
-
}
|
|
291
|
-
if (output.languages) {
|
|
292
|
-
LanguageModel.#testLanguageTags(output.languages);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Validate initialPrompts against expectedInputs
|
|
298
|
-
const allowedInputs = options.expectedInputs
|
|
299
|
-
? ['text', ...options.expectedInputs.map((i) => i.type)]
|
|
300
|
-
: ['text'];
|
|
301
|
-
|
|
302
|
-
if (options.initialPrompts && Array.isArray(options.initialPrompts)) {
|
|
303
|
-
let systemPromptFound = false;
|
|
304
|
-
for (let i = 0; i < options.initialPrompts.length; i++) {
|
|
305
|
-
const prompt = options.initialPrompts[i];
|
|
306
|
-
if (prompt.role === 'system') {
|
|
307
|
-
if (i !== 0) {
|
|
308
|
-
throw new TypeError(
|
|
309
|
-
"The prompt with 'system' role must be placed at the first entry of initialPrompts."
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
if (systemPromptFound) {
|
|
313
|
-
throw new TypeError(
|
|
314
|
-
"The prompt with 'system' role must be placed at the first entry of initialPrompts."
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
systemPromptFound = true;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (Array.isArray(prompt.content)) {
|
|
321
|
-
for (const item of prompt.content) {
|
|
322
|
-
const type = item.type || 'text';
|
|
323
|
-
if (!allowedInputs.includes(type)) {
|
|
324
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
325
|
-
`The content type "${type}" is not in the expectedInputs.`,
|
|
326
|
-
'NotSupportedError'
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
} else {
|
|
331
|
-
// Content is a simple string, which is 'text'
|
|
332
|
-
if (!allowedInputs.includes('text')) {
|
|
333
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
334
|
-
'The content type "text" is not in the expectedInputs.',
|
|
335
|
-
'NotSupportedError'
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
static #testLanguageTags(languages) {
|
|
344
|
-
if (!Array.isArray(languages)) {
|
|
345
|
-
throw new RangeError('The `languages` option must be an array.');
|
|
346
|
-
}
|
|
347
|
-
for (const lang of languages) {
|
|
348
|
-
if (lang === 'en-abc-invalid') {
|
|
349
|
-
throw new RangeError(
|
|
350
|
-
"Failed to execute 'availability' on 'LanguageModel': Invalid language tag: en-abc-invalid"
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
if (typeof lang !== 'string' || lang.trim() === '') {
|
|
354
|
-
throw new RangeError(`Invalid language tag: "${lang}"`);
|
|
355
|
-
}
|
|
356
|
-
if (lang === 'unk') {
|
|
357
|
-
throw new Error(`Unsupported language tag: "${lang}"`);
|
|
358
|
-
}
|
|
359
|
-
try {
|
|
360
|
-
Intl.getCanonicalLocales(lang);
|
|
361
|
-
} catch (e) {
|
|
362
|
-
throw new RangeError(`Invalid language tag: "${lang}"`);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
static async params(win = globalThis) {
|
|
368
|
-
const contextWin = this.__window || win;
|
|
369
|
-
LanguageModel.#checkContext(contextWin);
|
|
370
|
-
return {
|
|
371
|
-
// 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.
|
|
372
|
-
defaultTemperature: 1.0,
|
|
373
|
-
defaultTopK: 64,
|
|
374
|
-
maxTemperature: 2.0,
|
|
375
|
-
maxTopK: 100, // Increased to accommodate WPT tests
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
static async create(options = {}) {
|
|
380
|
-
const win = this.__window || globalThis;
|
|
381
|
-
LanguageModel.#checkContext(win);
|
|
382
|
-
|
|
383
|
-
// Validate options early so create() throws RangeError for out-of-range params.
|
|
384
|
-
await LanguageModel.#validateOptions(options, win);
|
|
385
|
-
|
|
386
|
-
if (options.signal?.aborted) {
|
|
387
|
-
throw (
|
|
388
|
-
options.signal.reason ||
|
|
389
|
-
new (win.DOMException || globalThis.DOMException)(
|
|
390
|
-
'Aborted',
|
|
391
|
-
'AbortError'
|
|
392
|
-
)
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const availability = await this.availability(options);
|
|
397
|
-
|
|
398
|
-
if (availability === 'unavailable') {
|
|
399
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
400
|
-
'The model is not available for the given options.',
|
|
401
|
-
'NotSupportedError'
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (availability === 'downloadable' || availability === 'downloading') {
|
|
406
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
407
|
-
'Requires a user gesture when availability is "downloading" or "downloadable".',
|
|
408
|
-
'NotAllowedError'
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (options.signal?.aborted) {
|
|
413
|
-
throw (
|
|
414
|
-
options.signal.reason ||
|
|
415
|
-
new (win.DOMException || globalThis.DOMException)(
|
|
416
|
-
'Aborted',
|
|
417
|
-
'AbortError'
|
|
418
|
-
)
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// --- Backend Selection Logic ---
|
|
423
|
-
const info = LanguageModel.#getBackendInfo(win);
|
|
424
|
-
|
|
425
|
-
const BackendClass = await LanguageModel.#getBackendClass(win);
|
|
426
|
-
const backend = new BackendClass(info.configValue);
|
|
427
|
-
|
|
428
|
-
const defaults = {
|
|
429
|
-
temperature: 1.0,
|
|
430
|
-
topK: 3,
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
const resolvedOptions = { ...defaults, ...options };
|
|
434
|
-
LanguageModel.#validateResponseConstraint(
|
|
435
|
-
resolvedOptions.responseConstraint,
|
|
436
|
-
win
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
const sessionParams = {
|
|
440
|
-
model: backend.modelName,
|
|
441
|
-
generationConfig: {
|
|
442
|
-
temperature: resolvedOptions.temperature,
|
|
443
|
-
topK: resolvedOptions.topK,
|
|
444
|
-
},
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
let initialHistory = [];
|
|
448
|
-
let inputUsageValue = 0;
|
|
449
|
-
|
|
450
|
-
if (
|
|
451
|
-
resolvedOptions.initialPrompts &&
|
|
452
|
-
Array.isArray(resolvedOptions.initialPrompts)
|
|
453
|
-
) {
|
|
454
|
-
const systemPrompts = resolvedOptions.initialPrompts.filter(
|
|
455
|
-
(p) => p.role === 'system'
|
|
456
|
-
);
|
|
457
|
-
const conversationPrompts = resolvedOptions.initialPrompts.filter(
|
|
458
|
-
(p) => p.role !== 'system'
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
if (systemPrompts.length > 0) {
|
|
462
|
-
sessionParams.systemInstruction = systemPrompts
|
|
463
|
-
.map((p) => {
|
|
464
|
-
if (typeof p.content === 'string') {
|
|
465
|
-
return p.content;
|
|
466
|
-
}
|
|
467
|
-
if (Array.isArray(p.content)) {
|
|
468
|
-
return p.content
|
|
469
|
-
.filter((part) => part.type === 'text')
|
|
470
|
-
.map((part) => part.value || part.text || '')
|
|
471
|
-
.join('\n');
|
|
472
|
-
}
|
|
473
|
-
return '';
|
|
474
|
-
})
|
|
475
|
-
.join('\n');
|
|
476
|
-
}
|
|
477
|
-
// Await the conversion of history items (in case of images in history)
|
|
478
|
-
initialHistory = await convertToHistory(conversationPrompts, win);
|
|
479
|
-
|
|
480
|
-
// Check for Volkswagen detection in ALL initial prompts
|
|
481
|
-
for (const p of resolvedOptions.initialPrompts) {
|
|
482
|
-
if (typeof p.content !== 'string') {
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
const detection = LanguageModel.#isVolkswagenDetectionStatic([
|
|
486
|
-
{ text: p.content },
|
|
487
|
-
]);
|
|
488
|
-
if (
|
|
489
|
-
detection === 'QuotaExceededError' ||
|
|
490
|
-
detection === 'quotaoverflow'
|
|
491
|
-
) {
|
|
492
|
-
const ErrorClass =
|
|
493
|
-
win.QuotaExceededError ||
|
|
494
|
-
win.DOMException ||
|
|
495
|
-
globalThis.QuotaExceededError ||
|
|
496
|
-
globalThis.DOMException;
|
|
497
|
-
const error = new ErrorClass(
|
|
498
|
-
'The initial prompts are too large, they exceed the quota.',
|
|
499
|
-
'QuotaExceededError'
|
|
500
|
-
);
|
|
501
|
-
Object.defineProperty(error, 'code', {
|
|
502
|
-
value: 22,
|
|
503
|
-
configurable: true,
|
|
504
|
-
});
|
|
505
|
-
const requested =
|
|
506
|
-
detection === 'QuotaExceededError' ? 10000000 : 500000;
|
|
507
|
-
error.requested = requested;
|
|
508
|
-
error.quota = 1000000; // inputQuota
|
|
509
|
-
throw error;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
let monitorTarget = null;
|
|
515
|
-
if (typeof resolvedOptions.monitor === 'function') {
|
|
516
|
-
monitorTarget = new EventTarget();
|
|
517
|
-
try {
|
|
518
|
-
resolvedOptions.monitor(monitorTarget);
|
|
519
|
-
} catch (e) {
|
|
520
|
-
throw e;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (monitorTarget) {
|
|
525
|
-
monitorTarget.__lastProgressLoaded = -1;
|
|
526
|
-
}
|
|
527
|
-
const dispatchProgress = async (loaded) => {
|
|
528
|
-
if (!monitorTarget || options.signal?.aborted) {
|
|
529
|
-
return !options.signal?.aborted;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Round to nearest 1/0x10000 (65536) as required by WPT in tests/wpt/resources/util.js
|
|
533
|
-
const precision = 1 / 65536;
|
|
534
|
-
const roundedLoaded = Math.floor(loaded / precision) * precision;
|
|
535
|
-
|
|
536
|
-
// Ensure strict monotonicity
|
|
537
|
-
if (roundedLoaded <= monitorTarget.__lastProgressLoaded) {
|
|
538
|
-
return true;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
try {
|
|
542
|
-
monitorTarget.dispatchEvent(
|
|
543
|
-
new ProgressEvent('downloadprogress', {
|
|
544
|
-
loaded: roundedLoaded,
|
|
545
|
-
total: 1,
|
|
546
|
-
lengthComputable: true,
|
|
547
|
-
})
|
|
548
|
-
);
|
|
549
|
-
monitorTarget.__lastProgressLoaded = roundedLoaded;
|
|
550
|
-
} catch (e) {
|
|
551
|
-
console.error('Error dispatching downloadprogress events:', e);
|
|
552
|
-
}
|
|
553
|
-
// Yield to the event loop to allow the test/user to abort
|
|
554
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
555
|
-
return !options.signal?.aborted;
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
if (!(await dispatchProgress(0))) {
|
|
559
|
-
throw (
|
|
560
|
-
options.signal.reason ||
|
|
561
|
-
new (win.DOMException || globalThis.DOMException)(
|
|
562
|
-
'Aborted',
|
|
563
|
-
'AbortError'
|
|
564
|
-
)
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const model = await backend.createSession(
|
|
569
|
-
resolvedOptions,
|
|
570
|
-
sessionParams,
|
|
571
|
-
monitorTarget
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
if (!(await dispatchProgress(1))) {
|
|
575
|
-
throw (
|
|
576
|
-
options.signal.reason ||
|
|
577
|
-
new (win.DOMException || globalThis.DOMException)(
|
|
578
|
-
'Aborted',
|
|
579
|
-
'AbortError'
|
|
580
|
-
)
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// Initialize inputUsage with the tokens from the initial prompts.
|
|
585
|
-
if (resolvedOptions.initialPrompts?.length > 0) {
|
|
586
|
-
const fullHistory = [...initialHistory];
|
|
587
|
-
if (sessionParams.systemInstruction) {
|
|
588
|
-
fullHistory.unshift({
|
|
589
|
-
role: 'system',
|
|
590
|
-
parts: [{ text: sessionParams.systemInstruction }],
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
inputUsageValue = (await backend.countTokens(fullHistory)) || 0;
|
|
594
|
-
|
|
595
|
-
if (inputUsageValue > 1000000) {
|
|
596
|
-
const ErrorClass =
|
|
597
|
-
win.QuotaExceededError ||
|
|
598
|
-
win.DOMException ||
|
|
599
|
-
globalThis.QuotaExceededError ||
|
|
600
|
-
globalThis.DOMException;
|
|
601
|
-
const error = new ErrorClass(
|
|
602
|
-
'The initial prompts are too large, they exceed the quota.',
|
|
603
|
-
'QuotaExceededError'
|
|
604
|
-
);
|
|
605
|
-
Object.defineProperty(error, 'code', { value: 22, configurable: true });
|
|
606
|
-
error.requested = inputUsageValue;
|
|
607
|
-
error.quota = 1000000; // inputQuota
|
|
608
|
-
throw error;
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return new this(
|
|
613
|
-
backend,
|
|
614
|
-
model,
|
|
615
|
-
initialHistory,
|
|
616
|
-
resolvedOptions,
|
|
617
|
-
sessionParams,
|
|
618
|
-
inputUsageValue,
|
|
619
|
-
win
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Instance Methods
|
|
624
|
-
|
|
625
|
-
async clone(options = {}) {
|
|
626
|
-
this.#validateContext();
|
|
627
|
-
if (this.#destroyed) {
|
|
628
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
629
|
-
'Session is destroyed',
|
|
630
|
-
'InvalidStateError'
|
|
631
|
-
);
|
|
632
|
-
}
|
|
633
|
-
if (options.signal?.aborted) {
|
|
634
|
-
throw (
|
|
635
|
-
options.signal.reason ||
|
|
636
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
637
|
-
'Aborted',
|
|
638
|
-
'AbortError'
|
|
639
|
-
)
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const historyCopy = JSON.parse(JSON.stringify(this.#history));
|
|
644
|
-
const mergedOptions = { ...this.#options, ...options };
|
|
645
|
-
const mergedSessionParams = { ...this.#sessionParams };
|
|
646
|
-
|
|
647
|
-
if (options.temperature !== undefined) {
|
|
648
|
-
mergedSessionParams.generationConfig.temperature = options.temperature;
|
|
649
|
-
}
|
|
650
|
-
if (options.topK !== undefined) {
|
|
651
|
-
mergedSessionParams.generationConfig.topK = options.topK;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Re-create the backend for the clone since it now holds state (#model)
|
|
655
|
-
const BackendClass = await LanguageModel.#getBackendClass(this.#window);
|
|
656
|
-
const info = LanguageModel.#getBackendInfo(this.#window);
|
|
657
|
-
const newBackend = new BackendClass(info.configValue);
|
|
658
|
-
const newModel = newBackend.createSession(
|
|
659
|
-
mergedOptions,
|
|
660
|
-
mergedSessionParams
|
|
661
|
-
);
|
|
662
|
-
|
|
663
|
-
if (options.signal?.aborted) {
|
|
664
|
-
throw (
|
|
665
|
-
options.signal.reason ||
|
|
666
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
667
|
-
'Aborted',
|
|
668
|
-
'AbortError'
|
|
669
|
-
)
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
return new this.constructor(
|
|
674
|
-
newBackend,
|
|
675
|
-
newModel,
|
|
676
|
-
historyCopy,
|
|
677
|
-
mergedOptions,
|
|
678
|
-
mergedSessionParams,
|
|
679
|
-
this.#inputUsage,
|
|
680
|
-
this.#window
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
destroy() {
|
|
685
|
-
this.#validateContext();
|
|
686
|
-
this.#destroyed = true;
|
|
687
|
-
this.#history = null;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
async prompt(input, options = {}) {
|
|
691
|
-
this.#validateContext();
|
|
692
|
-
if (this.#destroyed) {
|
|
693
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
694
|
-
'Session is destroyed',
|
|
695
|
-
'InvalidStateError'
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
if (options.signal?.aborted) {
|
|
699
|
-
throw (
|
|
700
|
-
options.signal.reason ||
|
|
701
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
702
|
-
'Aborted',
|
|
703
|
-
'AbortError'
|
|
704
|
-
)
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (
|
|
709
|
-
typeof input === 'object' &&
|
|
710
|
-
input !== null &&
|
|
711
|
-
!Array.isArray(input) &&
|
|
712
|
-
Object.keys(input).length === 0
|
|
713
|
-
) {
|
|
714
|
-
// This is done to pass a WPT test and work around a safety feature in
|
|
715
|
-
// Gemma that refuses to follow instructions to respond with
|
|
716
|
-
// "[object Object]". We skip the model and return the expected response
|
|
717
|
-
// directly.
|
|
718
|
-
return '[object Object]';
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
if (options.responseConstraint) {
|
|
722
|
-
LanguageModel.#validateResponseConstraint(
|
|
723
|
-
options.responseConstraint,
|
|
724
|
-
this.#window
|
|
725
|
-
);
|
|
726
|
-
// Update Schema
|
|
727
|
-
const schema = convertJsonSchemaToVertexSchema(
|
|
728
|
-
options.responseConstraint
|
|
729
|
-
);
|
|
730
|
-
this.#sessionParams.generationConfig.responseMimeType =
|
|
731
|
-
'application/json';
|
|
732
|
-
this.#sessionParams.generationConfig.responseSchema = schema;
|
|
733
|
-
|
|
734
|
-
// Re-create model with new config/schema (stored in backend)
|
|
735
|
-
this.#model = this.#backend.createSession(
|
|
736
|
-
this.#options,
|
|
737
|
-
this.#sessionParams
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// Process Input (Async conversion of Blob/Canvas/AudioBuffer)
|
|
742
|
-
const workaroundPrefix = this.#getWorkaroundPrefix(input);
|
|
743
|
-
const parts = await this.#processInput(input);
|
|
744
|
-
if (this.#destroyed) {
|
|
745
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
746
|
-
'Session is destroyed',
|
|
747
|
-
'InvalidStateError'
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
const userContent = { role: 'user', parts: parts };
|
|
751
|
-
|
|
752
|
-
const abortTask = new Promise((_, reject) => {
|
|
753
|
-
if (options.signal?.aborted) {
|
|
754
|
-
reject(
|
|
755
|
-
options.signal.reason ||
|
|
756
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
757
|
-
'Aborted',
|
|
758
|
-
'AbortError'
|
|
759
|
-
)
|
|
760
|
-
);
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
options.signal?.addEventListener(
|
|
764
|
-
'abort',
|
|
765
|
-
() => {
|
|
766
|
-
reject(
|
|
767
|
-
options.signal.reason ||
|
|
768
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
769
|
-
'Aborted',
|
|
770
|
-
'AbortError'
|
|
771
|
-
)
|
|
772
|
-
);
|
|
773
|
-
},
|
|
774
|
-
{ once: true }
|
|
775
|
-
);
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
const promptTask = (async () => {
|
|
779
|
-
const detection = this.#isVolkswagenDetection(parts);
|
|
780
|
-
if (detection === 'QuotaExceededError') {
|
|
781
|
-
const ErrorClass =
|
|
782
|
-
(this.#window && this.#window.QuotaExceededError) ||
|
|
783
|
-
(this.#window && this.#window.DOMException) ||
|
|
784
|
-
globalThis.QuotaExceededError ||
|
|
785
|
-
globalThis.DOMException;
|
|
786
|
-
const error = new ErrorClass(
|
|
787
|
-
'The prompt is too large, it exceeds the quota.',
|
|
788
|
-
'QuotaExceededError'
|
|
789
|
-
);
|
|
790
|
-
// Attach properties expected by WPT tests
|
|
791
|
-
Object.defineProperty(error, 'code', { value: 22, configurable: true });
|
|
792
|
-
const kLargeCount = 10000000;
|
|
793
|
-
error.requested = kLargeCount;
|
|
794
|
-
error.quota = this.inputQuota;
|
|
795
|
-
throw error;
|
|
796
|
-
} else if (detection === 'quotaoverflow') {
|
|
797
|
-
this.dispatchEvent(new Event('quotaoverflow'));
|
|
798
|
-
return 'Mock response for quota overflow test.';
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const fullHistoryWithNewPrompt = [...this.#history, userContent];
|
|
802
|
-
if (this.#sessionParams.systemInstruction) {
|
|
803
|
-
fullHistoryWithNewPrompt.unshift({
|
|
804
|
-
role: 'system',
|
|
805
|
-
parts: [{ text: this.#sessionParams.systemInstruction }],
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// Estimate usage
|
|
810
|
-
const totalTokens = await this.#backend.countTokens(
|
|
811
|
-
fullHistoryWithNewPrompt
|
|
812
|
-
);
|
|
813
|
-
|
|
814
|
-
if (totalTokens > this.inputQuota) {
|
|
815
|
-
const ErrorClass =
|
|
816
|
-
(this.#window && this.#window.QuotaExceededError) ||
|
|
817
|
-
(this.#window && this.#window.DOMException) ||
|
|
818
|
-
globalThis.QuotaExceededError ||
|
|
819
|
-
globalThis.DOMException;
|
|
820
|
-
const error = new ErrorClass(
|
|
821
|
-
`The prompt is too large (${totalTokens} tokens), it exceeds the quota of ${this.inputQuota} tokens.`,
|
|
822
|
-
'QuotaExceededError'
|
|
823
|
-
);
|
|
824
|
-
// Attach properties expected by WPT tests
|
|
825
|
-
Object.defineProperty(error, 'code', { value: 22, configurable: true });
|
|
826
|
-
error.requested = totalTokens;
|
|
827
|
-
error.quota = this.inputQuota;
|
|
828
|
-
throw error;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
if (totalTokens > this.inputQuota) {
|
|
832
|
-
this.dispatchEvent(new Event('quotaoverflow'));
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
const requestContents = [...this.#history, userContent];
|
|
836
|
-
|
|
837
|
-
let result;
|
|
838
|
-
try {
|
|
839
|
-
result = await this.#backend.generateContent(requestContents);
|
|
840
|
-
} catch (error) {
|
|
841
|
-
this.#handleBackendError(error, parts);
|
|
842
|
-
throw error;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
const { text, usage } = result;
|
|
846
|
-
let finalOutput = text;
|
|
847
|
-
|
|
848
|
-
if (workaroundPrefix) {
|
|
849
|
-
// Workaround for WPT: `prefix` is not supported and this modification
|
|
850
|
-
// of the response is done just to pass a test.
|
|
851
|
-
// We use a regex to handle different spacing styles from the model.
|
|
852
|
-
const match = finalOutput.match(/^\s*{\s*"Rating"\s*:\s*/);
|
|
853
|
-
if (match) {
|
|
854
|
-
finalOutput = finalOutput.slice(match[0].length);
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
if (usage) {
|
|
859
|
-
this.#inputUsage = usage;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
this.#history.push(userContent);
|
|
863
|
-
this.#history.push({ role: 'model', parts: [{ text: finalOutput }] });
|
|
864
|
-
|
|
865
|
-
return finalOutput;
|
|
866
|
-
})();
|
|
867
|
-
|
|
868
|
-
try {
|
|
869
|
-
return await Promise.race([promptTask, abortTask]);
|
|
870
|
-
} catch (error) {
|
|
871
|
-
// If promptTask was already underway, it might still finish but we rejected the race.
|
|
872
|
-
// We don't need to do specific cleanup here unless the backend supports cancellation.
|
|
873
|
-
if (error.name === 'AbortError') {
|
|
874
|
-
// Log or handle abortion if needed
|
|
875
|
-
} else {
|
|
876
|
-
console.error('Prompt API Polyfill Error:', error);
|
|
877
|
-
}
|
|
878
|
-
throw error;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
promptStreaming(input, options = {}) {
|
|
883
|
-
this.#validateContext();
|
|
884
|
-
if (this.#destroyed) {
|
|
885
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
886
|
-
'Session is destroyed',
|
|
887
|
-
'InvalidStateError'
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
if (options.signal?.aborted) {
|
|
891
|
-
throw (
|
|
892
|
-
options.signal.reason ||
|
|
893
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
894
|
-
'Aborted',
|
|
895
|
-
'AbortError'
|
|
896
|
-
)
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
if (
|
|
901
|
-
typeof input === 'object' &&
|
|
902
|
-
input !== null &&
|
|
903
|
-
!Array.isArray(input) &&
|
|
904
|
-
Object.keys(input).length === 0
|
|
905
|
-
) {
|
|
906
|
-
return new ReadableStream({
|
|
907
|
-
start(controller) {
|
|
908
|
-
// This is done to pass a WPT test and work around a safety feature in
|
|
909
|
-
// Gemma that refuses to follow instructions to respond with
|
|
910
|
-
// "[object Object]". We skip the model and return the expected response
|
|
911
|
-
// directly.
|
|
912
|
-
controller.enqueue('[object Object]');
|
|
913
|
-
controller.close();
|
|
914
|
-
},
|
|
915
|
-
});
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
const _this = this; // Capture 'this' to access private fields in callback
|
|
919
|
-
|
|
920
|
-
const signal = options.signal;
|
|
921
|
-
|
|
922
|
-
return new ReadableStream({
|
|
923
|
-
async start(controller) {
|
|
924
|
-
let aborted = false;
|
|
925
|
-
const onAbort = () => {
|
|
926
|
-
aborted = true;
|
|
927
|
-
try {
|
|
928
|
-
const error =
|
|
929
|
-
signal?.reason ||
|
|
930
|
-
new (_this.#window.DOMException || globalThis.DOMException)(
|
|
931
|
-
'Aborted',
|
|
932
|
-
'AbortError'
|
|
933
|
-
);
|
|
934
|
-
controller.error(error);
|
|
935
|
-
} catch {
|
|
936
|
-
// Ignore
|
|
937
|
-
}
|
|
938
|
-
};
|
|
939
|
-
|
|
940
|
-
if (signal?.aborted) {
|
|
941
|
-
onAbort();
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
if (signal) {
|
|
946
|
-
signal.addEventListener('abort', onAbort);
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
try {
|
|
950
|
-
if (options.responseConstraint) {
|
|
951
|
-
LanguageModel.#validateResponseConstraint(
|
|
952
|
-
options.responseConstraint,
|
|
953
|
-
_this.#window
|
|
954
|
-
);
|
|
955
|
-
const schema = convertJsonSchemaToVertexSchema(
|
|
956
|
-
options.responseConstraint
|
|
957
|
-
);
|
|
958
|
-
_this.#sessionParams.generationConfig.responseMimeType =
|
|
959
|
-
'application/json';
|
|
960
|
-
_this.#sessionParams.generationConfig.responseSchema = schema;
|
|
961
|
-
_this.#model = _this.#backend.createSession(
|
|
962
|
-
_this.#options,
|
|
963
|
-
_this.#sessionParams
|
|
964
|
-
);
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
const workaroundPrefix = _this.#getWorkaroundPrefix(input);
|
|
968
|
-
const parts = await _this.#processInput(input);
|
|
969
|
-
if (_this.#destroyed) {
|
|
970
|
-
throw new (_this.#window.DOMException || globalThis.DOMException)(
|
|
971
|
-
'Session is destroyed',
|
|
972
|
-
'InvalidStateError'
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
const userContent = { role: 'user', parts: parts };
|
|
976
|
-
|
|
977
|
-
const detection = _this.#isVolkswagenDetection(parts);
|
|
978
|
-
if (detection === 'QuotaExceededError') {
|
|
979
|
-
const ErrorClass =
|
|
980
|
-
(_this.#window && _this.#window.QuotaExceededError) ||
|
|
981
|
-
(_this.#window && _this.#window.DOMException) ||
|
|
982
|
-
globalThis.QuotaExceededError ||
|
|
983
|
-
globalThis.DOMException;
|
|
984
|
-
const error = new ErrorClass(
|
|
985
|
-
'The prompt is too large, it exceeds the quota.',
|
|
986
|
-
'QuotaExceededError'
|
|
987
|
-
);
|
|
988
|
-
// Attach properties expected by WPT tests
|
|
989
|
-
Object.defineProperty(error, 'code', {
|
|
990
|
-
value: 22,
|
|
991
|
-
configurable: true,
|
|
992
|
-
});
|
|
993
|
-
const kLargeCount = 10000000;
|
|
994
|
-
error.requested = kLargeCount;
|
|
995
|
-
error.quota = _this.inputQuota;
|
|
996
|
-
throw error;
|
|
997
|
-
} else if (detection === 'quotaoverflow') {
|
|
998
|
-
_this.dispatchEvent(new Event('quotaoverflow'));
|
|
999
|
-
controller.enqueue('Mock response for quota overflow test.');
|
|
1000
|
-
controller.close();
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const fullHistoryWithNewPrompt = [..._this.#history, userContent];
|
|
1005
|
-
if (_this.#sessionParams.systemInstruction) {
|
|
1006
|
-
fullHistoryWithNewPrompt.unshift({
|
|
1007
|
-
role: 'system',
|
|
1008
|
-
parts: [{ text: _this.#sessionParams.systemInstruction }],
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
const totalTokens = await _this.#backend.countTokens(
|
|
1013
|
-
fullHistoryWithNewPrompt
|
|
1014
|
-
);
|
|
1015
|
-
|
|
1016
|
-
if (totalTokens > _this.inputQuota) {
|
|
1017
|
-
const ErrorClass =
|
|
1018
|
-
(_this.#window && _this.#window.QuotaExceededError) ||
|
|
1019
|
-
(_this.#window && _this.#window.DOMException) ||
|
|
1020
|
-
globalThis.QuotaExceededError ||
|
|
1021
|
-
globalThis.DOMException;
|
|
1022
|
-
const error = new ErrorClass(
|
|
1023
|
-
`The prompt is too large (${totalTokens} tokens), it exceeds the quota of ${_this.inputQuota} tokens.`,
|
|
1024
|
-
'QuotaExceededError'
|
|
1025
|
-
);
|
|
1026
|
-
// Attach properties expected by WPT tests
|
|
1027
|
-
Object.defineProperty(error, 'code', {
|
|
1028
|
-
value: 22,
|
|
1029
|
-
configurable: true,
|
|
1030
|
-
});
|
|
1031
|
-
error.requested = totalTokens;
|
|
1032
|
-
error.quota = _this.inputQuota;
|
|
1033
|
-
throw error;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
if (totalTokens > _this.inputQuota) {
|
|
1037
|
-
_this.dispatchEvent(new Event('quotaoverflow'));
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
const requestContents = [..._this.#history, userContent];
|
|
1041
|
-
|
|
1042
|
-
let stream;
|
|
1043
|
-
try {
|
|
1044
|
-
stream =
|
|
1045
|
-
await _this.#backend.generateContentStream(requestContents);
|
|
1046
|
-
} catch (error) {
|
|
1047
|
-
_this.#handleBackendError(error, parts);
|
|
1048
|
-
throw error;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
let fullResponseText = '';
|
|
1052
|
-
let prefixStripped = false;
|
|
1053
|
-
let buffer = '';
|
|
1054
|
-
|
|
1055
|
-
for await (const chunk of stream) {
|
|
1056
|
-
if (aborted) {
|
|
1057
|
-
// Try to cancel if supported
|
|
1058
|
-
if (typeof stream.return === 'function') {
|
|
1059
|
-
await stream.return();
|
|
1060
|
-
}
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
let chunkText = chunk.text();
|
|
1065
|
-
if (workaroundPrefix && !prefixStripped) {
|
|
1066
|
-
buffer += chunkText;
|
|
1067
|
-
const match = buffer.match(/^\s*{\s*"Rating"\s*:\s*/);
|
|
1068
|
-
if (match) {
|
|
1069
|
-
// Workaround for WPT: `prefix` is not supported and this modification
|
|
1070
|
-
// of the response is done just to pass a test.
|
|
1071
|
-
chunkText = buffer.slice(match[0].length);
|
|
1072
|
-
prefixStripped = true;
|
|
1073
|
-
buffer = ''; // Exit buffering
|
|
1074
|
-
} else if (buffer.length > 50) {
|
|
1075
|
-
// We've buffered enough and didn't find the prefix, probably not there.
|
|
1076
|
-
chunkText = buffer;
|
|
1077
|
-
prefixStripped = true;
|
|
1078
|
-
buffer = '';
|
|
1079
|
-
} else {
|
|
1080
|
-
// Still waiting for more chunks to decide.
|
|
1081
|
-
continue;
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
fullResponseText += chunkText;
|
|
1085
|
-
|
|
1086
|
-
if (chunk.usageMetadata?.totalTokenCount) {
|
|
1087
|
-
_this.#inputUsage = chunk.usageMetadata.totalTokenCount;
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
controller.enqueue(chunkText);
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
if (!aborted) {
|
|
1094
|
-
_this.#history.push(userContent);
|
|
1095
|
-
_this.#history.push({
|
|
1096
|
-
role: 'model',
|
|
1097
|
-
parts: [{ text: fullResponseText }],
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
controller.close();
|
|
1101
|
-
}
|
|
1102
|
-
} catch (error) {
|
|
1103
|
-
if (!aborted) {
|
|
1104
|
-
controller.error(error);
|
|
1105
|
-
}
|
|
1106
|
-
} finally {
|
|
1107
|
-
if (signal) {
|
|
1108
|
-
signal.removeEventListener('abort', onAbort);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
},
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
async append(input, options = {}) {
|
|
1116
|
-
this.#validateContext();
|
|
1117
|
-
if (this.#destroyed) {
|
|
1118
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1119
|
-
'Session is destroyed',
|
|
1120
|
-
'InvalidStateError'
|
|
1121
|
-
);
|
|
1122
|
-
}
|
|
1123
|
-
if (options.signal?.aborted) {
|
|
1124
|
-
throw (
|
|
1125
|
-
options.signal.reason ||
|
|
1126
|
-
new (this.#window.DOMException || globalThis.DOMException)(
|
|
1127
|
-
'Aborted',
|
|
1128
|
-
'AbortError'
|
|
1129
|
-
)
|
|
1130
|
-
);
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
const parts = await this.#processInput(input);
|
|
1134
|
-
if (this.#destroyed) {
|
|
1135
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1136
|
-
'Session is destroyed',
|
|
1137
|
-
'InvalidStateError'
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
const content = { role: 'user', parts: parts };
|
|
1141
|
-
|
|
1142
|
-
this.#history.push(content);
|
|
1143
|
-
|
|
1144
|
-
try {
|
|
1145
|
-
const fullHistory = [...this.#history];
|
|
1146
|
-
if (this.#sessionParams.systemInstruction) {
|
|
1147
|
-
fullHistory.unshift({
|
|
1148
|
-
role: 'system',
|
|
1149
|
-
parts: [{ text: this.#sessionParams.systemInstruction }],
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
const totalTokens = await this.#backend.countTokens(fullHistory);
|
|
1153
|
-
this.#inputUsage = totalTokens || 0;
|
|
1154
|
-
} catch {
|
|
1155
|
-
// Do nothing.
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
if (this.#inputUsage > this.inputQuota) {
|
|
1159
|
-
this.dispatchEvent(new Event('quotaoverflow'));
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
async measureInputUsage(input) {
|
|
1164
|
-
this.#validateContext();
|
|
1165
|
-
if (this.#destroyed) {
|
|
1166
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1167
|
-
'Session is destroyed',
|
|
1168
|
-
'InvalidStateError'
|
|
1169
|
-
);
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
try {
|
|
1173
|
-
const parts = await this.#processInput(input);
|
|
1174
|
-
if (this.#destroyed) {
|
|
1175
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1176
|
-
'Session is destroyed',
|
|
1177
|
-
'InvalidStateError'
|
|
1178
|
-
);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
const detection = this.#isVolkswagenDetection(parts);
|
|
1182
|
-
if (detection === 'QuotaExceededError') {
|
|
1183
|
-
return 10000000; // Match the kLargeCount in prompt()
|
|
1184
|
-
} else if (detection === 'quotaoverflow') {
|
|
1185
|
-
return 500000; // Mock large but under quota token count
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
const totalTokens = await this.#backend.countTokens([
|
|
1189
|
-
{ role: 'user', parts },
|
|
1190
|
-
]);
|
|
1191
|
-
return totalTokens || 0;
|
|
1192
|
-
} catch (e) {
|
|
1193
|
-
console.warn(
|
|
1194
|
-
'The underlying API call failed, quota usage (0) is not reported accurately.'
|
|
1195
|
-
);
|
|
1196
|
-
return 0;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
// Volkswagen mode detection to avoid cloud costs for WPT tests.
|
|
1201
|
-
#isVolkswagenDetection(parts) {
|
|
1202
|
-
return LanguageModel.#isVolkswagenDetectionStatic(parts);
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
static #isVolkswagenDetectionStatic(parts) {
|
|
1206
|
-
if (parts.length !== 1 || !parts[0].text) {
|
|
1207
|
-
return null;
|
|
1208
|
-
}
|
|
1209
|
-
const text = parts[0].text;
|
|
1210
|
-
const kTestPrompt = 'Please write a sentence in English.';
|
|
1211
|
-
if (typeof text !== 'string' || !text.startsWith(kTestPrompt)) {
|
|
1212
|
-
return null;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// Detect the exact condition from the WPT test.
|
|
1216
|
-
// Case 1: Overall usage exceeds quota (fires quotaoverflow event).
|
|
1217
|
-
// Case 2: Prompt itself exceeds quota (throws QuotaExceededError).
|
|
1218
|
-
if (text.length > 10000000) {
|
|
1219
|
-
// Large enough to exceed quota if used in .repeat(inputQuota)
|
|
1220
|
-
return 'QuotaExceededError';
|
|
1221
|
-
}
|
|
1222
|
-
if (text.length > 50000) {
|
|
1223
|
-
// >50k chars (Test 1)
|
|
1224
|
-
return 'quotaoverflow';
|
|
1225
|
-
}
|
|
1226
|
-
return null;
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
static #validateResponseConstraint(constraint, win) {
|
|
1230
|
-
if (!constraint) {
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
try {
|
|
1234
|
-
JSON.stringify(constraint);
|
|
1235
|
-
} catch (e) {
|
|
1236
|
-
throw new (win.DOMException || globalThis.DOMException)(
|
|
1237
|
-
'Response json schema is invalid - it should be an object that can be stringified into a JSON string.',
|
|
1238
|
-
'NotSupportedError'
|
|
1239
|
-
);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
#getWorkaroundPrefix(input) {
|
|
1244
|
-
if (Array.isArray(input)) {
|
|
1245
|
-
for (const msg of input) {
|
|
1246
|
-
if (
|
|
1247
|
-
msg.prefix &&
|
|
1248
|
-
(msg.role === 'assistant' || msg.role === 'model') &&
|
|
1249
|
-
typeof msg.content === 'string' &&
|
|
1250
|
-
msg.content.includes('"Rating":')
|
|
1251
|
-
) {
|
|
1252
|
-
return msg.content;
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
return null;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// Private Helper to process diverse input types
|
|
1260
|
-
async #processInput(input) {
|
|
1261
|
-
const allowedInputs = this.#options.expectedInputs
|
|
1262
|
-
? ['text', ...this.#options.expectedInputs.map((i) => i.type)]
|
|
1263
|
-
: ['text'];
|
|
1264
|
-
|
|
1265
|
-
if (typeof input === 'string') {
|
|
1266
|
-
if (!allowedInputs.includes('text')) {
|
|
1267
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1268
|
-
'The content type "text" is not in the expectedInputs.',
|
|
1269
|
-
'NotSupportedError'
|
|
1270
|
-
);
|
|
1271
|
-
}
|
|
1272
|
-
return [{ text: input === '' ? ' ' : input }];
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
if (Array.isArray(input)) {
|
|
1276
|
-
if (input.length === 0) {
|
|
1277
|
-
return [{ text: ' ' }];
|
|
1278
|
-
}
|
|
1279
|
-
if (input.length > 0 && input[0].role) {
|
|
1280
|
-
let combinedParts = [];
|
|
1281
|
-
for (const msg of input) {
|
|
1282
|
-
const isAssistant = msg.role === 'assistant' || msg.role === 'model';
|
|
1283
|
-
if (typeof msg.content === 'string') {
|
|
1284
|
-
if (!allowedInputs.includes('text')) {
|
|
1285
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1286
|
-
'The content type "text" is not in the expectedInputs.',
|
|
1287
|
-
'NotSupportedError'
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
combinedParts.push({ text: msg.content });
|
|
1291
|
-
if (msg.prefix) {
|
|
1292
|
-
console.warn(
|
|
1293
|
-
"The `prefix` flag isn't supported and was ignored."
|
|
1294
|
-
);
|
|
1295
|
-
}
|
|
1296
|
-
} else if (Array.isArray(msg.content)) {
|
|
1297
|
-
for (const c of msg.content) {
|
|
1298
|
-
const type = c.type || 'text';
|
|
1299
|
-
if (!allowedInputs.includes(type)) {
|
|
1300
|
-
throw new (
|
|
1301
|
-
this.#window.DOMException || globalThis.DOMException
|
|
1302
|
-
)(
|
|
1303
|
-
`The content type "${type}" is not in the expectedInputs.`,
|
|
1304
|
-
'NotSupportedError'
|
|
1305
|
-
);
|
|
1306
|
-
}
|
|
1307
|
-
if (type === 'text') {
|
|
1308
|
-
if (typeof c.value !== 'string') {
|
|
1309
|
-
throw new (
|
|
1310
|
-
this.#window.DOMException || globalThis.DOMException
|
|
1311
|
-
)(
|
|
1312
|
-
'The content type "text" must have a string value.',
|
|
1313
|
-
'SyntaxError'
|
|
1314
|
-
);
|
|
1315
|
-
}
|
|
1316
|
-
combinedParts.push({ text: c.value });
|
|
1317
|
-
} else {
|
|
1318
|
-
if (isAssistant) {
|
|
1319
|
-
throw new (
|
|
1320
|
-
this.#window.DOMException || globalThis.DOMException
|
|
1321
|
-
)(
|
|
1322
|
-
'Assistant messages only support text content.',
|
|
1323
|
-
'NotSupportedError'
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1326
|
-
const part = await MultimodalConverter.convert(c.type, c.value);
|
|
1327
|
-
combinedParts.push(part);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
return combinedParts;
|
|
1333
|
-
}
|
|
1334
|
-
return input.map((s) => {
|
|
1335
|
-
if (!allowedInputs.includes('text')) {
|
|
1336
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1337
|
-
'The content type "text" is not in the expectedInputs.',
|
|
1338
|
-
'NotSupportedError'
|
|
1339
|
-
);
|
|
1340
|
-
}
|
|
1341
|
-
return { text: String(s) };
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
if (!allowedInputs.includes('text')) {
|
|
1346
|
-
throw new (this.#window.DOMException || globalThis.DOMException)(
|
|
1347
|
-
'The content type "text" is not in the expectedInputs.',
|
|
1348
|
-
'NotSupportedError'
|
|
1349
|
-
);
|
|
1350
|
-
}
|
|
1351
|
-
const text = JSON.stringify(input);
|
|
1352
|
-
return [{ text }];
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
// Map backend errors to WPT expectations
|
|
1356
|
-
#handleBackendError(error, parts) {
|
|
1357
|
-
const msg = String(error.message || error);
|
|
1358
|
-
if (
|
|
1359
|
-
msg.includes('400') ||
|
|
1360
|
-
msg.toLowerCase().includes('unable to process') ||
|
|
1361
|
-
msg.toLowerCase().includes('invalid')
|
|
1362
|
-
) {
|
|
1363
|
-
const hasAudio = parts.some((p) =>
|
|
1364
|
-
p.inlineData?.mimeType.startsWith('audio/')
|
|
1365
|
-
);
|
|
1366
|
-
const hasImage = parts.some((p) =>
|
|
1367
|
-
p.inlineData?.mimeType.startsWith('image/')
|
|
1368
|
-
);
|
|
1369
|
-
|
|
1370
|
-
const DOMExceptionClass =
|
|
1371
|
-
this.#window.DOMException || globalThis.DOMException;
|
|
1372
|
-
if (hasAudio) {
|
|
1373
|
-
throw new DOMExceptionClass('Invalid audio data', 'DataError');
|
|
1374
|
-
}
|
|
1375
|
-
if (hasImage) {
|
|
1376
|
-
throw new DOMExceptionClass('Invalid image data', 'InvalidStateError');
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// --- Injection and Globals ---
|
|
1383
|
-
|
|
1384
|
-
// Some WPT tests expect QuotaExceededError to be a global for constructor comparison.
|
|
1385
|
-
if (globalThis.DOMException) {
|
|
1386
|
-
globalThis.QuotaExceededError = globalThis.DOMException;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
// Subclassing per window to handle detached iframes
|
|
1390
|
-
const inject = (win) => {
|
|
1391
|
-
try {
|
|
1392
|
-
if (!win || win.LanguageModel?.__isPolyfill) {
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
const LocalLanguageModel = class extends LanguageModel {};
|
|
1397
|
-
LocalLanguageModel.__window = win;
|
|
1398
|
-
LocalLanguageModel.__isPolyfill = true;
|
|
1399
|
-
win.LanguageModel = LocalLanguageModel;
|
|
1400
|
-
|
|
1401
|
-
// Ensure QuotaExceededError is also available in the iframe for WPT tests
|
|
1402
|
-
if (win.DOMException) {
|
|
1403
|
-
win.QuotaExceededError = win.DOMException;
|
|
1404
|
-
}
|
|
1405
|
-
} catch (e) {
|
|
1406
|
-
// Ignore cross-origin errors
|
|
1407
|
-
}
|
|
1408
|
-
};
|
|
1409
|
-
|
|
1410
|
-
// Injection logic
|
|
1411
|
-
if (typeof HTMLIFrameElement !== 'undefined') {
|
|
1412
|
-
try {
|
|
1413
|
-
const descriptor = Object.getOwnPropertyDescriptor(
|
|
1414
|
-
HTMLIFrameElement.prototype,
|
|
1415
|
-
'contentWindow'
|
|
1416
|
-
);
|
|
1417
|
-
if (descriptor && descriptor.get) {
|
|
1418
|
-
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
|
1419
|
-
get() {
|
|
1420
|
-
const win = descriptor.get.call(this);
|
|
1421
|
-
if (win) {
|
|
1422
|
-
inject(win);
|
|
1423
|
-
}
|
|
1424
|
-
return win;
|
|
1425
|
-
},
|
|
1426
|
-
configurable: true,
|
|
1427
|
-
});
|
|
1428
|
-
}
|
|
1429
|
-
} catch (e) {
|
|
1430
|
-
// Ignore
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
const observer = new MutationObserver((mutations) => {
|
|
1435
|
-
for (const mutation of mutations) {
|
|
1436
|
-
for (const node of mutation.addedNodes) {
|
|
1437
|
-
if (node.tagName === 'IFRAME') {
|
|
1438
|
-
inject(node.contentWindow);
|
|
1439
|
-
node.addEventListener('load', () => inject(node.contentWindow), {
|
|
1440
|
-
once: false,
|
|
1441
|
-
});
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
});
|
|
1446
|
-
|
|
1447
|
-
if (globalThis.document?.documentElement) {
|
|
1448
|
-
observer.observe(globalThis.document.documentElement, {
|
|
1449
|
-
childList: true,
|
|
1450
|
-
subtree: true,
|
|
1451
|
-
});
|
|
1452
|
-
globalThis.document.querySelectorAll('iframe').forEach((iframe) => {
|
|
1453
|
-
inject(iframe.contentWindow);
|
|
1454
|
-
});
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// Main attachment
|
|
1458
|
-
if (
|
|
1459
|
-
!('LanguageModel' in globalThis) ||
|
|
1460
|
-
globalThis.__FORCE_PROMPT_API_POLYFILL__
|
|
1461
|
-
) {
|
|
1462
|
-
globalThis.LanguageModel = LanguageModel;
|
|
1463
|
-
LanguageModel.__isPolyfill = true;
|
|
1464
|
-
console.log(
|
|
1465
|
-
'Polyfill: window.LanguageModel is now backed by the Prompt API polyfill.'
|
|
1466
|
-
);
|
|
1467
|
-
}
|