@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,112 @@
|
|
|
1
|
+
// OpenAI twin conformance — a lightweight, offline structural check that the twin's served
|
|
2
|
+
// response ENVELOPES match the documented OpenAI shapes. (Unlike the OpenAPI-backed packs,
|
|
3
|
+
// OpenAI's published OpenAPI is huge and not vendored here, so this harness validates the
|
|
4
|
+
// load-bearing envelope SHAPES directly: chat.completion, the streaming chunk sequence,
|
|
5
|
+
// the Responses API, embeddings, models, files, batches, moderations, and the error envelope.
|
|
6
|
+
// Fully offline + deterministic (drives the local handler against a temp root) so it runs in
|
|
7
|
+
// CI without an API key. Honest scope: it checks the protocol envelope, NOT model output
|
|
8
|
+
// (a deterministic stub by design — see the out-of-scope carve-outs in the manifest).
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { handleOpenAITwinRequest, type OpenAIResponseEnvelope } from './openai-twin.ts';
|
|
13
|
+
import type { SseEvent } from './openai-types.ts';
|
|
14
|
+
|
|
15
|
+
export type ConformanceViolation = { check: string; detail: string };
|
|
16
|
+
export type OpenAIConformanceReport = {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
checksRun: number;
|
|
19
|
+
violations: ConformanceViolation[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Body = Record<string, unknown>;
|
|
23
|
+
const isObj = (v: unknown): v is Body => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
|
|
25
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
26
|
+
export async function checkOpenAIConformance(opts: { root?: string } = {}): Promise<OpenAIConformanceReport> {
|
|
27
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'openai-conf-'));
|
|
28
|
+
const owned = opts.root === undefined;
|
|
29
|
+
const violations: ConformanceViolation[] = [];
|
|
30
|
+
let checksRun = 0;
|
|
31
|
+
const fail = (check: string, detail: string) => violations.push({ check, detail });
|
|
32
|
+
const H = (method: string, path: string, body?: string, sseSink?: (e: SseEvent) => void) =>
|
|
33
|
+
handleOpenAITwinRequest({ method, path, body, root, ...(sseSink ? { sseSink } : {}) });
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// 1. chat.completion response envelope.
|
|
37
|
+
checksRun++;
|
|
38
|
+
const chat = await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }));
|
|
39
|
+
const cb = chat.body as Body;
|
|
40
|
+
if (chat.status !== 200) fail('chat.envelope', `status ${chat.status}`);
|
|
41
|
+
else {
|
|
42
|
+
for (const k of ['id', 'object', 'created', 'model', 'choices', 'usage']) if (!(k in cb)) fail('chat.envelope', `missing field "${k}"`);
|
|
43
|
+
if (cb.object !== 'chat.completion') fail('chat.envelope', `object is ${String(cb.object)}`);
|
|
44
|
+
const choices = cb.choices as Body[];
|
|
45
|
+
if (!Array.isArray(choices) || !isObj(choices[0]) || (choices[0]!.message as Body)?.role !== 'assistant') fail('chat.envelope', 'choices[0].message not assistant');
|
|
46
|
+
if ((choices[0]!.finish_reason) !== 'stop') fail('chat.envelope', `finish_reason is ${String(choices[0]!.finish_reason)}`);
|
|
47
|
+
const usage = cb.usage as Body;
|
|
48
|
+
if (!isObj(usage) || typeof usage.prompt_tokens !== 'number' || typeof usage.total_tokens !== 'number') fail('chat.usage', 'usage missing prompt/total tokens');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. chat streaming chunk sequence (ends with done).
|
|
52
|
+
checksRun++;
|
|
53
|
+
const events: SseEvent[] = [];
|
|
54
|
+
await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'gpt-4o', stream: true, messages: [{ role: 'user', content: 'stream' }] }), (e) => events.push(e));
|
|
55
|
+
const hasRole = events.some((e) => !e.done && ((e.data!.choices as Body[])?.[0]?.delta as Body)?.role === 'assistant');
|
|
56
|
+
const hasDone = events.length > 0 && events[events.length - 1]!.done === true;
|
|
57
|
+
const firstChunkObj = events[0]?.data?.object;
|
|
58
|
+
if (!hasRole) fail('chat.stream', 'no role delta chunk');
|
|
59
|
+
if (!hasDone) fail('chat.stream', 'stream did not end with [DONE]');
|
|
60
|
+
if (firstChunkObj !== 'chat.completion.chunk') fail('chat.stream', `first chunk object is ${String(firstChunkObj)}`);
|
|
61
|
+
|
|
62
|
+
// 3. tool_calls envelope when tools provided.
|
|
63
|
+
checksRun++;
|
|
64
|
+
const tool = await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'gpt-4o', tools: [{ type: 'function', function: { name: 'get_weather', parameters: { type: 'object' } } }], messages: [{ role: 'user', content: 'weather?' }] }));
|
|
65
|
+
const tc = (tool.body as Body).choices as Body[];
|
|
66
|
+
if ((tc?.[0]?.finish_reason) !== 'tool_calls') fail('chat.tool_calls', 'finish_reason not tool_calls');
|
|
67
|
+
const calls = (tc?.[0]?.message as Body)?.tool_calls as Body[];
|
|
68
|
+
if (!Array.isArray(calls) || calls[0]?.type !== 'function') fail('chat.tool_calls', 'no function tool_call');
|
|
69
|
+
|
|
70
|
+
// 4. Responses API envelope.
|
|
71
|
+
checksRun++;
|
|
72
|
+
const resp = await H('POST', '/v1/responses', JSON.stringify({ model: 'gpt-4o', input: 'hello' }));
|
|
73
|
+
const rb = resp.body as Body;
|
|
74
|
+
if (rb.object !== 'response' || !Array.isArray(rb.output) || (rb.output as Body[])[0]?.type !== 'message') fail('responses.envelope', 'bad responses envelope');
|
|
75
|
+
|
|
76
|
+
// 5. embeddings shape + determinism.
|
|
77
|
+
checksRun++;
|
|
78
|
+
const e1 = await H('POST', '/v1/embeddings', JSON.stringify({ model: 'text-embedding-3-small', input: 'vectorize me' }));
|
|
79
|
+
const e2 = await H('POST', '/v1/embeddings', JSON.stringify({ model: 'text-embedding-3-small', input: 'vectorize me' }));
|
|
80
|
+
const v1 = ((e1.body as Body).data as Body[])?.[0]?.embedding as number[];
|
|
81
|
+
const v2 = ((e2.body as Body).data as Body[])?.[0]?.embedding as number[];
|
|
82
|
+
if (!Array.isArray(v1) || v1.length === 0) fail('embeddings.shape', 'no embedding vector');
|
|
83
|
+
else if (JSON.stringify(v1) !== JSON.stringify(v2)) fail('embeddings.deterministic', 'same input produced different vectors');
|
|
84
|
+
|
|
85
|
+
// 6. models list + retrieve.
|
|
86
|
+
checksRun++;
|
|
87
|
+
const models = await H('GET', '/v1/models');
|
|
88
|
+
if (!Array.isArray((models.body as Body).data)) fail('models.list', 'no data array');
|
|
89
|
+
const one = await H('GET', '/v1/models/gpt-4o');
|
|
90
|
+
if ((one.body as Body).object !== 'model') fail('models.retrieve', 'object is not a model');
|
|
91
|
+
|
|
92
|
+
// 7. files create + batches create + moderations.
|
|
93
|
+
checksRun++;
|
|
94
|
+
const file = await H('POST', '/v1/files', JSON.stringify({ purpose: 'batch', filename: 'in.jsonl', content: '{}' }));
|
|
95
|
+
if ((file.body as Body).object !== 'file') fail('files.create', 'object is not a file');
|
|
96
|
+
const batch = await H('POST', '/v1/batches', JSON.stringify({ input_file_id: 'file-x', endpoint: '/v1/chat/completions', completion_window: '24h' }));
|
|
97
|
+
if ((batch.body as Body).object !== 'batch') fail('batches.create', 'object is not a batch');
|
|
98
|
+
const mod = await H('POST', '/v1/moderations', JSON.stringify({ input: 'hello' }));
|
|
99
|
+
if (!Array.isArray((mod.body as Body).results)) fail('moderations', 'no results array');
|
|
100
|
+
|
|
101
|
+
// 8. vendor-shaped error envelope.
|
|
102
|
+
checksRun++;
|
|
103
|
+
const bad = await H('POST', '/v1/chat/completions', JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] })) as OpenAIResponseEnvelope;
|
|
104
|
+
const eb = bad.body as Body;
|
|
105
|
+
if (bad.status !== 400 || !isObj(eb.error) || (eb.error as Body).type !== 'invalid_request_error') fail('error.envelope', 'missing model did not yield a 400 invalid_request_error envelope');
|
|
106
|
+
const nf = await H('GET', '/v1/nonexistent');
|
|
107
|
+
if (nf.status !== 404 || !isObj((nf.body as Body).error)) fail('error.not_found', 'unknown route did not 404');
|
|
108
|
+
} finally {
|
|
109
|
+
if (owned) rmSync(root, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
return { ok: violations.length === 0, checksRun, violations };
|
|
112
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// OpenAI CONNECTOR — the live-vendor pull/push path that gives the OpenAI twin the full
|
|
2
|
+
// "git for SaaS" lifecycle (pull real state → mirror; push local writes → real).
|
|
3
|
+
//
|
|
4
|
+
// PULL (real → twin): fetch real Files / Batches / Fine-tuning jobs / Vector stores, map
|
|
5
|
+
// snake_case → SyncResource[], fold into the event log via syncPull
|
|
6
|
+
// (shadow-diff dedup, so re-pulling identical state appends nothing).
|
|
7
|
+
// PUSH (twin → real): for every PENDING local action (create / cancel / delete), call the
|
|
8
|
+
// real OpenAI REST API and confirmAction on success (records the
|
|
9
|
+
// confirmed fields as an observed event + suppresses the local
|
|
10
|
+
// projection — counted exactly once).
|
|
11
|
+
//
|
|
12
|
+
// The vendor I/O is an INJECTED executor (B3 auth boundary): the kernel + this pack hold NO
|
|
13
|
+
// OpenAI key and import NO SDK at runtime. Tests pass a fake executor; live runs pass
|
|
14
|
+
// `liveOpenAIExecute(apiKey)`. Same code path either way — fully exercisable offline.
|
|
15
|
+
import { assertBudgetGuardIntact, confirmAction, pendingActions, syncPull } from '@volter/twin';
|
|
16
|
+
import type { SyncResource, TwinAction } from '@volter/twin';
|
|
17
|
+
import { OpenAIBudget, openaiCallWeight, type OpenAIBudgetOptions } from './openai-budget.ts';
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'openai';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The injected real-OpenAI boundary. `request` issues ONE OpenAI REST call:
|
|
23
|
+
* method — 'GET' | 'POST' | 'DELETE'
|
|
24
|
+
* path — e.g. '/v1/files' or '/v1/batches/batch_123/cancel'
|
|
25
|
+
* body — JSON body for POST (omitted otherwise)
|
|
26
|
+
* Returns the parsed JSON (an object, a `{ data }` list, or an `{ error }` envelope).
|
|
27
|
+
*/
|
|
28
|
+
export type OpenAIExecute = (
|
|
29
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
30
|
+
path: string,
|
|
31
|
+
body?: Record<string, unknown>,
|
|
32
|
+
) => Promise<{ data?: any; error?: { message?: string; type?: string }; [k: string]: unknown }>;
|
|
33
|
+
|
|
34
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
35
|
+
export type LiveOpenAIOptions = {
|
|
36
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
39
|
+
budget?: OpenAIBudget;
|
|
40
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
41
|
+
budgetOptions?: OpenAIBudgetOptions;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A live executor against the real OpenAI REST API (the user's own API key). Sends the
|
|
46
|
+
* required `Authorization: Bearer` header. Never imported by the pack's own code path — only
|
|
47
|
+
* constructed by a caller that opts into real I/O.
|
|
48
|
+
*
|
|
49
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.openai.com` request, and therefore the one
|
|
50
|
+
* place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the
|
|
51
|
+
* request goes out (`checkBudget`, which THROWS `OpenAIBudgetError` instead of returning when the
|
|
52
|
+
* ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `retry-after` /
|
|
53
|
+
* 429 / `x-ratelimit-remaining-requests: 0` signal becomes a persisted cooldown that makes every
|
|
54
|
+
* later call fail fast WITHOUT touching OpenAI. There is deliberately no OPTION to disable the guard, and no
|
|
55
|
+
* value a caller can pass for `budget` that yields an unguarded client. What that does NOT claim is
|
|
56
|
+
* immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an injected
|
|
57
|
+
* clock, restores the allowance, because the same seam tests need cannot be denied to a determined
|
|
58
|
+
* caller in the same process. The kernel header says so up front and this does not upgrade it — see `openai-budget.ts` for why, and for the limits of the guarantee.
|
|
59
|
+
*/
|
|
60
|
+
export function liveOpenAIExecute(
|
|
61
|
+
apiKey: string,
|
|
62
|
+
base = 'https://api.openai.com',
|
|
63
|
+
opts: LiveOpenAIOptions = {},
|
|
64
|
+
): OpenAIExecute {
|
|
65
|
+
// `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
|
|
66
|
+
// UNMODIFIED OpenAIBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
|
|
67
|
+
// Proxy that traps it are all refused, because all three are one-liners that would otherwise
|
|
68
|
+
// hand back a client with no ceiling at all (§9 finding, 2026-07-26 — `instanceof` alone was
|
|
69
|
+
// not a check). What this cannot stop is deliberate sabotage from inside the process (an
|
|
70
|
+
// injected clock, a throwaway ledger path); the kernel's header says so rather than pretending
|
|
71
|
+
// otherwise, and this guards the accident and the one-liner, which are the shapes that happen.
|
|
72
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
73
|
+
// The default ledger is keyed by a hash of THIS key — OpenAI limits per organization/project,
|
|
74
|
+
// so a cwd-scoped ledger would hand the same key a fresh allowance per checkout/worktree/CI leg.
|
|
75
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
76
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
77
|
+
// must be an UNMODIFIED OpenAIBudget — a duck-typed stand-in, a SUBCLASS overriding
|
|
78
|
+
// `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
|
|
79
|
+
// would otherwise hand back a client with no ceiling (§9 finding, 2026-07-26: `instanceof`
|
|
80
|
+
// alone was not a check — a subclass satisfied it). What this cannot stop is deliberate
|
|
81
|
+
// sabotage from inside the process (an injected clock, a throwaway ledger path); the kernel
|
|
82
|
+
// header states that limit rather than pretending otherwise. This closes the accident and the
|
|
83
|
+
// one-liner, which are the shapes that actually happen.
|
|
84
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
85
|
+
? assertBudgetGuardIntact(opts.budget, OpenAIBudget, 'liveOpenAIExecute')
|
|
86
|
+
: new OpenAIBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
87
|
+
return async (method, path, body) => {
|
|
88
|
+
const headers: Record<string, string> = { authorization: `Bearer ${apiKey}` };
|
|
89
|
+
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
90
|
+
if (method === 'POST') {
|
|
91
|
+
headers['content-type'] = 'application/json';
|
|
92
|
+
init.body = JSON.stringify(body ?? {});
|
|
93
|
+
}
|
|
94
|
+
const weight = openaiCallWeight(method, path);
|
|
95
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
96
|
+
const reservation = budget.checkBudget(weight);
|
|
97
|
+
const res = await doFetch(`${base}${path}`, init);
|
|
98
|
+
const resHeaders: Record<string, string> = {};
|
|
99
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
100
|
+
const parsed = (await res.json()) as { data?: any; error?: { message?: string; type?: string } };
|
|
101
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
102
|
+
// `retry-after` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
103
|
+
// first either way, so the refusal survives the throw.
|
|
104
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
105
|
+
return parsed;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function listOf(res: { data?: unknown }): any[] {
|
|
110
|
+
return Array.isArray(res.data) ? res.data : [];
|
|
111
|
+
}
|
|
112
|
+
function throwIfError(res: { error?: { message?: string } }, ctx: string): void {
|
|
113
|
+
if (res.error) throw new Error(`openai ${ctx} failed: ${res.error.message ?? 'unknown error'}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── PULL ────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** Map a real-OpenAI File object → a twin sync resource. */
|
|
119
|
+
export function mapFile(f: Record<string, unknown>): SyncResource {
|
|
120
|
+
return {
|
|
121
|
+
type: 'file',
|
|
122
|
+
id: String(f.id),
|
|
123
|
+
fields: {
|
|
124
|
+
object: 'file',
|
|
125
|
+
bytes: (f.bytes as number) ?? 0,
|
|
126
|
+
created_at: (f.created_at as number) ?? null,
|
|
127
|
+
filename: (f.filename as string) ?? null,
|
|
128
|
+
purpose: (f.purpose as string) ?? null,
|
|
129
|
+
status: (f.status as string) ?? null,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Map a real-OpenAI Batch object → a twin sync resource. */
|
|
135
|
+
export function mapBatch(b: Record<string, unknown>): SyncResource {
|
|
136
|
+
const counts = (b.request_counts && typeof b.request_counts === 'object') ? (b.request_counts as Record<string, unknown>) : {};
|
|
137
|
+
return {
|
|
138
|
+
type: 'batch',
|
|
139
|
+
id: String(b.id),
|
|
140
|
+
fields: {
|
|
141
|
+
object: 'batch',
|
|
142
|
+
endpoint: (b.endpoint as string) ?? null,
|
|
143
|
+
input_file_id: (b.input_file_id as string) ?? null,
|
|
144
|
+
completion_window: (b.completion_window as string) ?? null,
|
|
145
|
+
status: (b.status as string) ?? null,
|
|
146
|
+
output_file_id: (b.output_file_id as string) ?? null,
|
|
147
|
+
created_at: (b.created_at as number) ?? null,
|
|
148
|
+
completed_at: (b.completed_at as number) ?? null,
|
|
149
|
+
request_counts: counts,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Map a real-OpenAI fine-tuning job → a twin sync resource. */
|
|
155
|
+
export function mapFineTune(j: Record<string, unknown>): SyncResource {
|
|
156
|
+
return {
|
|
157
|
+
type: 'fine_tuning_job',
|
|
158
|
+
id: String(j.id),
|
|
159
|
+
fields: {
|
|
160
|
+
object: 'fine_tuning.job',
|
|
161
|
+
model: (j.model as string) ?? null,
|
|
162
|
+
status: (j.status as string) ?? null,
|
|
163
|
+
fine_tuned_model: (j.fine_tuned_model as string) ?? null,
|
|
164
|
+
training_file: (j.training_file as string) ?? null,
|
|
165
|
+
created_at: (j.created_at as number) ?? null,
|
|
166
|
+
finished_at: (j.finished_at as number) ?? null,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Map a real-OpenAI vector store → a twin sync resource. */
|
|
172
|
+
export function mapVectorStore(v: Record<string, unknown>): SyncResource {
|
|
173
|
+
const counts = (v.file_counts && typeof v.file_counts === 'object') ? (v.file_counts as Record<string, unknown>) : {};
|
|
174
|
+
return {
|
|
175
|
+
type: 'vector_store',
|
|
176
|
+
id: String(v.id),
|
|
177
|
+
fields: {
|
|
178
|
+
object: 'vector_store',
|
|
179
|
+
name: (v.name as string) ?? null,
|
|
180
|
+
status: (v.status as string) ?? null,
|
|
181
|
+
created_at: (v.created_at as number) ?? null,
|
|
182
|
+
usage_bytes: (v.usage_bytes as number) ?? 0,
|
|
183
|
+
file_counts: counts,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const COLLECTIONS: Array<{ path: string; map: (r: Record<string, unknown>) => SyncResource }> = [
|
|
189
|
+
{ path: '/v1/files', map: mapFile },
|
|
190
|
+
{ path: '/v1/batches', map: mapBatch },
|
|
191
|
+
{ path: '/v1/fine_tuning/jobs', map: mapFineTune },
|
|
192
|
+
{ path: '/v1/vector_stores', map: mapVectorStore },
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
/** Pull all modeled real collections via the executor and map them to twin sync resources. */
|
|
196
|
+
export async function pullOpenAIState(execute: OpenAIExecute): Promise<SyncResource[]> {
|
|
197
|
+
const out: SyncResource[] = [];
|
|
198
|
+
for (const c of COLLECTIONS) {
|
|
199
|
+
const res = await execute('GET', `${c.path}?limit=100`);
|
|
200
|
+
throwIfError(res, `pull ${c.path}`);
|
|
201
|
+
for (const item of listOf(res)) out.push(c.map(item));
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Pull from real OpenAI and fold into the twin (mirror seeding). syncPull's shadow-diff makes
|
|
208
|
+
* a re-pull of identical state a no-op.
|
|
209
|
+
*/
|
|
210
|
+
export async function syncOpenAIFromReal(
|
|
211
|
+
execute: OpenAIExecute,
|
|
212
|
+
opts: { root?: string; occurredAt: string },
|
|
213
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
214
|
+
const resources = await pullOpenAIState(execute);
|
|
215
|
+
const result = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
216
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── PUSH ────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
// The twin operations this connector knows how to push to real OpenAI. Anything not here must
|
|
222
|
+
// FAIL LOUDLY rather than be silently dropped — pushing an unrecognized op risks hitting the
|
|
223
|
+
// wrong endpoint or no-op'ing a real change.
|
|
224
|
+
const PUSHABLE_VERBS = new Set(['create', 'cancel', 'delete']);
|
|
225
|
+
|
|
226
|
+
/** Throw if `op` is not a write operation this connector can faithfully push. */
|
|
227
|
+
function assertPushable(op: string): void {
|
|
228
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
229
|
+
if (!PUSHABLE_VERBS.has(verb)) {
|
|
230
|
+
throw new Error(`openai push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Map a subject type → its REST collection path.
|
|
235
|
+
const COLLECTION_PATH: Record<string, string> = {
|
|
236
|
+
file: '/v1/files',
|
|
237
|
+
batch: '/v1/batches',
|
|
238
|
+
fine_tuning_job: '/v1/fine_tuning/jobs',
|
|
239
|
+
vector_store: '/v1/vector_stores',
|
|
240
|
+
vector_store_file: '/v1/vector_stores',
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve the REST (method, path) for ONE pending action — faithful to the real OpenAI REST
|
|
245
|
+
* surface for every write op the twin records:
|
|
246
|
+
* - <type>.create → POST <collection>
|
|
247
|
+
* - <type>.cancel → POST <collection>/:id/cancel
|
|
248
|
+
* - <type>.delete → DELETE <collection>/:id
|
|
249
|
+
*/
|
|
250
|
+
export function openaiRequestForAction(action: Pick<TwinAction, 'operation' | 'subject'>): { method: 'GET' | 'POST' | 'DELETE'; path: string } {
|
|
251
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
252
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
253
|
+
const collection = COLLECTION_PATH[action.subject.type] ?? `/v1/${action.subject.type}s`;
|
|
254
|
+
if (verb === 'create') return { method: 'POST', path: collection };
|
|
255
|
+
if (verb === 'cancel') return { method: 'POST', path: `${collection}/${action.subject.id}/cancel` };
|
|
256
|
+
if (verb === 'delete') return { method: 'DELETE', path: `${collection}/${action.subject.id}` };
|
|
257
|
+
// assertPushable rejects anything else, so this is only reached for the pushable verbs above.
|
|
258
|
+
return { method: 'POST', path: collection };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Push ONE pending action to REAL OpenAI via the injected executor. Returns the real external
|
|
263
|
+
* id (the object id from the response; for a create that's a freshly minted id, otherwise it
|
|
264
|
+
* echoes the subject). WRITES TO THE REAL ACCOUNT.
|
|
265
|
+
*/
|
|
266
|
+
export async function pushOpenAIAction(
|
|
267
|
+
execute: OpenAIExecute,
|
|
268
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
269
|
+
): Promise<{ externalId: string }> {
|
|
270
|
+
assertPushable(action.operation ?? `${action.subject.type}.update`);
|
|
271
|
+
const { method, path } = openaiRequestForAction(action);
|
|
272
|
+
const verb = (action.operation ?? '').includes('.') ? action.operation!.slice(action.operation!.indexOf('.') + 1) : '';
|
|
273
|
+
// On create, push the vendor-accepted create payload (the stored fields include derived
|
|
274
|
+
// lifecycle data the create endpoint does not accept; strip the twin's private fields).
|
|
275
|
+
const payload = verb === 'create' ? createPayload(action) : undefined;
|
|
276
|
+
const res = await execute(method, path, payload);
|
|
277
|
+
throwIfError(res, `push ${action.subject.type}`);
|
|
278
|
+
const id = (res as { id?: unknown }).id;
|
|
279
|
+
return { externalId: typeof id === 'string' && id ? id : action.subject.id };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function createPayload(action: Pick<TwinAction, 'subject' | 'fields'>): Record<string, unknown> {
|
|
283
|
+
const f = (action.fields ?? {}) as Record<string, unknown>;
|
|
284
|
+
switch (action.subject.type) {
|
|
285
|
+
case 'batch':
|
|
286
|
+
return { input_file_id: f.input_file_id, endpoint: f.endpoint, completion_window: f.completion_window, ...(f.metadata ? { metadata: f.metadata } : {}) };
|
|
287
|
+
case 'fine_tuning_job':
|
|
288
|
+
return { model: f.model, training_file: f.training_file, ...(f.validation_file ? { validation_file: f.validation_file } : {}) };
|
|
289
|
+
case 'vector_store':
|
|
290
|
+
return { ...(f.name ? { name: f.name } : {}) };
|
|
291
|
+
case 'file':
|
|
292
|
+
return { purpose: f.purpose, filename: f.filename };
|
|
293
|
+
default:
|
|
294
|
+
return {};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Push the twin's PENDING local actions to real OpenAI and CONFIRM each: for every pending
|
|
300
|
+
* action, call the real API; on success, confirmAction records the confirmed fields as an
|
|
301
|
+
* observed event and maps action → event (suppressing the local projection — counted exactly
|
|
302
|
+
* once). Idempotency: a confirmed action is no longer pending, so a re-push enacts NOTHING.
|
|
303
|
+
*/
|
|
304
|
+
export async function pushPendingOpenAIActions(
|
|
305
|
+
execute: OpenAIExecute,
|
|
306
|
+
opts: { root?: string; occurredAt: string },
|
|
307
|
+
): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
|
|
308
|
+
const confirmed: string[] = [];
|
|
309
|
+
const externalIds: Record<string, string> = {};
|
|
310
|
+
for (const action of pendingActions(SERVICE, opts.root)) {
|
|
311
|
+
const { externalId } = await pushOpenAIAction(execute, action);
|
|
312
|
+
confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: action.fields ?? {}, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
313
|
+
confirmed.push(action.id);
|
|
314
|
+
externalIds[action.id] = externalId;
|
|
315
|
+
}
|
|
316
|
+
return { pushed: confirmed.length, confirmed, externalIds };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── FULL bi-directional sync ─────────────────────────────────────────────────
|
|
320
|
+
/**
|
|
321
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to
|
|
322
|
+
* real OpenAI and confirm it, then (2) PULL all modeled collections back and fold them into the
|
|
323
|
+
* event log. Pushing first means the pull observes the twin's own writes as confirmed external
|
|
324
|
+
* state (no double-count). Re-running with no pending writes and identical real state is a
|
|
325
|
+
* no-op (push 0, deltasAppended 0). Same code path offline (fake executor) and live (real key).
|
|
326
|
+
*/
|
|
327
|
+
export async function fullSyncOpenAI(
|
|
328
|
+
execute: OpenAIExecute,
|
|
329
|
+
opts: { root?: string; occurredAt: string },
|
|
330
|
+
): Promise<{ pushed: number; observed: number; deltasAppended: number; collections: number }> {
|
|
331
|
+
// 1. PUSH pending local changes to real OpenAI (and confirm each).
|
|
332
|
+
const push = await pushPendingOpenAIActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
333
|
+
// 2. PULL all modeled collections back into the twin.
|
|
334
|
+
const resources = await pullOpenAIState(execute);
|
|
335
|
+
const pull = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
336
|
+
return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.deltasAppended, collections: COLLECTIONS.length };
|
|
337
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// OpenAI model catalog — the static `GET /v1/models` surface. Real OpenAI serves a list of
|
|
2
|
+
// model objects ({ id, object:'model', created, owned_by }). The twin returns a faithful,
|
|
3
|
+
// deterministic subset so `client.models.list()` / `client.models.retrieve(id)` round-trip
|
|
4
|
+
// exactly like the vendor.
|
|
5
|
+
//
|
|
6
|
+
// This is a STATIC surface (the model registry doesn't change at runtime), so it is a plain
|
|
7
|
+
// data table rather than kernel state — listing/retrieving models is a pure read.
|
|
8
|
+
export type OpenAIModel = {
|
|
9
|
+
id: string;
|
|
10
|
+
object: 'model';
|
|
11
|
+
created: number;
|
|
12
|
+
owned_by: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// A faithful slice of the published catalog (ids are the exact vendor strings).
|
|
16
|
+
export const OPENAI_MODELS: OpenAIModel[] = [
|
|
17
|
+
{ id: 'gpt-4o', object: 'model', created: 1715367049, owned_by: 'system' },
|
|
18
|
+
{ id: 'gpt-4o-mini', object: 'model', created: 1721172741, owned_by: 'system' },
|
|
19
|
+
{ id: 'gpt-4.1', object: 'model', created: 1744316542, owned_by: 'system' },
|
|
20
|
+
{ id: 'gpt-4.1-mini', object: 'model', created: 1744317547, owned_by: 'system' },
|
|
21
|
+
{ id: 'gpt-4-turbo', object: 'model', created: 1712361441, owned_by: 'system' },
|
|
22
|
+
{ id: 'o3', object: 'model', created: 1744225308, owned_by: 'system' },
|
|
23
|
+
{ id: 'o4-mini', object: 'model', created: 1744225351, owned_by: 'system' },
|
|
24
|
+
{ id: 'gpt-3.5-turbo', object: 'model', created: 1677610602, owned_by: 'openai' },
|
|
25
|
+
{ id: 'text-embedding-3-small', object: 'model', created: 1705948997, owned_by: 'system' },
|
|
26
|
+
{ id: 'text-embedding-3-large', object: 'model', created: 1705953180, owned_by: 'system' },
|
|
27
|
+
{ id: 'text-embedding-ada-002', object: 'model', created: 1671217299, owned_by: 'openai-internal' },
|
|
28
|
+
{ id: 'dall-e-3', object: 'model', created: 1698785189, owned_by: 'system' },
|
|
29
|
+
{ id: 'whisper-1', object: 'model', created: 1677532384, owned_by: 'openai-internal' },
|
|
30
|
+
{ id: 'omni-moderation-latest', object: 'model', created: 1731689265, owned_by: 'system' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Resolve a model object by id, or undefined if the twin doesn't model it. */
|
|
34
|
+
export function findModel(id: string): OpenAIModel | undefined {
|
|
35
|
+
return OPENAI_MODELS.find((m) => m.id === id);
|
|
36
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The openai pack's scenario system on the kernel's ONE engine (@volter/twin 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, type PackScenarioAdapter, ScenarioError, type ScenarioDocument, ScenarioEngine, type ScenarioFeatures } from '@volter/twin';
|
|
6
|
+
import { contentToText, lastUserText } from './openai-stub.ts';
|
|
7
|
+
import type { ChatMessageParam, ChatToolCall } from './openai-types.ts';
|
|
8
|
+
|
|
9
|
+
export type OpenAIScenarioRequest = { model: string; messages: ChatMessageParam[]; tools?: unknown; maxTokens?: number };
|
|
10
|
+
export type OpenAIScenarioEngine = ScenarioEngine<OpenAIScenarioRequest>;
|
|
11
|
+
|
|
12
|
+
export type ScenarioToolCall = { name: string; arguments: Record<string, unknown>; id?: string };
|
|
13
|
+
|
|
14
|
+
export type OpenAIScenarioRespond = {
|
|
15
|
+
text?: string;
|
|
16
|
+
toolCalls?: ScenarioToolCall | ScenarioToolCall[];
|
|
17
|
+
finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ScriptedResult = {
|
|
21
|
+
text: string | null;
|
|
22
|
+
toolCalls: ChatToolCall[];
|
|
23
|
+
finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const RESPOND_KEYS = new Set(['text', 'toolCalls', 'finishReason']);
|
|
27
|
+
const FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_filter']);
|
|
28
|
+
const nonEmptyString = (cond: unknown): cond is string => typeof cond === 'string' && cond.length > 0;
|
|
29
|
+
const positiveNumber = (cond: unknown): cond is number => typeof cond === 'number' && Number.isFinite(cond) && cond > 0;
|
|
30
|
+
|
|
31
|
+
function lastToolResultNames(messages: ChatMessageParam[]): Set<string> {
|
|
32
|
+
const names = new Set<string>();
|
|
33
|
+
const last = messages[messages.length - 1] as { role?: string; tool_call_id?: unknown } | undefined;
|
|
34
|
+
if (!last || last.role !== 'tool' || typeof last.tool_call_id !== 'string') return names;
|
|
35
|
+
for (const m of messages) {
|
|
36
|
+
const am = m as { role?: string; tool_calls?: Array<{ id?: unknown; function?: { name?: unknown } }> };
|
|
37
|
+
if (am.role !== 'assistant' || !Array.isArray(am.tool_calls)) continue;
|
|
38
|
+
for (const tc of am.tool_calls) {
|
|
39
|
+
if (tc?.id === last.tool_call_id && typeof tc?.function?.name === 'string') names.add(tc.function.name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return names;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toolNames(tools: unknown): string[] {
|
|
46
|
+
if (!Array.isArray(tools)) return [];
|
|
47
|
+
return (tools as Array<{ function?: { name?: unknown }; name?: unknown }>).map((t) =>
|
|
48
|
+
typeof t?.function?.name === 'string' ? t.function.name : typeof t?.name === 'string' ? t.name : null,
|
|
49
|
+
).filter((n): n is string => n !== null);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const openaiScenarioAdapter: PackScenarioAdapter<OpenAIScenarioRequest> = {
|
|
53
|
+
vendor: 'openai',
|
|
54
|
+
features: (req): ScenarioFeatures => ({
|
|
55
|
+
model: req.model,
|
|
56
|
+
lastUserText: lastUserText(req.messages).slice(0, 300),
|
|
57
|
+
tools: toolNames(req.tools),
|
|
58
|
+
lastMessageIsToolResult: (req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool',
|
|
59
|
+
toolResultFor: [...lastToolResultNames(req.messages)],
|
|
60
|
+
// The output cap the caller asked for (max_tokens / max_completion_tokens), or 0 when it sent none. A
|
|
61
|
+
// proxy between the agent and this twin may clamp it; a handler keyed on it makes that clamp visible.
|
|
62
|
+
maxTokens: req.maxTokens ?? 0,
|
|
63
|
+
}),
|
|
64
|
+
matchers: {
|
|
65
|
+
modelEquals: (req, cond) => nonEmptyString(cond) && req.model === cond,
|
|
66
|
+
userTextIncludes: (req, cond) => nonEmptyString(cond) && lastUserText(req.messages).toLowerCase().includes(cond.toLowerCase()),
|
|
67
|
+
anyTextIncludes: (req, cond) => nonEmptyString(cond) && req.messages.map((m) => contentToText(m.content)).join('\n').toLowerCase().includes(cond.toLowerCase()),
|
|
68
|
+
lastMessageIsToolResult: (req, cond) => typeof cond === 'boolean' && ((req.messages[req.messages.length - 1] as { role?: string } | undefined)?.role === 'tool') === cond,
|
|
69
|
+
toolResultFor: (req, cond) => nonEmptyString(cond) && lastToolResultNames(req.messages).has(cond),
|
|
70
|
+
hasTool: (req, cond) => nonEmptyString(cond) && toolNames(req.tools).includes(cond),
|
|
71
|
+
// A request whose output cap is below N (a cap it did not send counts as below every N).
|
|
72
|
+
maxTokensBelow: (req, cond) => positiveNumber(cond) && (req.maxTokens ?? 0) < cond,
|
|
73
|
+
maxTokensAtLeast: (req, cond) => positiveNumber(cond) && (req.maxTokens ?? 0) >= cond,
|
|
74
|
+
},
|
|
75
|
+
text: (req) => req.messages.map((m) => contentToText(m.content)).join('\n'),
|
|
76
|
+
validateOn: (on) => {
|
|
77
|
+
for (const k of ['modelEquals', 'userTextIncludes', 'anyTextIncludes', 'toolResultFor', 'hasTool'] as const) if (on[k] !== undefined && (typeof on[k] !== 'string' || !on[k])) return `on.${k} is a non-empty string`;
|
|
78
|
+
if (on.lastMessageIsToolResult !== undefined && typeof on.lastMessageIsToolResult !== 'boolean') return 'on.lastMessageIsToolResult is a boolean';
|
|
79
|
+
for (const k of ['maxTokensBelow', 'maxTokensAtLeast'] as const) if (on[k] !== undefined && !positiveNumber(on[k])) return `on.${k} is a positive number`;
|
|
80
|
+
return null;
|
|
81
|
+
},
|
|
82
|
+
validateRespond: (respond) => {
|
|
83
|
+
if (typeof respond !== 'object' || respond === null || Array.isArray(respond)) return 'respond is an object { text?, toolCalls?, finishReason? }';
|
|
84
|
+
const r = respond as Record<string, unknown>;
|
|
85
|
+
for (const k of Object.keys(r)) if (!RESPOND_KEYS.has(k)) return `respond: unknown key "${k}" (valid: ${[...RESPOND_KEYS].join(', ')})`;
|
|
86
|
+
if (r.text !== undefined && typeof r.text !== 'string') return 'respond.text is a string';
|
|
87
|
+
if (r.finishReason !== undefined && (typeof r.finishReason !== 'string' || !FINISH_REASONS.has(r.finishReason))) return `respond.finishReason is one of ${[...FINISH_REASONS].join(', ')}`;
|
|
88
|
+
if (r.toolCalls !== undefined) {
|
|
89
|
+
for (const tc of Array.isArray(r.toolCalls) ? r.toolCalls : [r.toolCalls]) {
|
|
90
|
+
const t = tc as Record<string, unknown>;
|
|
91
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) return 'respond.toolCalls entries are objects';
|
|
92
|
+
if (typeof t.name !== 'string' || !t.name) return 'respond.toolCalls[].name is a non-empty string';
|
|
93
|
+
if (!t.arguments || typeof t.arguments !== 'object' || Array.isArray(t.arguments)) return 'respond.toolCalls[].arguments is an object';
|
|
94
|
+
if (t.id !== undefined && typeof t.id !== 'string') return 'respond.toolCalls[].id is a string';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (r.text === undefined && r.toolCalls === undefined) return 'respond needs text or toolCalls';
|
|
98
|
+
return null;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function loadOpenAIScenarioDocument(path: string): ScenarioDocument {
|
|
103
|
+
let parsed: unknown;
|
|
104
|
+
try {
|
|
105
|
+
// Read through the ACTIVE WorldStore, never the filesystem directly (runtime contract
|
|
106
|
+
// R12b): the handlers document is WORLD STATE, so a MemoryWorldStore / DO-backed world
|
|
107
|
+
// serves ITS OWN scenario instead of whatever happens to sit on the host disk — and the
|
|
108
|
+
// serve path stays workerd-clean. A missing document keeps the historical ENOENT wording,
|
|
109
|
+
// so the loud load-time failure reads byte-identically to the read it replaces.
|
|
110
|
+
const raw = getActiveWorldStore().read(path);
|
|
111
|
+
if (raw === null) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
112
|
+
parsed = JSON.parse(raw);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
throw new ScenarioError(`openai scenario: cannot read/parse ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
115
|
+
}
|
|
116
|
+
return parseScenarioDocument(parsed, openaiScenarioAdapter);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createOpenAIScenarioEngine(document?: ScenarioDocument): OpenAIScenarioEngine {
|
|
120
|
+
return new ScenarioEngine(openaiScenarioAdapter, document);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let scriptedCallSeq = 0;
|
|
124
|
+
|
|
125
|
+
export function realizeOpenAIRespond(respond: OpenAIScenarioRespond): ScriptedResult {
|
|
126
|
+
const toolCalls: ChatToolCall[] = [];
|
|
127
|
+
for (const tc of respond.toolCalls ? (Array.isArray(respond.toolCalls) ? respond.toolCalls : [respond.toolCalls]) : []) {
|
|
128
|
+
scriptedCallSeq += 1;
|
|
129
|
+
toolCalls.push({ id: tc.id ?? `call_scripted_${scriptedCallSeq}`, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } });
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
text: respond.text ?? (toolCalls.length ? null : ''),
|
|
133
|
+
toolCalls,
|
|
134
|
+
finishReason: respond.finishReason ?? (toolCalls.length ? 'tool_calls' : 'stop'),
|
|
135
|
+
};
|
|
136
|
+
}
|