prompt-api-polyfill 0.2.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.
- package/backends/base.js +59 -0
- package/backends/defaults.js +9 -0
- package/backends/firebase.js +45 -0
- package/backends/gemini.js +48 -0
- package/backends/openai.js +340 -0
- package/backends/transformers.js +106 -0
- package/package.json +3 -2
- package/prompt-api-polyfill.js +4 -0
package/backends/base.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract class representing a backend for the LanguageModel polyfill.
|
|
3
|
+
*/
|
|
4
|
+
export default class PolyfillBackend {
|
|
5
|
+
#model;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} modelName - The name of the model.
|
|
9
|
+
*/
|
|
10
|
+
constructor(modelName) {
|
|
11
|
+
this.modelName = modelName;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Checks if the backend is available given the options.
|
|
16
|
+
* @param {Object} options - LanguageModel options.
|
|
17
|
+
* @returns {string} 'available', 'unavailable', 'downloadable', or 'downloading'.
|
|
18
|
+
*/
|
|
19
|
+
static availability(options) {
|
|
20
|
+
return 'available';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a model session and stores it.
|
|
25
|
+
* @param {Object} options - LanguageModel options.
|
|
26
|
+
* @param {Object} inCloudParams - Parameters for the cloud model.
|
|
27
|
+
* @returns {any} The created session object.
|
|
28
|
+
*/
|
|
29
|
+
createSession(options, inCloudParams) {
|
|
30
|
+
throw new Error('Not implemented');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generates content (non-streaming).
|
|
35
|
+
* @param {Array} content - The history + new message content.
|
|
36
|
+
* @returns {Promise<{text: string, usage: number}>}
|
|
37
|
+
*/
|
|
38
|
+
async generateContent(content) {
|
|
39
|
+
throw new Error('Not implemented');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generates content stream.
|
|
44
|
+
* @param {Array} content - The history + new content.
|
|
45
|
+
* @returns {Promise<AsyncIterable>} Stream of chunks.
|
|
46
|
+
*/
|
|
47
|
+
async generateContentStream(content) {
|
|
48
|
+
throw new Error('Not implemented');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Counts tokens.
|
|
53
|
+
* @param {Array} content - The content to count.
|
|
54
|
+
* @returns {Promise<number>} Total tokens.
|
|
55
|
+
*/
|
|
56
|
+
async countTokens(content) {
|
|
57
|
+
throw new Error('Not implemented');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { initializeApp } from 'https://esm.run/firebase/app';
|
|
2
|
+
import {
|
|
3
|
+
getAI,
|
|
4
|
+
getGenerativeModel,
|
|
5
|
+
GoogleAIBackend,
|
|
6
|
+
InferenceMode,
|
|
7
|
+
} from 'https://esm.run/firebase/ai';
|
|
8
|
+
import PolyfillBackend from './base.js';
|
|
9
|
+
import { DEFAULT_MODELS } from './defaults.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Firebase AI Logic Backend
|
|
13
|
+
*/
|
|
14
|
+
export default class FirebaseBackend extends PolyfillBackend {
|
|
15
|
+
#model;
|
|
16
|
+
|
|
17
|
+
constructor(config) {
|
|
18
|
+
super(config.modelName || DEFAULT_MODELS.firebase);
|
|
19
|
+
this.ai = getAI(initializeApp(config), { backend: new GoogleAIBackend() });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
createSession(_options, inCloudParams) {
|
|
23
|
+
this.#model = getGenerativeModel(this.ai, {
|
|
24
|
+
mode: InferenceMode.ONLY_IN_CLOUD,
|
|
25
|
+
inCloudParams,
|
|
26
|
+
});
|
|
27
|
+
return this.#model;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async generateContent(contents) {
|
|
31
|
+
const result = await this.#model.generateContent({ contents });
|
|
32
|
+
const usage = result.response.usageMetadata?.promptTokenCount || 0;
|
|
33
|
+
return { text: result.response.text(), usage };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async generateContentStream(contents) {
|
|
37
|
+
const result = await this.#model.generateContentStream({ contents });
|
|
38
|
+
return result.stream;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async countTokens(contents) {
|
|
42
|
+
const { totalTokens } = await this.#model.countTokens({ contents });
|
|
43
|
+
return totalTokens;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from 'https://esm.run/@google/generative-ai';
|
|
2
|
+
import PolyfillBackend from './base.js';
|
|
3
|
+
import { DEFAULT_MODELS } from './defaults.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Google Gemini API Backend
|
|
7
|
+
*/
|
|
8
|
+
export default class GeminiBackend extends PolyfillBackend {
|
|
9
|
+
#model;
|
|
10
|
+
|
|
11
|
+
constructor(config) {
|
|
12
|
+
super(config.modelName || DEFAULT_MODELS.gemini);
|
|
13
|
+
this.genAI = new GoogleGenerativeAI(config.apiKey);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
createSession(options, inCloudParams) {
|
|
17
|
+
const modelParams = {
|
|
18
|
+
model: options.modelName || this.modelName,
|
|
19
|
+
generationConfig: inCloudParams.generationConfig,
|
|
20
|
+
systemInstruction: inCloudParams.systemInstruction,
|
|
21
|
+
};
|
|
22
|
+
// Clean undefined systemInstruction
|
|
23
|
+
if (!modelParams.systemInstruction) {
|
|
24
|
+
delete modelParams.systemInstruction;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.#model = this.genAI.getGenerativeModel(modelParams);
|
|
28
|
+
return this.#model;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async generateContent(contents) {
|
|
32
|
+
// Gemini SDK expects { role, parts: [...] } which matches our internal structure
|
|
33
|
+
const result = await this.#model.generateContent({ contents });
|
|
34
|
+
const response = await result.response;
|
|
35
|
+
const usage = response.usageMetadata?.promptTokenCount || 0;
|
|
36
|
+
return { text: response.text(), usage };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async generateContentStream(contents) {
|
|
40
|
+
const result = await this.#model.generateContentStream({ contents });
|
|
41
|
+
return result.stream;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async countTokens(contents) {
|
|
45
|
+
const { totalTokens } = await this.#model.countTokens({ contents });
|
|
46
|
+
return totalTokens;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import OpenAI from 'https://esm.run/openai';
|
|
2
|
+
import PolyfillBackend from './base.js';
|
|
3
|
+
import { DEFAULT_MODELS } from './defaults.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OpenAI API Backend
|
|
7
|
+
*/
|
|
8
|
+
export default class OpenAIBackend extends PolyfillBackend {
|
|
9
|
+
#model;
|
|
10
|
+
|
|
11
|
+
constructor(config) {
|
|
12
|
+
super(config.modelName || DEFAULT_MODELS.openai);
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.openai = new OpenAI({
|
|
15
|
+
apiKey: config.apiKey,
|
|
16
|
+
dangerouslyAllowBrowser: true, // Required for client-side usage
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static availability(options = {}) {
|
|
21
|
+
if (options.expectedInputs) {
|
|
22
|
+
const hasAudio = options.expectedInputs.some(
|
|
23
|
+
(input) => input.type === 'audio'
|
|
24
|
+
);
|
|
25
|
+
const hasImage = options.expectedInputs.some(
|
|
26
|
+
(input) => input.type === 'image'
|
|
27
|
+
);
|
|
28
|
+
if (hasAudio && hasImage) {
|
|
29
|
+
return 'unavailable';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return 'available';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
createSession(options, inCloudParams) {
|
|
36
|
+
// OpenAI doesn't have a "session" object like Gemini, so we return a context object
|
|
37
|
+
// tailored for our generate methods.
|
|
38
|
+
this.#model = {
|
|
39
|
+
model: options.modelName || this.modelName,
|
|
40
|
+
temperature: inCloudParams.generationConfig?.temperature,
|
|
41
|
+
top_p: 1.0, // Default to 1.0 as topK is not directly supported the same way
|
|
42
|
+
systemInstruction: inCloudParams.systemInstruction,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const config = inCloudParams.generationConfig || {};
|
|
46
|
+
if (config.responseSchema) {
|
|
47
|
+
const { schema, wrapped } = this.#fixSchemaForOpenAI(
|
|
48
|
+
config.responseSchema
|
|
49
|
+
);
|
|
50
|
+
this.#model.response_format = {
|
|
51
|
+
type: 'json_schema',
|
|
52
|
+
json_schema: {
|
|
53
|
+
name: 'response',
|
|
54
|
+
strict: true,
|
|
55
|
+
schema: schema,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
this.#model.response_wrapped = wrapped;
|
|
59
|
+
} else if (config.responseMimeType === 'application/json') {
|
|
60
|
+
this.#model.response_format = { type: 'json_object' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return this.#model;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* OpenAI Structured Outputs require:
|
|
68
|
+
* 1. All fields in objects to be marked as 'required'.
|
|
69
|
+
* 2. Objects to have 'additionalProperties: false'.
|
|
70
|
+
* 3. The root must be an 'object'.
|
|
71
|
+
*/
|
|
72
|
+
#fixSchemaForOpenAI(schema) {
|
|
73
|
+
if (typeof schema !== 'object' || schema === null) {
|
|
74
|
+
return { schema, wrapped: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const processNode = (node) => {
|
|
78
|
+
if (node.type === 'object') {
|
|
79
|
+
if (node.properties) {
|
|
80
|
+
node.additionalProperties = false;
|
|
81
|
+
node.required = Object.keys(node.properties);
|
|
82
|
+
for (const key in node.properties) {
|
|
83
|
+
processNode(node.properties[key]);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
node.additionalProperties = false;
|
|
87
|
+
node.required = [];
|
|
88
|
+
}
|
|
89
|
+
} else if (node.type === 'array' && node.items) {
|
|
90
|
+
processNode(node.items);
|
|
91
|
+
}
|
|
92
|
+
return node;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Deep clone to avoid side effects
|
|
96
|
+
const cloned = JSON.parse(JSON.stringify(schema));
|
|
97
|
+
|
|
98
|
+
if (cloned.type !== 'object') {
|
|
99
|
+
// Wrap in object as OpenAI requires object root
|
|
100
|
+
return {
|
|
101
|
+
wrapped: true,
|
|
102
|
+
schema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: { value: cloned },
|
|
105
|
+
required: ['value'],
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
wrapped: false,
|
|
113
|
+
schema: processNode(cloned),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#validateContent(messages) {
|
|
118
|
+
let hasImage = false;
|
|
119
|
+
let hasAudio = false;
|
|
120
|
+
|
|
121
|
+
for (const msg of messages) {
|
|
122
|
+
if (Array.isArray(msg.content)) {
|
|
123
|
+
for (const part of msg.content) {
|
|
124
|
+
if (part.type === 'image_url') {
|
|
125
|
+
hasImage = true;
|
|
126
|
+
}
|
|
127
|
+
if (part.type === 'input_audio') {
|
|
128
|
+
hasAudio = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (hasImage && hasAudio) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'OpenAI backend does not support mixing images and audio in the same session. Please start a new session.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { hasImage, hasAudio };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#routeModel(hasAudio) {
|
|
144
|
+
// If the user explicitly provided a model in the session options, respect it.
|
|
145
|
+
// Otherwise, pick based on content.
|
|
146
|
+
if (this.#model.model !== this.modelName) {
|
|
147
|
+
return this.#model.model;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return hasAudio ? `${this.modelName}-audio-preview` : this.modelName;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async generateContent(contents) {
|
|
154
|
+
const { messages } = this.#convertContentsToInput(
|
|
155
|
+
contents,
|
|
156
|
+
this.#model.systemInstruction
|
|
157
|
+
);
|
|
158
|
+
const { hasAudio } = this.#validateContent(messages);
|
|
159
|
+
const model = this.#routeModel(hasAudio);
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
model === `${this.modelName}-audio-preview` &&
|
|
163
|
+
this.#model.response_format
|
|
164
|
+
) {
|
|
165
|
+
throw new DOMException(
|
|
166
|
+
`OpenAI audio model ('${model}') does not support structured outputs (responseConstraint).`,
|
|
167
|
+
'NotSupportedError'
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const options = {
|
|
172
|
+
model: model,
|
|
173
|
+
messages: messages,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (this.#model.temperature > 0) {
|
|
177
|
+
options.temperature = this.#model.temperature;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (this.#model.response_format) {
|
|
181
|
+
options.response_format = this.#model.response_format;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const response = await this.openai.chat.completions.create(options);
|
|
186
|
+
|
|
187
|
+
const choice = response.choices[0];
|
|
188
|
+
let text = choice.message.content;
|
|
189
|
+
|
|
190
|
+
if (this.#model.response_wrapped && text) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(text);
|
|
193
|
+
if (parsed && typeof parsed === 'object' && 'value' in parsed) {
|
|
194
|
+
text = JSON.stringify(parsed.value);
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// Ignore parsing error, return raw text
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const usage = response.usage?.prompt_tokens || 0;
|
|
202
|
+
|
|
203
|
+
return { text, usage };
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error('OpenAI Generate Content Error:', error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async generateContentStream(contents) {
|
|
211
|
+
const { messages } = this.#convertContentsToInput(
|
|
212
|
+
contents,
|
|
213
|
+
this.#model.systemInstruction
|
|
214
|
+
);
|
|
215
|
+
const { hasAudio } = this.#validateContent(messages);
|
|
216
|
+
const model = this.#routeModel(hasAudio);
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
model === `${this.modelName}-audio-preview` &&
|
|
220
|
+
this.#model.response_format
|
|
221
|
+
) {
|
|
222
|
+
throw new DOMException(
|
|
223
|
+
`OpenAI audio model ('${model}') does not support structured outputs (responseConstraint).`,
|
|
224
|
+
'NotSupportedError'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const options = {
|
|
229
|
+
model: model,
|
|
230
|
+
messages: messages,
|
|
231
|
+
stream: true,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (this.#model.temperature > 0) {
|
|
235
|
+
options.temperature = this.#model.temperature;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (this.#model.response_format) {
|
|
239
|
+
options.response_format = this.#model.response_format;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const stream = await this.openai.chat.completions.create(options);
|
|
244
|
+
|
|
245
|
+
// Convert OpenAI stream to an AsyncIterable that yields chunks
|
|
246
|
+
return (async function* () {
|
|
247
|
+
let firstChunk = true;
|
|
248
|
+
for await (const chunk of stream) {
|
|
249
|
+
let text = chunk.choices[0]?.delta?.content;
|
|
250
|
+
if (text) {
|
|
251
|
+
// Note: Unwrapping a wrapped object in a stream is complex.
|
|
252
|
+
// For now, streaming wrapped results will yield the full JSON including the wrapper.
|
|
253
|
+
yield {
|
|
254
|
+
text: () => text,
|
|
255
|
+
usageMetadata: { totalTokenCount: 0 },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
})();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error('OpenAI Generate Content Stream Error:', error);
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async countTokens(contents) {
|
|
267
|
+
// OpenAI does not provide a public API endpoint for counting tokens before generation.
|
|
268
|
+
// Implementing countTokens strictly requires a tokenizer like `tiktoken`.
|
|
269
|
+
// For this initial implementation, we use a character-based approximation (e.g., text.length / 4)
|
|
270
|
+
// to avoid adding heavy WASM dependencies (`tiktoken`) to the polyfill.
|
|
271
|
+
let totalText = '';
|
|
272
|
+
if (this.#model && this.#model.systemInstruction) {
|
|
273
|
+
totalText += this.#model.systemInstruction;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (Array.isArray(contents)) {
|
|
277
|
+
for (const content of contents) {
|
|
278
|
+
if (!content.parts) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
for (const part of content.parts) {
|
|
282
|
+
if (part.text) {
|
|
283
|
+
totalText += part.text;
|
|
284
|
+
} else if (part.inlineData) {
|
|
285
|
+
// Approximate image token cost (e.g., ~1000 chars worth)
|
|
286
|
+
totalText += ' '.repeat(1000);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Math.ceil(totalText.length / 4);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#convertContentsToInput(contents, systemInstruction) {
|
|
296
|
+
const messages = [];
|
|
297
|
+
|
|
298
|
+
// System instructions
|
|
299
|
+
if (systemInstruction) {
|
|
300
|
+
messages.push({
|
|
301
|
+
role: 'system',
|
|
302
|
+
content: systemInstruction,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const content of contents) {
|
|
307
|
+
const role = content.role === 'model' ? 'assistant' : 'user';
|
|
308
|
+
const contentParts = [];
|
|
309
|
+
|
|
310
|
+
for (const part of content.parts) {
|
|
311
|
+
if (part.text) {
|
|
312
|
+
contentParts.push({ type: 'text', text: part.text });
|
|
313
|
+
} else if (part.inlineData) {
|
|
314
|
+
const { data, mimeType } = part.inlineData;
|
|
315
|
+
if (mimeType.startsWith('image/')) {
|
|
316
|
+
contentParts.push({
|
|
317
|
+
type: 'image_url',
|
|
318
|
+
image_url: { url: `data:${mimeType};base64,${data}` },
|
|
319
|
+
});
|
|
320
|
+
} else if (mimeType.startsWith('audio/')) {
|
|
321
|
+
contentParts.push({
|
|
322
|
+
type: 'input_audio',
|
|
323
|
+
input_audio: {
|
|
324
|
+
data: data,
|
|
325
|
+
format: mimeType.split('/')[1] === 'mpeg' ? 'mp3' : 'wav',
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Simplification: if only one text part, just send string content for better compatibility
|
|
333
|
+
// but multimodal models usually prefer the array format.
|
|
334
|
+
// We'll keep the array format for consistency with multimodal inputs.
|
|
335
|
+
messages.push({ role, content: contentParts });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { messages };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { pipeline, TextStreamer } from 'https://esm.run/@huggingface/transformers';
|
|
2
|
+
import PolyfillBackend from './base.js';
|
|
3
|
+
import { DEFAULT_MODELS } from './defaults.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transformers.js (ONNX Runtime) Backend
|
|
7
|
+
*/
|
|
8
|
+
export default class TransformersBackend extends PolyfillBackend {
|
|
9
|
+
#generator;
|
|
10
|
+
#tokenizer;
|
|
11
|
+
|
|
12
|
+
constructor(config) {
|
|
13
|
+
super(config.modelName || DEFAULT_MODELS.transformers);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async #ensureGenerator() {
|
|
17
|
+
if (!this.#generator) {
|
|
18
|
+
console.log(`[Transformers.js] Loading model: ${this.modelName}`);
|
|
19
|
+
this.#generator = await pipeline('text-generation', this.modelName, {
|
|
20
|
+
device: 'webgpu',
|
|
21
|
+
});
|
|
22
|
+
this.#tokenizer = this.#generator.tokenizer;
|
|
23
|
+
}
|
|
24
|
+
return this.#generator;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async createSession(options, inCloudParams) {
|
|
28
|
+
// Initializing the generator can be slow, so we do it lazily or here.
|
|
29
|
+
// For now, let's trigger the loading.
|
|
30
|
+
await this.#ensureGenerator();
|
|
31
|
+
|
|
32
|
+
// We don't really have "sessions" in the same way Gemini does,
|
|
33
|
+
// but we can store the generation config.
|
|
34
|
+
this.generationConfig = {
|
|
35
|
+
max_new_tokens: 512, // Default limit
|
|
36
|
+
temperature: inCloudParams.generationConfig?.temperature || 1.0,
|
|
37
|
+
top_p: 1.0,
|
|
38
|
+
do_sample: inCloudParams.generationConfig?.temperature > 0,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return this.#generator;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async generateContent(contents) {
|
|
45
|
+
const generator = await this.#ensureGenerator();
|
|
46
|
+
const prompt = this.#convertContentsToPrompt(contents);
|
|
47
|
+
|
|
48
|
+
const output = await generator(prompt, this.generationConfig);
|
|
49
|
+
const text = output[0].generated_text.slice(prompt.length);
|
|
50
|
+
|
|
51
|
+
// Approximate usage
|
|
52
|
+
const usage = await this.countTokens(contents);
|
|
53
|
+
|
|
54
|
+
return { text, usage };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async generateContentStream(contents) {
|
|
58
|
+
const generator = await this.#ensureGenerator();
|
|
59
|
+
const prompt = this.#convertContentsToPrompt(contents);
|
|
60
|
+
|
|
61
|
+
const streamer = new TextStreamer(this.#tokenizer, {
|
|
62
|
+
skip_prompt: true,
|
|
63
|
+
skip_special_tokens: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Run generation in the background (don't await)
|
|
67
|
+
generator(prompt, {
|
|
68
|
+
...this.generationConfig,
|
|
69
|
+
streamer,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// streamer is an AsyncIterable in Transformers.js v3
|
|
73
|
+
return (async function* () {
|
|
74
|
+
for await (const newText of streamer) {
|
|
75
|
+
yield {
|
|
76
|
+
text: () => newText,
|
|
77
|
+
usageMetadata: { totalTokenCount: 0 },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async countTokens(contents) {
|
|
84
|
+
await this.#ensureGenerator();
|
|
85
|
+
const text = this.#convertContentsToPrompt(contents);
|
|
86
|
+
const { input_ids } = await this.#tokenizer(text);
|
|
87
|
+
return input_ids.size;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#convertContentsToPrompt(contents) {
|
|
91
|
+
// Simple ChatML-like format for Qwen/Llama
|
|
92
|
+
let prompt = '';
|
|
93
|
+
for (const content of contents) {
|
|
94
|
+
const role = content.role === 'model' ? 'assistant' : 'user';
|
|
95
|
+
prompt += `<|im_start|>${role}\n`;
|
|
96
|
+
for (const part of content.parts) {
|
|
97
|
+
if (part.text) {
|
|
98
|
+
prompt += part.text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
prompt += '<|im_end|>\n';
|
|
102
|
+
}
|
|
103
|
+
prompt += '<|im_start|>assistant\n';
|
|
104
|
+
return prompt;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prompt-api-polyfill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Polyfill for the Prompt API (`LanguageModel`) backed by Firebase AI Logic, Gemini API, or OpenAI API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./prompt-api-polyfill.js",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"json-schema-converter.js",
|
|
15
15
|
"multimodal-converter.js",
|
|
16
16
|
"prompt-api-polyfill.js",
|
|
17
|
-
"dot_env.json"
|
|
17
|
+
"dot_env.json",
|
|
18
|
+
"backends/"
|
|
18
19
|
],
|
|
19
20
|
"sideEffects": true,
|
|
20
21
|
"keywords": [
|
package/prompt-api-polyfill.js
CHANGED
|
@@ -120,6 +120,10 @@ export class LanguageModel extends EventTarget {
|
|
|
120
120
|
config: 'OPENAI_CONFIG',
|
|
121
121
|
path: './backends/openai.js',
|
|
122
122
|
},
|
|
123
|
+
{
|
|
124
|
+
config: 'TRANSFORMERS_CONFIG',
|
|
125
|
+
path: './backends/transformers.js',
|
|
126
|
+
},
|
|
123
127
|
];
|
|
124
128
|
|
|
125
129
|
static #getBackendInfo() {
|