@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.
- package/README.md +33 -30
- package/defaults/handlers.json +10 -0
- package/dist/defaults/handlers.json +10 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +29 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +72 -0
- package/dist/src/manifest.d.ts +6 -0
- package/dist/src/manifest.js +323 -0
- package/dist/src/openai-budget.d.ts +53 -0
- package/dist/src/openai-budget.js +147 -0
- package/dist/src/openai-capabilities.d.ts +4 -0
- package/dist/src/openai-capabilities.js +1569 -0
- package/dist/src/openai-conformance.d.ts +13 -0
- package/dist/src/openai-conformance.js +116 -0
- package/dist/src/openai-connector.d.ts +86 -0
- package/dist/src/openai-connector.js +291 -0
- package/dist/src/openai-media.d.ts +43 -0
- package/dist/src/openai-media.js +257 -0
- package/dist/src/openai-models.d.ts +74 -0
- package/dist/src/openai-models.js +148 -0
- package/dist/src/openai-scenario.d.ts +51 -0
- package/dist/src/openai-scenario.js +166 -0
- package/dist/src/openai-server.d.ts +40 -0
- package/dist/src/openai-server.js +126 -0
- package/dist/src/openai-stub.d.ts +82 -0
- package/dist/src/openai-stub.js +256 -0
- package/dist/src/openai-twin.d.ts +182 -0
- package/dist/src/openai-twin.js +1117 -0
- package/dist/src/openai-types.d.ts +194 -0
- package/dist/src/openai-types.js +4 -0
- package/dist/src/openai-webhooks.d.ts +47 -0
- package/dist/src/openai-webhooks.js +99 -0
- package/dist/src/screens/api-keys.d.ts +16 -0
- package/dist/src/screens/api-keys.js +131 -0
- package/dist/src/screens/session.d.ts +22 -0
- package/dist/src/screens/session.js +115 -0
- package/dist/src/semantics/assistants.d.ts +2 -0
- package/dist/src/semantics/assistants.js +331 -0
- package/dist/src/semantics/audio.d.ts +2 -0
- package/dist/src/semantics/audio.js +27 -0
- package/dist/src/semantics/batches.d.ts +4 -0
- package/dist/src/semantics/batches.js +86 -0
- package/dist/src/semantics/chat-completions.d.ts +3 -0
- package/dist/src/semantics/chat-completions.js +58 -0
- package/dist/src/semantics/containers.d.ts +2 -0
- package/dist/src/semantics/containers.js +147 -0
- package/dist/src/semantics/embeddings.d.ts +2 -0
- package/dist/src/semantics/embeddings.js +13 -0
- package/dist/src/semantics/evals.d.ts +2 -0
- package/dist/src/semantics/evals.js +173 -0
- package/dist/src/semantics/files.d.ts +13 -0
- package/dist/src/semantics/files.js +59 -0
- package/dist/src/semantics/fine-tuning.d.ts +4 -0
- package/dist/src/semantics/fine-tuning.js +178 -0
- package/dist/src/semantics/images.d.ts +2 -0
- package/dist/src/semantics/images.js +18 -0
- package/dist/src/semantics/index.d.ts +8 -0
- package/dist/src/semantics/index.js +46 -0
- package/dist/src/semantics/models.d.ts +2 -0
- package/dist/src/semantics/models.js +34 -0
- package/dist/src/semantics/moderations.d.ts +2 -0
- package/dist/src/semantics/moderations.js +12 -0
- package/dist/src/semantics/organization.d.ts +2 -0
- package/dist/src/semantics/organization.js +67 -0
- package/dist/src/semantics/progress.d.ts +22 -0
- package/dist/src/semantics/progress.js +63 -0
- package/dist/src/semantics/responses.d.ts +3 -0
- package/dist/src/semantics/responses.js +153 -0
- package/dist/src/semantics/shared.d.ts +32 -0
- package/dist/src/semantics/shared.js +69 -0
- package/dist/src/semantics/uploads.d.ts +2 -0
- package/dist/src/semantics/uploads.js +84 -0
- package/dist/src/semantics/vector-stores.d.ts +2 -0
- package/dist/src/semantics/vector-stores.js +281 -0
- package/dist/test-fixtures/openai-openapi-operations.SOURCE.md +18 -0
- package/dist/test-fixtures/openai-openapi-operations.json +1849 -0
- package/package.json +21 -10
- package/src/cli.ts +9 -7
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +20 -10
- package/src/manifest.ts +343 -0
- package/src/openai-budget.ts +4 -4
- package/src/openai-capabilities.ts +177 -195
- package/src/openai-conformance.ts +1 -1
- package/src/openai-connector.ts +40 -43
- package/src/openai-media.ts +225 -0
- package/src/openai-models.ts +145 -15
- package/src/openai-scenario.ts +46 -10
- package/src/openai-server.ts +65 -108
- package/src/openai-stub.ts +54 -30
- package/src/openai-twin.ts +760 -1665
- package/src/openai-types.ts +24 -6
- package/src/openai-webhooks.ts +2 -1
- package/src/screens/api-keys.tsx +138 -0
- package/src/screens/session.tsx +131 -0
- package/src/semantics/assistants.ts +336 -0
- package/src/semantics/audio.ts +31 -0
- package/src/semantics/batches.ts +88 -0
- package/src/semantics/chat-completions.ts +66 -0
- package/src/semantics/containers.ts +151 -0
- package/src/semantics/embeddings.ts +19 -0
- package/src/semantics/evals.ts +182 -0
- package/src/semantics/files.ts +67 -0
- package/src/semantics/fine-tuning.ts +185 -0
- package/src/semantics/images.ts +23 -0
- package/src/semantics/index.ts +52 -0
- package/src/semantics/models.ts +41 -0
- package/src/semantics/moderations.ts +14 -0
- package/src/semantics/organization.ts +76 -0
- package/src/semantics/progress.ts +72 -0
- package/src/semantics/responses.ts +151 -0
- package/src/semantics/shared.ts +82 -0
- package/src/semantics/uploads.ts +92 -0
- package/src/semantics/vector-stores.ts +279 -0
- package/test-fixtures/openai-openapi-operations.SOURCE.md +4 -5
- package/test-fixtures/openai-openapi-operations.json +224 -1334
package/src/openai-server.ts
CHANGED
|
@@ -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:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
19
|
-
import
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
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),
|
package/src/openai-stub.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// THE GENERATIVE-STUB CORE (the
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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 (
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
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
|
}
|