@johnhenry/andbox 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/README.md +148 -0
- package/package.json +9 -3
- package/src/bridge-client.mjs +476 -0
- package/src/bridge-host.mjs +870 -0
- package/src/bridges/chrome-ai.d.ts +53 -0
- package/src/bridges/chrome-ai.mjs +424 -0
- package/src/iframe-host.mjs +5 -4
- package/src/index.d.ts +151 -1
- package/src/index.mjs +1 -0
- package/src/sandbox.mjs +68 -3
- package/src/worker-source.mjs +27 -6
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@johnhenry/andbox/bridges/chrome-ai`: Chrome's built-in AI APIs, bridged
|
|
3
|
+
* into a sandbox (andbox#46). See the README section "Chrome AI bridge".
|
|
4
|
+
*/
|
|
5
|
+
import type { BridgeDefinition, BridgeLimits, BridgeRequest } from '../index.js';
|
|
6
|
+
|
|
7
|
+
/** The API keys `chromeAI({ apis })` accepts; each is a namespace of the sandbox global. */
|
|
8
|
+
export type ChromeAIApi =
|
|
9
|
+
| 'languageModel'
|
|
10
|
+
| 'summarizer'
|
|
11
|
+
| 'writer'
|
|
12
|
+
| 'rewriter'
|
|
13
|
+
| 'translator'
|
|
14
|
+
| 'languageDetector'
|
|
15
|
+
| 'proofreader';
|
|
16
|
+
|
|
17
|
+
export declare const CHROME_AI_APIS: readonly ChromeAIApi[];
|
|
18
|
+
|
|
19
|
+
export interface ChromeAIBudgets {
|
|
20
|
+
/**
|
|
21
|
+
* Model input tokens per sandbox lifetime, measured with
|
|
22
|
+
* `measureContextUsage()` (or `measureInputUsage()`) before each
|
|
23
|
+
* prompt/append/summarize/write/rewrite/translate/detect/proofread call,
|
|
24
|
+
* plus the tokens `initialPrompts` use at create(). Output tokens are not
|
|
25
|
+
* counted. A call over budget rejects with QuotaExceededError before it
|
|
26
|
+
* reaches the model. 0 = unlimited.
|
|
27
|
+
*/
|
|
28
|
+
maxInputTokens?: number;
|
|
29
|
+
/** Model input tokens of a single call. 0 = unlimited. */
|
|
30
|
+
maxInputTokensPerCall?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ChromeAIOptions {
|
|
34
|
+
/** Which APIs to expose (default: all). Missing ones on the host report 'unavailable'. */
|
|
35
|
+
apis?: ChromeAIApi[];
|
|
36
|
+
/**
|
|
37
|
+
* Consent hook for every model call (`method` is e.g. 'languageModel.create'
|
|
38
|
+
* or 'LanguageModel.prompt'). Only `true` allows. When
|
|
39
|
+
* `requiresUserActivation` is 'sticky' (the model must be downloaded),
|
|
40
|
+
* resolve from a click handler so the page has activation.
|
|
41
|
+
*/
|
|
42
|
+
onRequest?: (request: BridgeRequest) => boolean | Promise<boolean>;
|
|
43
|
+
budgets?: ChromeAIBudgets;
|
|
44
|
+
/** Bridge limits. Default { maxHandles: 8, maxStreams: 4 }. */
|
|
45
|
+
limits?: BridgeLimits;
|
|
46
|
+
/** Also install LanguageModel, Summarizer, ... in the sandbox as aliases of `ai.languageModel`, ... */
|
|
47
|
+
globals?: boolean;
|
|
48
|
+
/** Where the platform constructors are looked up (default globalThis). For tests and polyfills. */
|
|
49
|
+
scope?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The Chrome built-in AI bridge: `createSandbox({ bridges: { ai: chromeAI() } })`. */
|
|
53
|
+
export declare function chromeAI(options?: ChromeAIOptions): BridgeDefinition<{ inputTokens: number }>;
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome built-in AI bridge (andbox#46).
|
|
3
|
+
*
|
|
4
|
+
* import { chromeAI } from '@johnhenry/andbox/bridges/chrome-ai';
|
|
5
|
+
* const sandbox = await createSandbox({ bridges: { ai: chromeAI({ onRequest }) } });
|
|
6
|
+
* await sandbox.evaluate(`
|
|
7
|
+
* const session = await ai.languageModel.create();
|
|
8
|
+
* return session.prompt('Hello');
|
|
9
|
+
* `);
|
|
10
|
+
*
|
|
11
|
+
* Chrome exposes its built-in AI APIs (LanguageModel, Summarizer, Writer,
|
|
12
|
+
* Rewriter, Translator, LanguageDetector, Proofreader) to Window contexts only:
|
|
13
|
+
* not to Workers, and not to a sandboxed opaque-origin iframe (permissions
|
|
14
|
+
* policy features default to 'self'). So sandboxed code reaches them only
|
|
15
|
+
* through the page, which is what this bridge does: the page owns every model
|
|
16
|
+
* session; the sandbox holds handles to them.
|
|
17
|
+
*
|
|
18
|
+
* Zero dependencies. The sandbox-side surface mirrors the platform's names
|
|
19
|
+
* and shapes (see README "Chrome AI bridge" for the differences).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { defineBridge } from '../bridge-host.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The APIs this bridge knows. `global` is the platform constructor on the
|
|
26
|
+
* host; `methods` are instance methods (`input`: argument 0 is the model
|
|
27
|
+
* input, counted by token budgets; `opts`: index of the options argument,
|
|
28
|
+
* which gets the call's AbortSignal).
|
|
29
|
+
*/
|
|
30
|
+
const API_TABLE = {
|
|
31
|
+
languageModel: {
|
|
32
|
+
global: 'LanguageModel',
|
|
33
|
+
methods: {
|
|
34
|
+
prompt: { input: true, opts: 1 },
|
|
35
|
+
promptStreaming: { input: true, opts: 1, stream: true },
|
|
36
|
+
append: { input: true, opts: 1 },
|
|
37
|
+
measureContextUsage: { opts: 1, alias: 'measureInputUsage' },
|
|
38
|
+
measureInputUsage: { opts: 1, alias: 'measureContextUsage' },
|
|
39
|
+
clone: { opts: 0, handle: true },
|
|
40
|
+
},
|
|
41
|
+
props: ['contextUsage', 'contextWindow', 'inputUsage', 'inputQuota', 'samplingMode', 'topK', 'temperature'],
|
|
42
|
+
},
|
|
43
|
+
summarizer: {
|
|
44
|
+
global: 'Summarizer',
|
|
45
|
+
methods: {
|
|
46
|
+
summarize: { input: true, opts: 1 },
|
|
47
|
+
summarizeStreaming: { input: true, opts: 1, stream: true },
|
|
48
|
+
measureInputUsage: { opts: 1 },
|
|
49
|
+
},
|
|
50
|
+
props: ['sharedContext', 'type', 'format', 'length', 'expectedInputLanguages', 'expectedContextLanguages', 'outputLanguage', 'inputQuota'],
|
|
51
|
+
},
|
|
52
|
+
writer: {
|
|
53
|
+
global: 'Writer',
|
|
54
|
+
methods: {
|
|
55
|
+
write: { input: true, opts: 1 },
|
|
56
|
+
writeStreaming: { input: true, opts: 1, stream: true },
|
|
57
|
+
measureInputUsage: { opts: 1 },
|
|
58
|
+
},
|
|
59
|
+
props: ['sharedContext', 'tone', 'format', 'length', 'expectedInputLanguages', 'expectedContextLanguages', 'outputLanguage', 'inputQuota'],
|
|
60
|
+
},
|
|
61
|
+
rewriter: {
|
|
62
|
+
global: 'Rewriter',
|
|
63
|
+
methods: {
|
|
64
|
+
rewrite: { input: true, opts: 1 },
|
|
65
|
+
rewriteStreaming: { input: true, opts: 1, stream: true },
|
|
66
|
+
measureInputUsage: { opts: 1 },
|
|
67
|
+
},
|
|
68
|
+
props: ['sharedContext', 'tone', 'format', 'length', 'expectedInputLanguages', 'expectedContextLanguages', 'outputLanguage', 'inputQuota'],
|
|
69
|
+
},
|
|
70
|
+
translator: {
|
|
71
|
+
global: 'Translator',
|
|
72
|
+
methods: {
|
|
73
|
+
translate: { input: true, opts: 1 },
|
|
74
|
+
translateStreaming: { input: true, opts: 1, stream: true },
|
|
75
|
+
measureInputUsage: { opts: 1 },
|
|
76
|
+
},
|
|
77
|
+
props: ['sourceLanguage', 'targetLanguage', 'inputQuota'],
|
|
78
|
+
},
|
|
79
|
+
languageDetector: {
|
|
80
|
+
global: 'LanguageDetector',
|
|
81
|
+
methods: {
|
|
82
|
+
detect: { input: true, opts: 1 },
|
|
83
|
+
measureInputUsage: { opts: 1 },
|
|
84
|
+
},
|
|
85
|
+
props: ['expectedInputLanguages', 'inputQuota'],
|
|
86
|
+
},
|
|
87
|
+
proofreader: {
|
|
88
|
+
global: 'Proofreader',
|
|
89
|
+
methods: {
|
|
90
|
+
proofread: { input: true, opts: 1 },
|
|
91
|
+
measureInputUsage: { opts: 1 },
|
|
92
|
+
},
|
|
93
|
+
props: ['includeCorrectionTypes', 'includeCorrectionExplanations', 'expectedInputLanguages', 'correctionExplanationLanguage', 'inputQuota'],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Every API key `chromeAI({ apis })` accepts, in order. */
|
|
98
|
+
export const CHROME_AI_APIS = Object.freeze(Object.keys(API_TABLE));
|
|
99
|
+
|
|
100
|
+
/** Create-option keys that never reach the platform's `availability()`. */
|
|
101
|
+
const NOT_FOR_AVAILABILITY = new Set(['signal', 'monitor', 'onDownloadProgress', 'initialPrompts', 'sharedContext']);
|
|
102
|
+
|
|
103
|
+
const DEFAULT_LIMITS = Object.freeze({ maxHandles: 8, maxStreams: 4 });
|
|
104
|
+
|
|
105
|
+
function domError(message, name) {
|
|
106
|
+
return new DOMException(message, name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isObject(v) {
|
|
110
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function checkBudget(where, key, value) {
|
|
114
|
+
if (value === undefined) return;
|
|
115
|
+
if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
|
|
116
|
+
throw new TypeError(`${where}.${key} must be a non-negative number (0 = unlimited)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The sandbox-side adapter, stringified into the sandbox (no outside
|
|
122
|
+
* references). Chrome's `monitor` is a synchronous callback that receives an
|
|
123
|
+
* EventTarget; a function cannot cross, so the adapter calls `monitor` with a
|
|
124
|
+
* local EventTarget and passes the host a progress callback instead, whose
|
|
125
|
+
* calls it turns into `downloadprogress` events.
|
|
126
|
+
*/
|
|
127
|
+
function chromeAIClient(api) {
|
|
128
|
+
const G = globalThis;
|
|
129
|
+
const EventTargetCtor = G.EventTarget;
|
|
130
|
+
const EventCtor = G.Event;
|
|
131
|
+
const ProgressEventCtor = G.ProgressEvent;
|
|
132
|
+
const defineProperty = Object.defineProperty;
|
|
133
|
+
const keys = Object.keys;
|
|
134
|
+
|
|
135
|
+
function progressEvent(p) {
|
|
136
|
+
const init = {
|
|
137
|
+
lengthComputable: true,
|
|
138
|
+
loaded: p && typeof p.loaded === 'number' ? p.loaded : 0,
|
|
139
|
+
total: p && typeof p.total === 'number' ? p.total : 1,
|
|
140
|
+
};
|
|
141
|
+
if (typeof ProgressEventCtor === 'function') return new ProgressEventCtor('downloadprogress', init);
|
|
142
|
+
const e = new EventCtor('downloadprogress');
|
|
143
|
+
for (const k of keys(init)) defineProperty(e, k, { value: init[k], enumerable: true });
|
|
144
|
+
return e;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function makeMonitor() {
|
|
148
|
+
const target = new EventTargetCtor();
|
|
149
|
+
let handler = null;
|
|
150
|
+
defineProperty(target, 'ondownloadprogress', {
|
|
151
|
+
enumerable: true,
|
|
152
|
+
get() { return handler; },
|
|
153
|
+
set(fn) {
|
|
154
|
+
if (handler) target.removeEventListener('downloadprogress', handler);
|
|
155
|
+
handler = typeof fn === 'function' ? fn : null;
|
|
156
|
+
if (handler) target.addEventListener('downloadprogress', handler);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
return target;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const key of keys(api)) {
|
|
163
|
+
const ns = api[key];
|
|
164
|
+
if (!ns || typeof ns.create !== 'function') continue;
|
|
165
|
+
const create = ns.create;
|
|
166
|
+
ns.create = function create_(options) {
|
|
167
|
+
if (options !== null && typeof options === 'object' && typeof options.monitor === 'function') {
|
|
168
|
+
const monitor = makeMonitor();
|
|
169
|
+
options.monitor(monitor);
|
|
170
|
+
const rest = {};
|
|
171
|
+
for (const k of keys(options)) if (k !== 'monitor') rest[k] = options[k];
|
|
172
|
+
rest.onDownloadProgress = (p) => { monitor.dispatchEvent(progressEvent(p)); };
|
|
173
|
+
options = rest;
|
|
174
|
+
}
|
|
175
|
+
return create(options);
|
|
176
|
+
};
|
|
177
|
+
defineProperty(ns.create, 'name', { value: 'create' });
|
|
178
|
+
}
|
|
179
|
+
return api;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Tool-use content (`tool-call` / `tool-response`) holds platform objects
|
|
184
|
+
* (LanguageModelToolCall, LanguageModelToolSuccess, LanguageModelToolError)
|
|
185
|
+
* that cannot be cloned. In the sandbox they are plain objects with the same
|
|
186
|
+
* fields; the host converts in both directions.
|
|
187
|
+
*/
|
|
188
|
+
function toPlatformContent(item, scope) {
|
|
189
|
+
if (!isObject(item) || !isObject(item.value)) return item;
|
|
190
|
+
const v = item.value;
|
|
191
|
+
if (item.type === 'tool-call' && typeof scope.LanguageModelToolCall === 'function') {
|
|
192
|
+
return { ...item, value: new scope.LanguageModelToolCall(v) };
|
|
193
|
+
}
|
|
194
|
+
if (item.type === 'tool-response') {
|
|
195
|
+
if (v.errorMessage !== undefined && typeof scope.LanguageModelToolError === 'function') {
|
|
196
|
+
return { ...item, value: new scope.LanguageModelToolError(v) };
|
|
197
|
+
}
|
|
198
|
+
if (typeof scope.LanguageModelToolSuccess === 'function') {
|
|
199
|
+
return { ...item, value: new scope.LanguageModelToolSuccess(v) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return item;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toPlatformPrompt(input, scope) {
|
|
206
|
+
if (!Array.isArray(input)) return input;
|
|
207
|
+
return input.map((msg) => (isObject(msg) && Array.isArray(msg.content)
|
|
208
|
+
? { ...msg, content: msg.content.map((c) => toPlatformContent(c, scope)) }
|
|
209
|
+
: msg));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function toPlainContent(item) {
|
|
213
|
+
if (!isObject(item) || item.value === null || typeof item.value !== 'object') return item;
|
|
214
|
+
const v = item.value;
|
|
215
|
+
if (item.type === 'tool-call') {
|
|
216
|
+
return { type: item.type, value: { callId: v.callId, name: v.name, arguments: v.arguments ?? null } };
|
|
217
|
+
}
|
|
218
|
+
if (item.type === 'tool-response') {
|
|
219
|
+
return {
|
|
220
|
+
type: item.type,
|
|
221
|
+
value: v.errorMessage !== undefined
|
|
222
|
+
? { callId: v.callId, name: v.name, errorMessage: v.errorMessage }
|
|
223
|
+
: { callId: v.callId, name: v.name, result: v.result ? [...v.result] : [] },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return item;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function toPlainResult(value) {
|
|
230
|
+
return Array.isArray(value) ? value.map(toPlainContent) : value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Create the Chrome built-in AI bridge.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} [options]
|
|
237
|
+
* @param {string[]} [options.apis] which APIs to expose (default: all of CHROME_AI_APIS)
|
|
238
|
+
* @param {(request: object) => boolean | Promise<boolean>} [options.onRequest] consent hook; only `true` allows
|
|
239
|
+
* @param {{ maxInputTokens?: number, maxInputTokensPerCall?: number }} [options.budgets] token budgets per sandbox
|
|
240
|
+
* @param {object} [options.limits] bridge limits (default { maxHandles: 8, maxStreams: 4 })
|
|
241
|
+
* @param {boolean} [options.globals] also install LanguageModel, Summarizer, ... as sandbox globals
|
|
242
|
+
* @param {object} [options.scope] where the platform constructors live (default globalThis; tests pass fakes)
|
|
243
|
+
*/
|
|
244
|
+
export function chromeAI(options = {}) {
|
|
245
|
+
if (!isObject(options)) throw new TypeError('chromeAI(options): options must be an object');
|
|
246
|
+
for (const k of Object.keys(options)) {
|
|
247
|
+
if (!['apis', 'onRequest', 'budgets', 'limits', 'globals', 'scope'].includes(k)) {
|
|
248
|
+
throw new TypeError(`chromeAI: '${k}' is not a known option (apis, onRequest, budgets, limits, globals, scope)`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const { apis = CHROME_AI_APIS, onRequest, budgets = {}, limits = {}, globals = false, scope = globalThis } = options;
|
|
252
|
+
if (!Array.isArray(apis) || !apis.every((a) => CHROME_AI_APIS.includes(a))) {
|
|
253
|
+
throw new TypeError(`chromeAI: apis must be an array of ${CHROME_AI_APIS.map((a) => `'${a}'`).join(', ')}`);
|
|
254
|
+
}
|
|
255
|
+
if (!isObject(budgets)) throw new TypeError('chromeAI: budgets must be an object');
|
|
256
|
+
for (const k of Object.keys(budgets)) {
|
|
257
|
+
if (!['maxInputTokens', 'maxInputTokensPerCall'].includes(k)) {
|
|
258
|
+
throw new TypeError(`chromeAI: budgets.${k} is not a known budget (maxInputTokens, maxInputTokensPerCall)`);
|
|
259
|
+
}
|
|
260
|
+
checkBudget('chromeAI: budgets', k, budgets[k]);
|
|
261
|
+
}
|
|
262
|
+
if (!isObject(scope)) throw new TypeError('chromeAI: scope must be an object');
|
|
263
|
+
const maxTotal = budgets.maxInputTokens ?? 0;
|
|
264
|
+
const maxPerCall = budgets.maxInputTokensPerCall ?? 0;
|
|
265
|
+
|
|
266
|
+
/** Count model input against the budgets before it reaches the model. */
|
|
267
|
+
async function charge(ctx, target, typeName, input, opts) {
|
|
268
|
+
if (!maxTotal && !maxPerCall) return;
|
|
269
|
+
const measure = typeof target.measureContextUsage === 'function'
|
|
270
|
+
? target.measureContextUsage
|
|
271
|
+
: typeof target.measureInputUsage === 'function' ? target.measureInputUsage : null;
|
|
272
|
+
if (!measure) {
|
|
273
|
+
throw domError(`${typeName} cannot measure its input in this browser, so the token budget cannot be enforced`, 'NotSupportedError');
|
|
274
|
+
}
|
|
275
|
+
const { signal, ...measureOpts } = opts;
|
|
276
|
+
const n = Number(await measure.call(target, input, { ...measureOpts, signal }));
|
|
277
|
+
const tokens = Number.isFinite(n) ? n : 0;
|
|
278
|
+
if (maxPerCall > 0 && tokens > maxPerCall) {
|
|
279
|
+
throw Object.assign(domError(`Input is ${tokens} tokens, over budgets.maxInputTokensPerCall (${maxPerCall})`, 'QuotaExceededError'), { requested: tokens, quota: maxPerCall });
|
|
280
|
+
}
|
|
281
|
+
if (maxTotal > 0 && ctx.state.inputTokens + tokens > maxTotal) {
|
|
282
|
+
throw Object.assign(
|
|
283
|
+
domError(`Input of ${tokens} tokens would exceed budgets.maxInputTokens (${ctx.state.inputTokens} of ${maxTotal} used)`, 'QuotaExceededError'),
|
|
284
|
+
{ requested: tokens, quota: maxTotal - ctx.state.inputTokens }
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
ctx.state.inputTokens += tokens;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const api = {};
|
|
291
|
+
const handles = {};
|
|
292
|
+
const aliases = {};
|
|
293
|
+
for (const key of apis) {
|
|
294
|
+
const info = API_TABLE[key];
|
|
295
|
+
const typeName = info.global;
|
|
296
|
+
const getAPI = () => {
|
|
297
|
+
const C = scope[typeName];
|
|
298
|
+
return typeof C === 'function' || isObject(C) ? C : null;
|
|
299
|
+
};
|
|
300
|
+
const coreOptions = (opts) => {
|
|
301
|
+
if (!isObject(opts)) return opts;
|
|
302
|
+
const out = {};
|
|
303
|
+
for (const k of Object.keys(opts)) if (!NOT_FOR_AVAILABILITY.has(k)) out[k] = opts[k];
|
|
304
|
+
return out;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const ns = {
|
|
308
|
+
async availability(ctx, opts) {
|
|
309
|
+
const C = getAPI();
|
|
310
|
+
if (!C || typeof C.availability !== 'function') return 'unavailable';
|
|
311
|
+
return C.availability(coreOptions(opts));
|
|
312
|
+
},
|
|
313
|
+
create: {
|
|
314
|
+
handle: true,
|
|
315
|
+
// Chrome needs a user gesture when the model still has to be
|
|
316
|
+
// downloaded (sticky activation is enough in shipping Chrome).
|
|
317
|
+
async requiresUserActivation(ctx, opts) {
|
|
318
|
+
const C = getAPI();
|
|
319
|
+
if (!C || typeof C.availability !== 'function') return false;
|
|
320
|
+
try {
|
|
321
|
+
return (await C.availability(coreOptions(opts))) === 'downloadable' ? 'sticky' : false;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
async call(ctx, opts) {
|
|
327
|
+
const C = getAPI();
|
|
328
|
+
if (!C || typeof C.create !== 'function') {
|
|
329
|
+
throw domError(`${typeName} is not available in this browser`, 'NotSupportedError');
|
|
330
|
+
}
|
|
331
|
+
let createOpts = opts;
|
|
332
|
+
if (isObject(opts)) {
|
|
333
|
+
const { monitor, onDownloadProgress, signal, ...rest } = opts;
|
|
334
|
+
if (Array.isArray(rest.tools) && rest.tools.some((t) => isObject(t) && typeof t.execute === 'function')) {
|
|
335
|
+
throw domError(
|
|
336
|
+
"Prompt API tools have no execute() callback: tool use is open-loop (prompt() returns 'tool-call' content " +
|
|
337
|
+
"and you append a 'tool-response'). Drop execute from the tool declarations.",
|
|
338
|
+
'NotSupportedError'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
createOpts = { ...rest, signal: ctx.signal };
|
|
342
|
+
if (Array.isArray(rest.initialPrompts)) createOpts.initialPrompts = toPlatformPrompt(rest.initialPrompts, scope);
|
|
343
|
+
if (typeof onDownloadProgress === 'function') {
|
|
344
|
+
createOpts.monitor = (m) => {
|
|
345
|
+
m.addEventListener('downloadprogress', (e) => {
|
|
346
|
+
onDownloadProgress({ loaded: e.loaded, total: e.total }).catch(() => {});
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
} else if (opts === undefined) {
|
|
351
|
+
createOpts = { signal: ctx.signal };
|
|
352
|
+
}
|
|
353
|
+
const instance = await C.create(createOpts);
|
|
354
|
+
// The tokens initial prompts already used count against the budget.
|
|
355
|
+
if (maxTotal > 0 && key === 'languageModel') {
|
|
356
|
+
const used = Number(instance?.contextUsage ?? instance?.inputUsage ?? 0);
|
|
357
|
+
if (Number.isFinite(used) && used > 0) {
|
|
358
|
+
if (ctx.state.inputTokens + used > maxTotal) {
|
|
359
|
+
try { instance.destroy?.(); } catch {}
|
|
360
|
+
throw Object.assign(
|
|
361
|
+
domError(`initialPrompts use ${used} tokens, over budgets.maxInputTokens (${ctx.state.inputTokens} of ${maxTotal} used)`, 'QuotaExceededError'),
|
|
362
|
+
{ requested: used, quota: maxTotal - ctx.state.inputTokens }
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
ctx.state.inputTokens += used;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return ctx.handle(typeName, instance);
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
if (key === 'languageModel' && typeof scope.LanguageModel?.params === 'function') {
|
|
373
|
+
// Extension contexts only; LanguageModelParams is a platform object.
|
|
374
|
+
ns.params = async () => {
|
|
375
|
+
const p = await scope.LanguageModel.params();
|
|
376
|
+
return p == null ? null : {
|
|
377
|
+
defaultTopK: p.defaultTopK, maxTopK: p.maxTopK, defaultTemperature: p.defaultTemperature, maxTemperature: p.maxTemperature,
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
api[key] = ns;
|
|
382
|
+
|
|
383
|
+
const methods = {};
|
|
384
|
+
for (const [name, m] of Object.entries(info.methods)) {
|
|
385
|
+
const call = async (ctx, ...args) => {
|
|
386
|
+
const target = ctx.target;
|
|
387
|
+
let fn = target[name];
|
|
388
|
+
if (typeof fn !== 'function' && m.alias) fn = target[m.alias];
|
|
389
|
+
if (typeof fn !== 'function') {
|
|
390
|
+
throw domError(`${typeName}.${name}() is not available in this browser`, 'NotSupportedError');
|
|
391
|
+
}
|
|
392
|
+
const a = args.slice();
|
|
393
|
+
while (a.length <= m.opts) a.push(undefined);
|
|
394
|
+
const opts = isObject(a[m.opts]) ? { ...a[m.opts] } : {};
|
|
395
|
+
opts.signal = ctx.signal;
|
|
396
|
+
a[m.opts] = opts;
|
|
397
|
+
if (key === 'languageModel' && m.opts === 1) a[0] = toPlatformPrompt(a[0], scope);
|
|
398
|
+
if (m.input) await charge(ctx, target, typeName, a[0], opts);
|
|
399
|
+
const result = fn.apply(target, a);
|
|
400
|
+
if (m.stream) return result;
|
|
401
|
+
if (m.handle) return ctx.handle(typeName, await result);
|
|
402
|
+
return toPlainResult(await result);
|
|
403
|
+
};
|
|
404
|
+
methods[name] = { call, stream: m.stream === true, handle: m.handle === true };
|
|
405
|
+
}
|
|
406
|
+
handles[typeName] = {
|
|
407
|
+
methods,
|
|
408
|
+
props: info.props,
|
|
409
|
+
destroy: (target) => target.destroy?.(),
|
|
410
|
+
};
|
|
411
|
+
aliases[typeName] = key;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return defineBridge({
|
|
415
|
+
api,
|
|
416
|
+
handles,
|
|
417
|
+
limits: { ...DEFAULT_LIMITS, ...limits },
|
|
418
|
+
...(onRequest !== undefined ? { onRequest } : {}),
|
|
419
|
+
createState: () => ({ inputTokens: 0 }),
|
|
420
|
+
stats: (state) => ({ inputTokens: state.inputTokens }),
|
|
421
|
+
client: chromeAIClient,
|
|
422
|
+
...(globals ? { globals: aliases } : {}),
|
|
423
|
+
});
|
|
424
|
+
}
|
package/src/iframe-host.mjs
CHANGED
|
@@ -29,11 +29,12 @@ const OWNED_ATTRIBUTES = new Set(['srcdoc', 'src', 'sandbox']);
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* The runtime script the frame runs: Worker mode's, minus the global lockdown.
|
|
32
|
-
* @param {{ networkFetch?: boolean }} [options] `networkFetch`: replace the
|
|
33
|
-
* frame's `fetch` with the host-backed one (`createSandbox({ network })`)
|
|
32
|
+
* @param {{ networkFetch?: boolean, bridges?: boolean }} [options] `networkFetch`: replace the
|
|
33
|
+
* frame's `fetch` with the host-backed one (`createSandbox({ network })`);
|
|
34
|
+
* `bridges`: include the bridge client (`createSandbox({ bridges })`).
|
|
34
35
|
*/
|
|
35
|
-
export function makeIframeRuntimeSource({ networkFetch = false } = {}) {
|
|
36
|
-
return makeRuntimeSource({ lockdown: false, networkFetch });
|
|
36
|
+
export function makeIframeRuntimeSource({ networkFetch = false, bridges = false } = {}) {
|
|
37
|
+
return makeRuntimeSource({ lockdown: false, networkFetch, bridges });
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
package/src/index.d.ts
CHANGED
|
@@ -295,6 +295,141 @@ export interface SandboxNetworkOptions {
|
|
|
295
295
|
credentials?: RequestCredentials;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// ── bridges (createSandbox({ bridges }), andbox#46) ──
|
|
299
|
+
|
|
300
|
+
/** Per-sandbox limits of one bridge. 0 = unlimited. */
|
|
301
|
+
export interface BridgeLimits {
|
|
302
|
+
/** Live handles at a time. Default 32 (chromeAI: 8). */
|
|
303
|
+
maxHandles?: number;
|
|
304
|
+
/** Open streams at a time. Default 8 (chromeAI: 4). */
|
|
305
|
+
maxStreams?: number;
|
|
306
|
+
/** Approximate size of one result or stream chunk (strings as UTF-8, binary by byteLength). Default 0. */
|
|
307
|
+
maxResultBytes?: number;
|
|
308
|
+
/** Wall-clock time for one call; for a stream method, until the stream ends. Default 0. */
|
|
309
|
+
timeoutMs?: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Defaults for {@link BridgeLimits}. */
|
|
313
|
+
export declare const DEFAULT_BRIDGE_LIMITS: Readonly<Required<BridgeLimits>>;
|
|
314
|
+
|
|
315
|
+
/** An opaque reference to a host object, from `ctx.handle()`. Return it (or put it in a result). */
|
|
316
|
+
export interface BridgeHandleRef<T = unknown> {
|
|
317
|
+
readonly type: string;
|
|
318
|
+
readonly target: T;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** What every bridge method receives as its first argument. */
|
|
322
|
+
export interface BridgeContext<State = any, Target = any> {
|
|
323
|
+
/** The bridge's name (its global in the sandbox). */
|
|
324
|
+
bridge: string;
|
|
325
|
+
/** `'chat.create'` for an api method, `'Session.ask'` for a handle method. */
|
|
326
|
+
method: string;
|
|
327
|
+
/**
|
|
328
|
+
* Aborts when the sandbox's AbortSignal for this call fires, the call times
|
|
329
|
+
* out (`limits.timeoutMs`), its handle is destroyed, the stream is
|
|
330
|
+
* cancelled, or the sandbox's Worker/frame is terminated. Pass it on.
|
|
331
|
+
*/
|
|
332
|
+
signal: AbortSignal;
|
|
333
|
+
/** Per-sandbox state from `createState()` (lives as long as the sandbox, across restarts). */
|
|
334
|
+
state: State;
|
|
335
|
+
/** Handle methods: the host object the sandbox's handle refers to. */
|
|
336
|
+
target: Target;
|
|
337
|
+
/** Wrap a host object so the sandbox gets a handle to it (type: a key of `handles`). */
|
|
338
|
+
handle<T>(type: string, target: T): BridgeHandleRef<T>;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A bridge method. Arguments arrive by structured clone, with sandbox
|
|
343
|
+
* functions replaced by async proxies (reverse calls into the sandbox),
|
|
344
|
+
* AbortSignals by `ctx.signal`, and handles by their host objects.
|
|
345
|
+
*/
|
|
346
|
+
export type BridgeMethodFn<State = any, Target = any> = (ctx: BridgeContext<State, Target>, ...args: any[]) => unknown;
|
|
347
|
+
|
|
348
|
+
/** The activation a method needs: `true`/`'transient'` (a gesture in the last few seconds) or `'sticky'` (any gesture since load). */
|
|
349
|
+
export type BridgeActivation = boolean | 'transient' | 'sticky';
|
|
350
|
+
|
|
351
|
+
export interface BridgeMethodSpec<State = any, Target = any> {
|
|
352
|
+
call: BridgeMethodFn<State, Target>;
|
|
353
|
+
/** Returns a ReadableStream / (async) iterable; the sandbox gets a ReadableStream at once. */
|
|
354
|
+
stream?: boolean;
|
|
355
|
+
/** Returns a handle: `limits.maxHandles` is checked before the method runs. */
|
|
356
|
+
handle?: boolean;
|
|
357
|
+
/** Needs page user activation; a function decides per call (e.g. only when a download is needed). */
|
|
358
|
+
requiresUserActivation?: BridgeActivation | ((ctx: BridgeContext<State, Target>, ...args: any[]) => BridgeActivation | Promise<BridgeActivation>);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export type BridgeMethod<State = any, Target = any> = BridgeMethodFn<State, Target> | BridgeMethodSpec<State, Target>;
|
|
362
|
+
|
|
363
|
+
/** A tree of namespaces and methods: `{ chat: { create, list } }` becomes `svc.chat.create()`. */
|
|
364
|
+
export interface BridgeApi<State = any> {
|
|
365
|
+
[name: string]: BridgeMethod<State> | BridgeApi<State>;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A host object type the sandbox holds handles to. */
|
|
369
|
+
export interface BridgeHandleType<State = any, Target = any> {
|
|
370
|
+
/** Methods callable on the sandbox's handle; `ctx.target` is the host object. */
|
|
371
|
+
methods?: Record<string, BridgeMethod<State, Target>>;
|
|
372
|
+
/** Properties copied to the sandbox (primitives and arrays of primitives) with the handle and after every call. */
|
|
373
|
+
props?: string[];
|
|
374
|
+
/** Release the host object: on handle.destroy(), and for every live handle when the sandbox is disposed, restarted or killed. */
|
|
375
|
+
destroy?: (target: Target) => void | Promise<void>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What `onRequest` sees for every bridge method call (not for destroy/cancel/abort). */
|
|
379
|
+
export interface BridgeRequest {
|
|
380
|
+
bridge: string;
|
|
381
|
+
/** `'chat.create'` or `'Session.ask'`; the gate name is `${bridge}.${method}`. */
|
|
382
|
+
method: string;
|
|
383
|
+
/** Decoded arguments (sandbox functions are proxies; signals are the call's signal). */
|
|
384
|
+
args: unknown[];
|
|
385
|
+
handle: { id: string; type: string } | null;
|
|
386
|
+
/** The activation the method needs for this call, or false. */
|
|
387
|
+
requiresUserActivation: false | 'transient' | 'sticky';
|
|
388
|
+
/** The page's `navigator.userActivation` now; null where the platform has none (Node). */
|
|
389
|
+
userActivation: { isActive: boolean; hasBeenActive: boolean } | null;
|
|
390
|
+
/** Aborts when the call does (the sandbox gave up, timed out or was terminated): close your consent UI. */
|
|
391
|
+
signal: AbortSignal;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface BridgeDefinition<State = any> {
|
|
395
|
+
/** Methods and namespaces of the sandbox global. */
|
|
396
|
+
api?: BridgeApi<State>;
|
|
397
|
+
/** Host object types, by name. A handle type cannot share a name with an api member. */
|
|
398
|
+
handles?: Record<string, BridgeHandleType<State>>;
|
|
399
|
+
limits?: BridgeLimits;
|
|
400
|
+
/**
|
|
401
|
+
* Consent hook, asked on the host before every method call. Only `true`
|
|
402
|
+
* (or a promise of it) allows; anything else, or a throw, rejects the call
|
|
403
|
+
* in the sandbox with `NotAllowedError`. It may wait for the user.
|
|
404
|
+
*/
|
|
405
|
+
onRequest?: (request: BridgeRequest) => boolean | Promise<boolean>;
|
|
406
|
+
/** Per-sandbox state (`ctx.state`), created once per sandbox. */
|
|
407
|
+
createState?: () => State;
|
|
408
|
+
/** Extra fields for `sandbox.stats().bridges[name]`. */
|
|
409
|
+
stats?: (state: State) => Record<string, unknown>;
|
|
410
|
+
/**
|
|
411
|
+
* Sandbox-side adapter: a self-contained function (stringified; no outside
|
|
412
|
+
* references) called in the sandbox with the generated global and
|
|
413
|
+
* `clientOptions`; returns the global to install.
|
|
414
|
+
*/
|
|
415
|
+
client?: ((api: any, options: any) => any) | string;
|
|
416
|
+
/** JSON passed to `client` in the sandbox. */
|
|
417
|
+
clientOptions?: unknown;
|
|
418
|
+
/** Extra sandbox globals aliasing paths of the api: `{ LanguageModel: 'languageModel' }`. */
|
|
419
|
+
globals?: Record<string, string>;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Check a bridge definition (throws a TypeError) and return it unchanged. */
|
|
423
|
+
export declare function defineBridge<State = any>(definition: BridgeDefinition<State>): BridgeDefinition<State>;
|
|
424
|
+
|
|
425
|
+
/** `sandbox.stats().bridges[name]`. */
|
|
426
|
+
export interface BridgeStats {
|
|
427
|
+
handles: number;
|
|
428
|
+
streams: number;
|
|
429
|
+
pendingCalls: number;
|
|
430
|
+
[extra: string]: unknown;
|
|
431
|
+
}
|
|
432
|
+
|
|
298
433
|
// ── stdio ──
|
|
299
434
|
|
|
300
435
|
/** An async iterable stdio stream with push/end controls. */
|
|
@@ -325,9 +460,11 @@ export declare function createStdio(): StdioStream;
|
|
|
325
460
|
* @param options.networkFetch Install the host-backed global `fetch` shim
|
|
326
461
|
* (what `createSandbox({ network })` uses). It calls the host's `fetch`
|
|
327
462
|
* capability. Default false: `fetch` is removed like the other network globals.
|
|
463
|
+
* @param options.bridges Include the bridge client (`createSandbox({ bridges })`);
|
|
464
|
+
* the globals are built from the manifests sent with `configure`. Default false.
|
|
328
465
|
* @returns The complete Worker script source code as a string.
|
|
329
466
|
*/
|
|
330
|
-
export declare function makeWorkerSource(options?: { networkFetch?: boolean }): string;
|
|
467
|
+
export declare function makeWorkerSource(options?: { networkFetch?: boolean; bridges?: boolean }): string;
|
|
331
468
|
|
|
332
469
|
// ── service-worker-source ──
|
|
333
470
|
|
|
@@ -505,6 +642,17 @@ export interface SandboxOptions {
|
|
|
505
642
|
* capability named `fetch`.
|
|
506
643
|
*/
|
|
507
644
|
network?: SandboxNetworkOptions;
|
|
645
|
+
/**
|
|
646
|
+
* `worker`, `node-worker` and `iframe` modes (throws in the others): one
|
|
647
|
+
* sandbox global per entry that proxies a host API (andbox#46). Host
|
|
648
|
+
* objects never cross: the sandbox holds opaque handles, gets structured
|
|
649
|
+
* clones, pulls streams chunk by chunk, and its functions run in the
|
|
650
|
+
* sandbox when the host calls them. Every method call goes through the
|
|
651
|
+
* capability gate as `${bridge}.${method}` (so `policy` limits it) and
|
|
652
|
+
* through the bridge's `onRequest`; every handle is destroyed when the
|
|
653
|
+
* sandbox is disposed, restarted or killed. See `@johnhenry/andbox/bridges/chrome-ai`.
|
|
654
|
+
*/
|
|
655
|
+
bridges?: Record<string, BridgeDefinition>;
|
|
508
656
|
}
|
|
509
657
|
|
|
510
658
|
/** Options for the built-in Node worker_threads mode (`nodeWorker`). */
|
|
@@ -557,6 +705,8 @@ export interface SandboxStats {
|
|
|
557
705
|
pendingEvaluations: number;
|
|
558
706
|
virtualModules: string[];
|
|
559
707
|
gate: GateStatsResult;
|
|
708
|
+
/** With `bridges`: live handles/streams/calls per bridge, plus the bridge's own `stats()`. */
|
|
709
|
+
bridges?: Record<string, BridgeStats>;
|
|
560
710
|
/** `mode: 'wasm'` only: interrupt polls used by the most recent evaluate(). */
|
|
561
711
|
fuelUsed?: number;
|
|
562
712
|
/** `mode: 'wasm'` only: peak sampled JS heap bytes seen so far. */
|
package/src/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ export { createVirtualModuleRegistry } from './virtual-module-registry.mjs';
|
|
|
10
10
|
export { gateCapabilities } from './capability-gate.mjs';
|
|
11
11
|
export { createStdio } from './stdio.mjs';
|
|
12
12
|
export { createNetworkFetch } from './network-policy.mjs';
|
|
13
|
+
export { defineBridge, DEFAULT_BRIDGE_LIMITS } from './bridge-host.mjs';
|
|
13
14
|
export { makeDeferred, makeAbortError, makeTimeoutError } from './deferred.mjs';
|
|
14
15
|
export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
|
|
15
16
|
export { makeWorkerSource } from './worker-source.mjs';
|