@volter/twin-openai 0.1.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/LICENSE +202 -0
- package/README.md +168 -0
- package/package.json +62 -0
- package/src/cli.ts +26 -0
- package/src/index.ts +108 -0
- package/src/openai-budget.ts +173 -0
- package/src/openai-capabilities.ts +1556 -0
- package/src/openai-conformance.ts +112 -0
- package/src/openai-connector.ts +337 -0
- package/src/openai-models.ts +36 -0
- package/src/openai-scenario.ts +136 -0
- package/src/openai-server.ts +167 -0
- package/src/openai-stub.ts +247 -0
- package/src/openai-twin.ts +2100 -0
- package/src/openai-types.ts +126 -0
- package/src/openai-webhooks.ts +128 -0
- package/test-fixtures/openai-openapi-operations.SOURCE.md +19 -0
- package/test-fixtures/openai-openapi-operations.json +2959 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// OpenAI twin HTTP server — serve the full OpenAI twin handler over HTTP so the real `openai`
|
|
2
|
+
// SDK (constructed with `baseURL: http://127.0.0.1:<port>/v1`) works unmodified. JSON bodies
|
|
3
|
+
// (what the SDK sends for most calls) pass straight through. Writable by default; pass readOnly
|
|
4
|
+
// to reject mutations (D3).
|
|
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).
|
|
14
|
+
//
|
|
15
|
+
// The surface is a plain `fetch` (`createOpenAITwinFetch`) and the SERVER is one line of
|
|
16
|
+
// `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
|
|
17
|
+
// port to bind, so it mounts the fetch in-process).
|
|
18
|
+
import { handleOpenAITwinRequest } from './openai-twin.ts';
|
|
19
|
+
import { twinManifest, worldNow } from '@volter/twin';
|
|
20
|
+
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
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options every OpenAI-twin HTTP surface needs, independent of who owns the socket. */
|
|
64
|
+
export interface OpenAITwinFetchOptions {
|
|
65
|
+
root?: string;
|
|
66
|
+
readOnly?: boolean;
|
|
67
|
+
scenarioPath?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
72
|
+
*
|
|
73
|
+
* This is the composable form (runtime contract R12's Cloudflare consequence): a Worker /
|
|
74
|
+
* Durable Object entry has NO loopback ports, so it must mount a pack's handler IN-PROCESS.
|
|
75
|
+
* `createOpenAITwinServer` is nothing but `Bun.serve` wrapped around this closure, so the
|
|
76
|
+
* standalone (R1) and hosted surfaces are the SAME code — there is no second HTTP adaptation
|
|
77
|
+
* to drift.
|
|
78
|
+
*
|
|
79
|
+
* WHAT IT SERVES IS UNCHANGED (R9): openai is a GENERATIVE pack, so chat/responses/embeddings
|
|
80
|
+
* answer a labeled deterministic stub or a scripted scenario — never a model, here or anywhere.
|
|
81
|
+
* The only wall-clock-shaped call on this path is `worldNow()`, the world's frozen instant.
|
|
82
|
+
*
|
|
83
|
+
* The scenario document is read ONCE, when the factory is called — the per-request path never
|
|
84
|
+
* touches storage at all. That one read goes through the ACTIVE WorldStore (runtime contract
|
|
85
|
+
* R12b), so a serverless entry MAY pass a `scenarioPath`: the handlers document is hydrated
|
|
86
|
+
* world state like any other, and a memory-backed namespace serves its own scripted answers
|
|
87
|
+
* with no filesystem in the picture (openai-serverless.test.ts pins exactly that).
|
|
88
|
+
*/
|
|
89
|
+
export function createOpenAITwinFetch(options: OpenAITwinFetchOptions): (request: Request) => Promise<Response> {
|
|
90
|
+
const readOnly = options.readOnly ?? false;
|
|
91
|
+
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> => {
|
|
94
|
+
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') {
|
|
97
|
+
return Response.json(twinManifest({
|
|
98
|
+
vendor: 'openai',
|
|
99
|
+
twinOf: 'OpenAI API (chat completions)',
|
|
100
|
+
stateSentence: 'Seed stored completions/files through the ordinary API with any key.',
|
|
101
|
+
behaviorSentence: 'Chat completions are scripted by MSW-shaped handlers in the world dir (handlers/openai.json): {on:{userTextIncludes|anyTextIncludes|modelEquals|hasTool|toolResultFor|lastMessageIsToolResult|nthCall}, respond:{text|toolCalls, finishReason?}, once?, phase?}. Unmatched requests answer a labeled stub naming this door.',
|
|
102
|
+
exampleHandler: { on: { userTextIncludes: 'summarize', hasTool: 'file_search' }, respond: { text: 'Scripted summary.' }, once: true },
|
|
103
|
+
engine: scenarioEngine as never,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
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 ?? {}) } });
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createOpenAITwinServer(options: { root?: string; port?: number; readOnly?: boolean; scenarioPath?: string }): { port: number; stop: () => void } {
|
|
161
|
+
const server = Bun.serve({
|
|
162
|
+
port: options.port ?? 0,
|
|
163
|
+
idleTimeout: 60,
|
|
164
|
+
fetch: createOpenAITwinFetch(options),
|
|
165
|
+
});
|
|
166
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
167
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// THE GENERATIVE-STUB CORE (the honest carve-outs).
|
|
2
|
+
//
|
|
3
|
+
// The twin CANNOT run the model — there are no weights here. So `POST /v1/chat/completions`
|
|
4
|
+
// and `POST /v1/responses` return a DETERMINISTIC STUB completion that is CLEARLY a twin stub,
|
|
5
|
+
// NEVER pretending to be real model output. `POST /v1/embeddings` returns DETERMINISTIC
|
|
6
|
+
// pseudo-vectors (seeded from the input hash) — never real embedding values. What IS faithful
|
|
7
|
+
// is the ENTIRE PROTOCOL ENVELOPE: the response shape, streaming chunk sequence, tool_calls
|
|
8
|
+
// shape, finish_reason, and deterministic token counts.
|
|
9
|
+
//
|
|
10
|
+
// Surfaced as `openai.chat.inference` + `openai.embeddings.real_vectors` (out of scope) in the
|
|
11
|
+
// capability manifest and the README ## Coverage: the protocol is real; the generation is a stub.
|
|
12
|
+
|
|
13
|
+
import type { ChatMessageParam, ChatToolCall } from './openai-types.ts';
|
|
14
|
+
|
|
15
|
+
/** Deterministic token estimate for a string: ~1 token per 4 chars (faithful order of
|
|
16
|
+
* magnitude; deterministic so usage counts are assertable, like the vendor's tokenizer on a
|
|
17
|
+
* fixed input). Never zero for non-empty text. */
|
|
18
|
+
export function estimateTokens(text: string): number {
|
|
19
|
+
if (!text) return 0;
|
|
20
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Flatten a chat message's content (string OR content-part array) to its text for token
|
|
24
|
+
* counting / echo. Non-text parts contribute their JSON length so the count is deterministic
|
|
25
|
+
* and reflects payload size. */
|
|
26
|
+
export function contentToText(content: ChatMessageParam['content']): string {
|
|
27
|
+
if (typeof content === 'string') return content;
|
|
28
|
+
if (content === null || content === undefined) return '';
|
|
29
|
+
if (!Array.isArray(content)) return '';
|
|
30
|
+
return content
|
|
31
|
+
.map((part) => {
|
|
32
|
+
if (part && typeof part === 'object' && (part as { type?: string }).type === 'text') {
|
|
33
|
+
return String((part as { text?: unknown }).text ?? '');
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(part);
|
|
36
|
+
})
|
|
37
|
+
.join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deterministic prompt-token count for the full set of messages. */
|
|
41
|
+
export function countPromptTokens(messages: ChatMessageParam[]): number {
|
|
42
|
+
let total = 0;
|
|
43
|
+
for (const m of messages) {
|
|
44
|
+
total += estimateTokens(contentToText(m.content));
|
|
45
|
+
if (m.name) total += estimateTokens(m.name);
|
|
46
|
+
for (const tc of m.tool_calls ?? []) total += estimateTokens(JSON.stringify(tc));
|
|
47
|
+
}
|
|
48
|
+
return total;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The last user turn's text — the thing the stub echoes (deterministic, clearly labeled). */
|
|
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
|
+
// 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) : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the deterministic stub ASSISTANT text. It is unmistakably a twin stub: it carries the
|
|
62
|
+
* `[twin-stub:<model>]` marker and echoes the prompt, so no caller can mistake it for real
|
|
63
|
+
* model output. Deterministic for a given prompt → assertable in tests.
|
|
64
|
+
*/
|
|
65
|
+
export function stubAssistantText(messages: ChatMessageParam[], model: string): string {
|
|
66
|
+
const prompt = lastUserText(messages).trim();
|
|
67
|
+
const echo = prompt.length > 200 ? `${prompt.slice(0, 200)}…` : prompt;
|
|
68
|
+
return `[twin-stub:${model}] This is a deterministic stub from the OpenAI twin (no model weights are run). Echoing your last message: ${echo || '(empty)'}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Extract a tool's function name from either a Chat Completions tool
|
|
72
|
+
* (`{ type:'function', function:{ name } }`) or a legacy `functions` entry (`{ name }`). */
|
|
73
|
+
function toolName(t: unknown): string {
|
|
74
|
+
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';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function placeholderForSchema(def: unknown): unknown {
|
|
81
|
+
const d = def as { type?: unknown; enum?: unknown[] } | undefined;
|
|
82
|
+
if (Array.isArray(d?.enum) && d!.enum!.length) return d!.enum![0];
|
|
83
|
+
switch (d?.type) {
|
|
84
|
+
case 'number':
|
|
85
|
+
case 'integer': return 0;
|
|
86
|
+
case 'boolean': return false;
|
|
87
|
+
case 'array': return [];
|
|
88
|
+
case 'object': return {};
|
|
89
|
+
default: return '';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
|
|
95
|
+
* `parameters` object (especially with `strict:true`), real models emit arguments that validate
|
|
96
|
+
* 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.
|
|
98
|
+
*/
|
|
99
|
+
export function stubToolArguments(tool: unknown): string {
|
|
100
|
+
const o = tool as { function?: { parameters?: unknown }; parameters?: unknown } | undefined;
|
|
101
|
+
const schema = (o?.function?.parameters ?? o?.parameters) as { properties?: Record<string, unknown> } | undefined;
|
|
102
|
+
const props = schema && typeof schema === 'object' ? schema.properties : undefined;
|
|
103
|
+
if (!props || typeof props !== 'object') return '{}';
|
|
104
|
+
const out: Record<string, unknown> = {};
|
|
105
|
+
for (const [key, def] of Object.entries(props)) out[key] = placeholderForSchema(def);
|
|
106
|
+
return JSON.stringify(out);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* When tools/functions are provided, real models may respond with `tool_calls` and
|
|
111
|
+
* `finish_reason:'tool_calls'`. The stub deterministically "calls" the tool selected by
|
|
112
|
+
* `forcedName` (a named tool_choice) or the FIRST provided tool. Arguments are synthesized
|
|
113
|
+
* from the tool's JSON schema so strict callers parse them — clearly a stub, but a
|
|
114
|
+
* vendor-faithful tool_calls envelope. Returns the tool_call, or null when no tools provided.
|
|
115
|
+
*/
|
|
116
|
+
export function stubToolCall(tools: unknown, seq: number, forcedName?: string): ChatToolCall | null {
|
|
117
|
+
if (!Array.isArray(tools) || tools.length === 0) return null;
|
|
118
|
+
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) } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build a deterministic JSON-object stub for `response_format` json_object / json_schema. The
|
|
124
|
+
* model's job is to emit parseable JSON; the twin returns a clearly-labeled deterministic object
|
|
125
|
+
* (and, for json_schema, fills every declared property with a schema-typed placeholder so the
|
|
126
|
+
* caller's strict parse succeeds). Always valid JSON.
|
|
127
|
+
*/
|
|
128
|
+
export function stubJsonObject(messages: ChatMessageParam[], model: string, jsonSchema?: unknown): string {
|
|
129
|
+
const schema = jsonSchema as { schema?: { properties?: Record<string, unknown> }; properties?: Record<string, unknown> } | undefined;
|
|
130
|
+
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
|
+
}
|
|
136
|
+
return JSON.stringify({ _twin_stub: true, model, echo: lastUserText(messages).slice(0, 200) });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Logprobs (deterministic pseudo-logprobs) ────────────────────────────────────────────
|
|
140
|
+
/** The per-token logprob shape OpenAI returns in `choices[].logprobs.content[]`. */
|
|
141
|
+
export type LogprobToken = {
|
|
142
|
+
token: string;
|
|
143
|
+
logprob: number;
|
|
144
|
+
bytes: number[];
|
|
145
|
+
top_logprobs: Array<{ token: string; logprob: number; bytes: number[] }>;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** A whitespace-preserving token split: deterministic chunks of the stub text whose `token`
|
|
149
|
+
* fields re-join into the exact text. NOT a real BPE tokenizer. */
|
|
150
|
+
function splitForLogprobs(text: string): string[] {
|
|
151
|
+
if (!text) return [];
|
|
152
|
+
return text.match(/\s+|\S+/g) ?? [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build deterministic pseudo-logprobs for the stub completion text. Real models return a per-token
|
|
157
|
+
* logprob (a negative number) plus `top_logprobs` alternatives; the twin synthesizes a deterministic
|
|
158
|
+
* negative logprob per token (seeded from the token text) and `topN` alternatives. NOT real
|
|
159
|
+
* probabilities — the SHAPE and DETERMINISM are faithful, the values carry no model meaning.
|
|
160
|
+
* Re-joining `content[].token` reconstructs the full text exactly.
|
|
161
|
+
*/
|
|
162
|
+
export function buildLogprobs(text: string, topN: number): { content: LogprobToken[] } {
|
|
163
|
+
const tokens = splitForLogprobs(text);
|
|
164
|
+
const content: LogprobToken[] = tokens.map((tok) => {
|
|
165
|
+
const seed = fnv1a(tok);
|
|
166
|
+
const lp = -((seed % 5000) / 1000); // deterministic logprob in (-5, 0]
|
|
167
|
+
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
|
+
}
|
|
173
|
+
return { token: tok, logprob: lp, bytes, top_logprobs: top.slice(0, Math.max(1, topN)) };
|
|
174
|
+
});
|
|
175
|
+
return { content };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Embeddings: deterministic pseudo-vectors ────────────────────────────────────────────
|
|
179
|
+
/** A small deterministic 32-bit hash (FNV-1a) of a string — the seed for a pseudo-vector. */
|
|
180
|
+
function fnv1a(text: string): number {
|
|
181
|
+
let h = 0x811c9dc5;
|
|
182
|
+
for (let i = 0; i < text.length; i++) {
|
|
183
|
+
h ^= text.charCodeAt(i);
|
|
184
|
+
h = Math.imul(h, 0x01000193);
|
|
185
|
+
}
|
|
186
|
+
return h >>> 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A deterministic, reproducible L2-normalized pseudo-embedding vector for `text`. NOT a real
|
|
190
|
+
* 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. */
|
|
192
|
+
export function pseudoEmbedding(text: string, dimensions: number): number[] {
|
|
193
|
+
const dim = Math.max(1, Math.floor(dimensions));
|
|
194
|
+
let state = fnv1a(text) || 1;
|
|
195
|
+
const raw = new Array<number>(dim);
|
|
196
|
+
let norm = 0;
|
|
197
|
+
for (let i = 0; i < dim; i++) {
|
|
198
|
+
// xorshift32 PRNG seeded from the text hash → deterministic per (text, index).
|
|
199
|
+
state ^= state << 13; state >>>= 0;
|
|
200
|
+
state ^= state >> 17;
|
|
201
|
+
state ^= state << 5; state >>>= 0;
|
|
202
|
+
// map to [-1, 1)
|
|
203
|
+
const v = (state / 0xffffffff) * 2 - 1;
|
|
204
|
+
raw[i] = v;
|
|
205
|
+
norm += v * v;
|
|
206
|
+
}
|
|
207
|
+
norm = Math.sqrt(norm) || 1;
|
|
208
|
+
for (let i = 0; i < dim; i++) raw[i] = raw[i]! / norm;
|
|
209
|
+
return raw;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Moderations: deterministic classifier ───────────────────────────────────────────────
|
|
213
|
+
/** The moderation categories the twin reports (faithful key set). */
|
|
214
|
+
export const MODERATION_CATEGORIES = [
|
|
215
|
+
'hate', 'hate/threatening', 'harassment', 'harassment/threatening',
|
|
216
|
+
'self-harm', 'self-harm/intent', 'self-harm/instructions',
|
|
217
|
+
'sexual', 'sexual/minors', 'violence', 'violence/graphic',
|
|
218
|
+
] as const;
|
|
219
|
+
|
|
220
|
+
// Deterministic keyword → category map. The twin can't run the real classifier, so it flags
|
|
221
|
+
// on a fixed keyword list (clearly a heuristic). Shape is faithful; the decision is a stub.
|
|
222
|
+
const MODERATION_KEYWORDS: Record<string, string> = {
|
|
223
|
+
kill: 'violence', murder: 'violence', attack: 'violence',
|
|
224
|
+
hate: 'hate', hateful: 'hate',
|
|
225
|
+
harass: 'harassment',
|
|
226
|
+
suicide: 'self-harm', 'self-harm': 'self-harm',
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export type ModerationResult = {
|
|
230
|
+
flagged: boolean;
|
|
231
|
+
categories: Record<string, boolean>;
|
|
232
|
+
category_scores: Record<string, number>;
|
|
233
|
+
category_applied_input_types?: Record<string, string[]>;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Deterministically moderate one input string into the faithful result shape. */
|
|
237
|
+
export function moderateText(text: string): ModerationResult {
|
|
238
|
+
const lower = text.toLowerCase();
|
|
239
|
+
const categories: Record<string, boolean> = {};
|
|
240
|
+
const scores: Record<string, number> = {};
|
|
241
|
+
for (const c of MODERATION_CATEGORIES) { categories[c] = false; scores[c] = 0; }
|
|
242
|
+
let flagged = false;
|
|
243
|
+
for (const [kw, cat] of Object.entries(MODERATION_KEYWORDS)) {
|
|
244
|
+
if (lower.includes(kw)) { categories[cat] = true; scores[cat] = 0.99; flagged = true; }
|
|
245
|
+
}
|
|
246
|
+
return { flagged, categories, category_scores: scores };
|
|
247
|
+
}
|