@volter/twin-openai 0.1.2 → 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
@@ -3,68 +3,35 @@
3
3
  // (what the SDK sends for most calls) pass straight through. Writable by default; pass readOnly
4
4
  // to reject mutations (D3).
5
5
  //
6
- // Streaming: when the request body has `"stream": true`, the server constructs a REAL SSE
7
- // response by feeding the handler an sseSink that writes each chunk onto the HTTP stream in
8
- // OpenAI's `data: <json>\n\n` wire format, ending with `data: [DONE]\n\n`. (The handler itself
9
- // stays socket-free — the sink is the only place a socket is touched, on the live HTTP path.)
10
- //
11
- // File uploads: the SDK sends `multipart/form-data` to POST /v1/files. The server parses the
12
- // multipart form into the handler's JSON contract ({ purpose, filename, content, bytes }) so
13
- // the handler stays a pure JSON function (offline-testable).
6
+ // Streaming: a chat completion or response with `"stream": true` is answered by its semantics
7
+ // handler as server-sent events in OpenAI's `data: <json>\n\n` framing, ending `data: [DONE]`
8
+ // (the manifest's framing); a failure decided before the stream answers JSON with its status.
14
9
  //
15
10
  // The surface is a plain `fetch` (`createOpenAITwinFetch`) and the SERVER is one line of
16
11
  // `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
17
12
  // port to bind, so it mounts the fetch in-process).
18
- import { handleOpenAITwinRequest } from './openai-twin.ts';
19
- import { twinManifest, worldNow } from '@volter/twin';
13
+ import { bindSemantics, coreFor, createDerivedFetch, crossCutting, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
14
+ import surface from './generated/surface.gen.json' with { type: 'json' };
15
+ import { manifest } from './manifest.ts';
16
+ import { openaiSemantics } from './semantics/index.ts';
17
+ import { answerVendorErrors, RefusedWriteError } from '@volter/world-core';
18
+ import { twinManifest } from '@volter/world-core';
20
19
  import { createOpenAIScenarioEngine, loadOpenAIScenarioDocument, type OpenAIScenarioEngine } from './openai-scenario.ts';
21
- import type { SseEvent } from './openai-types.ts';
22
-
23
- function wantsStream(body: string): boolean {
24
- if (!body) return false;
25
- try {
26
- return (JSON.parse(body) as { stream?: unknown })?.stream === true;
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
- function encodeSse(event: SseEvent): string {
33
- if (event.done) return 'data: [DONE]\n\n';
34
- return `data: ${JSON.stringify(event.data)}\n\n`;
35
- }
36
-
37
- const STREAMABLE = new Set(['/v1/chat/completions', '/v1/responses']);
38
-
39
- async function multipartToJson(request: Request): Promise<string> {
40
- try {
41
- const form = await request.formData();
42
- const out: Record<string, unknown> = {};
43
- // Forward every scalar field generically (model, purpose, response_format, language, …).
44
- for (const [key, value] of form.entries()) {
45
- if (typeof value === 'string') out[key] = value;
46
- }
47
- const file = form.get('file');
48
- if (file instanceof File) {
49
- // The handler's contract uses `file` as the filename and `content`/`bytes` for the body.
50
- const content = await file.text();
51
- out.file = file.name || 'upload';
52
- out.filename = file.name || 'upload';
53
- out.content = content;
54
- out.bytes = file.size || content.length;
55
- }
56
- if (out.purpose === undefined) out.purpose = '';
57
- return JSON.stringify(out);
58
- } catch {
59
- return '{}';
60
- }
61
- }
20
+ import { openaiApiKeysScreen, projectKeyUse } from './screens/api-keys.tsx';
21
+ import { openaiSessionFlow } from './screens/session.tsx';
22
+ import { expireFiles } from './semantics/files.ts';
23
+ import { observeBatches } from './semantics/batches.ts';
24
+ import { observeJobs } from './semantics/fine-tuning.ts';
62
25
 
63
26
  /** Options every OpenAI-twin HTTP surface needs, independent of who owns the socket. */
64
27
  export interface OpenAITwinFetchOptions {
65
28
  root?: string;
66
29
  readOnly?: boolean;
67
30
  scenarioPath?: string;
31
+ /** a scenario engine already built (an in-process caller's), in place of `scenarioPath` */
32
+ scenarioEngine?: OpenAIScenarioEngine;
33
+ /** the World instant, when a caller pins it */
34
+ clock?: () => string;
68
35
  }
69
36
 
70
37
  /**
@@ -89,11 +56,14 @@ export interface OpenAITwinFetchOptions {
89
56
  export function createOpenAITwinFetch(options: OpenAITwinFetchOptions): (request: Request) => Promise<Response> {
90
57
  const readOnly = options.readOnly ?? false;
91
58
  const scenarioPath = options.scenarioPath ?? process.env.TWIN_OPENAI_SCENARIO;
92
- const scenarioEngine: OpenAIScenarioEngine | undefined = scenarioPath ? createOpenAIScenarioEngine(loadOpenAIScenarioDocument(scenarioPath)) : undefined;
93
- return async (request: Request): Promise<Response> => {
59
+ const scenarioEngine: OpenAIScenarioEngine | undefined = options.scenarioEngine ?? (scenarioPath ? createOpenAIScenarioEngine(loadOpenAIScenarioDocument(scenarioPath)) : undefined);
60
+ const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
61
+ // THE READ DOORS (TWIN-PROGRAMMING-MODEL): discovery + inspection, read-only.
62
+ const door = (request: Request): Response | undefined => {
94
63
  const url = new URL(request.url);
95
- // THE READ DOORS (TWIN-PROGRAMMING-MODEL): discovery + inspection, read-only.
96
- if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
64
+ if (request.method !== 'GET') return undefined;
65
+ const path = url.pathname.replace(/\/+$/, '');
66
+ if (path === '/twin') {
97
67
  return Response.json(twinManifest({
98
68
  vendor: 'openai',
99
69
  twinOf: 'OpenAI API (chat completions)',
@@ -103,62 +73,49 @@ export function createOpenAITwinFetch(options: OpenAITwinFetchOptions): (request
103
73
  engine: scenarioEngine as never,
104
74
  }));
105
75
  }
106
- if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin/scenario') {
107
- return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'openai', handlers: [], misses: 0, recentMisses: [] });
108
- }
109
- const path = url.pathname + (url.search || '');
110
- const cleanPath = url.pathname.replace(/\/+$/, '');
111
- const contentType = request.headers.get('content-type') ?? '';
112
-
113
- // Pass through the headers the handler models (auth 401, idempotency dedup, rate-limit
114
- // trigger), lower-cased. Their presence is what makes the live wire auth/idempotency/
115
- // rate-limit-aware (in-process trusted calls omit them and are not gated).
116
- const passHeaders: Record<string, string> = {};
117
- for (const k of ['authorization', 'idempotency-key', 'x-twin-force-rate-limit']) {
118
- const v = request.headers.get(k);
119
- if (v !== null) passHeaders[k] = v;
120
- }
121
-
122
- let body = '';
123
- if (request.method !== 'GET') {
124
- body = contentType.includes('multipart/form-data') ? await multipartToJson(request) : await request.text();
125
- }
126
-
127
- // Streaming POST → a real text/event-stream response built from the sink.
128
- if (!readOnly && request.method.toUpperCase() === 'POST' && STREAMABLE.has(cleanPath) && wantsStream(body)) {
129
- const stream = new ReadableStream<Uint8Array>({
130
- async start(controller) {
131
- const enc = new TextEncoder();
132
- const sink = (e: SseEvent) => controller.enqueue(enc.encode(encodeSse(e)));
133
- const { status, body: out } = await handleOpenAITwinRequest({
134
- ...(scenarioEngine ? { scenarioEngine } : {}),
135
- method: request.method, path, body, readOnly, occurredAt: worldNow(), headers: passHeaders,
136
- ...(options.root !== undefined ? { root: options.root } : {}), sseSink: sink,
137
- });
138
- // A validation error before streaming → emit a single SSE data event (vendor shape).
139
- if (status >= 400) controller.enqueue(enc.encode(`data: ${JSON.stringify(out)}\n\n`));
140
- controller.close();
141
- },
142
- });
143
- return new Response(stream, { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', 'x-request-id': 'req_twin' } });
144
- }
145
-
146
- const { status, body: out, headers: outHeaders } = await handleOpenAITwinRequest({
147
- ...(scenarioEngine ? { scenarioEngine } : {}),
148
- method: request.method, path, body, readOnly,
149
- occurredAt: worldNow(), headers: passHeaders,
150
- ...(options.root !== undefined ? { root: options.root } : {}),
151
- });
152
- // File content downloads return raw text, not JSON.
153
- if (request.method === 'GET' && /\/v1\/files\/[^/]+\/content$/.test(cleanPath) && typeof out === 'string') {
154
- return new Response(out, { status, headers: { 'content-type': 'application/octet-stream', 'x-request-id': 'req_twin' } });
155
- }
156
- return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'x-request-id': 'req_twin', ...(outHeaders ?? {}) } });
76
+ if (path === '/twin/scenario') return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'openai', handlers: [], misses: 0, recentMisses: [] });
77
+ return undefined;
78
+ };
79
+ // a refused or failed write at the head (kernel head.ts) answers in the vendor's own error shape
80
+ // The derived dispatch owns the wire: semantics handlers, then the derived core for resources the
81
+ // manifest declares; an operation neither models answers OpenAI's unknown-URL 404.
82
+ const inner = createDerivedFetch({
83
+ surface,
84
+ handlers: bindSemantics(manifest, openaiSemantics({ ...(scenarioEngine ? { scenarioEngine } : {}), readOnly }), scope),
85
+ core: coreFor(manifest, scope),
86
+ around: crossCutting(manifest, { readOnly, ...scope }),
87
+ gap: (request) => vendorError(manifest, { status: 404, message: `Unknown request URL: ${request.method} ${new URL(request.url).pathname}. Please check the URL for typos.` }),
88
+ });
89
+ const handle = answerVendorErrors(inner, (error) => Response.json({ error: { message: error.message, type: error instanceof RefusedWriteError ? 'invalid_request_error' : 'server_error', param: null, code: error instanceof RefusedWriteError ? 'refused' : 'vendor_failed' } }, { status: error instanceof RefusedWriteError ? 400 : 502 }));
90
+ // OpenAI's own pages sit beside the API: the log-in (and the World's account door), and the dashboard's API keys page,
91
+ // where a project's keys are made
92
+ const screens = [openaiSessionFlow(scope), openaiApiKeysScreen(scope)];
93
+ // which part serves each spec operation (the derived dispatch's owners), for the pack's report
94
+ // (a scenario's authoring failure is answered where the scenario is served: openai-scenario.ts)
95
+ // time's moves OpenAI makes on its own (a file expiring: semantics/files.ts) are caught up to the World's clock
96
+ // before anything is answered, so every door reads the organization as it stands now
97
+ const filesClock = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'listFiles')!;
98
+ // a project key presented to the API is used: its last use is recorded, and a revoked one refused (screens/api-keys.tsx)
99
+ const useKey = projectKeyUse(scope);
100
+ // and a read of the files observes the work whose outputs are files (a batch's output, a fine-tuning job's results)
101
+ // first, as a read of that batch or job does: the files a read lists never depend on which other read came before it
102
+ const catchUp = async (request: Request): Promise<void> => {
103
+ const ctx = await semanticsContext(manifest, new Request(request.url), filesClock, scope);
104
+ await expireFiles(ctx);
105
+ if (request.method === 'GET' && /^\/v1\/files(\/|$)/.test(new URL(request.url).pathname)) for (const observe of [observeBatches, observeJobs]) await observe(ctx);
157
106
  };
107
+ return Object.assign(async (request: Request): Promise<Response> => {
108
+ const opened = door(request);
109
+ if (opened) return opened;
110
+ if (!readOnly) await catchUp(request);
111
+ if (!readOnly) { const refused = await useKey(request); if (refused) return refused; }
112
+ if (!readOnly) for (const screen of screens) { const page = await screen(request); if (page) return page; }
113
+ return handle(request);
114
+ }, { owners: inner.owners });
158
115
  }
159
116
 
160
- export function createOpenAITwinServer(options: { root?: string; port?: number; readOnly?: boolean; scenarioPath?: string }): { port: number; stop: () => void } {
161
- const server = Bun.serve({
117
+ export async function createOpenAITwinServer(options: { root?: string; port?: number; readOnly?: boolean; scenarioPath?: string }): Promise<{ port: number; stop: () => void }> {
118
+ const server = await serveHttp({
162
119
  port: options.port ?? 0,
163
120
  idleTimeout: 60,
164
121
  fetch: createOpenAITwinFetch(options),
@@ -1,4 +1,4 @@
1
- // THE GENERATIVE-STUB CORE (the honest carve-outs).
1
+ // THE GENERATIVE-STUB CORE (the twin's answer to generation).
2
2
  //
3
3
  // The twin CANNOT run the model — there are no weights here. So `POST /v1/chat/completions`
4
4
  // and `POST /v1/responses` return a DETERMINISTIC STUB completion that is CLEARLY a twin stub,
@@ -7,7 +7,7 @@
7
7
  // is the ENTIRE PROTOCOL ENVELOPE: the response shape, streaming chunk sequence, tool_calls
8
8
  // shape, finish_reason, and deterministic token counts.
9
9
  //
10
- // Surfaced as `openai.chat.inference` + `openai.embeddings.real_vectors` (out of scope) in the
10
+ // Pinned by `openai.chat.stub_labeled` and `openai.embeddings.deterministic` in the manifest.
11
11
  // capability manifest and the README ## Coverage: the protocol is real; the generation is a stub.
12
12
 
13
13
  import type { ChatMessageParam, ChatToolCall } from './openai-types.ts';
@@ -50,11 +50,9 @@ export function countPromptTokens(messages: ChatMessageParam[]): number {
50
50
 
51
51
  /** The last user turn's text — the thing the stub echoes (deterministic, clearly labeled). */
52
52
  export function lastUserText(messages: ChatMessageParam[]): string {
53
- for (let i = messages.length - 1; i >= 0; i--) {
54
- if (messages[i]!.role === 'user') return contentToText(messages[i]!.content);
55
- }
56
53
  // No user turn (e.g. only system) → fall back to the last message's text.
57
- return messages.length ? contentToText(messages[messages.length - 1]!.content) : '';
54
+ const turn = [...messages].reverse().find((m) => m.role === 'user') ?? messages.at(-1);
55
+ return turn ? contentToText(turn.content) : '';
58
56
  }
59
57
 
60
58
  /**
@@ -72,9 +70,7 @@ export function stubAssistantText(messages: ChatMessageParam[], model: string):
72
70
  * (`{ type:'function', function:{ name } }`) or a legacy `functions` entry (`{ name }`). */
73
71
  function toolName(t: unknown): string {
74
72
  const o = t as { function?: { name?: unknown }; name?: unknown } | undefined;
75
- if (o?.function && typeof o.function.name === 'string') return o.function.name;
76
- if (typeof o?.name === 'string') return o.name;
77
- return 'unknown_function';
73
+ return o?.function && typeof o.function.name === 'string' ? o.function.name : typeof o?.name === 'string' ? o.name : 'unknown_function';
78
74
  }
79
75
 
80
76
  function placeholderForSchema(def: unknown): unknown {
@@ -90,19 +86,40 @@ function placeholderForSchema(def: unknown): unknown {
90
86
  }
91
87
  }
92
88
 
89
+ // Words that name nothing a tool could take as a value.
90
+ const STOPWORDS = new Set(['a', 'an', 'the', 'do', 'does', 'you', 'your', 'have', 'has', 'is', 'are', 'for', 'me', 'my', 'please', 'this', 'that', 'one', 'like', 'with', 'what', 'which', 'how', 'can', 'could', 'would', 'and', 'or', 'of', 'to', 'in', 'on', 'it', 'its', 'small', 'large', 'big', 'some', 'any']);
91
+
92
+ /** The value the user's message names for a string parameter: its longest content word (the thing asked about: "Do you
93
+ * have a small monstera?" names `monstera`), or none. */
94
+ function namedIn(userText: string): string | undefined {
95
+ // a link the message carries (an image's URL) is not what it asks about
96
+ const words = (userText.replace(/\S+:\/\/\S+/g, ' ').toLowerCase().match(/[a-z][a-z0-9-]*/g) ?? []).filter((w) => !STOPWORDS.has(w));
97
+ return words.reduce<string | undefined>((best, w) => (!best || w.length > best.length ? w : best), undefined);
98
+ }
99
+
93
100
  /**
94
101
  * Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
95
102
  * `parameters` object (especially with `strict:true`), real models emit arguments that validate
96
103
  * against the schema; the twin synthesizes a deterministic object containing every declared
97
- * property with a type-appropriate placeholder so `strict` callers parse it cleanly.
104
+ * property with a type-appropriate placeholder so `strict` callers parse it cleanly. A required
105
+ * string is never left empty: an empty value is schema-valid in type but names nothing, so an
106
+ * application's tool could not act on it and a story could not follow the call. It takes what the
107
+ * user's message names (`namedIn`), else a deterministic value of the parameter's own name; an enum
108
+ * takes the value the message names, else its first.
98
109
  */
99
- export function stubToolArguments(tool: unknown): string {
110
+ export function stubToolArguments(tool: unknown, userText = ''): string {
100
111
  const o = tool as { function?: { parameters?: unknown }; parameters?: unknown } | undefined;
101
- const schema = (o?.function?.parameters ?? o?.parameters) as { properties?: Record<string, unknown> } | undefined;
112
+ const schema = (o?.function?.parameters ?? o?.parameters) as { properties?: Record<string, unknown>; required?: unknown } | undefined;
102
113
  const props = schema && typeof schema === 'object' ? schema.properties : undefined;
103
114
  if (!props || typeof props !== 'object') return '{}';
115
+ const required = new Set(Array.isArray(schema!.required) ? schema!.required.map(String) : []);
116
+ const words = new Set(userText.toLowerCase().match(/[a-z0-9-]+/g) ?? []);
104
117
  const out: Record<string, unknown> = {};
105
- for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
118
+ for (const [key, def] of Object.entries(props)) {
119
+ const d = def as { type?: unknown; enum?: unknown[] } | undefined;
120
+ const named = Array.isArray(d?.enum) ? d!.enum!.find((e) => typeof e === 'string' && words.has(e.toLowerCase())) : undefined;
121
+ out[key] = named ?? (required.has(key) && d?.type === 'string' && !Array.isArray(d?.enum) ? namedIn(userText) ?? `twin-${key}` : placeholderForSchema(def));
122
+ }
106
123
  return JSON.stringify(out);
107
124
  }
108
125
 
@@ -113,10 +130,10 @@ export function stubToolArguments(tool: unknown): string {
113
130
  * from the tool's JSON schema so strict callers parse them — clearly a stub, but a
114
131
  * vendor-faithful tool_calls envelope. Returns the tool_call, or null when no tools provided.
115
132
  */
116
- export function stubToolCall(tools: unknown, seq: number, forcedName?: string): ChatToolCall | null {
133
+ export function stubToolCall(tools: unknown, seq: number, forcedName?: string, userText = ''): ChatToolCall | null {
117
134
  if (!Array.isArray(tools) || tools.length === 0) return null;
118
135
  const chosen = forcedName ? (tools.find((t) => toolName(t) === forcedName) ?? tools[0]) : tools[0];
119
- return { id: `call_twin_${seq}`, type: 'function', function: { name: toolName(chosen), arguments: stubToolArguments(chosen) } };
136
+ return { id: `call_twin_${seq}`, type: 'function', function: { name: toolName(chosen), arguments: stubToolArguments(chosen, userText) } };
120
137
  }
121
138
 
122
139
  /**
@@ -128,11 +145,14 @@ export function stubToolCall(tools: unknown, seq: number, forcedName?: string):
128
145
  export function stubJsonObject(messages: ChatMessageParam[], model: string, jsonSchema?: unknown): string {
129
146
  const schema = jsonSchema as { schema?: { properties?: Record<string, unknown> }; properties?: Record<string, unknown> } | undefined;
130
147
  const props = schema?.schema?.properties ?? schema?.properties;
131
- if (props && typeof props === 'object') {
132
- const out: Record<string, unknown> = {};
133
- for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
134
- return JSON.stringify(out);
135
- }
148
+ if (!props || typeof props !== 'object') return freeformJson(messages, model);
149
+ const out: Record<string, unknown> = {};
150
+ for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
151
+ return JSON.stringify(out);
152
+ }
153
+
154
+ /** JSON mode with no schema (`json_object`): a labeled object echoing the request. */
155
+ function freeformJson(messages: ChatMessageParam[], model: string): string {
136
156
  return JSON.stringify({ _twin_stub: true, model, echo: lastUserText(messages).slice(0, 200) });
137
157
  }
138
158
 
@@ -165,11 +185,8 @@ export function buildLogprobs(text: string, topN: number): { content: LogprobTok
165
185
  const seed = fnv1a(tok);
166
186
  const lp = -((seed % 5000) / 1000); // deterministic logprob in (-5, 0]
167
187
  const bytes = Array.from(new TextEncoder().encode(tok));
168
- const top: LogprobToken['top_logprobs'] = [{ token: tok, logprob: lp, bytes }];
169
- for (let i = 0; i < topN; i++) {
170
- const altSeed = fnv1a(`${tok}#${i}`);
171
- top.push({ token: `«alt${i}»`, logprob: lp - 1 - (altSeed % 3000) / 1000, bytes: [] });
172
- }
188
+ // the token itself, then `topN` labeled alternatives, each less likely
189
+ const top: LogprobToken['top_logprobs'] = [{ token: tok, logprob: lp, bytes }, ...Array.from({ length: topN }, (_, i) => ({ token: `«alt${i}»`, logprob: lp - 1 - (fnv1a(`${tok}#${i}`) % 3000) / 1000, bytes: [] as number[] }))];
173
190
  return { token: tok, logprob: lp, bytes, top_logprobs: top.slice(0, Math.max(1, topN)) };
174
191
  });
175
192
  return { content };
@@ -188,7 +205,7 @@ function fnv1a(text: string): number {
188
205
 
189
206
  /** A deterministic, reproducible L2-normalized pseudo-embedding vector for `text`. NOT a real
190
207
  * embedding — the values carry no semantic meaning; only the SHAPE and DETERMINISM are
191
- * faithful (the `openai.embeddings.real_vectors` carve-out). Same text → same vector. */
208
+ * faithful (`openai.embeddings.deterministic`). Same text → same vector. */
192
209
  export function pseudoEmbedding(text: string, dimensions: number): number[] {
193
210
  const dim = Math.max(1, Math.floor(dimensions));
194
211
  let state = fnv1a(text) || 1;
@@ -212,10 +229,13 @@ export function pseudoEmbedding(text: string, dimensions: number): number[] {
212
229
  // ── Moderations: deterministic classifier ───────────────────────────────────────────────
213
230
  /** The moderation categories the twin reports (faithful key set). */
214
231
  export const MODERATION_CATEGORIES = [
215
- 'hate', 'hate/threatening', 'harassment', 'harassment/threatening',
232
+ 'hate', 'hate/threatening', 'harassment', 'harassment/threatening', 'illicit', 'illicit/violent',
216
233
  'self-harm', 'self-harm/intent', 'self-harm/instructions',
217
234
  'sexual', 'sexual/minors', 'violence', 'violence/graphic',
218
235
  ] as const;
236
+ /** The categories an image is classified in besides text (the spec's `category_applied_input_types`; the moderation
237
+ * guide, https://platform.openai.com/docs/guides/moderation). */
238
+ const IMAGE_CATEGORIES = new Set(['self-harm', 'self-harm/intent', 'self-harm/instructions', 'sexual', 'violence', 'violence/graphic']);
219
239
 
220
240
  // Deterministic keyword → category map. The twin can't run the real classifier, so it flags
221
241
  // on a fixed keyword list (clearly a heuristic). Shape is faithful; the decision is a stub.
@@ -233,8 +253,10 @@ export type ModerationResult = {
233
253
  category_applied_input_types?: Record<string, string[]>;
234
254
  };
235
255
 
236
- /** Deterministically moderate one input string into the faithful result shape. */
237
- export function moderateText(text: string): ModerationResult {
256
+ /** Deterministically moderate one input (its text, and whether it held an image) into the faithful result shape: each
257
+ * category with the input types its score applies to, as every result carries them
258
+ * (https://platform.openai.com/docs/api-reference/moderations/object). */
259
+ export function moderateText(text: string, image = false): ModerationResult {
238
260
  const lower = text.toLowerCase();
239
261
  const categories: Record<string, boolean> = {};
240
262
  const scores: Record<string, number> = {};
@@ -243,5 +265,7 @@ export function moderateText(text: string): ModerationResult {
243
265
  for (const [kw, cat] of Object.entries(MODERATION_KEYWORDS)) {
244
266
  if (lower.includes(kw)) { categories[cat] = true; scores[cat] = 0.99; flagged = true; }
245
267
  }
246
- return { flagged, categories, category_scores: scores };
268
+ const applied: Record<string, string[]> = {};
269
+ for (const c of MODERATION_CATEGORIES) applied[c] = image && IMAGE_CATEGORIES.has(c) ? ['text', 'image'] : ['text'];
270
+ return { flagged, categories, category_scores: scores, category_applied_input_types: applied };
247
271
  }