@tangle-network/sandbox 0.1.2 → 0.2.1
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 +490 -2
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.js +271 -1
- package/dist/client-Uve6A5C6.js +2280 -0
- package/dist/collaboration/index.d.ts +1 -1
- package/dist/collaboration/index.js +2 -1
- package/dist/collaboration-CRyb5e8F.js +201 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +4 -1
- package/dist/errors-BI75IXOM.d.ts +1177 -0
- package/dist/errors-CljiGR__.js +262 -0
- package/dist/{index-BuS8nl3b.d.ts → index-CCsA3S0D.d.ts} +6 -1
- package/dist/{index-t7xkzv0U.d.ts → index-DhNGZ0h4.d.ts} +3 -3
- package/dist/{index-gA-oRjOi.d.ts → index-Dpj1oB5i.d.ts} +35 -4
- package/dist/index.d.ts +109 -62
- package/dist/index.js +825 -1
- package/dist/openai/index.d.ts +642 -0
- package/dist/openai/index.js +1721 -0
- package/dist/platform-integrations.d.ts +2 -0
- package/dist/platform-integrations.js +2 -0
- package/dist/{sandbox-BvZ0-Iv7.d.ts → sandbox-aBpWqler.d.ts} +1528 -41
- package/dist/sandbox-ksXTNlo-.js +3394 -0
- package/dist/session-gateway/index.js +667 -1
- package/dist/tangle/index.d.ts +1 -1
- package/dist/tangle/index.js +2 -1
- package/dist/tangle-DQ05paN7.js +826 -0
- package/package.json +93 -34
- package/LICENSE +0 -11
- package/dist/client-CcRvqt85.js +0 -1
- package/dist/collaboration-CVvhPU8M.js +0 -1
- package/dist/errors-AIT8qikt.d.ts +0 -491
- package/dist/errors-CdMTv7uG.js +0 -1
- package/dist/sandbox-D1JnQIJx.js +0 -1
- package/dist/tangle-CSb9rjAh.js +0 -1
|
@@ -0,0 +1,1721 @@
|
|
|
1
|
+
//#region src/openai/hooks.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sequential hook composer. Hooks run in registration order; `block` and
|
|
4
|
+
* `terminate` short-circuit. `rewrite` and `override` thread their
|
|
5
|
+
* mutated payloads through to the remaining hooks so subsequent hooks
|
|
6
|
+
* observe the rewritten args / overridden result.
|
|
7
|
+
*/
|
|
8
|
+
var HookChain = class {
|
|
9
|
+
hooks;
|
|
10
|
+
constructor(hooks = []) {
|
|
11
|
+
this.hooks = hooks;
|
|
12
|
+
}
|
|
13
|
+
/** Number of hooks in the chain. */
|
|
14
|
+
get size() {
|
|
15
|
+
return this.hooks.length;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run every `beforeToolCall` in registration order. Returns the first
|
|
19
|
+
* non-`allow` outcome (block/rewrite), or `allow` if the chain ran
|
|
20
|
+
* clean. `rewrite` outcomes are threaded forward so later hooks see
|
|
21
|
+
* the mutated args.
|
|
22
|
+
*/
|
|
23
|
+
async runBefore(ctx) {
|
|
24
|
+
let current = ctx;
|
|
25
|
+
let lastRewrite = null;
|
|
26
|
+
for (const hook of this.hooks) {
|
|
27
|
+
if (!hook.beforeToolCall) continue;
|
|
28
|
+
const outcome = await hook.beforeToolCall(current);
|
|
29
|
+
if (outcome.action === "block") return outcome;
|
|
30
|
+
if (outcome.action === "rewrite") {
|
|
31
|
+
current = {
|
|
32
|
+
...current,
|
|
33
|
+
args: outcome.args
|
|
34
|
+
};
|
|
35
|
+
lastRewrite = outcome;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return lastRewrite ?? { action: "allow" };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Run every `afterToolCall` in registration order. Returns the first
|
|
42
|
+
* `terminate` outcome immediately. `override` outcomes are threaded
|
|
43
|
+
* forward so later hooks see the mutated result; the final result is
|
|
44
|
+
* surfaced as the last `override`, or `pass` if no hook overrode.
|
|
45
|
+
*/
|
|
46
|
+
async runAfter(ctx, result) {
|
|
47
|
+
let current = result;
|
|
48
|
+
let lastOverride = null;
|
|
49
|
+
for (const hook of this.hooks) {
|
|
50
|
+
if (!hook.afterToolCall) continue;
|
|
51
|
+
const outcome = await hook.afterToolCall(ctx, current);
|
|
52
|
+
if (outcome.action === "terminate") return outcome;
|
|
53
|
+
if (outcome.action === "override") {
|
|
54
|
+
current = outcome.result;
|
|
55
|
+
lastOverride = outcome;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lastOverride ?? { action: "pass" };
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Emits a structured `AuditEvent` for every tool call (before + after).
|
|
63
|
+
* Default sink is `console.info`. The route layer swaps the sink for
|
|
64
|
+
* the eval-runs DuckDB writer.
|
|
65
|
+
*/
|
|
66
|
+
function auditLogHook(opts = {}) {
|
|
67
|
+
const sink = opts.sink ?? ((event) => {
|
|
68
|
+
console.info("[audit]", event);
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
async beforeToolCall(ctx) {
|
|
72
|
+
await sink({
|
|
73
|
+
phase: "before",
|
|
74
|
+
runId: ctx.runId,
|
|
75
|
+
threadId: ctx.threadId,
|
|
76
|
+
partnerId: ctx.partnerId,
|
|
77
|
+
toolName: ctx.toolName,
|
|
78
|
+
callId: ctx.callId,
|
|
79
|
+
timestamp: ctx.timestamp,
|
|
80
|
+
args: ctx.args
|
|
81
|
+
});
|
|
82
|
+
return { action: "allow" };
|
|
83
|
+
},
|
|
84
|
+
async afterToolCall(ctx, result) {
|
|
85
|
+
await sink({
|
|
86
|
+
phase: "after",
|
|
87
|
+
runId: ctx.runId,
|
|
88
|
+
threadId: ctx.threadId,
|
|
89
|
+
partnerId: ctx.partnerId,
|
|
90
|
+
toolName: ctx.toolName,
|
|
91
|
+
callId: ctx.callId,
|
|
92
|
+
timestamp: ctx.timestamp,
|
|
93
|
+
result
|
|
94
|
+
});
|
|
95
|
+
return { action: "pass" };
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const NETWORK_TOOL_REGEX = /^(fetch|http|https|browser|playwright|curl|wget|computer-use:click|computer-use:type)/i;
|
|
100
|
+
const URL_FIELD_NAMES = new Set([
|
|
101
|
+
"url",
|
|
102
|
+
"uri",
|
|
103
|
+
"endpoint",
|
|
104
|
+
"href",
|
|
105
|
+
"src",
|
|
106
|
+
"target",
|
|
107
|
+
"address"
|
|
108
|
+
]);
|
|
109
|
+
function extractUrlsFromArgs(args) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const visit = (val) => {
|
|
112
|
+
if (typeof val === "string") {
|
|
113
|
+
if (/^https?:\/\//i.test(val)) try {
|
|
114
|
+
const u = new URL(val);
|
|
115
|
+
out.push({
|
|
116
|
+
raw: val,
|
|
117
|
+
host: u.host
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
out.push({
|
|
121
|
+
raw: val,
|
|
122
|
+
host: null
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (val === null || typeof val !== "object") return;
|
|
128
|
+
if (Array.isArray(val)) {
|
|
129
|
+
for (const item of val) visit(item);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const [k, v] of Object.entries(val)) {
|
|
133
|
+
if (URL_FIELD_NAMES.has(k.toLowerCase()) && typeof v === "string") {
|
|
134
|
+
try {
|
|
135
|
+
const u = new URL(v);
|
|
136
|
+
out.push({
|
|
137
|
+
raw: v,
|
|
138
|
+
host: u.host
|
|
139
|
+
});
|
|
140
|
+
} catch {
|
|
141
|
+
out.push({
|
|
142
|
+
raw: v,
|
|
143
|
+
host: null
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
visit(v);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
visit(args);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
function isNetworkLike(ctx, urls) {
|
|
155
|
+
if (NETWORK_TOOL_REGEX.test(ctx.toolName)) return true;
|
|
156
|
+
return urls.length > 0;
|
|
157
|
+
}
|
|
158
|
+
function hostMatches(host, pattern) {
|
|
159
|
+
if (pattern === host) return true;
|
|
160
|
+
if (pattern.startsWith("*.")) {
|
|
161
|
+
const suffix = pattern.slice(1);
|
|
162
|
+
return host.endsWith(suffix);
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Best-effort allow/deny filter for tool-call URLs.
|
|
168
|
+
*
|
|
169
|
+
* Block tool calls that look network-ish (toolName matches the network
|
|
170
|
+
* regex OR args contain url-like fields) when the resolved host hits
|
|
171
|
+
* the deny list. The allow list is treated as an explicit allowance —
|
|
172
|
+
* when set, only listed hosts pass; unmatched hosts are blocked.
|
|
173
|
+
* `allowFn` overrides both lists when present.
|
|
174
|
+
*
|
|
175
|
+
* **What this hook does NOT catch.** It inspects URL-shaped strings in
|
|
176
|
+
* the tool-call arguments only. The following bypass it silently:
|
|
177
|
+
*
|
|
178
|
+
* - URLs encoded as base64 / hex / `Buffer.from(...)` literals
|
|
179
|
+
* - URLs assembled at execution time from string fragments
|
|
180
|
+
* - URLs reached via redirects, DNS rebinding, or proxy hosts that
|
|
181
|
+
* match the allow list but forward to denied destinations
|
|
182
|
+
* - Network calls made by spawned subprocesses, generated code, or
|
|
183
|
+
* tools whose argument schema does not name a URL field
|
|
184
|
+
*
|
|
185
|
+
* Treat this hook as a UX guardrail (clearer error messages,
|
|
186
|
+
* short-circuiting obvious mistakes) on top of a real egress boundary
|
|
187
|
+
* — not as one. When egress containment is a security requirement
|
|
188
|
+
* (exfil prevention, data residency), enforce it at the runtime
|
|
189
|
+
* sandbox layer (egress firewall, CNI policy, outbound proxy with
|
|
190
|
+
* mTLS) where the agent cannot evade it.
|
|
191
|
+
*/
|
|
192
|
+
function egressPolicyHook(opts = {}) {
|
|
193
|
+
const denyList = opts.denyList ?? [];
|
|
194
|
+
const allowList = opts.allowList;
|
|
195
|
+
return { async beforeToolCall(ctx) {
|
|
196
|
+
const urls = extractUrlsFromArgs(ctx.args);
|
|
197
|
+
if (!isNetworkLike(ctx, urls)) return { action: "allow" };
|
|
198
|
+
if (opts.allowFn) return await opts.allowFn(ctx) ? { action: "allow" } : {
|
|
199
|
+
action: "block",
|
|
200
|
+
reason: "egress denied by allowFn"
|
|
201
|
+
};
|
|
202
|
+
for (const u of urls) {
|
|
203
|
+
if (!u.host) continue;
|
|
204
|
+
for (const pat of denyList) if (hostMatches(u.host, pat)) return {
|
|
205
|
+
action: "block",
|
|
206
|
+
reason: `egress denied: host "${u.host}" matches deny pattern "${pat}"`
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (allowList && allowList.length > 0) for (const u of urls) {
|
|
210
|
+
const host = u.host;
|
|
211
|
+
if (!host) return {
|
|
212
|
+
action: "block",
|
|
213
|
+
reason: "egress denied: unparseable url"
|
|
214
|
+
};
|
|
215
|
+
if (!allowList.some((pat) => hostMatches(host, pat))) return {
|
|
216
|
+
action: "block",
|
|
217
|
+
reason: `egress denied: host "${host}" is not on the allow list`
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { action: "allow" };
|
|
221
|
+
} };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Terminates the run when the partner's accumulated cost is at or
|
|
225
|
+
* above `ceiling`. The cost lookup is injected so any usage ledger can
|
|
226
|
+
* back it.
|
|
227
|
+
*/
|
|
228
|
+
function costCapHook(opts) {
|
|
229
|
+
const getCurrentCost = opts.getCurrentCost ?? (async () => 0);
|
|
230
|
+
return {
|
|
231
|
+
async beforeToolCall(ctx) {
|
|
232
|
+
const current = await getCurrentCost(ctx.partnerId);
|
|
233
|
+
if (current >= opts.ceiling) return {
|
|
234
|
+
action: "block",
|
|
235
|
+
reason: `cost cap reached: ${current} >= ${opts.ceiling}`
|
|
236
|
+
};
|
|
237
|
+
return { action: "allow" };
|
|
238
|
+
},
|
|
239
|
+
async afterToolCall(ctx) {
|
|
240
|
+
const current = await getCurrentCost(ctx.partnerId);
|
|
241
|
+
if (current >= opts.ceiling) return {
|
|
242
|
+
action: "terminate",
|
|
243
|
+
reason: `cost cap reached after tool call: ${current} >= ${opts.ceiling}`
|
|
244
|
+
};
|
|
245
|
+
return { action: "pass" };
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Token-bucket rate limiter scoped per session id (`runId`) over
|
|
251
|
+
* `computer-use:*` tool calls. Excess calls are blocked with a clear
|
|
252
|
+
* reason. Non-`computer-use` tools pass through untouched.
|
|
253
|
+
*/
|
|
254
|
+
function actionRateLimitHook(opts) {
|
|
255
|
+
const now = opts.now ?? Date.now;
|
|
256
|
+
const capacity = Math.max(1, Math.floor(opts.perSecondPerSession));
|
|
257
|
+
const refillPerMs = opts.perSecondPerSession / 1e3;
|
|
258
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
259
|
+
return { async beforeToolCall(ctx) {
|
|
260
|
+
if (!ctx.toolName.startsWith("computer-use:")) return { action: "allow" };
|
|
261
|
+
const key = ctx.runId;
|
|
262
|
+
const t = now();
|
|
263
|
+
const bucket = buckets.get(key) ?? {
|
|
264
|
+
tokens: capacity,
|
|
265
|
+
lastRefillMs: t
|
|
266
|
+
};
|
|
267
|
+
const elapsed = Math.max(0, t - bucket.lastRefillMs);
|
|
268
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillPerMs);
|
|
269
|
+
bucket.lastRefillMs = t;
|
|
270
|
+
if (bucket.tokens < 1) {
|
|
271
|
+
buckets.set(key, bucket);
|
|
272
|
+
return {
|
|
273
|
+
action: "block",
|
|
274
|
+
reason: `action rate limit exceeded: ${opts.perSecondPerSession}/s`
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
bucket.tokens -= 1;
|
|
278
|
+
buckets.set(key, bucket);
|
|
279
|
+
return { action: "allow" };
|
|
280
|
+
} };
|
|
281
|
+
}
|
|
282
|
+
function readScreenshotPayload(result) {
|
|
283
|
+
const candidates = [
|
|
284
|
+
result.content,
|
|
285
|
+
result.details?.screenshot,
|
|
286
|
+
result.details
|
|
287
|
+
];
|
|
288
|
+
for (const c of candidates) if (c && typeof c === "object") {
|
|
289
|
+
if (typeof c.pngBase64 === "string") return c;
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Applied AFTER `computer-use:screenshot`. v1 ships without the
|
|
295
|
+
* `sharp` dependency: when regions are configured, we surface a warning
|
|
296
|
+
* exactly once per process and pass the screenshot through untouched.
|
|
297
|
+
* Pixel-blur lands in a follow-up that introduces `sharp` deliberately.
|
|
298
|
+
*/
|
|
299
|
+
function screenshotRedactionHook(opts = {}) {
|
|
300
|
+
const warn = opts.warn ?? ((msg) => console.warn(msg));
|
|
301
|
+
const hasRegions = (opts.regions?.length ?? 0) > 0;
|
|
302
|
+
let warned = false;
|
|
303
|
+
return { async afterToolCall(ctx, result) {
|
|
304
|
+
if (ctx.toolName !== "computer-use:screenshot") return { action: "pass" };
|
|
305
|
+
if (!hasRegions) return { action: "pass" };
|
|
306
|
+
if (!readScreenshotPayload(result)) return { action: "pass" };
|
|
307
|
+
if (!warned) {
|
|
308
|
+
warned = true;
|
|
309
|
+
warn("[screenshotRedactionHook] regions configured but `sharp` is not a dependency in v1; passing screenshot through unmodified");
|
|
310
|
+
}
|
|
311
|
+
return { action: "pass" };
|
|
312
|
+
} };
|
|
313
|
+
}
|
|
314
|
+
const TYPE_TEXT_FIELDS = [
|
|
315
|
+
"text",
|
|
316
|
+
"value",
|
|
317
|
+
"input",
|
|
318
|
+
"query"
|
|
319
|
+
];
|
|
320
|
+
const CLICK_LABEL_FIELDS = [
|
|
321
|
+
"label",
|
|
322
|
+
"alt",
|
|
323
|
+
"title",
|
|
324
|
+
"ariaLabel",
|
|
325
|
+
"near"
|
|
326
|
+
];
|
|
327
|
+
function readStringField(args, fields) {
|
|
328
|
+
if (!args || typeof args !== "object") return null;
|
|
329
|
+
const a = args;
|
|
330
|
+
for (const f of fields) {
|
|
331
|
+
const v = a[f];
|
|
332
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Block before-execute when a `computer-use:type` text or
|
|
338
|
+
* `computer-use:click` label matches any deny pattern. Mitigates
|
|
339
|
+
* destructive-action sequences (e.g. "delete account", "wire transfer")
|
|
340
|
+
* before they hit the OS.
|
|
341
|
+
*/
|
|
342
|
+
function destructiveActionGuardHook(opts) {
|
|
343
|
+
const patterns = opts.denyPatterns;
|
|
344
|
+
return { async beforeToolCall(ctx) {
|
|
345
|
+
let candidate = null;
|
|
346
|
+
if (ctx.toolName === "computer-use:type") candidate = readStringField(ctx.args, TYPE_TEXT_FIELDS);
|
|
347
|
+
else if (ctx.toolName === "computer-use:click") candidate = readStringField(ctx.args, CLICK_LABEL_FIELDS);
|
|
348
|
+
else return { action: "allow" };
|
|
349
|
+
if (!candidate) return { action: "allow" };
|
|
350
|
+
for (const re of patterns) if (re.test(candidate)) return {
|
|
351
|
+
action: "block",
|
|
352
|
+
reason: `destructive action denied: input matches ${re.toString()}`
|
|
353
|
+
};
|
|
354
|
+
return { action: "allow" };
|
|
355
|
+
} };
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/openai/responses-store.ts
|
|
359
|
+
var InMemoryResponseStore = class {
|
|
360
|
+
chains = /* @__PURE__ */ new Map();
|
|
361
|
+
async getPriorTurns(responseId) {
|
|
362
|
+
const turns = this.chains.get(responseId);
|
|
363
|
+
return turns ? turns.map((t) => ({ ...t })) : null;
|
|
364
|
+
}
|
|
365
|
+
async putPriorTurns(responseId, priorTurns) {
|
|
366
|
+
this.chains.set(responseId, priorTurns.map((t) => ({ ...t })));
|
|
367
|
+
}
|
|
368
|
+
/** Convenience accessor for tests / debug; not part of `ResponseStore`. */
|
|
369
|
+
size() {
|
|
370
|
+
return this.chains.size;
|
|
371
|
+
}
|
|
372
|
+
/** Drop a chain. */
|
|
373
|
+
delete(responseId) {
|
|
374
|
+
return this.chains.delete(responseId);
|
|
375
|
+
}
|
|
376
|
+
/** Drop everything. Useful between test cases. */
|
|
377
|
+
clear() {
|
|
378
|
+
this.chains.clear();
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/openai/runs.ts
|
|
383
|
+
const TERMINAL_STATUSES = new Set([
|
|
384
|
+
"completed",
|
|
385
|
+
"failed",
|
|
386
|
+
"cancelled",
|
|
387
|
+
"expired"
|
|
388
|
+
]);
|
|
389
|
+
function isTerminal(status) {
|
|
390
|
+
return TERMINAL_STATUSES.has(status);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Pure async iterator over the underlying source. No state, no
|
|
394
|
+
* buffering. Iteration ends when the source closes or yields a
|
|
395
|
+
* terminal frame; the iterator itself does not classify frames.
|
|
396
|
+
*/
|
|
397
|
+
async function* runEvents(threadId, runId, source) {
|
|
398
|
+
for await (const event of source.open(threadId, runId)) yield event;
|
|
399
|
+
}
|
|
400
|
+
function readToolCallFromFrame(event) {
|
|
401
|
+
if (event.type === "requires_action" || event.type === "tool_call_requested") {
|
|
402
|
+
const data = event.data ?? event;
|
|
403
|
+
const id = String(data.callId ?? data.call_id ?? data.id ?? "");
|
|
404
|
+
const name = String(data.toolName ?? data.tool_name ?? data.name ?? "");
|
|
405
|
+
if (!id || !name) return null;
|
|
406
|
+
return {
|
|
407
|
+
callId: id,
|
|
408
|
+
toolName: name,
|
|
409
|
+
args: data.args ?? data.arguments ?? data.input ?? {},
|
|
410
|
+
requiresAction: true
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (event.type !== "raw") return null;
|
|
414
|
+
const data = event.data ?? event;
|
|
415
|
+
const inner = typeof data.type === "string" ? data.type : "";
|
|
416
|
+
if (!(inner === "tool-invocation" || inner === "tool_call" || inner === "computer-use" || inner === "computer_call")) return null;
|
|
417
|
+
const ti = data.toolInvocation ?? data.tool_invocation ?? data.computerUse ?? data.computer_use ?? data;
|
|
418
|
+
const id = String(ti.toolCallId ?? ti.tool_call_id ?? ti.callId ?? ti.id ?? "");
|
|
419
|
+
const name = inner === "computer-use" || inner === "computer_call" ? `computer-use:${String(ti.action?.type ?? "action")}` : String(ti.toolName ?? ti.tool_name ?? ti.name ?? "");
|
|
420
|
+
if (!id || !name) return null;
|
|
421
|
+
return {
|
|
422
|
+
callId: id,
|
|
423
|
+
toolName: name,
|
|
424
|
+
args: ti.args ?? ti.arguments ?? ti.input ?? {},
|
|
425
|
+
requiresAction: false
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function readTerminal(event) {
|
|
429
|
+
if (event.type === "done") {
|
|
430
|
+
const outcome = typeof event.outcome === "string" && event.outcome || "success";
|
|
431
|
+
if (outcome === "success" || outcome === "completed" || outcome === "succeeded" || outcome === "done") return { status: "completed" };
|
|
432
|
+
return {
|
|
433
|
+
status: "failed",
|
|
434
|
+
reason: typeof event.error === "string" ? event.error : outcome
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (event.type === "error") return {
|
|
438
|
+
status: "failed",
|
|
439
|
+
reason: typeof event.message === "string" && event.message || typeof event.error === "string" && event.error || "stream error"
|
|
440
|
+
};
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Internal queue helper: an async iterable backed by a bounded
|
|
445
|
+
* pending-list with a settle promise that resolves whenever a new
|
|
446
|
+
* event lands or the queue closes.
|
|
447
|
+
*
|
|
448
|
+
* Each iterator returned by `iterator()` shares the queue's buffer
|
|
449
|
+
* and waiter list. Two concurrent iterators would therefore steal
|
|
450
|
+
* events from each other (whichever called `next()` first wins),
|
|
451
|
+
* which silently drops events for the second consumer. Because there
|
|
452
|
+
* is only ever one consumer of `Run.events()` by design, this class
|
|
453
|
+
* enforces single-consumption explicitly: a second `iterator()` call
|
|
454
|
+
* throws so the contract violation is loud rather than silent.
|
|
455
|
+
*/
|
|
456
|
+
var EventQueue = class {
|
|
457
|
+
buffer = [];
|
|
458
|
+
waiters = [];
|
|
459
|
+
closed = false;
|
|
460
|
+
iteratorCreated = false;
|
|
461
|
+
push(value) {
|
|
462
|
+
if (this.closed) return;
|
|
463
|
+
const w = this.waiters.shift();
|
|
464
|
+
if (w) {
|
|
465
|
+
w({
|
|
466
|
+
value,
|
|
467
|
+
done: false
|
|
468
|
+
});
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.buffer.push(value);
|
|
472
|
+
}
|
|
473
|
+
close() {
|
|
474
|
+
if (this.closed) return;
|
|
475
|
+
this.closed = true;
|
|
476
|
+
while (this.waiters.length > 0) this.waiters.shift()?.({
|
|
477
|
+
value: void 0,
|
|
478
|
+
done: true
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
iterator() {
|
|
482
|
+
if (this.iteratorCreated) throw new Error("Run.events() may only be consumed once. Multiple iterators on the same Run share state and would interleave/steal events.");
|
|
483
|
+
this.iteratorCreated = true;
|
|
484
|
+
const self = this;
|
|
485
|
+
return {
|
|
486
|
+
[Symbol.asyncIterator]() {
|
|
487
|
+
return this;
|
|
488
|
+
},
|
|
489
|
+
next() {
|
|
490
|
+
if (self.buffer.length > 0) {
|
|
491
|
+
const value = self.buffer.shift();
|
|
492
|
+
return Promise.resolve({
|
|
493
|
+
value,
|
|
494
|
+
done: false
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (self.closed) return Promise.resolve({
|
|
498
|
+
value: void 0,
|
|
499
|
+
done: true
|
|
500
|
+
});
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
self.waiters.push(resolve);
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
return() {
|
|
506
|
+
self.close();
|
|
507
|
+
return Promise.resolve({
|
|
508
|
+
value: void 0,
|
|
509
|
+
done: true
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
/**
|
|
516
|
+
* High-level `Run`. Drives the underlying `RunEventSource`, manages
|
|
517
|
+
* status transitions, dispatches tool calls through the hook chain,
|
|
518
|
+
* and surfaces a typed `RunEvent` stream to consumers.
|
|
519
|
+
*/
|
|
520
|
+
var Run = class {
|
|
521
|
+
id;
|
|
522
|
+
threadId;
|
|
523
|
+
partnerId;
|
|
524
|
+
_status = "queued";
|
|
525
|
+
source;
|
|
526
|
+
hooks;
|
|
527
|
+
executeTool;
|
|
528
|
+
onStatusChange;
|
|
529
|
+
queue = new EventQueue();
|
|
530
|
+
startPromise = null;
|
|
531
|
+
pendingCalls = /* @__PURE__ */ new Map();
|
|
532
|
+
cancelRequested = false;
|
|
533
|
+
constructor(opts) {
|
|
534
|
+
this.id = opts.id;
|
|
535
|
+
this.threadId = opts.threadId;
|
|
536
|
+
this.partnerId = opts.partnerId;
|
|
537
|
+
this.source = opts.source;
|
|
538
|
+
this.hooks = opts.hooks;
|
|
539
|
+
this.executeTool = opts.executeTool;
|
|
540
|
+
this.onStatusChange = opts.onStatusChange;
|
|
541
|
+
}
|
|
542
|
+
get status() {
|
|
543
|
+
return this._status;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Subscribe to typed events. May only be consumed once per Run.
|
|
547
|
+
*
|
|
548
|
+
* The single-consumer constraint is permanent for the lifetime of
|
|
549
|
+
* the Run instance, including AFTER the run completes — calling
|
|
550
|
+
* `events()` a second time always throws, even if the first
|
|
551
|
+
* consumer drained the iterator to completion. This is intentional:
|
|
552
|
+
* the queue is a one-shot stream, not a re-readable buffer, and
|
|
553
|
+
* events are dropped after delivery to avoid unbounded retention.
|
|
554
|
+
* Callers that need to revisit terminal state should keep a handle
|
|
555
|
+
* to the original iterator's drained values, or use the Run's
|
|
556
|
+
* status/result accessors.
|
|
557
|
+
*/
|
|
558
|
+
events() {
|
|
559
|
+
return this.queue.iterator();
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Drive the underlying stream to completion. Idempotent: subsequent
|
|
563
|
+
* calls return the same in-flight promise.
|
|
564
|
+
*/
|
|
565
|
+
start() {
|
|
566
|
+
if (this.startPromise) return this.startPromise;
|
|
567
|
+
this.startPromise = this.driveStream();
|
|
568
|
+
return this.startPromise;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Cancel an in-flight run. Sets status to `cancelled` and closes
|
|
572
|
+
* the event queue. Tries the source-level cancel hint as a
|
|
573
|
+
* best-effort heads-up to the upstream stream.
|
|
574
|
+
*/
|
|
575
|
+
async cancel(reason) {
|
|
576
|
+
if (isTerminal(this._status)) return;
|
|
577
|
+
this.cancelRequested = true;
|
|
578
|
+
if (this.source.cancel) try {
|
|
579
|
+
await this.source.cancel(this.threadId, this.id);
|
|
580
|
+
} catch {}
|
|
581
|
+
this.transition("cancelled");
|
|
582
|
+
this.queue.push({
|
|
583
|
+
type: "cancelled",
|
|
584
|
+
reason
|
|
585
|
+
});
|
|
586
|
+
this.queue.close();
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Append a user message to the thread and restart the stream. The
|
|
590
|
+
* source's `steer` hook is invoked when present; otherwise we throw
|
|
591
|
+
* because a stateless source cannot honor a steer.
|
|
592
|
+
*/
|
|
593
|
+
async steer(message) {
|
|
594
|
+
if (!this.source.steer) throw new Error("RunEventSource does not support steer");
|
|
595
|
+
if (isTerminal(this._status)) throw new Error(`cannot steer a ${this._status} run`);
|
|
596
|
+
await this.source.steer(this.threadId, this.id, message);
|
|
597
|
+
if (this._status === "requires_action") this.transition("in_progress");
|
|
598
|
+
this.startPromise = this.driveStream();
|
|
599
|
+
await this.startPromise;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Resume a `requires_action` run by submitting tool outputs. The
|
|
603
|
+
* outputs are forwarded to the source so the upstream agent can
|
|
604
|
+
* continue. Status transitions back to `in_progress`.
|
|
605
|
+
*/
|
|
606
|
+
async submitToolOutputs(outputs) {
|
|
607
|
+
if (this._status !== "requires_action") throw new Error(`cannot submit tool outputs in status ${this._status}`);
|
|
608
|
+
if (!this.source.submitToolOutputs) throw new Error("RunEventSource does not support submitToolOutputs");
|
|
609
|
+
for (const o of outputs) this.pendingCalls.delete(o.toolCallId);
|
|
610
|
+
await this.source.submitToolOutputs(this.threadId, this.id, outputs);
|
|
611
|
+
this.transition("in_progress");
|
|
612
|
+
this.startPromise = this.driveStream();
|
|
613
|
+
await this.startPromise;
|
|
614
|
+
}
|
|
615
|
+
/** Externally drive the run into the `expired` terminal status. */
|
|
616
|
+
expire() {
|
|
617
|
+
if (isTerminal(this._status)) return;
|
|
618
|
+
this.transition("expired");
|
|
619
|
+
this.queue.push({
|
|
620
|
+
type: "failed",
|
|
621
|
+
reason: "expired"
|
|
622
|
+
});
|
|
623
|
+
this.queue.close();
|
|
624
|
+
}
|
|
625
|
+
transition(next) {
|
|
626
|
+
if (this._status === next) return;
|
|
627
|
+
const prev = this._status;
|
|
628
|
+
this._status = next;
|
|
629
|
+
this.queue.push({
|
|
630
|
+
type: "status",
|
|
631
|
+
status: next,
|
|
632
|
+
previous: prev
|
|
633
|
+
});
|
|
634
|
+
this.onStatusChange?.(next, prev);
|
|
635
|
+
}
|
|
636
|
+
async driveStream() {
|
|
637
|
+
try {
|
|
638
|
+
for await (const event of runEvents(this.threadId, this.id, this.source)) {
|
|
639
|
+
if (this.cancelRequested) return;
|
|
640
|
+
if (this._status === "queued") this.transition("in_progress");
|
|
641
|
+
this.queue.push({
|
|
642
|
+
type: "stream",
|
|
643
|
+
event
|
|
644
|
+
});
|
|
645
|
+
const tool = readToolCallFromFrame(event);
|
|
646
|
+
if (tool) {
|
|
647
|
+
const handled = await this.handleToolCall(tool);
|
|
648
|
+
if (handled === "terminate") return;
|
|
649
|
+
if (handled === "requires_action") return;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const terminal = readTerminal(event);
|
|
653
|
+
if (terminal) {
|
|
654
|
+
if (terminal.status === "completed") {
|
|
655
|
+
this.transition("completed");
|
|
656
|
+
this.queue.push({ type: "completed" });
|
|
657
|
+
} else {
|
|
658
|
+
this.transition("failed");
|
|
659
|
+
this.queue.push({
|
|
660
|
+
type: "failed",
|
|
661
|
+
reason: terminal.reason ?? "unknown failure"
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
this.queue.close();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (!isTerminal(this._status)) {
|
|
669
|
+
this.transition("completed");
|
|
670
|
+
this.queue.push({ type: "completed" });
|
|
671
|
+
this.queue.close();
|
|
672
|
+
}
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (isTerminal(this._status)) return;
|
|
675
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
676
|
+
this.transition("failed");
|
|
677
|
+
this.queue.push({
|
|
678
|
+
type: "failed",
|
|
679
|
+
reason
|
|
680
|
+
});
|
|
681
|
+
this.queue.close();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async handleToolCall(tool) {
|
|
685
|
+
const ctx = {
|
|
686
|
+
runId: this.id,
|
|
687
|
+
threadId: this.threadId,
|
|
688
|
+
partnerId: this.partnerId,
|
|
689
|
+
toolName: tool.toolName,
|
|
690
|
+
args: tool.args,
|
|
691
|
+
callId: tool.callId,
|
|
692
|
+
timestamp: Date.now()
|
|
693
|
+
};
|
|
694
|
+
let dispatchCtx = ctx;
|
|
695
|
+
if (this.hooks && this.hooks.size > 0) {
|
|
696
|
+
const before = await this.hooks.runBefore(ctx);
|
|
697
|
+
if (before.action === "block") {
|
|
698
|
+
const blockedResult = {
|
|
699
|
+
content: { error: before.reason },
|
|
700
|
+
isError: true,
|
|
701
|
+
details: { blockedBy: "beforeToolCall" }
|
|
702
|
+
};
|
|
703
|
+
this.queue.push({
|
|
704
|
+
type: "tool_blocked",
|
|
705
|
+
ctx,
|
|
706
|
+
reason: before.reason
|
|
707
|
+
});
|
|
708
|
+
this.queue.push({
|
|
709
|
+
type: "tool_result",
|
|
710
|
+
ctx,
|
|
711
|
+
result: blockedResult
|
|
712
|
+
});
|
|
713
|
+
if (tool.requiresAction) {
|
|
714
|
+
this.pendingCalls.set(tool.callId, ctx);
|
|
715
|
+
this.transition("requires_action");
|
|
716
|
+
this.queue.push({
|
|
717
|
+
type: "requires_action",
|
|
718
|
+
pendingCallIds: Array.from(this.pendingCalls.keys())
|
|
719
|
+
});
|
|
720
|
+
return "requires_action";
|
|
721
|
+
}
|
|
722
|
+
return "continue";
|
|
723
|
+
}
|
|
724
|
+
if (before.action === "rewrite") dispatchCtx = {
|
|
725
|
+
...ctx,
|
|
726
|
+
args: before.args
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (tool.requiresAction) {
|
|
730
|
+
this.pendingCalls.set(tool.callId, dispatchCtx);
|
|
731
|
+
this.transition("requires_action");
|
|
732
|
+
this.queue.push({
|
|
733
|
+
type: "tool_call",
|
|
734
|
+
ctx: dispatchCtx
|
|
735
|
+
});
|
|
736
|
+
this.queue.push({
|
|
737
|
+
type: "requires_action",
|
|
738
|
+
pendingCallIds: Array.from(this.pendingCalls.keys())
|
|
739
|
+
});
|
|
740
|
+
return "requires_action";
|
|
741
|
+
}
|
|
742
|
+
if (!this.executeTool) {
|
|
743
|
+
this.queue.push({
|
|
744
|
+
type: "tool_call",
|
|
745
|
+
ctx: dispatchCtx
|
|
746
|
+
});
|
|
747
|
+
return "continue";
|
|
748
|
+
}
|
|
749
|
+
this.queue.push({
|
|
750
|
+
type: "tool_call",
|
|
751
|
+
ctx: dispatchCtx
|
|
752
|
+
});
|
|
753
|
+
let result;
|
|
754
|
+
try {
|
|
755
|
+
result = await this.executeTool(dispatchCtx);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
result = {
|
|
758
|
+
content: { error: err instanceof Error ? err.message : String(err) },
|
|
759
|
+
isError: true
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
let finalResult = result;
|
|
763
|
+
if (this.hooks && this.hooks.size > 0) {
|
|
764
|
+
const after = await this.hooks.runAfter(dispatchCtx, result);
|
|
765
|
+
if (after.action === "terminate") {
|
|
766
|
+
this.queue.push({
|
|
767
|
+
type: "tool_result",
|
|
768
|
+
ctx: dispatchCtx,
|
|
769
|
+
result
|
|
770
|
+
});
|
|
771
|
+
this.transition("failed");
|
|
772
|
+
this.queue.push({
|
|
773
|
+
type: "failed",
|
|
774
|
+
reason: after.reason
|
|
775
|
+
});
|
|
776
|
+
this.queue.close();
|
|
777
|
+
return "terminate";
|
|
778
|
+
}
|
|
779
|
+
if (after.action === "override") finalResult = after.result;
|
|
780
|
+
}
|
|
781
|
+
this.queue.push({
|
|
782
|
+
type: "tool_result",
|
|
783
|
+
ctx: dispatchCtx,
|
|
784
|
+
result: finalResult
|
|
785
|
+
});
|
|
786
|
+
return "continue";
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region src/openai/translate/finish-reason.ts
|
|
791
|
+
function outcomeToFinishReason(outcome, hasToolCall) {
|
|
792
|
+
switch (outcome) {
|
|
793
|
+
case "success":
|
|
794
|
+
case "succeeded":
|
|
795
|
+
case "completed":
|
|
796
|
+
case "done": return hasToolCall ? "tool_calls" : "stop";
|
|
797
|
+
case "length_exceeded":
|
|
798
|
+
case "length":
|
|
799
|
+
case "max_tokens": return "length";
|
|
800
|
+
case "content_filtered":
|
|
801
|
+
case "content_filter":
|
|
802
|
+
case "filtered": return "content_filter";
|
|
803
|
+
default: return "stop";
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
//#endregion
|
|
807
|
+
//#region src/openai/translate/chunks.ts
|
|
808
|
+
/**
|
|
809
|
+
* Read the text token from a `token` event. Tangle emits the delta as
|
|
810
|
+
* either `delta`, `text`, or `token`; tolerate all three so the
|
|
811
|
+
* translator stays decoupled from minor runtime variations.
|
|
812
|
+
*/
|
|
813
|
+
function readTokenDelta(event) {
|
|
814
|
+
if (typeof event.delta === "string") return event.delta;
|
|
815
|
+
if (typeof event.text === "string") return event.text;
|
|
816
|
+
if (typeof event.token === "string") return event.token;
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
function readToolInvocation(event) {
|
|
820
|
+
if (event.type !== "raw") return null;
|
|
821
|
+
const data = event.data ?? event;
|
|
822
|
+
const inner = typeof data.type === "string" ? data.type : typeof event.type === "string" ? event.type : "";
|
|
823
|
+
if (!(inner === "tool-invocation" || inner === "tool_call" || typeof data.toolInvocation === "object" || typeof data.tool_invocation === "object")) return null;
|
|
824
|
+
const ti = data.toolInvocation ?? data.tool_invocation ?? data;
|
|
825
|
+
const id = typeof ti.toolCallId === "string" ? ti.toolCallId : typeof ti.tool_call_id === "string" ? ti.tool_call_id : typeof ti.id === "string" ? ti.id : typeof ti.callId === "string" ? ti.callId : "";
|
|
826
|
+
const name = typeof ti.toolName === "string" ? ti.toolName : typeof ti.tool_name === "string" ? ti.tool_name : typeof ti.name === "string" ? ti.name : "";
|
|
827
|
+
const args = ti.args ?? ti.arguments ?? ti.input ?? {};
|
|
828
|
+
if (!id || !name) return null;
|
|
829
|
+
return {
|
|
830
|
+
id,
|
|
831
|
+
name,
|
|
832
|
+
arguments: typeof args === "string" ? args : JSON.stringify(args)
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Read a usage payload from a `done` event. The runtime emits usage
|
|
837
|
+
* either at the top level or under a `usage` key; try both.
|
|
838
|
+
*/
|
|
839
|
+
function readUsage(event) {
|
|
840
|
+
const u = event.usage ?? event;
|
|
841
|
+
const prompt = Number(u.prompt_tokens ?? u.input_tokens ?? u.inputTokens ?? 0);
|
|
842
|
+
const completion = Number(u.completion_tokens ?? u.output_tokens ?? u.outputTokens ?? 0);
|
|
843
|
+
if (prompt === 0 && completion === 0) return null;
|
|
844
|
+
return {
|
|
845
|
+
prompt_tokens: prompt,
|
|
846
|
+
completion_tokens: completion,
|
|
847
|
+
total_tokens: Number(u.total_tokens ?? prompt + completion)
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function readOutcome(event) {
|
|
851
|
+
if (typeof event.outcome === "string") return event.outcome;
|
|
852
|
+
if (typeof event.status === "string") return event.status;
|
|
853
|
+
if (event.success === false) return "failure";
|
|
854
|
+
return "success";
|
|
855
|
+
}
|
|
856
|
+
function chatBaseChunk(ctx) {
|
|
857
|
+
ctx.chunkIndex += 1;
|
|
858
|
+
return {
|
|
859
|
+
id: ctx.runId,
|
|
860
|
+
created: ctx.createdAt,
|
|
861
|
+
model: ctx.modelId,
|
|
862
|
+
object: "chat.completion.chunk"
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function sandboxEventToChatChunk(event, ctx) {
|
|
866
|
+
switch (event.type) {
|
|
867
|
+
case "start": return null;
|
|
868
|
+
case "execution.started": return null;
|
|
869
|
+
case "status": return null;
|
|
870
|
+
case "session.updated": return null;
|
|
871
|
+
case "message.part.updated": return null;
|
|
872
|
+
case "token": {
|
|
873
|
+
const delta = readTokenDelta(event);
|
|
874
|
+
if (delta == null || delta.length === 0) return null;
|
|
875
|
+
return {
|
|
876
|
+
...chatBaseChunk(ctx),
|
|
877
|
+
choices: [{
|
|
878
|
+
index: 0,
|
|
879
|
+
delta: {
|
|
880
|
+
role: "assistant",
|
|
881
|
+
content: delta
|
|
882
|
+
},
|
|
883
|
+
finish_reason: null
|
|
884
|
+
}]
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
case "raw": {
|
|
888
|
+
const tool = readToolInvocation(event);
|
|
889
|
+
if (!tool) return null;
|
|
890
|
+
const existing = ctx.toolCallBuffer.get(tool.id);
|
|
891
|
+
const index = existing ? existing.index : ctx.toolCallBuffer.size;
|
|
892
|
+
ctx.toolCallBuffer.set(tool.id, {
|
|
893
|
+
index,
|
|
894
|
+
id: tool.id,
|
|
895
|
+
name: tool.name,
|
|
896
|
+
arguments: tool.arguments
|
|
897
|
+
});
|
|
898
|
+
return {
|
|
899
|
+
...chatBaseChunk(ctx),
|
|
900
|
+
choices: [{
|
|
901
|
+
index: 0,
|
|
902
|
+
delta: {
|
|
903
|
+
role: "assistant",
|
|
904
|
+
tool_calls: [{
|
|
905
|
+
index,
|
|
906
|
+
id: tool.id,
|
|
907
|
+
type: "function",
|
|
908
|
+
function: {
|
|
909
|
+
name: tool.name,
|
|
910
|
+
arguments: tool.arguments
|
|
911
|
+
}
|
|
912
|
+
}]
|
|
913
|
+
},
|
|
914
|
+
finish_reason: null
|
|
915
|
+
}]
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
case "error": {
|
|
919
|
+
const hasToolCall = ctx.toolCallBuffer.size > 0;
|
|
920
|
+
return {
|
|
921
|
+
...chatBaseChunk(ctx),
|
|
922
|
+
choices: [{
|
|
923
|
+
index: 0,
|
|
924
|
+
delta: {},
|
|
925
|
+
finish_reason: outcomeToFinishReason("error", hasToolCall)
|
|
926
|
+
}]
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
case "done": {
|
|
930
|
+
const hasToolCall = ctx.toolCallBuffer.size > 0;
|
|
931
|
+
const finish = outcomeToFinishReason(readOutcome(event), hasToolCall);
|
|
932
|
+
const usage = readUsage(event);
|
|
933
|
+
const chunk = {
|
|
934
|
+
...chatBaseChunk(ctx),
|
|
935
|
+
choices: [{
|
|
936
|
+
index: 0,
|
|
937
|
+
delta: {},
|
|
938
|
+
finish_reason: finish
|
|
939
|
+
}]
|
|
940
|
+
};
|
|
941
|
+
if (usage) chunk.usage = usage;
|
|
942
|
+
return chunk;
|
|
943
|
+
}
|
|
944
|
+
default: return null;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function completionBaseChunk(ctx) {
|
|
948
|
+
ctx.chunkIndex += 1;
|
|
949
|
+
return {
|
|
950
|
+
id: ctx.runId,
|
|
951
|
+
created: ctx.createdAt,
|
|
952
|
+
model: ctx.modelId,
|
|
953
|
+
object: "text_completion"
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function sandboxEventToCompletionChunk(event, ctx) {
|
|
957
|
+
switch (event.type) {
|
|
958
|
+
case "start":
|
|
959
|
+
case "execution.started":
|
|
960
|
+
case "status":
|
|
961
|
+
case "session.updated":
|
|
962
|
+
case "message.part.updated":
|
|
963
|
+
case "raw":
|
|
964
|
+
if (event.type === "raw") {
|
|
965
|
+
const tool = readToolInvocation(event);
|
|
966
|
+
if (tool) {
|
|
967
|
+
const existing = ctx.toolCallBuffer.get(tool.id);
|
|
968
|
+
const index = existing ? existing.index : ctx.toolCallBuffer.size;
|
|
969
|
+
ctx.toolCallBuffer.set(tool.id, {
|
|
970
|
+
index,
|
|
971
|
+
id: tool.id,
|
|
972
|
+
name: tool.name,
|
|
973
|
+
arguments: tool.arguments
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
case "token": {
|
|
979
|
+
const delta = readTokenDelta(event);
|
|
980
|
+
if (delta == null || delta.length === 0) return null;
|
|
981
|
+
return {
|
|
982
|
+
...completionBaseChunk(ctx),
|
|
983
|
+
choices: [{
|
|
984
|
+
index: 0,
|
|
985
|
+
text: delta,
|
|
986
|
+
finish_reason: null,
|
|
987
|
+
logprobs: null
|
|
988
|
+
}]
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
case "error":
|
|
992
|
+
case "done": {
|
|
993
|
+
const finish = outcomeToFinishReason(event.type === "error" ? "error" : readOutcome(event), false);
|
|
994
|
+
const reason = finish === "tool_calls" ? "stop" : finish;
|
|
995
|
+
const usage = readUsage(event);
|
|
996
|
+
const chunk = {
|
|
997
|
+
...completionBaseChunk(ctx),
|
|
998
|
+
choices: [{
|
|
999
|
+
index: 0,
|
|
1000
|
+
text: "",
|
|
1001
|
+
finish_reason: reason,
|
|
1002
|
+
logprobs: null
|
|
1003
|
+
}]
|
|
1004
|
+
};
|
|
1005
|
+
if (usage) chunk.usage = usage;
|
|
1006
|
+
return chunk;
|
|
1007
|
+
}
|
|
1008
|
+
default: return null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function getResponsesExtras(ctx) {
|
|
1012
|
+
const slot = ctx;
|
|
1013
|
+
if (!slot.__responsesExtras) slot.__responsesExtras = {
|
|
1014
|
+
sequence: 0,
|
|
1015
|
+
toolOutputIndex: /* @__PURE__ */ new Map()
|
|
1016
|
+
};
|
|
1017
|
+
return slot.__responsesExtras;
|
|
1018
|
+
}
|
|
1019
|
+
function nextSeq(extras) {
|
|
1020
|
+
extras.sequence += 1;
|
|
1021
|
+
return extras.sequence;
|
|
1022
|
+
}
|
|
1023
|
+
function emptyResponseUsage() {
|
|
1024
|
+
return {
|
|
1025
|
+
input_tokens: 0,
|
|
1026
|
+
output_tokens: 0,
|
|
1027
|
+
total_tokens: 0,
|
|
1028
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
1029
|
+
output_tokens_details: { reasoning_tokens: 0 }
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function usageFromTokens(usage) {
|
|
1033
|
+
return {
|
|
1034
|
+
input_tokens: usage.prompt_tokens,
|
|
1035
|
+
output_tokens: usage.completion_tokens,
|
|
1036
|
+
total_tokens: usage.total_tokens,
|
|
1037
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
1038
|
+
output_tokens_details: { reasoning_tokens: 0 }
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
function sandboxEventToResponsesEvent(event, ctx) {
|
|
1042
|
+
const extras = getResponsesExtras(ctx);
|
|
1043
|
+
switch (event.type) {
|
|
1044
|
+
case "start": return null;
|
|
1045
|
+
case "execution.started": return null;
|
|
1046
|
+
case "status": return null;
|
|
1047
|
+
case "session.updated": return null;
|
|
1048
|
+
case "message.part.updated": return null;
|
|
1049
|
+
case "token": {
|
|
1050
|
+
const delta = readTokenDelta(event);
|
|
1051
|
+
if (delta == null || delta.length === 0) return null;
|
|
1052
|
+
ctx.chunkIndex += 1;
|
|
1053
|
+
if (extras.messageOutputIndex === void 0) extras.messageOutputIndex = ctx.toolCallBuffer.size;
|
|
1054
|
+
return {
|
|
1055
|
+
type: "response.output_text.delta",
|
|
1056
|
+
content_index: 0,
|
|
1057
|
+
delta,
|
|
1058
|
+
item_id: `msg_${ctx.runId}`,
|
|
1059
|
+
logprobs: [],
|
|
1060
|
+
output_index: extras.messageOutputIndex,
|
|
1061
|
+
sequence_number: nextSeq(extras)
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
case "raw": {
|
|
1065
|
+
const tool = readToolInvocation(event);
|
|
1066
|
+
if (!tool) return null;
|
|
1067
|
+
ctx.chunkIndex += 1;
|
|
1068
|
+
const existing = ctx.toolCallBuffer.get(tool.id);
|
|
1069
|
+
const index = existing ? existing.index : ctx.toolCallBuffer.size;
|
|
1070
|
+
ctx.toolCallBuffer.set(tool.id, {
|
|
1071
|
+
index,
|
|
1072
|
+
id: tool.id,
|
|
1073
|
+
name: tool.name,
|
|
1074
|
+
arguments: tool.arguments
|
|
1075
|
+
});
|
|
1076
|
+
const outputIndex = extras.toolOutputIndex.get(tool.id) ?? extras.toolOutputIndex.size;
|
|
1077
|
+
extras.toolOutputIndex.set(tool.id, outputIndex);
|
|
1078
|
+
return {
|
|
1079
|
+
type: "response.output_item.added",
|
|
1080
|
+
item: {
|
|
1081
|
+
type: "function_call",
|
|
1082
|
+
id: `fc_${tool.id}`,
|
|
1083
|
+
call_id: tool.id,
|
|
1084
|
+
name: tool.name,
|
|
1085
|
+
arguments: tool.arguments,
|
|
1086
|
+
status: "completed"
|
|
1087
|
+
},
|
|
1088
|
+
output_index: outputIndex,
|
|
1089
|
+
sequence_number: nextSeq(extras)
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
case "error": {
|
|
1093
|
+
const outputIndex = extras.messageOutputIndex ?? 0;
|
|
1094
|
+
return {
|
|
1095
|
+
type: "response.output_item.done",
|
|
1096
|
+
item: {
|
|
1097
|
+
type: "message",
|
|
1098
|
+
id: `msg_${ctx.runId}`,
|
|
1099
|
+
role: "assistant",
|
|
1100
|
+
status: "incomplete",
|
|
1101
|
+
content: []
|
|
1102
|
+
},
|
|
1103
|
+
output_index: outputIndex,
|
|
1104
|
+
sequence_number: nextSeq(extras)
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
case "done": {
|
|
1108
|
+
const tokens = readUsage(event);
|
|
1109
|
+
const usage = tokens ? usageFromTokens(tokens) : emptyResponseUsage();
|
|
1110
|
+
return {
|
|
1111
|
+
type: "response.completed",
|
|
1112
|
+
sequence_number: nextSeq(extras),
|
|
1113
|
+
response: {
|
|
1114
|
+
id: ctx.runId,
|
|
1115
|
+
object: "response",
|
|
1116
|
+
created_at: ctx.createdAt,
|
|
1117
|
+
output_text: "",
|
|
1118
|
+
error: null,
|
|
1119
|
+
incomplete_details: null,
|
|
1120
|
+
instructions: null,
|
|
1121
|
+
metadata: null,
|
|
1122
|
+
model: ctx.modelId,
|
|
1123
|
+
output: [],
|
|
1124
|
+
parallel_tool_calls: false,
|
|
1125
|
+
temperature: null,
|
|
1126
|
+
tool_choice: "auto",
|
|
1127
|
+
tools: [],
|
|
1128
|
+
top_p: null,
|
|
1129
|
+
status: "completed",
|
|
1130
|
+
usage
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
default: return null;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
//#endregion
|
|
1138
|
+
//#region src/openai/translate/embeddings.ts
|
|
1139
|
+
/**
|
|
1140
|
+
* Error shape used for rejecting embedding requests. The route layer
|
|
1141
|
+
* lifts these into OpenAI's `{ error: { type, code, message } }` body.
|
|
1142
|
+
*/
|
|
1143
|
+
var EmbeddingValidationError = class extends Error {
|
|
1144
|
+
type = "invalid_request_error";
|
|
1145
|
+
code;
|
|
1146
|
+
param;
|
|
1147
|
+
constructor(message, code, param) {
|
|
1148
|
+
super(message);
|
|
1149
|
+
this.name = "EmbeddingValidationError";
|
|
1150
|
+
this.code = code;
|
|
1151
|
+
this.param = param;
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
const MAX_BATCH = 2048;
|
|
1155
|
+
function reject(message, code, param) {
|
|
1156
|
+
throw new EmbeddingValidationError(message, code, param);
|
|
1157
|
+
}
|
|
1158
|
+
function normalizeInput(input) {
|
|
1159
|
+
if (typeof input === "string") {
|
|
1160
|
+
if (input.length === 0) reject("Input string must not be empty", "empty_input", "input");
|
|
1161
|
+
return {
|
|
1162
|
+
kind: "text",
|
|
1163
|
+
values: [input]
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
if (!Array.isArray(input)) reject("Input must be a string, array of strings, or array of token arrays", "invalid_input_type", "input");
|
|
1167
|
+
if (input.length === 0) reject("Input array must not be empty", "empty_input", "input");
|
|
1168
|
+
if (input.length > MAX_BATCH) reject(`Input array exceeds maximum batch size of ${MAX_BATCH}`, "batch_too_large", "input");
|
|
1169
|
+
const first = input[0];
|
|
1170
|
+
if (typeof first === "string") {
|
|
1171
|
+
const values = [];
|
|
1172
|
+
for (let i = 0; i < input.length; i++) {
|
|
1173
|
+
const v = input[i];
|
|
1174
|
+
if (typeof v !== "string") reject(`Input array must be homogeneous; index ${i} is not a string`, "mixed_input_types", "input");
|
|
1175
|
+
if (v.length === 0) reject(`Input string at index ${i} must not be empty`, "empty_input", "input");
|
|
1176
|
+
values.push(v);
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
kind: "text",
|
|
1180
|
+
values
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
if (typeof first === "number") {
|
|
1184
|
+
for (let i = 0; i < input.length; i++) if (typeof input[i] !== "number") reject(`Token array must contain only numbers; index ${i} is ${typeof input[i]}`, "invalid_token_type", "input");
|
|
1185
|
+
return {
|
|
1186
|
+
kind: "tokens",
|
|
1187
|
+
values: [input]
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
if (Array.isArray(first)) {
|
|
1191
|
+
const values = [];
|
|
1192
|
+
for (let i = 0; i < input.length; i++) {
|
|
1193
|
+
const row = input[i];
|
|
1194
|
+
if (!Array.isArray(row)) reject(`Input array must be homogeneous; index ${i} is not an array`, "mixed_input_types", "input");
|
|
1195
|
+
if (row.length === 0) reject(`Token array at index ${i} must not be empty`, "empty_input", "input");
|
|
1196
|
+
for (let j = 0; j < row.length; j++) if (typeof row[j] !== "number") reject(`Token array at index ${i} must contain only numbers; element ${j} is ${typeof row[j]}`, "invalid_token_type", "input");
|
|
1197
|
+
values.push(row);
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
kind: "tokens",
|
|
1201
|
+
values
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
reject("Input must be a string, array of strings, or array of token arrays", "invalid_input_type", "input");
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Validate and normalize an embedding request. Throws
|
|
1208
|
+
* `EmbeddingValidationError` on any rejection.
|
|
1209
|
+
*/
|
|
1210
|
+
function validateEmbeddingRequest(req) {
|
|
1211
|
+
if (!req || typeof req !== "object") reject("Request body must be an object", "invalid_request", void 0);
|
|
1212
|
+
if (typeof req.model !== "string" || req.model.length === 0) reject("Model is required", "model_required", "model");
|
|
1213
|
+
if (req.dimensions !== void 0) {
|
|
1214
|
+
if (typeof req.dimensions !== "number" || !Number.isInteger(req.dimensions) || req.dimensions <= 0) reject("Dimensions must be a positive integer", "invalid_dimensions", "dimensions");
|
|
1215
|
+
}
|
|
1216
|
+
const encodingFormat = req.encoding_format ?? "float";
|
|
1217
|
+
if (encodingFormat !== "float" && encodingFormat !== "base64") reject("encoding_format must be 'float' or 'base64'", "invalid_encoding_format", "encoding_format");
|
|
1218
|
+
const input = normalizeInput(req.input);
|
|
1219
|
+
const out = {
|
|
1220
|
+
model: req.model,
|
|
1221
|
+
input,
|
|
1222
|
+
encodingFormat
|
|
1223
|
+
};
|
|
1224
|
+
if (req.dimensions !== void 0) out.dimensions = req.dimensions;
|
|
1225
|
+
if (req.user !== void 0) out.user = req.user;
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
//#endregion
|
|
1229
|
+
//#region src/openai/translate/messages.ts
|
|
1230
|
+
/**
|
|
1231
|
+
* Strip the leading `tangle/` namespace from a model id and split off an
|
|
1232
|
+
* optional variant. Examples:
|
|
1233
|
+
* - `tangle/claude-code` → `{ provider: "claude-code" }`
|
|
1234
|
+
* - `tangle/opencode/sonnet` → `{ provider: "opencode", variant: "sonnet" }`
|
|
1235
|
+
* - `claude-code` → `{ provider: "claude-code" }` (no prefix)
|
|
1236
|
+
*/
|
|
1237
|
+
function resolveProviderId(model) {
|
|
1238
|
+
const trimmed = model.trim();
|
|
1239
|
+
const withoutPrefix = trimmed.startsWith("tangle/") ? trimmed.slice(7) : trimmed;
|
|
1240
|
+
if (!withoutPrefix) throw new Error(`Invalid model id: ${JSON.stringify(model)}`);
|
|
1241
|
+
const slash = withoutPrefix.indexOf("/");
|
|
1242
|
+
if (slash === -1) return { provider: withoutPrefix };
|
|
1243
|
+
const provider = withoutPrefix.slice(0, slash);
|
|
1244
|
+
const variant = withoutPrefix.slice(slash + 1);
|
|
1245
|
+
if (!provider || !variant) throw new Error(`Invalid model id: ${JSON.stringify(model)}`);
|
|
1246
|
+
return {
|
|
1247
|
+
provider,
|
|
1248
|
+
variant
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Coerce an OpenAI chat content field into a normalized text + parts pair.
|
|
1253
|
+
* Refusal parts are dropped — they only appear on assistant turns and the
|
|
1254
|
+
* sandbox runtime does not consume them; assistant text is the only field
|
|
1255
|
+
* we need from the assistant side.
|
|
1256
|
+
*/
|
|
1257
|
+
function normalizeContent(content) {
|
|
1258
|
+
if (content == null) return {
|
|
1259
|
+
text: "",
|
|
1260
|
+
parts: []
|
|
1261
|
+
};
|
|
1262
|
+
if (typeof content === "string") return {
|
|
1263
|
+
text: content,
|
|
1264
|
+
parts: []
|
|
1265
|
+
};
|
|
1266
|
+
const parts = [];
|
|
1267
|
+
const textPieces = [];
|
|
1268
|
+
for (const part of content) if (part.type === "text") {
|
|
1269
|
+
textPieces.push(part.text);
|
|
1270
|
+
parts.push({
|
|
1271
|
+
type: "text",
|
|
1272
|
+
text: part.text
|
|
1273
|
+
});
|
|
1274
|
+
} else if (part.type === "image_url") parts.push({
|
|
1275
|
+
type: "image_url",
|
|
1276
|
+
image_url: {
|
|
1277
|
+
url: part.image_url.url,
|
|
1278
|
+
detail: part.image_url.detail
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
else if (part.type === "input_audio") parts.push({
|
|
1282
|
+
type: "input_audio",
|
|
1283
|
+
input_audio: {
|
|
1284
|
+
data: part.input_audio.data,
|
|
1285
|
+
format: part.input_audio.format
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
return {
|
|
1289
|
+
text: textPieces.join("\n"),
|
|
1290
|
+
parts
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Read assistant text content. Assistant messages can carry an array of
|
|
1295
|
+
* text + refusal parts; refusal text is preserved as plain text so the
|
|
1296
|
+
* provider sees the full assistant turn.
|
|
1297
|
+
*/
|
|
1298
|
+
function readAssistantText(content) {
|
|
1299
|
+
if (content == null) return "";
|
|
1300
|
+
if (typeof content === "string") return content;
|
|
1301
|
+
const pieces = [];
|
|
1302
|
+
for (const part of content) if (part.type === "text") pieces.push(part.text);
|
|
1303
|
+
else if (part.type === "refusal") pieces.push(part.refusal);
|
|
1304
|
+
return pieces.join("\n");
|
|
1305
|
+
}
|
|
1306
|
+
/** Read tool message content (string or array of text parts). */
|
|
1307
|
+
function readToolContent(content) {
|
|
1308
|
+
if (typeof content === "string") return content;
|
|
1309
|
+
return content.map((p) => p.text).join("\n");
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Convert an OpenAI tool-spec array into the SDK's internal forwarding
|
|
1313
|
+
* shape. Custom (non-function) tools are dropped at this layer because
|
|
1314
|
+
* downstream providers only accept function-shaped tools; the route
|
|
1315
|
+
* layer is responsible for surfacing a 400 if the caller requested
|
|
1316
|
+
* something the provider can't run.
|
|
1317
|
+
*/
|
|
1318
|
+
function convertTools(tools) {
|
|
1319
|
+
if (!tools || tools.length === 0) return void 0;
|
|
1320
|
+
const out = [];
|
|
1321
|
+
for (const tool of tools) if (tool.type === "function") out.push({
|
|
1322
|
+
type: "function",
|
|
1323
|
+
function: {
|
|
1324
|
+
name: tool.function.name,
|
|
1325
|
+
description: tool.function.description,
|
|
1326
|
+
parameters: tool.function.parameters ?? null,
|
|
1327
|
+
strict: tool.function.strict ?? null
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
return out.length > 0 ? out : void 0;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Translate an OpenAI chat-shape message thread into a SandboxRunInput.
|
|
1334
|
+
*
|
|
1335
|
+
* @param messages Ordered messages as the OpenAI client would send them.
|
|
1336
|
+
* @param tools Optional function tool descriptors to forward unchanged.
|
|
1337
|
+
* @param model Caller-supplied model id (`tangle/<provider>[/<variant>]`).
|
|
1338
|
+
*
|
|
1339
|
+
* @throws when the resolved provider id is empty, when the message thread
|
|
1340
|
+
* lacks any user message, or when an `assistant` `tool_calls` item
|
|
1341
|
+
* is malformed (missing id/name/arguments).
|
|
1342
|
+
*/
|
|
1343
|
+
function openaiMessagesToSandboxInput(messages, tools, model) {
|
|
1344
|
+
const { provider, variant } = resolveProviderId(model);
|
|
1345
|
+
const instructionsPieces = [];
|
|
1346
|
+
const userPieces = [];
|
|
1347
|
+
const priorTurns = [];
|
|
1348
|
+
let pendingTurn = null;
|
|
1349
|
+
let pendingUserParts = [];
|
|
1350
|
+
let trailingUserParts = [];
|
|
1351
|
+
let multimodal = false;
|
|
1352
|
+
const flushPending = () => {
|
|
1353
|
+
if (pendingTurn) {
|
|
1354
|
+
priorTurns.push(pendingTurn);
|
|
1355
|
+
pendingTurn = null;
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
let lastUserIndex = -1;
|
|
1359
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") {
|
|
1360
|
+
lastUserIndex = i;
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1363
|
+
if (lastUserIndex === -1) throw new Error("openaiMessagesToSandboxInput: messages must contain at least one user message");
|
|
1364
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1365
|
+
const msg = messages[i];
|
|
1366
|
+
switch (msg.role) {
|
|
1367
|
+
case "system":
|
|
1368
|
+
case "developer": {
|
|
1369
|
+
const { text } = normalizeContent(msg.content);
|
|
1370
|
+
if (text) instructionsPieces.push(text);
|
|
1371
|
+
break;
|
|
1372
|
+
}
|
|
1373
|
+
case "user": {
|
|
1374
|
+
const { text, parts } = normalizeContent(msg.content);
|
|
1375
|
+
if (parts.some((p) => p.type !== "text")) multimodal = true;
|
|
1376
|
+
if (i === lastUserIndex) {
|
|
1377
|
+
userPieces.push(text);
|
|
1378
|
+
if (parts.length > 0) trailingUserParts = parts;
|
|
1379
|
+
} else {
|
|
1380
|
+
flushPending();
|
|
1381
|
+
if (parts.length > 0) {
|
|
1382
|
+
pendingTurn = { userParts: parts };
|
|
1383
|
+
pendingUserParts = parts;
|
|
1384
|
+
} else {
|
|
1385
|
+
pendingTurn = {};
|
|
1386
|
+
pendingUserParts = [];
|
|
1387
|
+
}
|
|
1388
|
+
if (text) userPieces.push(text);
|
|
1389
|
+
}
|
|
1390
|
+
break;
|
|
1391
|
+
}
|
|
1392
|
+
case "assistant": {
|
|
1393
|
+
if (!pendingTurn) pendingTurn = {};
|
|
1394
|
+
const text = readAssistantText(msg.content);
|
|
1395
|
+
if (text) pendingTurn.assistantText = text;
|
|
1396
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
1397
|
+
const calls = [];
|
|
1398
|
+
for (const call of msg.tool_calls) {
|
|
1399
|
+
if (call.type !== "function") continue;
|
|
1400
|
+
if (!call.id || !call.function?.name || typeof call.function.arguments !== "string") throw new Error(`openaiMessagesToSandboxInput: malformed assistant tool_call at index ${i}`);
|
|
1401
|
+
calls.push({
|
|
1402
|
+
id: call.id,
|
|
1403
|
+
name: call.function.name,
|
|
1404
|
+
arguments: call.function.arguments
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
if (calls.length > 0) pendingTurn.toolCalls = calls;
|
|
1408
|
+
}
|
|
1409
|
+
if (pendingUserParts.length > 0 && !pendingTurn.userParts) pendingTurn.userParts = pendingUserParts;
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
case "tool": {
|
|
1413
|
+
const result = {
|
|
1414
|
+
toolCallId: msg.tool_call_id,
|
|
1415
|
+
content: readToolContent(msg.content)
|
|
1416
|
+
};
|
|
1417
|
+
let attached = false;
|
|
1418
|
+
if (pendingTurn?.toolCalls?.some((c) => c.id === msg.tool_call_id)) {
|
|
1419
|
+
if (!pendingTurn.toolResults) pendingTurn.toolResults = [];
|
|
1420
|
+
pendingTurn.toolResults.push(result);
|
|
1421
|
+
attached = true;
|
|
1422
|
+
} else for (let j = priorTurns.length - 1; j >= 0; j--) {
|
|
1423
|
+
const turn = priorTurns[j];
|
|
1424
|
+
if (turn.toolCalls?.some((c) => c.id === msg.tool_call_id)) {
|
|
1425
|
+
if (!turn.toolResults) turn.toolResults = [];
|
|
1426
|
+
turn.toolResults.push(result);
|
|
1427
|
+
attached = true;
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
if (!attached) throw new Error(`openaiMessagesToSandboxInput: tool message references unknown tool_call_id ${msg.tool_call_id}`);
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
case "function": break;
|
|
1435
|
+
}
|
|
1436
|
+
if (i === lastUserIndex) flushPending();
|
|
1437
|
+
}
|
|
1438
|
+
flushPending();
|
|
1439
|
+
const result = {
|
|
1440
|
+
provider,
|
|
1441
|
+
task: userPieces.join("\n\n")
|
|
1442
|
+
};
|
|
1443
|
+
if (variant) result.variant = variant;
|
|
1444
|
+
const instructions = instructionsPieces.join("\n\n");
|
|
1445
|
+
if (instructions) result.instructions = instructions;
|
|
1446
|
+
if (trailingUserParts.length > 0) result.taskParts = trailingUserParts;
|
|
1447
|
+
if (priorTurns.length > 0) result.priorTurns = priorTurns;
|
|
1448
|
+
const toolSpecs = convertTools(tools);
|
|
1449
|
+
if (toolSpecs) result.tools = toolSpecs;
|
|
1450
|
+
if (multimodal) result.multimodal = true;
|
|
1451
|
+
return result;
|
|
1452
|
+
}
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/openai/translate/responses.ts
|
|
1455
|
+
/**
|
|
1456
|
+
* Resolve a `previous_response_id` into the prior-turns prefix that
|
|
1457
|
+
* gets prepended to the new request. Throws when the id is supplied but
|
|
1458
|
+
* unknown — silently dropping it would corrupt conversation continuity.
|
|
1459
|
+
*/
|
|
1460
|
+
async function resolvePreviousResponseId(prevId, store) {
|
|
1461
|
+
if (!prevId) return { priorTurns: [] };
|
|
1462
|
+
const turns = await store.getPriorTurns(prevId);
|
|
1463
|
+
if (turns === null) throw new Error(`Unknown previous_response_id: ${prevId}`);
|
|
1464
|
+
return { priorTurns: turns };
|
|
1465
|
+
}
|
|
1466
|
+
function emptyUsage() {
|
|
1467
|
+
return {
|
|
1468
|
+
input_tokens: 0,
|
|
1469
|
+
output_tokens: 0,
|
|
1470
|
+
total_tokens: 0,
|
|
1471
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
1472
|
+
output_tokens_details: { reasoning_tokens: 0 }
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
function readUsageFromEvent(event) {
|
|
1476
|
+
const u = event.usage ?? event;
|
|
1477
|
+
const input = Number(u.input_tokens ?? u.prompt_tokens ?? u.inputTokens ?? 0);
|
|
1478
|
+
const output = Number(u.output_tokens ?? u.completion_tokens ?? u.outputTokens ?? 0);
|
|
1479
|
+
if (input === 0 && output === 0 && !("total_tokens" in u)) return null;
|
|
1480
|
+
const total = Number(u.total_tokens ?? input + output);
|
|
1481
|
+
const cached = Number(u.input_tokens_details?.cached_tokens ?? u.cached_tokens ?? 0);
|
|
1482
|
+
const reasoning = Number(u.output_tokens_details?.reasoning_tokens ?? u.reasoning_tokens ?? 0);
|
|
1483
|
+
return {
|
|
1484
|
+
input_tokens: input,
|
|
1485
|
+
output_tokens: output,
|
|
1486
|
+
total_tokens: total,
|
|
1487
|
+
input_tokens_details: { cached_tokens: cached },
|
|
1488
|
+
output_tokens_details: { reasoning_tokens: reasoning }
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function readToolInvocationFromRaw(event) {
|
|
1492
|
+
if (event.type !== "raw") return null;
|
|
1493
|
+
const data = event.data ?? event;
|
|
1494
|
+
const inner = typeof data.type === "string" ? data.type : "";
|
|
1495
|
+
const isComputerUse = inner === "computer-use" || inner === "computer_call" || typeof data.computerUse === "object" || typeof data.computer_use === "object";
|
|
1496
|
+
if (!(inner === "tool-invocation" || inner === "tool_call" || typeof data.toolInvocation === "object" || typeof data.tool_invocation === "object") && !isComputerUse) return null;
|
|
1497
|
+
const ti = data.toolInvocation ?? data.tool_invocation ?? data.computerUse ?? data.computer_use ?? data;
|
|
1498
|
+
const id = typeof ti.toolCallId === "string" ? ti.toolCallId : typeof ti.tool_call_id === "string" ? ti.tool_call_id : typeof ti.id === "string" ? ti.id : typeof ti.callId === "string" ? ti.callId : "";
|
|
1499
|
+
const name = isComputerUse ? "computer_use" : typeof ti.toolName === "string" ? ti.toolName : typeof ti.tool_name === "string" ? ti.tool_name : typeof ti.name === "string" ? ti.name : "";
|
|
1500
|
+
const args = ti.args ?? ti.arguments ?? ti.input ?? {};
|
|
1501
|
+
if (!id || !name) return null;
|
|
1502
|
+
return {
|
|
1503
|
+
id,
|
|
1504
|
+
name,
|
|
1505
|
+
arguments: typeof args === "string" ? args : JSON.stringify(args),
|
|
1506
|
+
isComputerUse,
|
|
1507
|
+
computerAction: isComputerUse ? ti.action ?? void 0 : void 0
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
function readReasoningFromPart(event) {
|
|
1511
|
+
if (event.type !== "message.part.updated") return null;
|
|
1512
|
+
const part = event.part ?? event;
|
|
1513
|
+
if (part.type !== "reasoning" && part.type !== "thinking") return null;
|
|
1514
|
+
if (typeof part.text === "string") return part.text;
|
|
1515
|
+
if (typeof part.content === "string") return part.content;
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Aggregate a Tangle SSE event sequence into the Responses-API output[]
|
|
1520
|
+
* array plus a usage envelope. This is the non-streaming path: callers
|
|
1521
|
+
* collect every event, hand them all in, and get back a settled response
|
|
1522
|
+
* payload ready to return as JSON.
|
|
1523
|
+
*/
|
|
1524
|
+
function assembleResponseOutput(events) {
|
|
1525
|
+
const accum = {
|
|
1526
|
+
textPieces: [],
|
|
1527
|
+
toolCalls: /* @__PURE__ */ new Map(),
|
|
1528
|
+
toolCallOrder: [],
|
|
1529
|
+
computerCalls: [],
|
|
1530
|
+
reasoningPieces: [],
|
|
1531
|
+
usage: emptyUsage(),
|
|
1532
|
+
hasUsage: false
|
|
1533
|
+
};
|
|
1534
|
+
for (const event of events) switch (event.type) {
|
|
1535
|
+
case "token": {
|
|
1536
|
+
const delta = typeof event.delta === "string" && event.delta || typeof event.text === "string" && event.text || typeof event.token === "string" && event.token || "";
|
|
1537
|
+
if (delta) accum.textPieces.push(delta);
|
|
1538
|
+
break;
|
|
1539
|
+
}
|
|
1540
|
+
case "raw": {
|
|
1541
|
+
const tool = readToolInvocationFromRaw(event);
|
|
1542
|
+
if (!tool) break;
|
|
1543
|
+
if (tool.isComputerUse) accum.computerCalls.push({
|
|
1544
|
+
type: "computer_call",
|
|
1545
|
+
id: `cc_${tool.id}`,
|
|
1546
|
+
call_id: tool.id,
|
|
1547
|
+
status: "completed",
|
|
1548
|
+
pending_safety_checks: [],
|
|
1549
|
+
...tool.computerAction ? { action: tool.computerAction } : {}
|
|
1550
|
+
});
|
|
1551
|
+
else {
|
|
1552
|
+
if (!accum.toolCalls.has(tool.id)) accum.toolCallOrder.push(tool.id);
|
|
1553
|
+
accum.toolCalls.set(tool.id, {
|
|
1554
|
+
type: "function_call",
|
|
1555
|
+
id: `fc_${tool.id}`,
|
|
1556
|
+
call_id: tool.id,
|
|
1557
|
+
name: tool.name,
|
|
1558
|
+
arguments: tool.arguments,
|
|
1559
|
+
status: "completed"
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
case "message.part.updated": {
|
|
1565
|
+
const reasoning = readReasoningFromPart(event);
|
|
1566
|
+
if (reasoning) accum.reasoningPieces.push(reasoning);
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
case "done": {
|
|
1570
|
+
const usage = readUsageFromEvent(event);
|
|
1571
|
+
if (usage) {
|
|
1572
|
+
accum.usage = foldUsage(accum.usage, usage);
|
|
1573
|
+
accum.hasUsage = true;
|
|
1574
|
+
}
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
const output = [];
|
|
1579
|
+
if (accum.reasoningPieces.length > 0) {
|
|
1580
|
+
const reasoning = {
|
|
1581
|
+
type: "reasoning",
|
|
1582
|
+
id: "rs_0",
|
|
1583
|
+
summary: [],
|
|
1584
|
+
content: accum.reasoningPieces.map((text) => ({
|
|
1585
|
+
type: "reasoning_text",
|
|
1586
|
+
text
|
|
1587
|
+
})),
|
|
1588
|
+
status: "completed"
|
|
1589
|
+
};
|
|
1590
|
+
output.push(reasoning);
|
|
1591
|
+
}
|
|
1592
|
+
if (accum.textPieces.length > 0) {
|
|
1593
|
+
const message = {
|
|
1594
|
+
type: "message",
|
|
1595
|
+
id: "msg_0",
|
|
1596
|
+
role: "assistant",
|
|
1597
|
+
status: "completed",
|
|
1598
|
+
content: [{
|
|
1599
|
+
type: "output_text",
|
|
1600
|
+
text: accum.textPieces.join(""),
|
|
1601
|
+
annotations: []
|
|
1602
|
+
}]
|
|
1603
|
+
};
|
|
1604
|
+
output.push(message);
|
|
1605
|
+
}
|
|
1606
|
+
for (const id of accum.toolCallOrder) {
|
|
1607
|
+
const call = accum.toolCalls.get(id);
|
|
1608
|
+
if (call) output.push(call);
|
|
1609
|
+
}
|
|
1610
|
+
for (const cc of accum.computerCalls) output.push(cc);
|
|
1611
|
+
return {
|
|
1612
|
+
output,
|
|
1613
|
+
usage: accum.usage
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
function foldUsage(a, b) {
|
|
1617
|
+
return {
|
|
1618
|
+
input_tokens: a.input_tokens + b.input_tokens,
|
|
1619
|
+
output_tokens: a.output_tokens + b.output_tokens,
|
|
1620
|
+
total_tokens: a.total_tokens + b.total_tokens,
|
|
1621
|
+
input_tokens_details: { cached_tokens: a.input_tokens_details.cached_tokens + b.input_tokens_details.cached_tokens },
|
|
1622
|
+
output_tokens_details: { reasoning_tokens: a.output_tokens_details.reasoning_tokens + b.output_tokens_details.reasoning_tokens }
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Fold every usage frame in a Tangle event sequence into a single
|
|
1627
|
+
* ResponseUsage envelope. Multiple `done` events are tolerated (e.g.
|
|
1628
|
+
* during retries) by summing.
|
|
1629
|
+
*/
|
|
1630
|
+
function usageFromEvents(events) {
|
|
1631
|
+
let total = emptyUsage();
|
|
1632
|
+
for (const event of events) {
|
|
1633
|
+
if (event.type !== "done") continue;
|
|
1634
|
+
const u = readUsageFromEvent(event);
|
|
1635
|
+
if (u) total = foldUsage(total, u);
|
|
1636
|
+
}
|
|
1637
|
+
return total;
|
|
1638
|
+
}
|
|
1639
|
+
const SUPPORTED_TOOL_TYPES = new Set([
|
|
1640
|
+
"function",
|
|
1641
|
+
"computer_use_preview",
|
|
1642
|
+
"code_interpreter"
|
|
1643
|
+
]);
|
|
1644
|
+
const REJECTED_TOOL_TYPES = new Set([
|
|
1645
|
+
"web_search",
|
|
1646
|
+
"web_search_preview",
|
|
1647
|
+
"file_search"
|
|
1648
|
+
]);
|
|
1649
|
+
var ResponsesValidationError = class extends Error {
|
|
1650
|
+
type = "invalid_request_error";
|
|
1651
|
+
code;
|
|
1652
|
+
param;
|
|
1653
|
+
constructor(message, code, param) {
|
|
1654
|
+
super(message);
|
|
1655
|
+
this.name = "ResponsesValidationError";
|
|
1656
|
+
this.code = code;
|
|
1657
|
+
this.param = param;
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
/**
|
|
1661
|
+
* Reject Responses requests that ask for tool types Tangle cannot
|
|
1662
|
+
* service. Function tools, the OpenAI computer-use preview tool, and
|
|
1663
|
+
* the code interpreter (every Tangle sandbox can run code) are
|
|
1664
|
+
* accepted; web_search and file_search are explicitly rejected with
|
|
1665
|
+
* the OpenAI error shape.
|
|
1666
|
+
*/
|
|
1667
|
+
function validateResponsesRequest(req) {
|
|
1668
|
+
const tools = req.tools;
|
|
1669
|
+
if (!tools || tools.length === 0) return;
|
|
1670
|
+
for (let i = 0; i < tools.length; i++) {
|
|
1671
|
+
const type = tools[i].type;
|
|
1672
|
+
if (typeof type !== "string") throw new ResponsesValidationError(`tools[${i}] is missing a type discriminator`, "invalid_tool", `tools[${i}].type`);
|
|
1673
|
+
if (REJECTED_TOOL_TYPES.has(type)) throw new ResponsesValidationError(`Tool type "${type}" is not supported by this endpoint`, "unsupported_tool", `tools[${i}].type`);
|
|
1674
|
+
if (!SUPPORTED_TOOL_TYPES.has(type)) throw new ResponsesValidationError(`Tool type "${type}" is not recognized`, "unsupported_tool", `tools[${i}].type`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Build a settled `Response` shell from an aggregated output + usage.
|
|
1679
|
+
* Caller fills in the runtime-specific fields (id, model, created_at,
|
|
1680
|
+
* status, instructions, etc.) to avoid having to re-derive them inside
|
|
1681
|
+
* the translator.
|
|
1682
|
+
*/
|
|
1683
|
+
function buildResponseShell(args) {
|
|
1684
|
+
const outputText = args.output.filter((item) => item.type === "message").flatMap((m) => m.content).filter((c) => c.type === "output_text").map((c) => c.text).join("");
|
|
1685
|
+
return {
|
|
1686
|
+
id: args.id,
|
|
1687
|
+
object: "response",
|
|
1688
|
+
created_at: args.createdAt,
|
|
1689
|
+
output_text: outputText,
|
|
1690
|
+
error: null,
|
|
1691
|
+
incomplete_details: null,
|
|
1692
|
+
instructions: args.instructions ?? null,
|
|
1693
|
+
metadata: null,
|
|
1694
|
+
model: args.model,
|
|
1695
|
+
output: args.output,
|
|
1696
|
+
parallel_tool_calls: false,
|
|
1697
|
+
temperature: null,
|
|
1698
|
+
tool_choice: "auto",
|
|
1699
|
+
tools: [],
|
|
1700
|
+
top_p: null,
|
|
1701
|
+
status: "completed",
|
|
1702
|
+
usage: args.usage,
|
|
1703
|
+
previous_response_id: args.previousResponseId ?? null
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1707
|
+
//#region src/openai/types.ts
|
|
1708
|
+
/**
|
|
1709
|
+
* Construct a fresh translator context. The factory is deliberately tiny
|
|
1710
|
+
* — call sites that need to override fields can do so via the partial.
|
|
1711
|
+
*/
|
|
1712
|
+
function createTranslatorContext(init) {
|
|
1713
|
+
return {
|
|
1714
|
+
chunkIndex: 0,
|
|
1715
|
+
toolCallBuffer: /* @__PURE__ */ new Map(),
|
|
1716
|
+
createdAt: Math.floor(Date.now() / 1e3),
|
|
1717
|
+
...init
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
//#endregion
|
|
1721
|
+
export { EmbeddingValidationError, HookChain, InMemoryResponseStore, ResponsesValidationError, Run, actionRateLimitHook, assembleResponseOutput, auditLogHook, buildResponseShell, costCapHook, createTranslatorContext, destructiveActionGuardHook, egressPolicyHook, openaiMessagesToSandboxInput, outcomeToFinishReason, resolvePreviousResponseId, runEvents, sandboxEventToChatChunk, sandboxEventToCompletionChunk, sandboxEventToResponsesEvent, screenshotRedactionHook, usageFromEvents, validateEmbeddingRequest, validateResponsesRequest };
|