@volter/twin-openai 0.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +33 -30
  2. package/defaults/handlers.json +10 -0
  3. package/dist/defaults/handlers.json +10 -0
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +29 -0
  6. package/dist/src/generated/surface.gen.json +1 -0
  7. package/dist/src/generated/ui.gen.json +1 -0
  8. package/dist/src/index.d.ts +19 -0
  9. package/dist/src/index.js +72 -0
  10. package/dist/src/manifest.d.ts +6 -0
  11. package/dist/src/manifest.js +323 -0
  12. package/dist/src/openai-budget.d.ts +53 -0
  13. package/dist/src/openai-budget.js +147 -0
  14. package/dist/src/openai-capabilities.d.ts +4 -0
  15. package/dist/src/openai-capabilities.js +1569 -0
  16. package/dist/src/openai-conformance.d.ts +13 -0
  17. package/dist/src/openai-conformance.js +116 -0
  18. package/dist/src/openai-connector.d.ts +86 -0
  19. package/dist/src/openai-connector.js +291 -0
  20. package/dist/src/openai-media.d.ts +43 -0
  21. package/dist/src/openai-media.js +257 -0
  22. package/dist/src/openai-models.d.ts +74 -0
  23. package/dist/src/openai-models.js +148 -0
  24. package/dist/src/openai-scenario.d.ts +51 -0
  25. package/dist/src/openai-scenario.js +166 -0
  26. package/dist/src/openai-server.d.ts +40 -0
  27. package/dist/src/openai-server.js +126 -0
  28. package/dist/src/openai-stub.d.ts +82 -0
  29. package/dist/src/openai-stub.js +256 -0
  30. package/dist/src/openai-twin.d.ts +182 -0
  31. package/dist/src/openai-twin.js +1117 -0
  32. package/dist/src/openai-types.d.ts +194 -0
  33. package/dist/src/openai-types.js +4 -0
  34. package/dist/src/openai-webhooks.d.ts +47 -0
  35. package/dist/src/openai-webhooks.js +99 -0
  36. package/dist/src/screens/api-keys.d.ts +16 -0
  37. package/dist/src/screens/api-keys.js +131 -0
  38. package/dist/src/screens/session.d.ts +22 -0
  39. package/dist/src/screens/session.js +115 -0
  40. package/dist/src/semantics/assistants.d.ts +2 -0
  41. package/dist/src/semantics/assistants.js +331 -0
  42. package/dist/src/semantics/audio.d.ts +2 -0
  43. package/dist/src/semantics/audio.js +27 -0
  44. package/dist/src/semantics/batches.d.ts +4 -0
  45. package/dist/src/semantics/batches.js +86 -0
  46. package/dist/src/semantics/chat-completions.d.ts +3 -0
  47. package/dist/src/semantics/chat-completions.js +58 -0
  48. package/dist/src/semantics/containers.d.ts +2 -0
  49. package/dist/src/semantics/containers.js +147 -0
  50. package/dist/src/semantics/embeddings.d.ts +2 -0
  51. package/dist/src/semantics/embeddings.js +13 -0
  52. package/dist/src/semantics/evals.d.ts +2 -0
  53. package/dist/src/semantics/evals.js +173 -0
  54. package/dist/src/semantics/files.d.ts +13 -0
  55. package/dist/src/semantics/files.js +59 -0
  56. package/dist/src/semantics/fine-tuning.d.ts +4 -0
  57. package/dist/src/semantics/fine-tuning.js +178 -0
  58. package/dist/src/semantics/images.d.ts +2 -0
  59. package/dist/src/semantics/images.js +18 -0
  60. package/dist/src/semantics/index.d.ts +8 -0
  61. package/dist/src/semantics/index.js +46 -0
  62. package/dist/src/semantics/models.d.ts +2 -0
  63. package/dist/src/semantics/models.js +34 -0
  64. package/dist/src/semantics/moderations.d.ts +2 -0
  65. package/dist/src/semantics/moderations.js +12 -0
  66. package/dist/src/semantics/organization.d.ts +2 -0
  67. package/dist/src/semantics/organization.js +67 -0
  68. package/dist/src/semantics/progress.d.ts +22 -0
  69. package/dist/src/semantics/progress.js +63 -0
  70. package/dist/src/semantics/responses.d.ts +3 -0
  71. package/dist/src/semantics/responses.js +153 -0
  72. package/dist/src/semantics/shared.d.ts +32 -0
  73. package/dist/src/semantics/shared.js +69 -0
  74. package/dist/src/semantics/uploads.d.ts +2 -0
  75. package/dist/src/semantics/uploads.js +84 -0
  76. package/dist/src/semantics/vector-stores.d.ts +2 -0
  77. package/dist/src/semantics/vector-stores.js +281 -0
  78. package/dist/test-fixtures/openai-openapi-operations.SOURCE.md +18 -0
  79. package/dist/test-fixtures/openai-openapi-operations.json +1849 -0
  80. package/package.json +21 -10
  81. package/src/cli.ts +9 -7
  82. package/src/generated/surface.gen.json +1 -0
  83. package/src/generated/ui.gen.json +1 -0
  84. package/src/index.ts +20 -10
  85. package/src/manifest.ts +343 -0
  86. package/src/openai-budget.ts +4 -4
  87. package/src/openai-capabilities.ts +177 -195
  88. package/src/openai-conformance.ts +1 -1
  89. package/src/openai-connector.ts +40 -43
  90. package/src/openai-media.ts +225 -0
  91. package/src/openai-models.ts +145 -15
  92. package/src/openai-scenario.ts +46 -10
  93. package/src/openai-server.ts +65 -108
  94. package/src/openai-stub.ts +54 -30
  95. package/src/openai-twin.ts +760 -1665
  96. package/src/openai-types.ts +24 -6
  97. package/src/openai-webhooks.ts +2 -1
  98. package/src/screens/api-keys.tsx +138 -0
  99. package/src/screens/session.tsx +131 -0
  100. package/src/semantics/assistants.ts +336 -0
  101. package/src/semantics/audio.ts +31 -0
  102. package/src/semantics/batches.ts +88 -0
  103. package/src/semantics/chat-completions.ts +66 -0
  104. package/src/semantics/containers.ts +151 -0
  105. package/src/semantics/embeddings.ts +19 -0
  106. package/src/semantics/evals.ts +182 -0
  107. package/src/semantics/files.ts +67 -0
  108. package/src/semantics/fine-tuning.ts +185 -0
  109. package/src/semantics/images.ts +23 -0
  110. package/src/semantics/index.ts +52 -0
  111. package/src/semantics/models.ts +41 -0
  112. package/src/semantics/moderations.ts +14 -0
  113. package/src/semantics/organization.ts +76 -0
  114. package/src/semantics/progress.ts +72 -0
  115. package/src/semantics/responses.ts +151 -0
  116. package/src/semantics/shared.ts +82 -0
  117. package/src/semantics/uploads.ts +92 -0
  118. package/src/semantics/vector-stores.ts +279 -0
  119. package/test-fixtures/openai-openapi-operations.SOURCE.md +4 -5
  120. package/test-fixtures/openai-openapi-operations.json +224 -1334
@@ -5,7 +5,7 @@
5
5
  // the Responses API, embeddings, models, files, batches, moderations, and the error envelope.
6
6
  // Fully offline + deterministic (drives the local handler against a temp root) so it runs in
7
7
  // CI without an API key. Honest scope: it checks the protocol envelope, NOT model output
8
- // (a deterministic stub by design — see the out-of-scope carve-outs in the manifest).
8
+ // (a deterministic stub by design — the labeled stub is the twin's answer).
9
9
  import { mkdtempSync, rmSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
@@ -12,9 +12,9 @@
12
12
  // The vendor I/O is an INJECTED executor (B3 auth boundary): the kernel + this pack hold NO
13
13
  // OpenAI key and import NO SDK at runtime. Tests pass a fake executor; live runs pass
14
14
  // `liveOpenAIExecute(apiKey)`. Same code path either way — fully exercisable offline.
15
- import { assertBudgetGuardIntact, confirmAction, pendingActions, syncPull } from '@volter/twin';
16
- import type { SyncResource, TwinAction } from '@volter/twin';
17
- import { OpenAIBudget, openaiCallWeight, type OpenAIBudgetOptions } from './openai-budget.ts';
15
+ import { assertBudgetGuardIntact, type RemoteExecute, type PerformContext, type PushOutcome, observeResource } from '@volter/world-core';
16
+ import type { SyncResource, TwinAction } from '@volter/world-core';
17
+ import { OpenAIBudget, OpenAIBudgetError, openaiCallWeight, type OpenAIBudgetOptions } from './openai-budget.ts';
18
18
 
19
19
  const SERVICE = 'openai';
20
20
 
@@ -101,7 +101,14 @@ export function liveOpenAIExecute(
101
101
  // Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
102
102
  // `retry-after` beyond the cap is not something to sleep off) — the cooldown is persisted
103
103
  // first either way, so the refusal survives the throw.
104
- budget.recordCall(weight, resHeaders, { status: res.status, reservation });
104
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
105
+ // call that louder refusal wins; an answer OpenAI ACCEPTED is kept, so a write that landed is
106
+ // never recorded as failed and performed again on retry.
107
+ try {
108
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
109
+ } catch (error) {
110
+ if (!(error instanceof OpenAIBudgetError) || !res.ok) throw error;
111
+ }
105
112
  return parsed;
106
113
  };
107
114
  }
@@ -212,8 +219,10 @@ export async function syncOpenAIFromReal(
212
219
  opts: { root?: string; occurredAt: string },
213
220
  ): Promise<{ observed: number; deltasAppended: number }> {
214
221
  const resources = await pullOpenAIState(execute);
215
- const result = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
216
- return { observed: result.observed, deltasAppended: result.deltasAppended };
222
+ // PROTOCOL 2: the pack observes each resource; the kernel diffs it against the tree and folds what changed
223
+ let appended = 0;
224
+ for (const r of resources) appended += observeResource(SERVICE, { type: r.type, id: r.id, fields: r.fields }, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt }).appended;
225
+ return { observed: resources.length, deltasAppended: appended };
217
226
  }
218
227
 
219
228
  // ── PUSH ────────────────────────────────────────────────────────────────────
@@ -295,43 +304,31 @@ function createPayload(action: Pick<TwinAction, 'subject' | 'fields'>): Record<s
295
304
  }
296
305
  }
297
306
 
298
- /**
299
- * Push the twin's PENDING local actions to real OpenAI and CONFIRM each: for every pending
300
- * action, call the real API; on success, confirmAction records the confirmed fields as an
301
- * observed event and maps action → event (suppressing the local projection — counted exactly
302
- * once). Idempotency: a confirmed action is no longer pending, so a re-push enacts NOTHING.
303
- */
304
- export async function pushPendingOpenAIActions(
305
- execute: OpenAIExecute,
306
- opts: { root?: string; occurredAt: string },
307
- ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
308
- const confirmed: string[] = [];
309
- const externalIds: Record<string, string> = {};
310
- for (const action of pendingActions(SERVICE, opts.root)) {
311
- const { externalId } = await pushOpenAIAction(execute, action);
312
- confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: action.fields ?? {}, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
313
- confirmed.push(action.id);
314
- externalIds[action.id] = externalId;
315
- }
316
- return { pushed: confirmed.length, confirmed, externalIds };
307
+
308
+
309
+ // ── THE PERFORM ADAPTER (protocol 2, state-system.ts) ─────────────────────────────────────────
310
+ // One entry against the real account over the kernel executor. A generative vendor's change is
311
+ // the CALL: a recorded completion crosses as the same request (its model and messages), and the
312
+ // vendor's completion id is the receipt. The account-shaped writes (files, batches, fine-tuning
313
+ // jobs, vector stores) cross through `pushOpenAIAction` as before. The credential never enters
314
+ // this file: the executor applies it.
315
+ export function openaiExecuteOver(execute: RemoteExecute): OpenAIExecute {
316
+ return async (method, path, body) => {
317
+ const res = await execute({ method, path, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
318
+ try { return JSON.parse(res.body) as Awaited<ReturnType<OpenAIExecute>>; } catch { return { error: { message: `openai answered ${res.status} with a body that is not JSON` } }; }
319
+ };
317
320
  }
318
321
 
319
- // ── FULL bi-directional sync ─────────────────────────────────────────────────
320
- /**
321
- * FULL bi-directional sync over the injected client: (1) PUSH every pending local action to
322
- * real OpenAI and confirm it, then (2) PULL all modeled collections back and fold them into the
323
- * event log. Pushing first means the pull observes the twin's own writes as confirmed external
324
- * state (no double-count). Re-running with no pending writes and identical real state is a
325
- * no-op (push 0, deltasAppended 0). Same code path offline (fake executor) and live (real key).
326
- */
327
- export async function fullSyncOpenAI(
328
- execute: OpenAIExecute,
329
- opts: { root?: string; occurredAt: string },
330
- ): Promise<{ pushed: number; observed: number; deltasAppended: number; collections: number }> {
331
- // 1. PUSH pending local changes to real OpenAI (and confirm each).
332
- const push = await pushPendingOpenAIActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
333
- // 2. PULL all modeled collections back into the twin.
334
- const resources = await pullOpenAIState(execute);
335
- const pull = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
336
- return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.deltasAppended, collections: COLLECTIONS.length };
322
+ export async function performOpenAIAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
323
+ if (action.subject.type === 'chat_completion') {
324
+ const f = (action.fields ?? {}) as Record<string, unknown>;
325
+ const messages = Array.isArray(f._input_messages) ? (f._input_messages as Array<{ role: string; content: unknown }>).map((m) => ({ role: m.role, content: m.content })) : [];
326
+ const res = await openaiExecuteOver(execute)('POST', '/v1/chat/completions', { model: f.model, messages, ...(f._stored === true ? { store: true } : {}) });
327
+ throwIfError(res, 'perform chat.completions.create');
328
+ const id = (res as { id?: unknown }).id;
329
+ // the vendor's completion is the answer: it rides on the receipt and reaches the app under live use
330
+ return { externalId: typeof id === 'string' && id ? id : action.subject.id, data: res as Record<string, unknown> };
331
+ }
332
+ const { externalId } = await pushOpenAIAction(openaiExecuteOver(execute), action);
333
+ return { externalId };
337
334
  }
@@ -0,0 +1,225 @@
1
+ // The media an image or audio endpoint takes and gives. OpenAI takes an uploaded image or audio file only in the
2
+ // formats its reference names, told by the file's bytes, and a GPT image model answers the image itself as base64:
3
+ // • an image to edit is "a `png`, `webp`, or `jpg` file" for the GPT image models, and for `dall-e-2` "a square
4
+ // `png`" (developers.openai.com/api/reference/resources/images/methods/edit, `image`); a variation's is "a valid
5
+ // PNG file, less than 4MB, and square" (…/images/methods/create_variation, `image`);
6
+ // • a transcription or translation takes "the audio file object (not file name)" in "one of these formats: flac,
7
+ // mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm" (…/audio/subresources/transcriptions/methods/create, `file`);
8
+ // • the GPT image models "always return base64-encoded images" (…/images/methods/generate, `response_format`).
9
+ // Where the documentation stops and the twin decides: a format is told by its signature bytes; the image the twin
10
+ // answers is a real PNG of the requested size in one flat colour drawn from the prompt, labeled in its own text
11
+ // chunk, since the twin renders no pixels.
12
+ import { createHash } from 'node:crypto';
13
+ import { deflateSync } from 'node:zlib';
14
+
15
+ /** The raw bytes of an uploaded file part, as the request's multipart parse keeps them; undefined for a string. */
16
+ export function partBytes(v: unknown): Uint8Array | undefined {
17
+ const b = v && typeof v === 'object' ? (v as { bytes?: unknown }).bytes : undefined;
18
+ return b instanceof Uint8Array ? b : undefined;
19
+ }
20
+
21
+ const has = (b: Uint8Array, sig: string | number[], at = 0): boolean =>
22
+ (typeof sig === 'string' ? [...sig].map((c) => c.charCodeAt(0)) : sig).every((x, i) => b[at + i] === x);
23
+
24
+ const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
25
+
26
+ /** Each image format a signature tells, checked in this order. */
27
+ const IMAGE: Array<['png' | 'jpg' | 'webp', (b: Uint8Array) => boolean]> = [
28
+ ['jpg', (b) => has(b, [0xff, 0xd8, 0xff])],
29
+ ['webp', (b) => has(b, 'RIFF') && has(b, 'WEBP', 8)],
30
+ ['png', (b) => has(b, PNG) && has(b, 'IHDR', 12)],
31
+ ];
32
+ /** An image's format by its signature: png, jpg or webp, else undefined. */
33
+ export const imageFormat = (b: Uint8Array): 'png' | 'jpg' | 'webp' | undefined => IMAGE.find(([, is]) => is(b))?.[0];
34
+
35
+ /** A PNG's width and height from its IHDR chunk. */
36
+ export function pngSize(b: Uint8Array): { width: number; height: number } {
37
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
38
+ return { width: v.getUint32(16), height: v.getUint32(20) };
39
+ }
40
+
41
+ /** Each audio format OpenAI transcribes that a signature tells, checked in this order. */
42
+ const AUDIO: Array<[string, (b: Uint8Array) => boolean]> = [
43
+ ['flac', (b) => has(b, 'fLaC')],
44
+ ['ogg', (b) => has(b, 'OggS')],
45
+ ['webm', (b) => has(b, [0x1a, 0x45, 0xdf, 0xa3])],
46
+ ['mp4', (b) => has(b, 'ftyp', 4)],
47
+ ['mp3', (b) => has(b, 'ID3') || (b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0)],
48
+ ['wav', (b) => has(b, 'RIFF') && has(b, 'WAVE', 8)],
49
+ ];
50
+ /** An audio file's format by its signature, among those OpenAI transcribes, else undefined. */
51
+ export const audioFormat = (b: Uint8Array): string | undefined => AUDIO.find(([, is]) => is(b))?.[0];
52
+
53
+ /** A WAV file's length in seconds, from its `fmt ` byte rate and its `data` size (RIFF chunks in any order); undefined
54
+ * for any other format, whose length the twin does not read. */
55
+ export function wavSeconds(b: Uint8Array): number | undefined {
56
+ if (!has(b, 'RIFF') || !has(b, 'WAVE', 8)) return undefined;
57
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
58
+ let rate = 0;
59
+ let data = 0;
60
+ for (let at = 12; at + 8 <= b.length; at += 8 + v.getUint32(at + 4, true) + (v.getUint32(at + 4, true) & 1)) {
61
+ if (has(b, 'fmt ', at)) rate = v.getUint32(at + 16, true);
62
+ if (has(b, 'data', at)) data = v.getUint32(at + 4, true);
63
+ }
64
+ return rate > 0 ? Math.round((data / rate) * 1000) / 1000 : undefined;
65
+ }
66
+
67
+ const CRC = Array.from({ length: 256 }, (_, n) => {
68
+ let c = n;
69
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
70
+ return c >>> 0;
71
+ });
72
+ function crc32(b: Uint8Array): number {
73
+ let c = 0xffffffff;
74
+ for (const x of b) c = CRC[(c ^ x) & 0xff]! ^ (c >>> 8);
75
+ return (c ^ 0xffffffff) >>> 0;
76
+ }
77
+ function chunk(type: string, data: Uint8Array): Uint8Array {
78
+ const out = new Uint8Array(12 + data.length);
79
+ const v = new DataView(out.buffer);
80
+ v.setUint32(0, data.length);
81
+ out.set([...type].map((c) => c.charCodeAt(0)), 4);
82
+ out.set(data, 8);
83
+ v.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
84
+ return out;
85
+ }
86
+
87
+ /** The size an image request asks for (`1536x1024`), or the fallback for `auto` or none. */
88
+ export function requestedSize(size: unknown, fallback: { width: number; height: number } = { width: 1024, height: 1024 }): { width: number; height: number } {
89
+ const m = typeof size === 'string' ? /^(\d+)x(\d+)$/.exec(size) : null;
90
+ return m ? { width: Number(m[1]), height: Number(m[2]) } : fallback;
91
+ }
92
+
93
+ /** A real PNG of the size asked, one flat colour drawn from `seed`, labeled as the twin's placeholder. */
94
+ export function placeholderPng(size: { width: number; height: number }, seed: string): Uint8Array {
95
+ const [r, g, bl] = createHash('sha256').update(seed).digest();
96
+ const { width, height } = size;
97
+ const ihdr = new Uint8Array(13);
98
+ const v = new DataView(ihdr.buffer);
99
+ v.setUint32(0, width);
100
+ v.setUint32(4, height);
101
+ ihdr.set([8, 2, 0, 0, 0], 8); // 8-bit RGB, no interlace
102
+ const row = new Uint8Array(1 + width * 3);
103
+ for (let x = 0; x < width; x++) row.set([r!, g!, bl!], 1 + x * 3);
104
+ const raw = new Uint8Array(row.length * height);
105
+ for (let y = 0; y < height; y++) raw.set(row, y * row.length);
106
+ const text = new TextEncoder().encode(`Comment\0[twin-stub] placeholder image, no model is run: ${seed}`);
107
+ const parts = [Uint8Array.from(PNG), chunk('IHDR', ihdr), chunk('tEXt', text), chunk('IDAT', new Uint8Array(deflateSync(raw))), chunk('IEND', new Uint8Array(0))];
108
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
109
+ let at = 0;
110
+ for (const p of parts) { out.set(p, at); at += p.length; }
111
+ return out;
112
+ }
113
+
114
+ /** Bytes as base64. */
115
+ export function base64(b: Uint8Array): string {
116
+ let bin = '';
117
+ for (let i = 0; i < b.length; i += 0x8000) bin += String.fromCharCode(...b.subarray(i, i + 0x8000));
118
+ return btoa(bin);
119
+ }
120
+
121
+ // ── Speech: the audio file itself ────────────────────────────────────────────────────────────────────────────────
122
+ // Text to speech answers "the audio file content" in the requested `response_format`, `mp3` by default, of mp3, opus,
123
+ // aac, flac, wav and pcm (developers.openai.com/api/reference/resources/audio/subresources/speech/methods/create); its
124
+ // audio is 24 kHz mono. The twin speaks nothing: each is a real file of that format, a second of silence.
125
+ const RATE = 24000;
126
+
127
+ /** A second of 24 kHz 16-bit mono silence as samples' bytes: `pcm`, raw little-endian with no header. */
128
+ export const silentPcm = (): Uint8Array => new Uint8Array(RATE * 2);
129
+
130
+ /** The same second as a WAV file. */
131
+ export function silentWav(): Uint8Array {
132
+ const data = RATE * 2;
133
+ const b = new Uint8Array(44 + data);
134
+ const v = new DataView(b.buffer);
135
+ b.set(ascii('RIFF'), 0); v.setUint32(4, 36 + data, true); b.set(ascii('WAVEfmt '), 8);
136
+ v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, RATE, true); v.setUint32(28, RATE * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
137
+ b.set(ascii('data'), 36); v.setUint32(40, data, true);
138
+ return b;
139
+ }
140
+
141
+ /** The same second as MP3: MPEG-2 Layer III frames at 24 kHz, 32 kbit/s, mono, each 576 samples in 96 bytes whose
142
+ * side information codes no spectral data, which a decoder plays as silence. */
143
+ export function silentMp3(): Uint8Array {
144
+ const frames = Math.ceil(RATE / 576);
145
+ const b = new Uint8Array(frames * 96);
146
+ for (let i = 0; i < frames; i++) b.set([0xff, 0xf3, 0x44, 0xc4], i * 96);
147
+ return b;
148
+ }
149
+
150
+ /** The same second as FLAC: a STREAMINFO block and frames of 4096 samples, each one CONSTANT subframe of zero. */
151
+ export function silentFlac(): Uint8Array {
152
+ const block = 4096;
153
+ const info = new Bits();
154
+ info.put(block, 16); info.put(block, 16); info.put(0, 24); info.put(0, 24);
155
+ info.put(RATE, 20); info.put(0, 3); info.put(15, 5); info.put(0, 4); info.put(RATE, 32);
156
+ for (let i = 0; i < 16; i++) info.put(0, 8); // MD5 unknown
157
+ const out: number[] = [...ascii('fLaC'), 0x80, 0, 0, 34, ...info.bytes()];
158
+ for (let n = 0, left = RATE; left > 0; n++, left -= block) {
159
+ const size = Math.min(block, left);
160
+ // blocksize 4096 (code 12) or, for the last, 16 bits of size-1 at the header's end (code 7); 24 kHz is code 7
161
+ const head = [0xff, 0xf8, ((size === block ? 12 : 7) << 4) | 7, 0x08, n, ...(size === block ? [] : [(size - 1) >> 8, (size - 1) & 0xff])];
162
+ const frame = [...head, crc8(head), 0x00, 0x00, 0x00];
163
+ const c = crc16(frame);
164
+ out.push(...frame, c >> 8, c & 0xff);
165
+ }
166
+ return Uint8Array.from(out);
167
+ }
168
+
169
+ /** The same second as AAC in ADTS: AAC-LC frames of 1024 samples at 24 kHz, mono, each one channel element with
170
+ * no scale-factor bands, which a decoder plays as silence. */
171
+ export function silentAac(): Uint8Array {
172
+ const payload = [0x00, 0x00, 0x00, 0x07]; // SCE, global gain 0, max_sfb 0, no tools; END
173
+ const length = 7 + payload.length;
174
+ const frame = new Bits();
175
+ frame.put(0xfff, 12); frame.put(0, 1); frame.put(0, 2); frame.put(1, 1); // MPEG-4, no CRC
176
+ frame.put(1, 2); frame.put(6, 4); frame.put(0, 1); frame.put(1, 3); // AAC-LC, 24 kHz, mono
177
+ frame.put(0, 4); frame.put(length, 13); frame.put(0x7ff, 11); frame.put(0, 2);
178
+ const one = [...frame.bytes(), ...payload];
179
+ return Uint8Array.from(Array.from({ length: Math.ceil(RATE / 1024) }, () => one).flat());
180
+ }
181
+
182
+ /** The same second as Opus in Ogg: its identification and comment headers, then 20 ms packets of silence. */
183
+ export function silentOpus(): Uint8Array {
184
+ const head = [...ascii('OpusHead'), 1, 1, 0x38, 0x01, ...le32(RATE), 0, 0, 0];
185
+ const tags = [...ascii('OpusTags'), ...le32(4), ...ascii('twin'), ...le32(0)];
186
+ const packets = Array.from({ length: 50 }, () => [0xf8, 0xff, 0xfe]);
187
+ return Uint8Array.from([...oggPage(0x02, 0, 0, [head]), ...oggPage(0, 0, 1, [tags]), ...oggPage(0x04, 312 + 48000, 2, packets)]);
188
+ }
189
+
190
+ const ascii = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
191
+ const le32 = (n: number): number[] => [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff];
192
+
193
+ /** A big-endian bit writer. */
194
+ class Bits {
195
+ private out: number[] = [];
196
+ private acc = 0;
197
+ private n = 0;
198
+ put(value: number, bits: number): void {
199
+ for (let i = bits - 1; i >= 0; i--) {
200
+ this.acc = (this.acc << 1) | (Math.floor(value / 2 ** i) & 1);
201
+ if (++this.n === 8) { this.out.push(this.acc); this.acc = 0; this.n = 0; }
202
+ }
203
+ }
204
+ bytes(): number[] { return this.out; }
205
+ }
206
+
207
+ function crc8(b: number[]): number {
208
+ let c = 0;
209
+ for (const x of b) { c ^= x; for (let k = 0; k < 8; k++) c = c & 0x80 ? ((c << 1) ^ 0x07) & 0xff : (c << 1) & 0xff; }
210
+ return c;
211
+ }
212
+ function crc16(b: number[]): number {
213
+ let c = 0;
214
+ for (const x of b) { c ^= x << 8; for (let k = 0; k < 8; k++) c = c & 0x8000 ? ((c << 1) ^ 0x8005) & 0xffff : (c << 1) & 0xffff; }
215
+ return c;
216
+ }
217
+ /** One Ogg page of stream 1 holding whole packets, its CRC over the page with the field zeroed. */
218
+ function oggPage(flags: number, granule: number, seq: number, packets: number[][]): number[] {
219
+ const lacing = packets.flatMap((p) => [...Array(Math.floor(p.length / 255)).fill(255), p.length % 255]);
220
+ const page = [...ascii('OggS'), 0, flags, ...le32(granule), 0, 0, 0, 0, ...le32(1), ...le32(seq), 0, 0, 0, 0, lacing.length, ...lacing, ...packets.flat()];
221
+ let c = 0;
222
+ for (const x of page) { c ^= x << 24; for (let k = 0; k < 8; k++) c = c & 0x80000000 ? ((c << 1) ^ 0x04c11db7) >>> 0 : (c << 1) >>> 0; }
223
+ page.splice(22, 4, ...le32(c));
224
+ return page;
225
+ }
@@ -10,27 +10,157 @@ export type OpenAIModel = {
10
10
  object: 'model';
11
11
  created: number;
12
12
  owned_by: string;
13
+ /** When the model shuts down, or null when no date is announced (https://platform.openai.com/docs/api-reference/models/object) */
14
+ shutdown_date: string | null;
13
15
  };
14
16
 
15
- // A faithful slice of the published catalog (ids are the exact vendor strings).
17
+ // OpenAI's timeline, as data (the catalog, each API family's removal, and the dated policy changes).
18
+ //
19
+ // Every model the pack's walks name, with its release (`created`) and its shutdown. Sources: the changelog
20
+ // (https://developers.openai.com/api/docs/changelog) dates a release: "Released GPT-6 Astra" 2026-09-03, "Released GPT
21
+ // Image 2.5 Sunburst and GPT Image 2.5 Flare" 2026-09-08, "Released GPT-5.2" 2025-12-11, "Released gpt-image-1.5"
22
+ // 2025-12-16, "Added a new image generation model, `gpt-image-1`" 2025-04-23, "Released GPT-5 family of models"
23
+ // 2025-08-07, "Added two new o-series reasoning models, `o3` and `o4-mini`" 2025-04-16, "Launched o3-mini" 2025-01-31,
24
+ // "Added `gpt-4o-mini-tts`, `gpt-4o-transcribe`..." 2025-03-20, "Released GPT-4o mini" 2024-07-18, "Released new
25
+ // `omni-moderation-latest`" 2024-09-26. The deprecations page (https://developers.openai.com/api/docs/deprecations) dates
26
+ // a shutdown: gpt-3.5-turbo, gpt-4-turbo, o4-mini, o3-mini and gpt-image-1 on 2026-10-23; dall-e-2 and dall-e-3 on
27
+ // 2026-05-12; gpt-image-1.5 on 2026-12-01; whisper-1 and the gpt-4o transcribe models on 2027-02-26.
28
+ // Where neither page dates a release, `created` is the value OpenAI's own model object answers (the earlier catalog's
29
+ // entries, and dall-e-2's and tts-1's). Extrapolation: gpt-4o-transcribe-diarize's release is documented nowhere the twin could
30
+ // read; it is dated with the transcribe family it belongs to (2025-03-20).
31
+ const model = (id: string, created: number, shutdown_date: string | null = null, owned_by = 'system'): OpenAIModel => ({ id, object: 'model', created, owned_by, shutdown_date });
16
32
  export const OPENAI_MODELS: OpenAIModel[] = [
17
- { id: 'gpt-4o', object: 'model', created: 1715367049, owned_by: 'system' },
18
- { id: 'gpt-4o-mini', object: 'model', created: 1721172741, owned_by: 'system' },
19
- { id: 'gpt-4.1', object: 'model', created: 1744316542, owned_by: 'system' },
20
- { id: 'gpt-4.1-mini', object: 'model', created: 1744317547, owned_by: 'system' },
21
- { id: 'gpt-4-turbo', object: 'model', created: 1712361441, owned_by: 'system' },
22
- { id: 'o3', object: 'model', created: 1744225308, owned_by: 'system' },
23
- { id: 'o4-mini', object: 'model', created: 1744225351, owned_by: 'system' },
24
- { id: 'gpt-3.5-turbo', object: 'model', created: 1677610602, owned_by: 'openai' },
25
- { id: 'text-embedding-3-small', object: 'model', created: 1705948997, owned_by: 'system' },
26
- { id: 'text-embedding-3-large', object: 'model', created: 1705953180, owned_by: 'system' },
27
- { id: 'text-embedding-ada-002', object: 'model', created: 1671217299, owned_by: 'openai-internal' },
28
- { id: 'dall-e-3', object: 'model', created: 1698785189, owned_by: 'system' },
29
- { id: 'whisper-1', object: 'model', created: 1677532384, owned_by: 'openai-internal' },
30
- { id: 'omni-moderation-latest', object: 'model', created: 1731689265, owned_by: 'system' },
33
+ model('gpt-6-astra', 1788393600, null, 'openai'),
34
+ model('gpt-5.2', 1765411200),
35
+ model('gpt-5', 1754524800),
36
+ model('gpt-4o', 1715367049),
37
+ model('gpt-4o-mini', 1721172741),
38
+ model('gpt-4o-mini-2024-07-18', 1721260800),
39
+ model('gpt-4.1', 1744316542),
40
+ model('gpt-4.1-mini', 1744317547),
41
+ model('gpt-4-turbo', 1712361441, '2026-10-23'),
42
+ model('o3', 1744761600),
43
+ model('o4-mini', 1744761600, '2026-10-23'),
44
+ model('o3-mini', 1738281600, '2026-10-23'),
45
+ model('gpt-3.5-turbo', 1677610602, '2026-10-23', 'openai'),
46
+ model('text-embedding-3-small', 1705948997),
47
+ model('text-embedding-3-large', 1705953180),
48
+ model('text-embedding-ada-002', 1671217299, null, 'openai-internal'),
49
+ model('dall-e-2', 1698798177, '2026-05-12'),
50
+ model('dall-e-3', 1698785189, '2026-05-12'),
51
+ model('gpt-image-1', 1745366400, '2026-10-23'),
52
+ model('gpt-image-1.5', 1765843200, '2026-12-01'),
53
+ model('gpt-image-2.5-flare', 1788825600),
54
+ model('whisper-1', 1677532384, '2027-02-26', 'openai-internal'),
55
+ model('gpt-4o-transcribe', 1742428800, '2027-02-26'),
56
+ model('gpt-4o-mini-transcribe', 1742428800, '2027-02-26'),
57
+ model('gpt-4o-transcribe-diarize', 1742428800, '2027-02-26'),
58
+ model('tts-1', 1681940951, null, 'openai-internal'),
59
+ model('gpt-4o-mini-tts', 1742428800),
60
+ model('omni-moderation-latest', 1727308800),
61
+ ];
62
+
63
+ /** The instant a shutdown date takes effect: "At the time of the shut down, the model or endpoint will no longer be
64
+ * accessible" (the deprecations page); the twin takes the date's first instant, UTC. */
65
+ export const shutdownAt = (date: string): number => Date.parse(`${date}T00:00:00Z`) / 1000;
66
+
67
+ /** Whether a catalogued model is live at the World instant `now` (unix seconds): released, and not yet shut down. A model
68
+ * the catalog does not hold is not live: it does not exist. */
69
+ export function live(id: string, now: number): boolean {
70
+ const m = OPENAI_MODELS.find((x) => x.id === id);
71
+ return !!m && m.created <= now && (m.shutdown_date === null || now < shutdownAt(m.shutdown_date));
72
+ }
73
+
74
+ /** The API families OpenAI removed, by the operations they serve, and the instant each is gone: the Assistants API on
75
+ * 2026-08-26 ("removal from the API one year later, on August 26, 2026"), the Evals API on 2026-11-30 ("The Evals
76
+ * dashboard and API are scheduled to shut down"), its evals read-only from 2026-10-31 ("Existing evals become
77
+ * read-only") (https://developers.openai.com/api/docs/deprecations). */
78
+ export const REMOVED_FAMILIES: Array<{ family: string; removed: string; operations: RegExp; readOnlyFrom?: string; writes?: RegExp }> = [
79
+ { family: 'Assistants API', removed: '2026-08-26', operations: /^(createAssistant|listAssistants|getAssistant|modifyAssistant|deleteAssistant|createThread|createThreadAndRun|getThread|modifyThread|deleteThread|createMessage|listMessages|getMessage|modifyMessage|deleteMessage|createRun|listRuns|getRun|modifyRun|cancelRun|submitToolOuputsToRun|listRunSteps|getRunStep)$/ },
80
+ { family: 'Evals API', removed: '2026-11-30', operations: /^(createEval|listEvals|getEval|updateEval|deleteEval|createEvalRun|getEvalRuns|getEvalRun|cancelEvalRun|deleteEvalRun|getEvalRunOutputItems|getEvalRunOutputItem)$/, readOnlyFrom: '2026-10-31', writes: /^(createEval|updateEval|deleteEval|createEvalRun|cancelEvalRun|deleteEvalRun)$/ },
31
81
  ];
32
82
 
83
+ /** A removed family's answer to an operation at `now`, or none while it is served. The deprecations page says only that
84
+ * a removed endpoint "will no longer be accessible": the status and wording of the answer are the twin's own. */
85
+ export function removedAt(operation: string, now: number): { status: number; message: string } | undefined {
86
+ for (const f of REMOVED_FAMILIES) {
87
+ if (!f.operations.test(operation)) continue;
88
+ if (now >= shutdownAt(f.removed)) return { status: 404, message: `The ${f.family} was removed on ${f.removed}.` };
89
+ if (f.readOnlyFrom && f.writes?.test(operation) && now >= shutdownAt(f.readOnlyFrom)) return { status: 400, message: `Evals are read-only since ${f.readOnlyFrom}.` };
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** Fine-tuning's dated availability (the deprecations page, "Self-serve fine-tuning availability"): from 2026-05-07
95
+ * "Creating fine-tuning jobs or training is not available to organizations that have not previously run fine-tuning";
96
+ * from 2026-07-02 "Creating fine-tuning jobs is no longer available to organizations that have not run inference on a
97
+ * fine-tuned model in the past 60 days"; from 2027-01-06 "Active existing customers will no longer be able to create
98
+ * new fine-tuning jobs". The page documents no error: the refusal (403, its wording) is the twin's own. */
99
+ export function fineTuningClosed(now: number, org: { everFineTuned: boolean; lastFineTunedInference?: number }): string | undefined {
100
+ if (now >= shutdownAt('2027-01-06')) return 'Creating fine-tuning jobs is no longer available (since 2027-01-06).';
101
+ if (now >= shutdownAt('2026-07-02') && (org.lastFineTunedInference === undefined || now - org.lastFineTunedInference > 60 * 86400)) return 'Creating fine-tuning jobs is not available to organizations that have not run inference on a fine-tuned model in the past 60 days (since 2026-07-02).';
102
+ if (now >= shutdownAt('2026-05-07') && !org.everFineTuned) return 'Creating fine-tuning jobs is not available to organizations that have not previously run fine-tuning (since 2026-05-07).';
103
+ return undefined;
104
+ }
105
+
33
106
  /** Resolve a model object by id, or undefined if the twin doesn't model it. */
34
107
  export function findModel(id: string): OpenAIModel | undefined {
35
108
  return OPENAI_MODELS.find((m) => m.id === id);
36
109
  }
110
+
111
+ /** Whether a model reasons, so that its responses list the reasoning item that describes its chain of thought and count
112
+ * its reasoning tokens (the spec's ReasoningItem: "A description of the chain of thought used by a reasoning model while
113
+ * generating a response"). The o-series and the GPT models from GPT-5 on reason: the spec speaks of "all reasoning models
114
+ * after `gpt-5`", and the reasoning guide (https://developers.openai.com/api/docs/guides/reasoning) lists gpt-6-astra,
115
+ * gpt-5.6, gpt-5.5 and gpt-5.4 among them; gpt-6-astra's page names "Reasoning token support"
116
+ * (https://developers.openai.com/api/docs/models/gpt-6-astra). A fine-tuned model reasons as its base does. */
117
+ export function reasons(model: string): boolean {
118
+ const base = model.startsWith('ft:') ? model.split(':')[1] ?? '' : model;
119
+ return /^o\d/.test(base) || /^gpt-([5-9]|\d{2,})(\b|[.-])/.test(base);
120
+ }
121
+
122
+ /** The reasoning efforts the spec's ReasoningEffort names; a model may document fewer. */
123
+ export const REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
124
+
125
+ /** The effort a reasoning model spends when the request names none: the spec's ReasoningEffort `default: medium`. */
126
+ export const DEFAULT_EFFORT = 'medium';
127
+
128
+ /** What a model's page says it takes and refuses. gpt-6-astra: "`reasoning.effort` supports `low`, `medium`, `high`,
129
+ * `xhigh`, and `max`" (its model page); "GPT-6 Astra does not support `none` reasoning effort. Setting
130
+ * `reasoning.effort` (Responses) or `reasoning_effort` (Chat Completions) to `none` returns HTTP 400" (the reasoning
131
+ * guide); "does not support custom `temperature` or `top_p` values or log probabilities (`logprobs`)" (the changelog,
132
+ * 2026-09-03). */
133
+ const LIMITS: Record<string, { efforts: string[]; sampling: false; logprobs: false; chatTools: false }> = {
134
+ 'gpt-6-astra': { efforts: ['low', 'medium', 'high', 'xhigh', 'max'], sampling: false, logprobs: false, chatTools: false },
135
+ };
136
+
137
+ /** OpenAI's refusal of a request a model does not take (an effort, custom sampling, log probabilities), or none. */
138
+ export function unsupported(model: string, req: { effort?: unknown; effortParam: string; temperature?: unknown; top_p?: unknown; logprobs?: unknown; chatTools?: unknown }): { message: string; param: string } | undefined {
139
+ const base = model.startsWith('ft:') ? model.split(':')[1] ?? '' : model;
140
+ const limits = LIMITS[base];
141
+ const efforts = limits?.efforts ?? REASONING_EFFORTS;
142
+ if (req.effort !== undefined && (typeof req.effort !== 'string' || !efforts.includes(req.effort))) {
143
+ return { message: `Unsupported value: '${req.effortParam}' does not support ${JSON.stringify(req.effort)} with this model. Supported values are: ${efforts.map((e) => `'${e}'`).join(', ')}.`, param: req.effortParam };
144
+ }
145
+ if (!limits) return undefined;
146
+ for (const k of ['temperature', 'top_p'] as const) {
147
+ if (req[k] !== undefined && req[k] !== 1) return { message: `Unsupported value: '${k}' does not support ${JSON.stringify(req[k])} with this model. Only the default (1) value is supported.`, param: k };
148
+ }
149
+ if (req.logprobs === true) return { message: "Unsupported parameter: 'logprobs' is not supported with this model.", param: 'logprobs' };
150
+ // "Tool calling requires the Responses API. If you use tools with Chat Completions, follow the Responses migration
151
+ // guide" (https://developers.openai.com/api/docs/changelog, 2026-09-03). The changelog gives no wording for the
152
+ // refusal: this message is the twin's own.
153
+ if (Array.isArray(req.chatTools) && req.chatTools.length) return { message: 'Tool calling with this model requires the Responses API: send the request to /v1/responses.', param: 'tools' };
154
+ return undefined;
155
+ }
156
+
157
+ /** The model an operation runs on when the request names none, from the spec's request schemas: a generation's
158
+ * `dall-e-2` (CreateImageRequest `model` default), an edit's `gpt-image-1.5` (CreateImageEditRequest), a variation's
159
+ * `dall-e-2` (it takes no other), a moderation's `omni-moderation-latest` (CreateModerationRequest). */
160
+ export const DEFAULT_MODEL: Record<string, string> = { createImage: 'dall-e-2', createImageEdit: 'gpt-image-1.5', createImageVariation: 'dall-e-2', createModeration: 'omni-moderation-latest' };
161
+
162
+ /** The model a request runs on: the one it names, else its operation's default (a variation runs on dall-e-2 whatever it names). */
163
+ export function modelOf(operation: string, params: { model?: unknown }): string | undefined {
164
+ if (operation === 'createImageVariation') return DEFAULT_MODEL.createImageVariation;
165
+ return typeof params.model === 'string' && params.model ? params.model : DEFAULT_MODEL[operation];
166
+ }
@@ -1,10 +1,10 @@
1
- // The openai pack's scenario system on the kernel's ONE engine (@volter/twin scenario.ts):
1
+ // The openai pack's scenario system on the kernel's ONE engine (@volter/world-core scenario.ts):
2
2
  // chat-completions vocabulary + the scripted-turn respond shape. New with the
3
3
  // TWIN-PROGRAMMING-MODEL consolidation — this pack previously had no scripting at all. The
4
4
  // handler FILE (handlers/openai.json in a world dir) is the only write surface.
5
- import { getActiveWorldStore, parseScenarioDocument, type PackScenarioAdapter, ScenarioError, type ScenarioDocument, ScenarioEngine, type ScenarioFeatures } from '@volter/twin';
6
- import { contentToText, lastUserText } from './openai-stub.ts';
7
- import type { ChatMessageParam, ChatToolCall } from './openai-types.ts';
5
+ import { getActiveWorldStore, parseScenarioDocument, type PackScenarioAdapter, type ScenarioDecision, ScenarioError, type ScenarioDocument, ScenarioEngine, type ScenarioFeatures } from '@volter/world-core';
6
+ import { contentToText, estimateTokens, lastUserText } from './openai-stub.ts';
7
+ import type { ChatChoice, ChatMessageParam, ChatToolCall } from './openai-types.ts';
8
8
 
9
9
  export type OpenAIScenarioRequest = { model: string; messages: ChatMessageParam[]; tools?: unknown; maxTokens?: number };
10
10
  export type OpenAIScenarioEngine = ScenarioEngine<OpenAIScenarioRequest>;
@@ -50,6 +50,18 @@ function toolNames(tools: unknown): string[] {
50
50
  }
51
51
 
52
52
  export const openaiScenarioAdapter: PackScenarioAdapter<OpenAIScenarioRequest> = {
53
+ // R15 — a status fault in THIS vendor's envelope: the same {error:{message,type,param,code}}
54
+ // openai-twin.ts serves for its own refusals (its 429 is `type: 'rate_limit_exceeded'`).
55
+ renderFault: (f) => ({
56
+ body: {
57
+ error: {
58
+ message: f.message ?? (f.status === 429 ? 'Rate limit reached for requests. Limit your request rate or retry after the indicated delay.' : f.status >= 500 ? 'The server had an error while processing your request. Sorry about that!' : 'The request was refused by a scripted fault.'),
59
+ type: f.status === 429 ? 'rate_limit_exceeded' : f.status >= 500 ? 'server_error' : 'invalid_request_error',
60
+ param: null,
61
+ code: f.status === 429 ? 'rate_limit_exceeded' : null,
62
+ },
63
+ },
64
+ }),
53
65
  vendor: 'openai',
54
66
  features: (req): ScenarioFeatures => ({
55
67
  model: req.model,
@@ -116,17 +128,41 @@ export function loadOpenAIScenarioDocument(path: string): ScenarioDocument {
116
128
  return parseScenarioDocument(parsed, openaiScenarioAdapter);
117
129
  }
118
130
 
131
+ /** What the scenario makes of one request: the decision a builder realizes, or a fault answered at once
132
+ * (a status the script names; the script's own authoring failure is a 500 before any stream starts). */
133
+ export async function serveScenario(engine: OpenAIScenarioEngine, req: OpenAIScenarioRequest, path: string): Promise<ScenarioDecision | { kind: 'fault'; result: { status: number; body: unknown; headers?: Record<string, string> } }> {
134
+ try {
135
+ return await engine.serve(req);
136
+ } catch (error) {
137
+ if (!(error instanceof ScenarioError)) throw error;
138
+ console.error(`[openai twin] scenario error on ${path}: ${error.message}`);
139
+ return { kind: 'fault', result: { status: 500, body: { error: { message: error.message, type: 'twin_scenario_error', param: null, code: 'scenario_error' } }, headers: { 'x-request-id': 'req_twin' } } };
140
+ }
141
+ }
142
+
143
+ /** A decision as a turn: the scripted result, or, on a miss, the note that teaches where to script it. */
144
+ export function scenarioTurn(decision: ScenarioDecision, callIdPrefix?: string): { scripted: ScriptedResult; missTeach?: undefined } | { scripted?: undefined; missTeach: string } {
145
+ if (decision.kind === 'handler') return { scripted: realizeOpenAIRespond(decision.respond as OpenAIScenarioRespond, callIdPrefix) };
146
+ return { missTeach: `\n[twin-scenario miss — no handler matched. Author one in the world dir's handlers/openai.json (GET /twin explains; GET /twin/scenario lists handlers + misses). Features seen: ${JSON.stringify(decision.miss.features)}]` };
147
+ }
148
+
149
+ /** A scripted turn as a chat completion's choice. */
150
+ export function scriptedChoice(scripted: ScriptedResult, index: number): { choice: ChatChoice; completionTokens: number } {
151
+ const message = scripted.toolCalls.length
152
+ ? { role: 'assistant' as const, content: scripted.text, tool_calls: scripted.toolCalls, refusal: null }
153
+ : { role: 'assistant' as const, content: scripted.text ?? '', refusal: null };
154
+ return { choice: { index, message, logprobs: null, finish_reason: scripted.finishReason }, completionTokens: estimateTokens(JSON.stringify(scripted.toolCalls.length ? scripted.toolCalls : scripted.text ?? '')) };
155
+ }
156
+
119
157
  export function createOpenAIScenarioEngine(document?: ScenarioDocument): OpenAIScenarioEngine {
120
158
  return new ScenarioEngine(openaiScenarioAdapter, document);
121
159
  }
122
160
 
123
- let scriptedCallSeq = 0;
124
-
125
- export function realizeOpenAIRespond(respond: OpenAIScenarioRespond): ScriptedResult {
161
+ export function realizeOpenAIRespond(respond: OpenAIScenarioRespond, callIdPrefix = 'call_scripted'): ScriptedResult {
126
162
  const toolCalls: ChatToolCall[] = [];
127
- for (const tc of respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : []) {
128
- scriptedCallSeq += 1;
129
- toolCalls.push({ id: tc.id ?? `call_scripted_${scriptedCallSeq}`, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } });
163
+ // Responses supplies a response-specific prefix so chained calls keep distinct identities.
164
+ for (const [i, tc] of (respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : []).entries()) {
165
+ toolCalls.push({ id: tc.id ?? `${callIdPrefix}_${i + 1}`, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } });
130
166
  }
131
167
  return {
132
168
  text: respond.text ?? (toolCalls.length ? null : ''),