@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.
@@ -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 };