@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The openai pack's scenario system on the kernel's ONE engine (@volter/world-core scenario.ts):
|
|
2
|
+
// chat-completions vocabulary + the scripted-turn respond shape. New with the
|
|
3
|
+
// TWIN-PROGRAMMING-MODEL consolidation — this pack previously had no scripting at all. The
|
|
4
|
+
// handler FILE (handlers/openai.json in a world dir) is the only write surface.
|
|
5
|
+
import { getActiveWorldStore, parseScenarioDocument, ScenarioError, ScenarioEngine } from '@volter/world-core';
|
|
6
|
+
import { contentToText, estimateTokens, lastUserText } from "./openai-stub.js";
|
|
7
|
+
const RESPOND_KEYS = new Set(['text', 'toolCalls', 'finishReason']);
|
|
8
|
+
const FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_filter']);
|
|
9
|
+
const nonEmptyString = (cond) => typeof cond === 'string' && cond.length > 0;
|
|
10
|
+
const positiveNumber = (cond) => typeof cond === 'number' && Number.isFinite(cond) && cond > 0;
|
|
11
|
+
function lastToolResultNames(messages) {
|
|
12
|
+
const names = new Set();
|
|
13
|
+
const last = messages[messages.length - 1];
|
|
14
|
+
if (!last || last.role !== 'tool' || typeof last.tool_call_id !== 'string')
|
|
15
|
+
return names;
|
|
16
|
+
for (const m of messages) {
|
|
17
|
+
const am = m;
|
|
18
|
+
if (am.role !== 'assistant' || !Array.isArray(am.tool_calls))
|
|
19
|
+
continue;
|
|
20
|
+
for (const tc of am.tool_calls) {
|
|
21
|
+
if (tc?.id === last.tool_call_id && typeof tc?.function?.name === 'string')
|
|
22
|
+
names.add(tc.function.name);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
function toolNames(tools) {
|
|
28
|
+
if (!Array.isArray(tools))
|
|
29
|
+
return [];
|
|
30
|
+
return tools.map((t) => typeof t?.function?.name === 'string' ? t.function.name : typeof t?.name === 'string' ? t.name : null).filter((n) => n !== null);
|
|
31
|
+
}
|
|
32
|
+
export const openaiScenarioAdapter = {
|
|
33
|
+
// R15 — a status fault in THIS vendor's envelope: the same {error:{message,type,param,code}}
|
|
34
|
+
// openai-twin.ts serves for its own refusals (its 429 is `type: 'rate_limit_exceeded'`).
|
|
35
|
+
renderFault: (f) => ({
|
|
36
|
+
body: {
|
|
37
|
+
error: {
|
|
38
|
+
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.'),
|
|
39
|
+
type: f.status === 429 ? 'rate_limit_exceeded' : f.status >= 500 ? 'server_error' : 'invalid_request_error',
|
|
40
|
+
param: null,
|
|
41
|
+
code: f.status === 429 ? 'rate_limit_exceeded' : null,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
vendor: 'openai',
|
|
46
|
+
features: (req) => ({
|
|
47
|
+
model: req.model,
|
|
48
|
+
lastUserText: lastUserText(req.messages).slice(0, 300),
|
|
49
|
+
tools: toolNames(req.tools),
|
|
50
|
+
lastMessageIsToolResult: req.messages[req.messages.length - 1]?.role === 'tool',
|
|
51
|
+
toolResultFor: [...lastToolResultNames(req.messages)],
|
|
52
|
+
// The output cap the caller asked for (max_tokens / max_completion_tokens), or 0 when it sent none. A
|
|
53
|
+
// proxy between the agent and this twin may clamp it; a handler keyed on it makes that clamp visible.
|
|
54
|
+
maxTokens: req.maxTokens ?? 0,
|
|
55
|
+
}),
|
|
56
|
+
matchers: {
|
|
57
|
+
modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
|
|
58
|
+
userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages).toLowerCase().includes(cond.toLowerCase()),
|
|
59
|
+
anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
|
|
60
|
+
lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && (req.messages[req.messages.length - 1]?.role === 'tool') === cond,
|
|
61
|
+
toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
|
|
62
|
+
hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
|
|
63
|
+
// A request whose output cap is below N (a cap it did not send counts as below every N).
|
|
64
|
+
maxTokensBelow: (req, cond) => positiveNumber(cond) && (req.maxTokens ?? 0) < cond,
|
|
65
|
+
maxTokensAtLeast: (req, cond) => positiveNumber(cond) && (req.maxTokens ?? 0) >= cond,
|
|
66
|
+
},
|
|
67
|
+
text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
|
|
68
|
+
validateOn: (on) => {
|
|
69
|
+
for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool'])
|
|
70
|
+
if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k]))
|
|
71
|
+
return `on.${k} is a non-empty string`;
|
|
72
|
+
if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean')
|
|
73
|
+
return 'on.lastMessageIsToolResult is a boolean';
|
|
74
|
+
for (const k of ['maxTokensBelow', 'maxTokensAtLeast'])
|
|
75
|
+
if (on[k] !== undefined && !positiveNumber(on[k]))
|
|
76
|
+
return `on.${k} is a positive number`;
|
|
77
|
+
return null;
|
|
78
|
+
},
|
|
79
|
+
validateRespond: (respond) => {
|
|
80
|
+
if (typeof respond !== 'object' || respond === null || Array.isArray(respond))
|
|
81
|
+
return 'respond is an object { text?, toolCalls?, finishReason? }';
|
|
82
|
+
const r = respond;
|
|
83
|
+
for (const k of Object.keys(r))
|
|
84
|
+
if (!RESPOND_KEYS.has(k))
|
|
85
|
+
return `respond: unknown key "${k}" (valid: ${[...RESPOND_KEYS].join(', ')})`;
|
|
86
|
+
if (r.text !== undefined && typeof r.text !== 'string')
|
|
87
|
+
return 'respond.text is a string';
|
|
88
|
+
if (r.finishReason !== undefined && (typeof r.finishReason !== 'string' || !FINISH_REASONS.has(r.finishReason)))
|
|
89
|
+
return `respond.finishReason is one of ${[...FINISH_REASONS].join(', ')}`;
|
|
90
|
+
if (r.toolCalls !== undefined) {
|
|
91
|
+
for (const tc of Array.isArray(r.toolCalls) ? r.toolCalls : [r.toolCalls]) {
|
|
92
|
+
const t = tc;
|
|
93
|
+
if (!t || typeof t !== 'object' || Array.isArray(t))
|
|
94
|
+
return 'respond.toolCalls entries are objects';
|
|
95
|
+
if (typeof t.name !== 'string' || !t.name)
|
|
96
|
+
return 'respond.toolCalls[].name is a non-empty string';
|
|
97
|
+
if (!t.arguments || typeof t.arguments !== 'object' || Array.isArray(t.arguments))
|
|
98
|
+
return 'respond.toolCalls[].arguments is an object';
|
|
99
|
+
if (t.id !== undefined && typeof t.id !== 'string')
|
|
100
|
+
return 'respond.toolCalls[].id is a string';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (r.text === undefined && r.toolCalls === undefined)
|
|
104
|
+
return 'respond needs text or toolCalls';
|
|
105
|
+
return null;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
export function loadOpenAIScenarioDocument(path) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
// Read through the ACTIVE WorldStore, never the filesystem directly (runtime contract
|
|
112
|
+
// R12b): the handlers document is WORLD STATE, so a MemoryWorldStore / DO-backed world
|
|
113
|
+
// serves ITS OWN scenario instead of whatever happens to sit on the host disk — and the
|
|
114
|
+
// serve path stays workerd-clean. A missing document keeps the historical ENOENT wording,
|
|
115
|
+
// so the loud load-time failure reads byte-identically to the read it replaces.
|
|
116
|
+
const raw = getActiveWorldStore().read(path);
|
|
117
|
+
if (raw === null)
|
|
118
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
119
|
+
parsed = JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
throw new ScenarioError(`openai scenario: cannot read/parse ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
123
|
+
}
|
|
124
|
+
return parseScenarioDocument(parsed, openaiScenarioAdapter);
|
|
125
|
+
}
|
|
126
|
+
/** What the scenario makes of one request: the decision a builder realizes, or a fault answered at once
|
|
127
|
+
* (a status the script names; the script's own authoring failure is a 500 before any stream starts). */
|
|
128
|
+
export async function serveScenario(engine, req, path) {
|
|
129
|
+
try {
|
|
130
|
+
return await engine.serve(req);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!(error instanceof ScenarioError))
|
|
134
|
+
throw error;
|
|
135
|
+
console.error(`[openai twin] scenario error on ${path}: ${error.message}`);
|
|
136
|
+
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' } } };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** A decision as a turn: the scripted result, or, on a miss, the note that teaches where to script it. */
|
|
140
|
+
export function scenarioTurn(decision, callIdPrefix) {
|
|
141
|
+
if (decision.kind === 'handler')
|
|
142
|
+
return { scripted: realizeOpenAIRespond(decision.respond, callIdPrefix) };
|
|
143
|
+
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)}]` };
|
|
144
|
+
}
|
|
145
|
+
/** A scripted turn as a chat completion's choice. */
|
|
146
|
+
export function scriptedChoice(scripted, index) {
|
|
147
|
+
const message = scripted.toolCalls.length
|
|
148
|
+
? { role: 'assistant', content: scripted.text, tool_calls: scripted.toolCalls, refusal: null }
|
|
149
|
+
: { role: 'assistant', content: scripted.text ?? '', refusal: null };
|
|
150
|
+
return { choice: { index, message, logprobs: null, finish_reason: scripted.finishReason }, completionTokens: estimateTokens(JSON.stringify(scripted.toolCalls.length ? scripted.toolCalls : scripted.text ?? '')) };
|
|
151
|
+
}
|
|
152
|
+
export function createOpenAIScenarioEngine(document) {
|
|
153
|
+
return new ScenarioEngine(openaiScenarioAdapter, document);
|
|
154
|
+
}
|
|
155
|
+
export function realizeOpenAIRespond(respond, callIdPrefix = 'call_scripted') {
|
|
156
|
+
const toolCalls = [];
|
|
157
|
+
// Responses supplies a response-specific prefix so chained calls keep distinct identities.
|
|
158
|
+
for (const [i, tc] of (respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : []).entries()) {
|
|
159
|
+
toolCalls.push({ id: tc.id ?? `${callIdPrefix}_${i + 1}`, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } });
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
text: respond.text ?? (toolCalls.length ? null : ''),
|
|
163
|
+
toolCalls,
|
|
164
|
+
finishReason: respond.finishReason ?? (toolCalls.length ? 'tool_calls' : 'stop'),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type OpenAIScenarioEngine } from './openai-scenario.js';
|
|
2
|
+
/** Options every OpenAI-twin HTTP surface needs, independent of who owns the socket. */
|
|
3
|
+
export interface OpenAITwinFetchOptions {
|
|
4
|
+
root?: string;
|
|
5
|
+
readOnly?: boolean;
|
|
6
|
+
scenarioPath?: string;
|
|
7
|
+
/** a scenario engine already built (an in-process caller's), in place of `scenarioPath` */
|
|
8
|
+
scenarioEngine?: OpenAIScenarioEngine;
|
|
9
|
+
/** the World instant, when a caller pins it */
|
|
10
|
+
clock?: () => string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
14
|
+
*
|
|
15
|
+
* This is the composable form (runtime contract R12's Cloudflare consequence): a Worker /
|
|
16
|
+
* Durable Object entry has NO loopback ports, so it must mount a pack's handler IN-PROCESS.
|
|
17
|
+
* `createOpenAITwinServer` is nothing but `Bun.serve` wrapped around this closure, so the
|
|
18
|
+
* standalone (R1) and hosted surfaces are the SAME code — there is no second HTTP adaptation
|
|
19
|
+
* to drift.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT SERVES IS UNCHANGED (R9): openai is a GENERATIVE pack, so chat/responses/embeddings
|
|
22
|
+
* answer a labeled deterministic stub or a scripted scenario — never a model, here or anywhere.
|
|
23
|
+
* The only wall-clock-shaped call on this path is `worldNow()`, the world's frozen instant.
|
|
24
|
+
*
|
|
25
|
+
* The scenario document is read ONCE, when the factory is called — the per-request path never
|
|
26
|
+
* touches storage at all. That one read goes through the ACTIVE WorldStore (runtime contract
|
|
27
|
+
* R12b), so a serverless entry MAY pass a `scenarioPath`: the handlers document is hydrated
|
|
28
|
+
* world state like any other, and a memory-backed namespace serves its own scripted answers
|
|
29
|
+
* with no filesystem in the picture (openai-serverless.test.ts pins exactly that).
|
|
30
|
+
*/
|
|
31
|
+
export declare function createOpenAITwinFetch(options: OpenAITwinFetchOptions): (request: Request) => Promise<Response>;
|
|
32
|
+
export declare function createOpenAITwinServer(options: {
|
|
33
|
+
root?: string;
|
|
34
|
+
port?: number;
|
|
35
|
+
readOnly?: boolean;
|
|
36
|
+
scenarioPath?: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
port: number;
|
|
39
|
+
stop: () => void;
|
|
40
|
+
}>;
|
|
@@ -0,0 +1,126 @@
|
|
|
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: 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.
|
|
9
|
+
//
|
|
10
|
+
// The surface is a plain `fetch` (`createOpenAITwinFetch`) and the SERVER is one line of
|
|
11
|
+
// `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
|
|
12
|
+
// port to bind, so it mounts the fetch in-process).
|
|
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.js";
|
|
16
|
+
import { openaiSemantics } from "./semantics/index.js";
|
|
17
|
+
import { answerVendorErrors, RefusedWriteError } from '@volter/world-core';
|
|
18
|
+
import { twinManifest } from '@volter/world-core';
|
|
19
|
+
import { createOpenAIScenarioEngine, loadOpenAIScenarioDocument } from "./openai-scenario.js";
|
|
20
|
+
import { openaiApiKeysScreen, projectKeyUse } from "./screens/api-keys.js";
|
|
21
|
+
import { openaiSessionFlow } from "./screens/session.js";
|
|
22
|
+
import { expireFiles } from "./semantics/files.js";
|
|
23
|
+
import { observeBatches } from "./semantics/batches.js";
|
|
24
|
+
import { observeJobs } from "./semantics/fine-tuning.js";
|
|
25
|
+
/**
|
|
26
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
27
|
+
*
|
|
28
|
+
* This is the composable form (runtime contract R12's Cloudflare consequence): a Worker /
|
|
29
|
+
* Durable Object entry has NO loopback ports, so it must mount a pack's handler IN-PROCESS.
|
|
30
|
+
* `createOpenAITwinServer` is nothing but `Bun.serve` wrapped around this closure, so the
|
|
31
|
+
* standalone (R1) and hosted surfaces are the SAME code — there is no second HTTP adaptation
|
|
32
|
+
* to drift.
|
|
33
|
+
*
|
|
34
|
+
* WHAT IT SERVES IS UNCHANGED (R9): openai is a GENERATIVE pack, so chat/responses/embeddings
|
|
35
|
+
* answer a labeled deterministic stub or a scripted scenario — never a model, here or anywhere.
|
|
36
|
+
* The only wall-clock-shaped call on this path is `worldNow()`, the world's frozen instant.
|
|
37
|
+
*
|
|
38
|
+
* The scenario document is read ONCE, when the factory is called — the per-request path never
|
|
39
|
+
* touches storage at all. That one read goes through the ACTIVE WorldStore (runtime contract
|
|
40
|
+
* R12b), so a serverless entry MAY pass a `scenarioPath`: the handlers document is hydrated
|
|
41
|
+
* world state like any other, and a memory-backed namespace serves its own scripted answers
|
|
42
|
+
* with no filesystem in the picture (openai-serverless.test.ts pins exactly that).
|
|
43
|
+
*/
|
|
44
|
+
export function createOpenAITwinFetch(options) {
|
|
45
|
+
const readOnly = options.readOnly ?? false;
|
|
46
|
+
const scenarioPath = options.scenarioPath ?? process.env.TWIN_OPENAI_SCENARIO;
|
|
47
|
+
const scenarioEngine = options.scenarioEngine ?? (scenarioPath ? createOpenAIScenarioEngine(loadOpenAIScenarioDocument(scenarioPath)) : undefined);
|
|
48
|
+
const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
|
|
49
|
+
// THE READ DOORS (TWIN-PROGRAMMING-MODEL): discovery + inspection, read-only.
|
|
50
|
+
const door = (request) => {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
if (request.method !== 'GET')
|
|
53
|
+
return undefined;
|
|
54
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
55
|
+
if (path === '/twin') {
|
|
56
|
+
return Response.json(twinManifest({
|
|
57
|
+
vendor: 'openai',
|
|
58
|
+
twinOf: 'OpenAI API (chat completions)',
|
|
59
|
+
stateSentence: 'Seed stored completions/files through the ordinary API with any key.',
|
|
60
|
+
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.',
|
|
61
|
+
exampleHandler: { on: { userTextIncludes: 'summarize', hasTool: 'file_search' }, respond: { text: 'Scripted summary.' }, once: true },
|
|
62
|
+
engine: scenarioEngine,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
if (path === '/twin/scenario')
|
|
66
|
+
return Response.json(scenarioEngine ? scenarioEngine.status() : { vendor: 'openai', handlers: [], misses: 0, recentMisses: [] });
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
// a refused or failed write at the head (kernel head.ts) answers in the vendor's own error shape
|
|
70
|
+
// The derived dispatch owns the wire: semantics handlers, then the derived core for resources the
|
|
71
|
+
// manifest declares; an operation neither models answers OpenAI's unknown-URL 404.
|
|
72
|
+
const inner = createDerivedFetch({
|
|
73
|
+
surface,
|
|
74
|
+
handlers: bindSemantics(manifest, openaiSemantics({ ...(scenarioEngine ? { scenarioEngine } : {}), readOnly }), scope),
|
|
75
|
+
core: coreFor(manifest, scope),
|
|
76
|
+
around: crossCutting(manifest, { readOnly, ...scope }),
|
|
77
|
+
gap: (request) => vendorError(manifest, { status: 404, message: `Unknown request URL: ${request.method} ${new URL(request.url).pathname}. Please check the URL for typos.` }),
|
|
78
|
+
});
|
|
79
|
+
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 }));
|
|
80
|
+
// OpenAI's own pages sit beside the API: the log-in (and the World's account door), and the dashboard's API keys page,
|
|
81
|
+
// where a project's keys are made
|
|
82
|
+
const screens = [openaiSessionFlow(scope), openaiApiKeysScreen(scope)];
|
|
83
|
+
// which part serves each spec operation (the derived dispatch's owners), for the pack's report
|
|
84
|
+
// (a scenario's authoring failure is answered where the scenario is served: openai-scenario.ts)
|
|
85
|
+
// time's moves OpenAI makes on its own (a file expiring: semantics/files.ts) are caught up to the World's clock
|
|
86
|
+
// before anything is answered, so every door reads the organization as it stands now
|
|
87
|
+
const filesClock = surface.operations.find((o) => o.id === 'listFiles');
|
|
88
|
+
// a project key presented to the API is used: its last use is recorded, and a revoked one refused (screens/api-keys.tsx)
|
|
89
|
+
const useKey = projectKeyUse(scope);
|
|
90
|
+
// and a read of the files observes the work whose outputs are files (a batch's output, a fine-tuning job's results)
|
|
91
|
+
// first, as a read of that batch or job does: the files a read lists never depend on which other read came before it
|
|
92
|
+
const catchUp = async (request) => {
|
|
93
|
+
const ctx = await semanticsContext(manifest, new Request(request.url), filesClock, scope);
|
|
94
|
+
await expireFiles(ctx);
|
|
95
|
+
if (request.method === 'GET' && /^\/v1\/files(\/|$)/.test(new URL(request.url).pathname))
|
|
96
|
+
for (const observe of [observeBatches, observeJobs])
|
|
97
|
+
await observe(ctx);
|
|
98
|
+
};
|
|
99
|
+
return Object.assign(async (request) => {
|
|
100
|
+
const opened = door(request);
|
|
101
|
+
if (opened)
|
|
102
|
+
return opened;
|
|
103
|
+
if (!readOnly)
|
|
104
|
+
await catchUp(request);
|
|
105
|
+
if (!readOnly) {
|
|
106
|
+
const refused = await useKey(request);
|
|
107
|
+
if (refused)
|
|
108
|
+
return refused;
|
|
109
|
+
}
|
|
110
|
+
if (!readOnly)
|
|
111
|
+
for (const screen of screens) {
|
|
112
|
+
const page = await screen(request);
|
|
113
|
+
if (page)
|
|
114
|
+
return page;
|
|
115
|
+
}
|
|
116
|
+
return handle(request);
|
|
117
|
+
}, { owners: inner.owners });
|
|
118
|
+
}
|
|
119
|
+
export async function createOpenAITwinServer(options) {
|
|
120
|
+
const server = await serveHttp({
|
|
121
|
+
port: options.port ?? 0,
|
|
122
|
+
idleTimeout: 60,
|
|
123
|
+
fetch: createOpenAITwinFetch(options),
|
|
124
|
+
});
|
|
125
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
126
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ChatMessageParam, ChatToolCall } from './openai-types.js';
|
|
2
|
+
/** Deterministic token estimate for a string: ~1 token per 4 chars (faithful order of
|
|
3
|
+
* magnitude; deterministic so usage counts are assertable, like the vendor's tokenizer on a
|
|
4
|
+
* fixed input). Never zero for non-empty text. */
|
|
5
|
+
export declare function estimateTokens(text: string): number;
|
|
6
|
+
/** Flatten a chat message's content (string OR content-part array) to its text for token
|
|
7
|
+
* counting / echo. Non-text parts contribute their JSON length so the count is deterministic
|
|
8
|
+
* and reflects payload size. */
|
|
9
|
+
export declare function contentToText(content: ChatMessageParam['content']): string;
|
|
10
|
+
/** Deterministic prompt-token count for the full set of messages. */
|
|
11
|
+
export declare function countPromptTokens(messages: ChatMessageParam[]): number;
|
|
12
|
+
/** The last user turn's text — the thing the stub echoes (deterministic, clearly labeled). */
|
|
13
|
+
export declare function lastUserText(messages: ChatMessageParam[]): string;
|
|
14
|
+
/**
|
|
15
|
+
* Build the deterministic stub ASSISTANT text. It is unmistakably a twin stub: it carries the
|
|
16
|
+
* `[twin-stub:<model>]` marker and echoes the prompt, so no caller can mistake it for real
|
|
17
|
+
* model output. Deterministic for a given prompt → assertable in tests.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stubAssistantText(messages: ChatMessageParam[], model: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Build a deterministic stub argument string for a tool. When the tool declares a JSON-schema
|
|
22
|
+
* `parameters` object (especially with `strict:true`), real models emit arguments that validate
|
|
23
|
+
* against the schema; the twin synthesizes a deterministic object containing every declared
|
|
24
|
+
* property with a type-appropriate placeholder so `strict` callers parse it cleanly. A required
|
|
25
|
+
* string is never left empty: an empty value is schema-valid in type but names nothing, so an
|
|
26
|
+
* application's tool could not act on it and a story could not follow the call. It takes what the
|
|
27
|
+
* user's message names (`namedIn`), else a deterministic value of the parameter's own name; an enum
|
|
28
|
+
* takes the value the message names, else its first.
|
|
29
|
+
*/
|
|
30
|
+
export declare function stubToolArguments(tool: unknown, userText?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* When tools/functions are provided, real models may respond with `tool_calls` and
|
|
33
|
+
* `finish_reason:'tool_calls'`. The stub deterministically "calls" the tool selected by
|
|
34
|
+
* `forcedName` (a named tool_choice) or the FIRST provided tool. Arguments are synthesized
|
|
35
|
+
* from the tool's JSON schema so strict callers parse them — clearly a stub, but a
|
|
36
|
+
* vendor-faithful tool_calls envelope. Returns the tool_call, or null when no tools provided.
|
|
37
|
+
*/
|
|
38
|
+
export declare function stubToolCall(tools: unknown, seq: number, forcedName?: string, userText?: string): ChatToolCall | null;
|
|
39
|
+
/**
|
|
40
|
+
* Build a deterministic JSON-object stub for `response_format` json_object / json_schema. The
|
|
41
|
+
* model's job is to emit parseable JSON; the twin returns a clearly-labeled deterministic object
|
|
42
|
+
* (and, for json_schema, fills every declared property with a schema-typed placeholder so the
|
|
43
|
+
* caller's strict parse succeeds). Always valid JSON.
|
|
44
|
+
*/
|
|
45
|
+
export declare function stubJsonObject(messages: ChatMessageParam[], model: string, jsonSchema?: unknown): string;
|
|
46
|
+
/** The per-token logprob shape OpenAI returns in `choices[].logprobs.content[]`. */
|
|
47
|
+
export type LogprobToken = {
|
|
48
|
+
token: string;
|
|
49
|
+
logprob: number;
|
|
50
|
+
bytes: number[];
|
|
51
|
+
top_logprobs: Array<{
|
|
52
|
+
token: string;
|
|
53
|
+
logprob: number;
|
|
54
|
+
bytes: number[];
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Build deterministic pseudo-logprobs for the stub completion text. Real models return a per-token
|
|
59
|
+
* logprob (a negative number) plus `top_logprobs` alternatives; the twin synthesizes a deterministic
|
|
60
|
+
* negative logprob per token (seeded from the token text) and `topN` alternatives. NOT real
|
|
61
|
+
* probabilities — the SHAPE and DETERMINISM are faithful, the values carry no model meaning.
|
|
62
|
+
* Re-joining `content[].token` reconstructs the full text exactly.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildLogprobs(text: string, topN: number): {
|
|
65
|
+
content: LogprobToken[];
|
|
66
|
+
};
|
|
67
|
+
/** A deterministic, reproducible L2-normalized pseudo-embedding vector for `text`. NOT a real
|
|
68
|
+
* embedding — the values carry no semantic meaning; only the SHAPE and DETERMINISM are
|
|
69
|
+
* faithful (`openai.embeddings.deterministic`). Same text → same vector. */
|
|
70
|
+
export declare function pseudoEmbedding(text: string, dimensions: number): number[];
|
|
71
|
+
/** The moderation categories the twin reports (faithful key set). */
|
|
72
|
+
export declare const MODERATION_CATEGORIES: readonly ["hate", "hate/threatening", "harassment", "harassment/threatening", "illicit", "illicit/violent", "self-harm", "self-harm/intent", "self-harm/instructions", "sexual", "sexual/minors", "violence", "violence/graphic"];
|
|
73
|
+
export type ModerationResult = {
|
|
74
|
+
flagged: boolean;
|
|
75
|
+
categories: Record<string, boolean>;
|
|
76
|
+
category_scores: Record<string, number>;
|
|
77
|
+
category_applied_input_types?: Record<string, string[]>;
|
|
78
|
+
};
|
|
79
|
+
/** Deterministically moderate one input (its text, and whether it held an image) into the faithful result shape: each
|
|
80
|
+
* category with the input types its score applies to, as every result carries them
|
|
81
|
+
* (https://platform.openai.com/docs/api-reference/moderations/object). */
|
|
82
|
+
export declare function moderateText(text: string, image?: boolean): ModerationResult;
|