@volter/twin-openai 0.1.2 → 2.0.1
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 +3 -2
- package/src/screens/api-keys.tsx +138 -0
- package/src/screens/session.tsx +131 -0
- package/src/semantics/assistants.ts +336 -0
- package/src/semantics/audio.ts +31 -0
- package/src/semantics/batches.ts +88 -0
- package/src/semantics/chat-completions.ts +66 -0
- package/src/semantics/containers.ts +151 -0
- package/src/semantics/embeddings.ts +19 -0
- package/src/semantics/evals.ts +182 -0
- package/src/semantics/files.ts +67 -0
- package/src/semantics/fine-tuning.ts +185 -0
- package/src/semantics/images.ts +23 -0
- package/src/semantics/index.ts +52 -0
- package/src/semantics/models.ts +41 -0
- package/src/semantics/moderations.ts +14 -0
- package/src/semantics/organization.ts +76 -0
- package/src/semantics/progress.ts +72 -0
- package/src/semantics/responses.ts +151 -0
- package/src/semantics/shared.ts +82 -0
- package/src/semantics/uploads.ts +92 -0
- package/src/semantics/vector-stores.ts +279 -0
- package/test-fixtures/openai-openapi-operations.SOURCE.md +4 -5
- package/test-fixtures/openai-openapi-operations.json +224 -1334
package/src/openai-twin.ts
CHANGED
|
@@ -1,38 +1,28 @@
|
|
|
1
|
-
// OpenAI twin
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// • POST/GET/DELETE /v1/files — stateful (kernel action log)
|
|
13
|
-
// • POST/GET /v1/batches (+ cancel) — stateful
|
|
14
|
-
// • POST /v1/moderations — deterministic classifier
|
|
15
|
-
// • Fine-tuning jobs (create/get/list/cancel/events) — stateful
|
|
16
|
-
// • Vector stores (+ files) — stateful
|
|
17
|
-
// • POST /v1/images/generations — response shape with a placeholder URL (real pixels
|
|
18
|
-
// are out of scope)
|
|
19
|
-
//
|
|
20
|
-
// State lives in the kernel action log (D1): all writes are local actions, reads are the
|
|
21
|
-
// projection. No real OpenAI is ever called from this path (D4). Streaming uses an INJECTED
|
|
22
|
-
// sink — no real sockets / setTimeout (D5 verify is offline + deterministic).
|
|
23
|
-
import { applyTwinWrite, projectResources } from '@volter/twin';
|
|
24
|
-
import { OPENAI_MODELS, findModel } from './openai-models.ts';
|
|
1
|
+
// OpenAI twin: the pack's behaviour and its in-process door. `handleOpenAITwinRequest` answers one
|
|
2
|
+
// request through the pack's fetch (openai-server.ts), which the derived dispatch owns: an operation
|
|
3
|
+
// OpenAI's spec publishes is a semantics handler (semantics/), the derived core (manifest.ts), or
|
|
4
|
+
// OpenAI's unknown-URL 404. What lives here is what those share:
|
|
5
|
+
// • the generative stubs: chat completions and responses (validation, the deterministic stub or a
|
|
6
|
+
// scripted scenario's turn, the streaming event sequences), embeddings (pseudo-vectors),
|
|
7
|
+
// moderations, images and audio (labeled placeholders); never a model's output;
|
|
8
|
+
// • the Responses API's atomic id allocation and background-poll completion;
|
|
9
|
+
// • the usage and costs reports over the recorded usage.
|
|
10
|
+
// No real OpenAI is called from this path.
|
|
11
|
+
import { applyTwinWriteAtomic, type ScenarioDecision } from '@volter/world-core';
|
|
25
12
|
import {
|
|
26
13
|
buildLogprobs,
|
|
27
14
|
countPromptTokens,
|
|
28
15
|
estimateTokens,
|
|
16
|
+
lastUserText,
|
|
29
17
|
moderateText,
|
|
30
18
|
pseudoEmbedding,
|
|
31
19
|
stubAssistantText,
|
|
32
20
|
stubJsonObject,
|
|
33
21
|
stubToolCall,
|
|
34
22
|
} from './openai-stub.ts';
|
|
35
|
-
import {
|
|
23
|
+
import { DEFAULT_EFFORT, modelOf, reasons, unsupported } from './openai-models.ts';
|
|
24
|
+
import { type OpenAIScenarioEngine, scenarioTurn, scriptedChoice } from './openai-scenario.ts';
|
|
25
|
+
import { audioFormat, base64, imageFormat, partBytes, placeholderPng, pngSize, requestedSize, silentAac, silentFlac, silentMp3, silentOpus, silentPcm, silentWav, wavSeconds } from './openai-media.ts';
|
|
36
26
|
import type {
|
|
37
27
|
ChatChoice,
|
|
38
28
|
ChatCompletion,
|
|
@@ -44,11 +34,14 @@ import type {
|
|
|
44
34
|
OpenAIResponse,
|
|
45
35
|
ResponseMessageItem,
|
|
46
36
|
ResponseOutputItem,
|
|
37
|
+
ResponseFunctionCallItem,
|
|
47
38
|
ResponseReasoningItem,
|
|
48
39
|
ResponseUsage,
|
|
49
40
|
SseSink,
|
|
50
41
|
} from './openai-types.ts';
|
|
51
42
|
|
|
43
|
+
import { createOpenAITwinFetch } from './openai-server.ts';
|
|
44
|
+
|
|
52
45
|
const SERVICE = 'openai';
|
|
53
46
|
|
|
54
47
|
export type OpenAIRequest = {
|
|
@@ -56,7 +49,8 @@ export type OpenAIRequest = {
|
|
|
56
49
|
scenarioEngine?: OpenAIScenarioEngine;
|
|
57
50
|
method: string;
|
|
58
51
|
path: string;
|
|
59
|
-
|
|
52
|
+
/** JSON text, or a multipart form (an upload) */
|
|
53
|
+
body?: string | FormData;
|
|
60
54
|
occurredAt?: string;
|
|
61
55
|
root?: string;
|
|
62
56
|
readOnly?: boolean;
|
|
@@ -66,8 +60,8 @@ export type OpenAIRequest = {
|
|
|
66
60
|
* (capability verify, connector) omit BOTH and are not auth-gated — the twin can't validate
|
|
67
61
|
* against real keys, so the modeled failure is the CHECKABLE missing/sentinel-invalid case. */
|
|
68
62
|
apiKey?: string;
|
|
69
|
-
/** Lower-cased request headers (e.g. `authorization
|
|
70
|
-
*
|
|
63
|
+
/** Lower-cased request headers (e.g. `authorization`) the HTTP server passes through so the
|
|
64
|
+
* handler can model auth (401) and the rate-limit trigger (429). */
|
|
71
65
|
headers?: Record<string, string>;
|
|
72
66
|
/** When set on a streaming POST, chunks are written here (no sockets). */
|
|
73
67
|
sseSink?: SseSink;
|
|
@@ -83,116 +77,10 @@ function errBody(type: string, message: string, code: string | null = null, para
|
|
|
83
77
|
function invalidRequest(message: string, param: string | null = null, code: string | null = null): OpenAIResponseEnvelope {
|
|
84
78
|
return { status: 400, body: errBody('invalid_request_error', message, code, param) };
|
|
85
79
|
}
|
|
86
|
-
function notFound(message: string, code: string | null = null): OpenAIResponseEnvelope {
|
|
87
|
-
return { status: 404, body: errBody('invalid_request_error', message, code) };
|
|
88
|
-
}
|
|
89
|
-
function authError(message: string, code = 'invalid_api_key'): OpenAIResponseEnvelope {
|
|
90
|
-
// Real OpenAI 401s carry error.type:'invalid_request_error' with a code like 'invalid_api_key'.
|
|
91
|
-
return { status: 401, body: errBody('invalid_request_error', message, code) };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ── modeled authentication (401) ────────────────────────────────────────────────────────
|
|
95
|
-
// Real OpenAI requires a bearer credential on every request and returns 401 when it is missing
|
|
96
|
-
// or invalid. The twin can't validate against real keys, so it models the CHECKABLE failures: a
|
|
97
|
-
// missing credential, and a reserved sentinel ('sk-invalid'/'invalid') for the invalid-key path.
|
|
98
|
-
// Any other non-empty key is accepted. Trusted in-process calls (verify/connector) carry NEITHER
|
|
99
|
-
// `headers` nor `apiKey` and are NOT auth-gated; the real `openai` SDK always sends a key → passes.
|
|
100
|
-
function checkAuth(req: OpenAIRequest): OpenAIResponseEnvelope | null {
|
|
101
|
-
const auth = req.headers?.['authorization'];
|
|
102
|
-
const bearer = typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
|
103
|
-
const key = (req.apiKey ?? '').trim() || bearer;
|
|
104
|
-
if (!key) return authError('You didn\'t provide an API key. You need to provide your API key in an Authorization header using Bearer auth (i.e. Authorization: Bearer YOUR_KEY).', 'invalid_api_key');
|
|
105
|
-
if (key === 'sk-invalid' || key === 'invalid') return authError('Incorrect API key provided. You can find your API key at https://platform.openai.com/account/api-keys.', 'invalid_api_key');
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ── modeled rate limiting (429) ─────────────────────────────────────────────────────────
|
|
110
|
-
// Rate limits are non-deterministic in production, so the twin exposes a DETERMINISTIC opt-in
|
|
111
|
-
// trigger: a request carrying `x-twin-force-rate-limit: 1` (or `true`) returns the faithful 429
|
|
112
|
-
// envelope (error.type:'rate_limit_exceeded') + Retry-After and the x-ratelimit-* header family.
|
|
113
|
-
// (No real timing/quotas — the twin can't reproduce them; this is the checkable plumbing.)
|
|
114
|
-
function rateLimitError(): OpenAIResponseEnvelope {
|
|
115
|
-
return {
|
|
116
|
-
status: 429,
|
|
117
|
-
body: errBody('rate_limit_exceeded', 'Rate limit reached for requests. Limit your request rate or retry after the indicated delay.', 'rate_limit_exceeded'),
|
|
118
|
-
headers: {
|
|
119
|
-
'retry-after': '1',
|
|
120
|
-
'x-ratelimit-limit-requests': '10000',
|
|
121
|
-
'x-ratelimit-remaining-requests': '0',
|
|
122
|
-
'x-ratelimit-reset-requests': '1s',
|
|
123
|
-
'x-ratelimit-limit-tokens': '2000000',
|
|
124
|
-
'x-ratelimit-remaining-tokens': '0',
|
|
125
|
-
'x-ratelimit-reset-tokens': '6ms',
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
function rateLimitTriggered(req: OpenAIRequest): boolean {
|
|
130
|
-
const v = req.headers?.['x-twin-force-rate-limit'];
|
|
131
|
-
return v === '1' || v === 'true';
|
|
132
|
-
}
|
|
133
80
|
|
|
134
81
|
function nowEpoch(occurredAt?: string): number {
|
|
135
82
|
return Math.floor((occurredAt ? Date.parse(occurredAt) : 0) / 1000);
|
|
136
83
|
}
|
|
137
|
-
function nowIso(occurredAt?: string): string {
|
|
138
|
-
return occurredAt ? new Date(Date.parse(occurredAt)).toISOString() : '1970-01-01T00:00:00.000Z';
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── kernel helpers ──────────────────────────────────────────────────────────────────────
|
|
142
|
-
function rows(type: string, root?: string): Array<Record<string, unknown>> {
|
|
143
|
-
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
144
|
-
}
|
|
145
|
-
function nextId(type: string, prefix: string, root?: string): string {
|
|
146
|
-
let max = 0;
|
|
147
|
-
for (const r of rows(type, root)) {
|
|
148
|
-
const m = new RegExp(`^${prefix}-twin-(\\d+)$`).exec(String(r.id));
|
|
149
|
-
if (m) max = Math.max(max, Number(m[1]));
|
|
150
|
-
}
|
|
151
|
-
return `${prefix}-twin-${max + 1}`;
|
|
152
|
-
}
|
|
153
|
-
function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined {
|
|
154
|
-
return rows(type, root).find((r) => r.id === id);
|
|
155
|
-
}
|
|
156
|
-
// The kernel reserves the field name `type` as its resource-type discriminator, so the stored
|
|
157
|
-
// vendor `object` field survives projection but the kernel `type` shadows nothing — we strip
|
|
158
|
-
// the kernel housekeeping fields and re-shape the served view per resource.
|
|
159
|
-
function strip(r: Record<string, unknown>): Record<string, unknown> {
|
|
160
|
-
const out: Record<string, unknown> = {};
|
|
161
|
-
for (const [k, v] of Object.entries(r)) {
|
|
162
|
-
// drop the kernel housekeeping field + the twin's private underscore-prefixed fields.
|
|
163
|
-
if (k === 'type' || k === 'updatedAt' || k.startsWith('_')) continue;
|
|
164
|
-
out[k] = v;
|
|
165
|
-
}
|
|
166
|
-
return out;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ── idempotency (Idempotency-Key header dedup) ───────────────────────────────────────────
|
|
170
|
-
// A mutation carrying an Idempotency-Key is replayed verbatim on re-issue with the same key — the
|
|
171
|
-
// vendor returns the original response (same id) and never re-applies the side effect. The twin
|
|
172
|
-
// stores the serialized {status,body,headers} keyed by a hash of the key (type-prefixed id, so it
|
|
173
|
-
// can't collide with another resource's numeric id — kernel (type,id) caveat).
|
|
174
|
-
function idemId(key: string): string {
|
|
175
|
-
let h = 0x811c9dc5;
|
|
176
|
-
for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
|
177
|
-
return `idem-${(h >>> 0).toString(36)}`;
|
|
178
|
-
}
|
|
179
|
-
function getIdempotentResult(key: string, root?: string): OpenAIResponseEnvelope | null {
|
|
180
|
-
const row = getRow('idempotency_record', idemId(key), root);
|
|
181
|
-
if (!row || typeof row._result !== 'string') return null;
|
|
182
|
-
try { return JSON.parse(row._result as string) as OpenAIResponseEnvelope; } catch { return null; }
|
|
183
|
-
}
|
|
184
|
-
async function storeIdempotentResult(key: string, result: OpenAIResponseEnvelope, req: OpenAIRequest): Promise<void> {
|
|
185
|
-
const id = idemId(key);
|
|
186
|
-
if (getRow('idempotency_record', id, req.root)) return; // a concurrent insert won
|
|
187
|
-
await applyTwinWrite(SERVICE, {
|
|
188
|
-
operation: 'idempotency_record.create',
|
|
189
|
-
subjectType: 'idempotency_record',
|
|
190
|
-
subjectId: id,
|
|
191
|
-
fields: { object: 'idempotency_record', _key: key, _result: JSON.stringify(result) },
|
|
192
|
-
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
193
|
-
actor: { kind: 'agent' },
|
|
194
|
-
}, req.root);
|
|
195
|
-
}
|
|
196
84
|
|
|
197
85
|
// ── usage ledger (real recorded usage → the usage/costs reporting endpoints) ──────────────
|
|
198
86
|
// Every billable inference call appends a usage record (model + token counts + a synthetic cost
|
|
@@ -215,60 +103,121 @@ const MODEL_PRICES: Record<string, { input: number; output: number }> = {
|
|
|
215
103
|
function modelPrice(model: string): { input: number; output: number } {
|
|
216
104
|
return MODEL_PRICES[model] ?? { input: 0, output: 0 };
|
|
217
105
|
}
|
|
218
|
-
function usageCost(model: string, inputTokens: number, outputTokens: number): number {
|
|
106
|
+
export function usageCost(model: string, inputTokens: number, outputTokens: number): number {
|
|
219
107
|
const p = modelPrice(model);
|
|
220
108
|
return (inputTokens / 1e6) * p.input + (outputTokens / 1e6) * p.output;
|
|
221
109
|
}
|
|
222
|
-
async function recordUsage(req: OpenAIRequest, kind: string, model: string, inputTokens: number, outputTokens: number): Promise<void> {
|
|
223
|
-
const id = nextId('usage_record', 'usage', req.root);
|
|
224
|
-
await applyTwinWrite(SERVICE, {
|
|
225
|
-
operation: 'usage_record.create',
|
|
226
|
-
subjectType: 'usage_record',
|
|
227
|
-
subjectId: id,
|
|
228
|
-
fields: {
|
|
229
|
-
object: 'usage_record',
|
|
230
|
-
kind,
|
|
231
|
-
model,
|
|
232
|
-
input_tokens: inputTokens,
|
|
233
|
-
output_tokens: outputTokens,
|
|
234
|
-
num_model_requests: 1,
|
|
235
|
-
cost_usd: usageCost(model, inputTokens, outputTokens),
|
|
236
|
-
created_at: nowEpoch(req.occurredAt),
|
|
237
|
-
},
|
|
238
|
-
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
239
|
-
actor: { kind: 'agent' },
|
|
240
|
-
}, req.root);
|
|
241
|
-
}
|
|
242
110
|
|
|
243
|
-
// ──
|
|
244
|
-
//
|
|
245
|
-
// `
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const hasMore = start + limit < items.length;
|
|
260
|
-
return { object: 'list', data: page, has_more: hasMore, first_id: page[0]?.id ?? null, last_id: page[page.length - 1]?.id ?? null };
|
|
261
|
-
}
|
|
111
|
+
// ── Usage and costs (computed over the recorded usage rows) ─────────────────────────────
|
|
112
|
+
// The vendor's Usage and Costs APIs return time buckets from `start_time` (to `end_time` or now), each
|
|
113
|
+
// `bucket_width` wide (Costs: only `1d`), `limit` buckets a page (Usage `1d`: 7 by default, at most 31;
|
|
114
|
+
// `1h`: 24, 168; `1m`: 60, 1440; Costs: 7, 180), with `next_page` when more remain; a bucket holds one
|
|
115
|
+
// result over its window, or one per group when `group_by` names `model` (Usage) or `line_item` (Costs)
|
|
116
|
+
// (https://platform.openai.com/docs/api-reference/usage/completions,
|
|
117
|
+
// https://platform.openai.com/docs/api-reference/usage/costs). The twin records a usage row per billable
|
|
118
|
+
// inference call (semantics/shared.ts recordUsage) and aggregates them here: nothing is hardcoded.
|
|
119
|
+
/** The query a report answers: its window, bucket width, page size, where the page starts, and its groups. */
|
|
120
|
+
export type ReportQuery = { start: number; end: number; width: number; limit: number; from: number; groupBy: string[] };
|
|
121
|
+
|
|
122
|
+
const WIDTHS: Record<string, { seconds: number; limit: number; max: number }> = {
|
|
123
|
+
'1m': { seconds: 60, limit: 60, max: 1440 },
|
|
124
|
+
'1h': { seconds: 3600, limit: 24, max: 168 },
|
|
125
|
+
'1d': { seconds: 86400, limit: 7, max: 31 },
|
|
126
|
+
};
|
|
262
127
|
|
|
263
|
-
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
128
|
+
/** A report's query from its parameters; Costs buckets by day only and pages up to 180 of them. */
|
|
129
|
+
export function reportQuery(params: Record<string, unknown>, now: number, costs: boolean): ReportQuery {
|
|
130
|
+
const width = WIDTHS[String(params.bucket_width ?? '1d')] ?? WIDTHS['1d']!;
|
|
131
|
+
const max = costs ? 180 : width.max;
|
|
132
|
+
const asked = Number(params.limit);
|
|
133
|
+
const limit = Number.isInteger(asked) && asked >= 1 ? Math.min(asked, max) : width.limit;
|
|
134
|
+
const start = Number(params.start_time);
|
|
135
|
+
const end = params.end_time !== undefined ? Number(params.end_time) : now;
|
|
136
|
+
const page = /^page_(\d+)$/.exec(String(params.page ?? ''));
|
|
137
|
+
const groups = params.group_by;
|
|
138
|
+
return { start, end, width: width.seconds, limit, from: page ? Number(page[1]) : start, groupBy: (Array.isArray(groups) ? groups : groups === undefined ? [] : [groups]).map(String) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Buckets of one page of the query, each holding `results(rows in its window)`. */
|
|
142
|
+
export function bucketed(records: Array<Record<string, unknown>>, q: ReportQuery, results: (rows: Array<Record<string, unknown>>, at: number, until: number) => unknown[]): Record<string, unknown> {
|
|
143
|
+
const data: unknown[] = [];
|
|
144
|
+
let at = q.from;
|
|
145
|
+
while (at < q.end && data.length < q.limit) {
|
|
146
|
+
const until = Math.min(at + q.width, q.end);
|
|
147
|
+
data.push({ object: 'bucket', start_time: at, end_time: until, results: results(records.filter((r) => Number(r.created_at) >= at && Number(r.created_at) < until), at, until) });
|
|
148
|
+
at = until;
|
|
149
|
+
}
|
|
150
|
+
return { object: 'page', data, has_more: at < q.end, next_page: at < q.end ? `page_${at}` : null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Rows grouped by `key` when grouping by it, else all in one group keyed null; none when there are no rows. */
|
|
154
|
+
function grouped(rows: Array<Record<string, unknown>>, by: string | null): Array<[string | null, Array<Record<string, unknown>>]> {
|
|
155
|
+
const out = new Map<string | null, Array<Record<string, unknown>>>();
|
|
156
|
+
for (const r of rows) {
|
|
157
|
+
const k = by === null ? null : String(r[by]);
|
|
158
|
+
out.set(k, [...(out.get(k) ?? []), r]);
|
|
159
|
+
}
|
|
160
|
+
return [...out.entries()].sort((a, b) => (String(a[0]) < String(b[0]) ? -1 : 1));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What each usage kind's result counts, and the object it answers as (the spec's Usage*Result schemas;
|
|
164
|
+
* https://platform.openai.com/docs/api-reference/usage). A result names the grouping it answers (model, and for images
|
|
165
|
+
* their size and source) and null for the groupings not asked for. */
|
|
166
|
+
const USAGE_RESULTS: Record<string, { object: string; count: (rows: Array<Record<string, unknown>>) => Record<string, unknown>; by?: string[] }> = {
|
|
167
|
+
completions: {
|
|
168
|
+
object: 'organization.usage.completions.result',
|
|
169
|
+
// the twin caches nothing and hears, sees and speaks nothing: every token is uncached text
|
|
170
|
+
count: (rs) => {
|
|
171
|
+
const input = sum(rs, 'input_tokens');
|
|
172
|
+
const output = sum(rs, 'output_tokens');
|
|
173
|
+
return { input_tokens: input, input_cached_tokens: 0, input_cache_write_tokens: 0, input_uncached_tokens: input, output_tokens: output, input_text_tokens: input, output_text_tokens: output, input_cached_text_tokens: 0, input_audio_tokens: 0, input_cached_audio_tokens: 0, output_audio_tokens: 0, input_image_tokens: 0, input_cached_image_tokens: 0, output_image_tokens: 0, num_model_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null, batch: null, service_tier: null };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
embeddings: { object: 'organization.usage.embeddings.result', count: (rs) => ({ input_tokens: sum(rs, 'input_tokens'), num_model_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null }) },
|
|
177
|
+
moderations: { object: 'organization.usage.moderations.result', count: (rs) => ({ input_tokens: sum(rs, 'input_tokens'), num_model_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null }) },
|
|
178
|
+
audio_speeches: { object: 'organization.usage.audio_speeches.result', count: (rs) => ({ characters: sum(rs, 'characters'), num_model_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null }) },
|
|
179
|
+
audio_transcriptions: { object: 'organization.usage.audio_transcriptions.result', count: (rs) => ({ seconds: sum(rs, 'seconds'), num_model_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null }) },
|
|
180
|
+
images: { object: 'organization.usage.images.result', by: ['size', 'source'], count: (rs) => ({ images: sum(rs, 'images'), num_model_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null }) },
|
|
181
|
+
code_interpreter_sessions: { object: 'organization.usage.code_interpreter_sessions.result', count: (rs) => ({ num_sessions: sum(rs, 'num_sessions') }) },
|
|
182
|
+
file_search_calls: { object: 'organization.usage.file_searches.result', count: (rs) => ({ num_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null, vector_store_id: null }) },
|
|
183
|
+
web_search_calls: { object: 'organization.usage.web_searches.result', count: (rs) => ({ num_model_requests: sum(rs, 'num_model_requests'), num_requests: sum(rs, 'num_model_requests'), user_id: null, api_key_id: null, context_level: null }) },
|
|
184
|
+
};
|
|
185
|
+
const sum = (rows: Array<Record<string, unknown>>, k: string): number => rows.reduce((n, r) => n + Number(r[k] ?? 0), 0);
|
|
186
|
+
/** The kinds whose results name a model. */
|
|
187
|
+
const MODELLESS = new Set(['code_interpreter_sessions', 'file_search_calls']);
|
|
188
|
+
|
|
189
|
+
/** The usage report over the recorded usage rows, for one kind ('completions', 'embeddings', …). */
|
|
190
|
+
export function usageReport(records: Array<Record<string, unknown>>, kind: string | undefined, q: ReportQuery): Record<string, unknown> {
|
|
191
|
+
const spec = USAGE_RESULTS[kind ?? 'completions'] ?? USAGE_RESULTS.completions!;
|
|
192
|
+
const byModel = q.groupBy.includes('model') && !MODELLESS.has(kind ?? '');
|
|
193
|
+
const extra = (spec.by ?? []).filter((k) => q.groupBy.includes(k));
|
|
194
|
+
return bucketed(records.filter((r) => kind === undefined || r.kind === kind), q, (rows) => groupedBy(rows, [...(byModel ? ['model'] : []), ...extra]).map(([key, rs]) => ({
|
|
195
|
+
object: spec.object,
|
|
196
|
+
...spec.count(rs),
|
|
197
|
+
project_id: null,
|
|
198
|
+
...(MODELLESS.has(kind ?? '') ? {} : { model: byModel ? key.model : null }),
|
|
199
|
+
...(spec.by ? Object.fromEntries(spec.by.map((k) => [k, extra.includes(k) ? key[k] : null])) : {}),
|
|
200
|
+
})));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Rows grouped by the values of `keys`, in key order; one group of all rows when no key; none when there are no rows. */
|
|
204
|
+
function groupedBy(rows: Array<Record<string, unknown>>, keys: string[]): Array<[Record<string, unknown>, Array<Record<string, unknown>>]> {
|
|
205
|
+
const out = new Map<string, [Record<string, unknown>, Array<Record<string, unknown>>]>();
|
|
206
|
+
for (const r of rows) {
|
|
207
|
+
const key = Object.fromEntries(keys.map((k) => [k, r[k] === undefined ? null : String(r[k])]));
|
|
208
|
+
const id = JSON.stringify(key);
|
|
209
|
+
out.set(id, [key, [...(out.get(id)?.[1] ?? []), r]]);
|
|
210
|
+
}
|
|
211
|
+
return [...out.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([, v]) => v);
|
|
212
|
+
}
|
|
213
|
+
/** The costs report: the recorded usage priced per model, per bucket, by line item (usage kind) when grouped so. */
|
|
214
|
+
export function costsReport(records: Array<Record<string, unknown>>, q: ReportQuery): Record<string, unknown> {
|
|
215
|
+
const byLine = q.groupBy.includes('line_item');
|
|
216
|
+
return bucketed(records, q, (rows) => grouped(rows, byLine ? 'kind' : null).map(([line, rs]) => ({
|
|
217
|
+
object: 'organization.costs.result',
|
|
218
|
+
amount: { value: rs.reduce((n, r) => n + Number(r.cost_usd ?? 0), 0), currency: 'usd' },
|
|
219
|
+
line_item: line, project_id: null, api_key_id: null, quantity: null, quantity_unit: null,
|
|
220
|
+
})));
|
|
272
221
|
}
|
|
273
222
|
|
|
274
223
|
const SYSTEM_FINGERPRINT = 'fp_twin_stub';
|
|
@@ -294,118 +243,155 @@ type ChatArgs = {
|
|
|
294
243
|
seed?: number;
|
|
295
244
|
logitBias?: Record<string, number>;
|
|
296
245
|
prediction?: string;
|
|
246
|
+
/** the reasoning effort a reasoning model spends (the request's `reasoning_effort`, else the default) */
|
|
247
|
+
reasoningEffort?: string;
|
|
297
248
|
store: boolean;
|
|
298
249
|
metadata?: Record<string, unknown>;
|
|
299
250
|
/** modalities: ['text'] (default) or ['text','audio'] — audio asks for a spoken output. */
|
|
300
251
|
audioOutput?: { voice: string; format: string };
|
|
301
252
|
};
|
|
302
253
|
|
|
303
|
-
|
|
254
|
+
/** The roles a chat message may have (the spec's ChatCompletionRequestMessage variants). */
|
|
255
|
+
const ROLES = new Set(['developer', 'system', 'user', 'assistant', 'tool', 'function']);
|
|
256
|
+
|
|
257
|
+
/** OpenAI's refusal of a chat message whose role is not one of the request message types
|
|
258
|
+
* (https://platform.openai.com/docs/api-reference/chat/create, `messages`). */
|
|
259
|
+
function invalidRole(): { error: OpenAIResponseEnvelope } {
|
|
260
|
+
return { error: invalidRequest("each message must have a valid 'role'", 'messages') };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type ChatRefusal = { error: OpenAIResponseEnvelope };
|
|
264
|
+
// The chat options below are OpenAI's (https://platform.openai.com/docs/api-reference/chat/create); each
|
|
265
|
+
// parser answers the option's value or OpenAI's refusal of it.
|
|
266
|
+
|
|
267
|
+
/** `n`: how many choices to generate, at least one. */
|
|
268
|
+
function chatN(raw: unknown): number | ChatRefusal {
|
|
269
|
+
const n = Number(raw);
|
|
270
|
+
return Number.isInteger(n) && n >= 1 ? n : { error: invalidRequest("'n' must be an integer >= 1", 'n') };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `max_completion_tokens` (or the legacy `max_tokens`): a cap of at least one token. */
|
|
274
|
+
function chatMaxTokens(raw: unknown): number | ChatRefusal {
|
|
275
|
+
const max = Number(raw);
|
|
276
|
+
return Number.isInteger(max) && max >= 1 ? max : { error: invalidRequest("'max_tokens' must be an integer >= 1", 'max_tokens') };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `stop`: one sequence or a list of them. */
|
|
280
|
+
function chatStop(raw: unknown): string[] | ChatRefusal {
|
|
281
|
+
if (typeof raw === 'string') return [raw];
|
|
282
|
+
return Array.isArray(raw) ? (raw as string[]) : { error: invalidRequest("'stop' must be a string or array of strings", 'stop') };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `tool_choice`: 'auto' | 'none' | 'required' | { type:'function', function:{ name } }. */
|
|
286
|
+
function chatToolChoice(raw: unknown): ToolChoice | ChatRefusal {
|
|
287
|
+
if (raw === 'auto' || raw === 'none' || raw === 'required') return raw;
|
|
288
|
+
if (!raw || typeof raw !== 'object') return { error: invalidRequest("'tool_choice' must be 'auto'/'none'/'required' or a named function", 'tool_choice') };
|
|
289
|
+
const fn = (raw as { function?: { name?: unknown } }).function;
|
|
290
|
+
return typeof fn?.name === 'string' ? { name: fn.name } : { error: invalidRequest("invalid 'tool_choice' — named choice requires function.name", 'tool_choice') };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** OpenAI's refusal of a `response_format` whose type is none of text, json_object and json_schema. */
|
|
294
|
+
function badResponseFormat(): ChatRefusal {
|
|
295
|
+
return { error: invalidRequest("'response_format.type' must be 'text', 'json_object', or 'json_schema'", 'response_format') };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `top_logprobs`: 0 to 20 alternatives per token, only with `logprobs: true`. */
|
|
299
|
+
function chatTopLogprobs(raw: unknown, logprobs: boolean): number | ChatRefusal {
|
|
300
|
+
const top = Number(raw);
|
|
301
|
+
if (!Number.isInteger(top) || top < 0 || top > 20) return { error: invalidRequest("'top_logprobs' must be an integer between 0 and 20", 'top_logprobs') };
|
|
302
|
+
return logprobs ? top : { error: invalidRequest("'top_logprobs' requires 'logprobs' to be true", 'top_logprobs') };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** `seed`: an integer for best-effort reproducible sampling. */
|
|
306
|
+
function chatSeed(raw: unknown): number | ChatRefusal {
|
|
307
|
+
const seed = Number(raw);
|
|
308
|
+
return Number.isInteger(seed) ? seed : { error: invalidRequest("'seed' must be an integer", 'seed') };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** `logit_bias`: token ids mapped to a bias from -100 to 100. */
|
|
312
|
+
function chatLogitBias(raw: unknown): Record<string, number> | ChatRefusal {
|
|
313
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: invalidRequest("'logit_bias' must be an object mapping token ids to bias values", 'logit_bias') };
|
|
314
|
+
const bias: Record<string, number> = {};
|
|
315
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
316
|
+
const num = Number(v);
|
|
317
|
+
if (!Number.isFinite(num) || num < -100 || num > 100) return { error: invalidRequest("each 'logit_bias' value must be a number between -100 and 100", 'logit_bias') };
|
|
318
|
+
bias[k] = num;
|
|
319
|
+
}
|
|
320
|
+
return bias;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `prediction`: predicted output, `{ type: 'content', content }`, its content a string or text parts. */
|
|
324
|
+
function chatPrediction(raw: unknown): string | ChatRefusal {
|
|
325
|
+
const p = raw as { type?: unknown; content?: unknown } | undefined;
|
|
326
|
+
if (!p || typeof p !== 'object' || p.type !== 'content' || p.content === undefined) return { error: invalidRequest("'prediction' must be an object with type 'content' and a content field", 'prediction') };
|
|
327
|
+
if (typeof p.content === 'string') return p.content;
|
|
328
|
+
return Array.isArray(p.content) ? p.content.map((c) => (c && typeof c === 'object' ? String((c as { text?: unknown }).text ?? '') : String(c))).join('') : '';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `modalities` with `audio`: a spoken answer needs `audio: { voice, format }` (the vendor's rule). The twin
|
|
332
|
+
* can't synthesize speech, so the returned bytes are a labeled stub; the envelope is faithful. */
|
|
333
|
+
function chatAudio(modalities: unknown, audio: unknown): { voice: string; format: string } | ChatRefusal | undefined {
|
|
334
|
+
if (!Array.isArray(modalities)) return { error: invalidRequest("'modalities' must be an array", 'modalities') };
|
|
335
|
+
if (!(modalities as unknown[]).includes('audio')) return undefined;
|
|
336
|
+
const a = audio as { voice?: unknown; format?: unknown } | undefined;
|
|
337
|
+
if (!a || typeof a !== 'object' || typeof a.voice !== 'string' || typeof a.format !== 'string') return { error: invalidRequest("'audio' with a 'voice' and 'format' is required when 'modalities' includes 'audio'", 'audio') };
|
|
338
|
+
return { voice: a.voice, format: a.format };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function validateChat(params: Record<string, unknown>): { args: ChatArgs } | { error: OpenAIResponseEnvelope } {
|
|
304
342
|
if (params.model === undefined || params.model === '') return { error: invalidRequest("you must provide a model parameter", 'model') };
|
|
305
343
|
if (typeof params.model !== 'string') return { error: invalidRequest("'model' must be a string", 'model') };
|
|
306
344
|
if (!Array.isArray(params.messages)) return { error: invalidRequest("you must provide a messages parameter", 'messages') };
|
|
307
345
|
if (params.messages.length === 0) return { error: invalidRequest("[] is too short - 'messages'", 'messages') };
|
|
308
346
|
const messages = params.messages as ChatMessageParam[];
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
let n = 1;
|
|
315
|
-
if (params.n !== undefined) {
|
|
316
|
-
n = Number(params.n);
|
|
317
|
-
if (!Number.isInteger(n) || n < 1) return { error: invalidRequest("'n' must be an integer >= 1", 'n') };
|
|
318
|
-
}
|
|
347
|
+
if (messages.some((m) => !m || typeof m !== 'object' || !ROLES.has(String(m.role)))) return invalidRole();
|
|
348
|
+
// Each option the request may carry is parsed by its own function (below): a function returns the
|
|
349
|
+
// option's value, or OpenAI's refusal of it as `{ error }`.
|
|
350
|
+
const n = params.n === undefined ? 1 : chatN(params.n);
|
|
351
|
+
if (typeof n !== 'number') return n;
|
|
319
352
|
// max_completion_tokens is the current name; max_tokens is the legacy alias.
|
|
320
353
|
const maxRaw = params.max_completion_tokens ?? params.max_tokens;
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
let stop: string[] | undefined;
|
|
328
|
-
if (stopRaw !== undefined) {
|
|
329
|
-
if (typeof stopRaw === 'string') stop = [stopRaw];
|
|
330
|
-
else if (Array.isArray(stopRaw)) stop = stopRaw as string[];
|
|
331
|
-
else return { error: invalidRequest("'stop' must be a string or array of strings", 'stop') };
|
|
332
|
-
}
|
|
333
|
-
// tool_choice: 'auto' | 'none' | 'required' | { type:'function', function:{ name } }.
|
|
334
|
-
let toolChoice: ToolChoice | undefined;
|
|
335
|
-
const tcRaw = params.tool_choice;
|
|
336
|
-
if (tcRaw !== undefined) {
|
|
337
|
-
if (tcRaw === 'auto' || tcRaw === 'none' || tcRaw === 'required') toolChoice = tcRaw;
|
|
338
|
-
else if (tcRaw && typeof tcRaw === 'object') {
|
|
339
|
-
const fn = (tcRaw as { function?: { name?: unknown } }).function;
|
|
340
|
-
if (typeof fn?.name === 'string') toolChoice = { name: fn.name };
|
|
341
|
-
else return { error: invalidRequest("invalid 'tool_choice' — named choice requires function.name", 'tool_choice') };
|
|
342
|
-
} else return { error: invalidRequest("'tool_choice' must be 'auto'/'none'/'required' or a named function", 'tool_choice') };
|
|
343
|
-
}
|
|
354
|
+
const maxTokens = maxRaw === undefined ? undefined : chatMaxTokens(maxRaw);
|
|
355
|
+
if (typeof maxTokens === 'object') return maxTokens;
|
|
356
|
+
const stop = params.stop === undefined ? undefined : chatStop(params.stop);
|
|
357
|
+
if (stop && !Array.isArray(stop)) return stop;
|
|
358
|
+
const toolChoice = params.tool_choice === undefined ? undefined : chatToolChoice(params.tool_choice);
|
|
359
|
+
if (toolChoice && typeof toolChoice === 'object' && 'error' in toolChoice) return toolChoice;
|
|
344
360
|
// response_format: { type:'text' | 'json_object' | 'json_schema', json_schema? }.
|
|
345
361
|
let responseFormat: ResponseFormat = { kind: 'text' };
|
|
346
362
|
const rf = params.response_format;
|
|
347
363
|
if (rf !== undefined) {
|
|
348
364
|
if (!rf || typeof rf !== 'object') return { error: invalidRequest("'response_format' must be an object", 'response_format') };
|
|
349
|
-
const t = (rf as { type?: unknown }).type;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
else return { error: invalidRequest("'response_format.type' must be 'text', 'json_object', or 'json_schema'", 'response_format') };
|
|
365
|
+
const t = (rf as { type?: unknown }).type ?? 'text';
|
|
366
|
+
const format: ResponseFormat | undefined = t === 'json_schema' ? { kind: 'json_schema', schema: (rf as { json_schema?: unknown }).json_schema } : t === 'json_object' || t === 'text' ? { kind: t } : undefined;
|
|
367
|
+
if (!format) return badResponseFormat();
|
|
368
|
+
responseFormat = format;
|
|
354
369
|
}
|
|
355
370
|
// stream_options.include_usage → emit a final usage-only chunk in the stream.
|
|
356
371
|
const so = params.stream_options as { include_usage?: unknown } | undefined;
|
|
357
372
|
const includeUsage = !!(so && typeof so === 'object' && so.include_usage === true);
|
|
358
373
|
// logprobs (boolean) + top_logprobs (0..20, requires logprobs:true) → per-token logprob detail.
|
|
359
374
|
const logprobs = params.logprobs === true;
|
|
360
|
-
|
|
361
|
-
if (
|
|
362
|
-
topLogprobs = Number(params.top_logprobs);
|
|
363
|
-
if (!Number.isInteger(topLogprobs) || topLogprobs < 0 || topLogprobs > 20) return { error: invalidRequest("'top_logprobs' must be an integer between 0 and 20", 'top_logprobs') };
|
|
364
|
-
if (!logprobs) return { error: invalidRequest("'top_logprobs' requires 'logprobs' to be true", 'top_logprobs') };
|
|
365
|
-
}
|
|
375
|
+
const topLogprobs = params.top_logprobs === undefined ? undefined : chatTopLogprobs(params.top_logprobs, logprobs);
|
|
376
|
+
if (typeof topLogprobs === 'object') return topLogprobs;
|
|
366
377
|
// seed → reproducible sampling (the twin is already deterministic; we echo it via fingerprint).
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
// logit_bias → a map of token-id → bias in [-100, 100]; must be an object of numbers.
|
|
373
|
-
let logitBias: Record<string, number> | undefined;
|
|
374
|
-
if (params.logit_bias !== undefined) {
|
|
375
|
-
const lb = params.logit_bias;
|
|
376
|
-
if (!lb || typeof lb !== 'object' || Array.isArray(lb)) return { error: invalidRequest("'logit_bias' must be an object mapping token ids to bias values", 'logit_bias') };
|
|
377
|
-
logitBias = {};
|
|
378
|
-
for (const [k, v] of Object.entries(lb as Record<string, unknown>)) {
|
|
379
|
-
const num = Number(v);
|
|
380
|
-
if (!Number.isFinite(num) || num < -100 || num > 100) return { error: invalidRequest("each 'logit_bias' value must be a number between -100 and 100", 'logit_bias') };
|
|
381
|
-
logitBias[k] = num;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
378
|
+
const seed = params.seed === undefined ? undefined : chatSeed(params.seed);
|
|
379
|
+
if (typeof seed === 'object') return seed;
|
|
380
|
+
// logit_bias → a map of token-id → bias in [-100, 100].
|
|
381
|
+
const logitBias = params.logit_bias === undefined ? undefined : chatLogitBias(params.logit_bias);
|
|
382
|
+
if (logitBias && 'error' in logitBias) return logitBias as { error: OpenAIResponseEnvelope };
|
|
384
383
|
// prediction → predicted outputs ({ type:'content', content }); content may be a string or parts.
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
? p.content
|
|
391
|
-
: Array.isArray(p.content) ? p.content.map((c) => (c && typeof c === 'object' ? String((c as { text?: unknown }).text ?? '') : String(c))).join('') : '';
|
|
392
|
-
}
|
|
393
|
-
// modalities + audio → audio output. modalities is ['text'] (default) or includes 'audio'; when
|
|
394
|
-
// it does, `audio: { voice, format }` is REQUIRED (vendor rule). The twin can't synthesize speech,
|
|
395
|
-
// so the returned bytes are a labeled stub — the envelope (message.audio shape) is faithful.
|
|
396
|
-
let audioOutput: { voice: string; format: string } | undefined;
|
|
397
|
-
const modalities = params.modalities;
|
|
398
|
-
if (modalities !== undefined) {
|
|
399
|
-
if (!Array.isArray(modalities)) return { error: invalidRequest("'modalities' must be an array", 'modalities') };
|
|
400
|
-
if ((modalities as unknown[]).includes('audio')) {
|
|
401
|
-
const a = params.audio as { voice?: unknown; format?: unknown } | undefined;
|
|
402
|
-
if (!a || typeof a !== 'object' || typeof a.voice !== 'string' || typeof a.format !== 'string') {
|
|
403
|
-
return { error: invalidRequest("'audio' with a 'voice' and 'format' is required when 'modalities' includes 'audio'", 'audio') };
|
|
404
|
-
}
|
|
405
|
-
audioOutput = { voice: a.voice, format: a.format };
|
|
406
|
-
}
|
|
407
|
-
}
|
|
384
|
+
const prediction = params.prediction === undefined ? undefined : chatPrediction(params.prediction);
|
|
385
|
+
if (typeof prediction === 'object') return prediction;
|
|
386
|
+
// modalities + audio → audio output.
|
|
387
|
+
const audioOutput = params.modalities === undefined ? undefined : chatAudio(params.modalities, params.audio);
|
|
388
|
+
if (audioOutput && 'error' in audioOutput) return audioOutput;
|
|
408
389
|
// store + metadata → stored completions (retrievable later); metadata must be a flat object.
|
|
390
|
+
// a model refuses what its page says it does not take (openai-models.ts); a reasoning model reasons with the effort
|
|
391
|
+
// asked for, else the spec's default
|
|
392
|
+
const refused = unsupported(params.model, { effort: params.reasoning_effort, effortParam: 'reasoning_effort', temperature: params.temperature, top_p: params.top_p, logprobs: params.logprobs, chatTools: params.tools ?? params.functions });
|
|
393
|
+
if (refused) return { error: invalidRequest(refused.message, refused.param, 'unsupported_value') };
|
|
394
|
+
const reasoningEffort = typeof params.reasoning_effort === 'string' ? params.reasoning_effort : reasons(params.model) ? DEFAULT_EFFORT : undefined;
|
|
409
395
|
const store = params.store === true;
|
|
410
396
|
let metadata: Record<string, unknown> | undefined;
|
|
411
397
|
if (params.metadata !== undefined) {
|
|
@@ -429,15 +415,51 @@ function validateChat(params: Record<string, unknown>): { args: ChatArgs } | { e
|
|
|
429
415
|
logprobs,
|
|
430
416
|
...(topLogprobs !== undefined ? { topLogprobs } : {}),
|
|
431
417
|
...(seed !== undefined ? { seed } : {}),
|
|
432
|
-
...(logitBias !== undefined ? { logitBias } : {}),
|
|
418
|
+
...(logitBias !== undefined ? { logitBias: logitBias as Record<string, number> } : {}),
|
|
433
419
|
...(prediction !== undefined ? { prediction } : {}),
|
|
420
|
+
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
|
|
434
421
|
store,
|
|
435
422
|
...(metadata !== undefined ? { metadata } : {}),
|
|
436
|
-
...(audioOutput !== undefined ? { audioOutput } : {}),
|
|
423
|
+
...(audioOutput !== undefined ? { audioOutput: audioOutput as { voice: string; format: string } } : {}),
|
|
437
424
|
},
|
|
438
425
|
};
|
|
439
426
|
}
|
|
440
427
|
|
|
428
|
+
/** The text cut at the EARLIEST-occurring stop sequence across the whole `stop` list, not whichever is
|
|
429
|
+
* listed first: the real vendor's "stop generation at the first hit", regardless of array order. */
|
|
430
|
+
function stoppedAt(text: string, stops: string[]): string {
|
|
431
|
+
let stopAt = -1;
|
|
432
|
+
for (const s of stops) {
|
|
433
|
+
if (!s) continue;
|
|
434
|
+
const i = text.indexOf(s);
|
|
435
|
+
if (i >= 0 && (stopAt < 0 || i < stopAt)) stopAt = i;
|
|
436
|
+
}
|
|
437
|
+
return stopAt >= 0 ? text.slice(0, stopAt) : text;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The text cut to a `max_completion_tokens` cap (about four characters a token); the choice finishes `length`. */
|
|
441
|
+
function cutAt(text: string, maxTokens: number): string {
|
|
442
|
+
return text.slice(0, maxTokens * 4);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** modalities:['audio'] → the assistant replies with an audio object (content is null, the text lives in
|
|
446
|
+
* audio.transcript). The twin can't synthesize speech, so `data` is a labeled-stub base64 string; the shape
|
|
447
|
+
* (id/data/transcript/expires_at) is vendor-faithful. */
|
|
448
|
+
function audioChoice(args: ChatArgs, idx: number, transcript: string, logprobs: ChatChoice['logprobs'], finish: ChatChoice['finish_reason']): { choice: ChatChoice; completionTokens: number } {
|
|
449
|
+
const voice = args.audioOutput!;
|
|
450
|
+
const stubBytes = `[twin-stub:${args.model}] no real audio synthesis — voice=${voice.voice} format=${voice.format}; transcript: ${transcript}`;
|
|
451
|
+
const audio = {
|
|
452
|
+
id: `audio-twin-${stableSuffix(args)}-${idx}`,
|
|
453
|
+
data: Buffer.from(stubBytes, 'utf8').toString('base64'),
|
|
454
|
+
transcript,
|
|
455
|
+
expires_at: nowEpoch() + 3600,
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
choice: { index: idx, message: { role: 'assistant', content: null, refusal: null, audio }, logprobs, finish_reason: finish },
|
|
459
|
+
completionTokens: estimateTokens(transcript),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
441
463
|
// Build ONE deterministic stub choice (index `idx`). Honors tools/functions (tool_calls +
|
|
442
464
|
// finish_reason tool_calls), tool_choice (none/required/named), parallel_tool_calls,
|
|
443
465
|
// response_format (json_object/json_schema), max_tokens, and stop sequences.
|
|
@@ -448,28 +470,21 @@ function buildChoice(args: ChatArgs, idx: number): { choice: ChatChoice; complet
|
|
|
448
470
|
// forces it (even when the heuristic otherwise would not); 'auto'/default calls when tools exist.
|
|
449
471
|
const forbidTools = args.toolChoice === 'none';
|
|
450
472
|
const forcedName = typeof args.toolChoice === 'object' ? args.toolChoice.name : undefined;
|
|
451
|
-
|
|
473
|
+
// a model answers a tool's result in words unless the caller insists on another call; one that always
|
|
474
|
+
// called a tool would never let an app's tool loop end
|
|
475
|
+
const answeringTool = ['tool', 'function'].includes(String(args.messages.at(-1)?.role));
|
|
476
|
+
const wantTool = hasTools && !forbidTools && (!answeringTool || forcedName !== undefined || args.toolChoice === 'required');
|
|
452
477
|
if (wantTool) {
|
|
453
478
|
// parallel_tool_calls (default true) → the stub may emit one call per provided tool; a named
|
|
454
479
|
// choice or parallel:false collapses to a single call.
|
|
455
|
-
|
|
456
|
-
const calls: ChatToolCall[] =
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
if (tc) calls.push(tc);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (calls.length) {
|
|
467
|
-
const completionTokens = estimateTokens(JSON.stringify(calls));
|
|
468
|
-
return {
|
|
469
|
-
choice: { index: idx, message: { role: 'assistant', content: null, tool_calls: calls, refusal: null }, logprobs: null, finish_reason: 'tool_calls' },
|
|
470
|
-
completionTokens,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
480
|
+
// (the tools are there, so each call is made)
|
|
481
|
+
const calls: ChatToolCall[] = forcedName || args.parallelToolCalls === false
|
|
482
|
+
? [stubToolCall(toolsSource, idx + 1, forcedName, lastUserText(args.messages))!]
|
|
483
|
+
: (toolsSource as unknown[]).map((tool, t) => stubToolCall([tool], idx * 100 + t + 1, undefined, lastUserText(args.messages))!);
|
|
484
|
+
return {
|
|
485
|
+
choice: { index: idx, message: { role: 'assistant', content: null, tool_calls: calls, refusal: null }, logprobs: null, finish_reason: 'tool_calls' },
|
|
486
|
+
completionTokens: estimateTokens(JSON.stringify(calls)),
|
|
487
|
+
};
|
|
473
488
|
}
|
|
474
489
|
// json_mode: when response_format requests json_object/json_schema, the content is valid JSON.
|
|
475
490
|
// prediction (predicted outputs): a real model uses the prediction to speed decoding but still
|
|
@@ -482,78 +497,57 @@ function buildChoice(args: ChatArgs, idx: number): { choice: ChatChoice; complet
|
|
|
482
497
|
: args.responseFormat.kind === 'json_schema'
|
|
483
498
|
? stubJsonObject(args.messages, args.model, args.responseFormat.schema)
|
|
484
499
|
: stubAssistantText(args.messages, args.model);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
let stopAt = -1;
|
|
490
|
-
for (const s of args.stop ?? []) {
|
|
491
|
-
if (!s) continue;
|
|
492
|
-
const i = text.indexOf(s);
|
|
493
|
-
if (i >= 0 && (stopAt < 0 || i < stopAt)) stopAt = i;
|
|
494
|
-
}
|
|
495
|
-
if (stopAt >= 0) text = text.slice(0, stopAt);
|
|
496
|
-
if (args.maxTokens !== undefined && estimateTokens(text) > args.maxTokens) {
|
|
497
|
-
text = text.slice(0, args.maxTokens * 4);
|
|
498
|
-
finish = 'length';
|
|
499
|
-
}
|
|
500
|
+
if (args.stop) text = stoppedAt(text, args.stop);
|
|
501
|
+
const capped = args.maxTokens !== undefined && estimateTokens(text) > args.maxTokens;
|
|
502
|
+
if (capped) text = cutAt(text, args.maxTokens!);
|
|
503
|
+
const finish: ChatChoice['finish_reason'] = capped ? 'length' : 'stop';
|
|
500
504
|
const logprobs = args.logprobs ? buildLogprobs(text, args.topLogprobs ?? 0) : null;
|
|
501
|
-
|
|
502
|
-
// lives in audio.transcript). The twin can't synthesize speech, so `data` is a labeled-stub
|
|
503
|
-
// base64 string; the shape (id/data/transcript/expires_at) is vendor-faithful.
|
|
504
|
-
if (args.audioOutput) {
|
|
505
|
-
const transcript = text;
|
|
506
|
-
const stubBytes = `[twin-stub:${args.model}] no real audio synthesis — voice=${args.audioOutput.voice} format=${args.audioOutput.format}; transcript: ${transcript}`;
|
|
507
|
-
const audio = {
|
|
508
|
-
id: `audio-twin-${stableSuffix(args)}-${idx}`,
|
|
509
|
-
data: Buffer.from(stubBytes, 'utf8').toString('base64'),
|
|
510
|
-
transcript,
|
|
511
|
-
expires_at: nowEpoch() + 3600,
|
|
512
|
-
};
|
|
513
|
-
return {
|
|
514
|
-
choice: { index: idx, message: { role: 'assistant', content: null, refusal: null, audio }, logprobs, finish_reason: finish },
|
|
515
|
-
completionTokens: estimateTokens(transcript),
|
|
516
|
-
};
|
|
517
|
-
}
|
|
505
|
+
if (args.audioOutput) return audioChoice(args, idx, text, logprobs, finish);
|
|
518
506
|
return {
|
|
519
|
-
|
|
507
|
+
// a text answer carries its annotations (none: the twin cites no web source), as OpenAI's does
|
|
508
|
+
// (https://platform.openai.com/docs/api-reference/chat/object, `choices[].message.annotations`)
|
|
509
|
+
choice: { index: idx, message: { role: 'assistant', content: text, refusal: null, annotations: [] }, logprobs, finish_reason: finish },
|
|
520
510
|
completionTokens: estimateTokens(text),
|
|
521
511
|
};
|
|
522
512
|
}
|
|
523
513
|
|
|
524
|
-
|
|
514
|
+
/** Predicted-output accounting: the twin echoes the prediction, so every predicted token is "accepted". */
|
|
515
|
+
function predictionUsage(prediction: string): Pick<NonNullable<ChatUsage['completion_tokens_details']>, 'accepted_prediction_tokens' | 'rejected_prediction_tokens'> {
|
|
516
|
+
return { accepted_prediction_tokens: estimateTokens(prediction), rejected_prediction_tokens: 0 };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function buildChatCompletion(args: ChatArgs, occurredAt?: string, decision?: ScenarioDecision): ChatCompletion {
|
|
525
520
|
const promptTokens = countPromptTokens(args.messages);
|
|
526
|
-
// Scenario handlers: a fired handler scripts the assistant turn; a miss teaches in the stub.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const decision = scenarioEngine.next({ model: args.model, messages: args.messages, tools: args.tools, maxTokens: args.maxTokens });
|
|
531
|
-
if (decision.kind === 'handler') scripted = realizeOpenAIRespond(decision.respond as OpenAIScenarioRespond);
|
|
532
|
-
else 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)}]`;
|
|
533
|
-
}
|
|
521
|
+
// Scenario handlers: a fired handler scripts the assistant turn; a miss teaches in the stub. The
|
|
522
|
+
// decision was made (and any fault honored) by the request handler through the engine's serve(); this
|
|
523
|
+
// builder only realizes it — a status fault never reaches here.
|
|
524
|
+
const turn = decision && scenarioTurn(decision);
|
|
534
525
|
const choices: ChatChoice[] = [];
|
|
535
526
|
let completionTokens = 0;
|
|
536
527
|
for (let i = 0; i < args.n; i++) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
? { role: 'assistant' as const, content: scripted.text, tool_calls: scripted.toolCalls, refusal: null }
|
|
540
|
-
: { role: 'assistant' as const, content: scripted.text ?? '', refusal: null };
|
|
541
|
-
const ct = estimateTokens(JSON.stringify(scripted.toolCalls.length ? scripted.toolCalls : scripted.text ?? ''));
|
|
542
|
-
choices.push({ index: i, message, logprobs: null, finish_reason: scripted.finishReason });
|
|
543
|
-
completionTokens += ct;
|
|
544
|
-
continue;
|
|
545
|
-
}
|
|
546
|
-
const { choice, completionTokens: ct } = buildChoice(args, i);
|
|
547
|
-
if (missTeach && typeof choice.message.content === 'string') choice.message.content += missTeach;
|
|
528
|
+
const { choice, completionTokens: ct } = turn?.scripted ? scriptedChoice(turn.scripted, i) : buildChoice(args, i);
|
|
529
|
+
if (turn?.missTeach && typeof choice.message.content === 'string') choice.message.content += turn.missTeach;
|
|
548
530
|
choices.push(choice);
|
|
549
531
|
completionTokens += ct;
|
|
550
532
|
}
|
|
551
|
-
|
|
533
|
+
// usage breaks its counts down as OpenAI's does (https://platform.openai.com/docs/api-reference/chat/object,
|
|
534
|
+
// `usage.prompt_tokens_details`, `usage.completion_tokens_details`): the twin caches nothing and reasons and
|
|
535
|
+
// speaks nothing, so those are zero
|
|
536
|
+
const usage: ChatUsage = {
|
|
537
|
+
prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens,
|
|
538
|
+
prompt_tokens_details: { cached_tokens: 0, audio_tokens: 0 },
|
|
539
|
+
completion_tokens_details: { reasoning_tokens: 0, audio_tokens: 0, accepted_prediction_tokens: 0, rejected_prediction_tokens: 0 },
|
|
540
|
+
};
|
|
552
541
|
// prediction (predicted outputs): real responses report how many predicted tokens were accepted
|
|
553
542
|
// vs rejected. The twin echoes the prediction, so all predicted tokens are "accepted".
|
|
554
|
-
if (args.prediction !== undefined) {
|
|
555
|
-
|
|
556
|
-
|
|
543
|
+
if (args.prediction !== undefined) usage.completion_tokens_details = { ...usage.completion_tokens_details!, ...predictionUsage(args.prediction) };
|
|
544
|
+
// a reasoning model's reasoning tokens are billed as output tokens (https://developers.openai.com/api/docs/guides/reasoning:
|
|
545
|
+
// "they still occupy space in the model's context window and are billed as output tokens")
|
|
546
|
+
if (args.reasoningEffort !== undefined && reasons(args.model)) {
|
|
547
|
+
const spent = REASONING_BUDGET[args.reasoningEffort] ?? 0;
|
|
548
|
+
usage.completion_tokens += spent;
|
|
549
|
+
usage.total_tokens += spent;
|
|
550
|
+
usage.completion_tokens_details = { ...usage.completion_tokens_details!, reasoning_tokens: spent };
|
|
557
551
|
}
|
|
558
552
|
return {
|
|
559
553
|
id: `chatcmpl-twin-${stableSuffix(args)}`,
|
|
@@ -563,27 +557,13 @@ function buildChatCompletion(args: ChatArgs, occurredAt?: string, scenarioEngine
|
|
|
563
557
|
choices,
|
|
564
558
|
usage,
|
|
565
559
|
system_fingerprint: SYSTEM_FINGERPRINT,
|
|
560
|
+
// the tier that served the request: the standard one, which is what `auto` and `default` pick for a project
|
|
561
|
+
// not on Scale Tier (https://platform.openai.com/docs/api-reference/chat/object, `service_tier`)
|
|
562
|
+
service_tier: 'default',
|
|
566
563
|
...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
|
|
567
564
|
};
|
|
568
565
|
}
|
|
569
566
|
|
|
570
|
-
// Persist a stored chat completion (store:true) so it can be retrieved/listed/deleted later, and
|
|
571
|
-
// keep the request messages for the /messages sub-resource (stored-completions input replay).
|
|
572
|
-
async function storeChatCompletion(resp: ChatCompletion, args: ChatArgs, req: OpenAIRequest): Promise<void> {
|
|
573
|
-
const inputMessages = args.messages.map((m, i) => ({ id: `${resp.id}-msg-${i}`, role: m.role, content: typeof m.content === 'string' ? m.content : (m.content ?? null) }));
|
|
574
|
-
await applyTwinWrite(SERVICE, {
|
|
575
|
-
operation: 'chat_completion.create',
|
|
576
|
-
subjectType: 'chat_completion',
|
|
577
|
-
subjectId: resp.id,
|
|
578
|
-
fields: { ...resp, _stored: true, _input_messages: inputMessages },
|
|
579
|
-
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
580
|
-
actor: { kind: 'agent' },
|
|
581
|
-
}, req.root);
|
|
582
|
-
}
|
|
583
|
-
function storedChatView(r: Record<string, unknown>): Record<string, unknown> {
|
|
584
|
-
return { id: r.id, ...strip(r) };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
567
|
// A deterministic id suffix from the request (so ids are stable + assertable, like the twin's
|
|
588
568
|
// other deterministic outputs). Hash of the prompt text + model + seed (seed changes sampling,
|
|
589
569
|
// so it changes the response id the way a real seed-distinct request does).
|
|
@@ -602,26 +582,30 @@ function chunkText(text: string): string[] {
|
|
|
602
582
|
return out;
|
|
603
583
|
}
|
|
604
584
|
|
|
585
|
+
/** A streamed choice's tool calls: one tool_calls delta-pair per call, each carrying its own `index`
|
|
586
|
+
* (parallel tool calls). */
|
|
587
|
+
function streamToolCalls(calls: ChatToolCall[], idx: number, base: Record<string, unknown>, sink: SseSink): void {
|
|
588
|
+
calls.forEach((tc, tIdx) => {
|
|
589
|
+
sink({ data: { ...base, choices: [{ index: idx, delta: { tool_calls: [{ index: tIdx, id: tc.id, type: 'function', function: { name: tc.function.name, arguments: '' } }] }, logprobs: null, finish_reason: null }] } });
|
|
590
|
+
sink({ data: { ...base, choices: [{ index: idx, delta: { tool_calls: [{ index: tIdx, function: { arguments: tc.function.arguments } }] }, logprobs: null, finish_reason: null }] } });
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
605
594
|
/**
|
|
606
595
|
* Emit the vendor-faithful Chat Completions streaming sequence into the injected sink (NO
|
|
607
596
|
* sockets, NO setTimeout). Real order: a first chunk with `delta:{role:'assistant'}`, then
|
|
608
597
|
* `delta:{content}` chunks (or tool_calls deltas), then a final chunk with `finish_reason`,
|
|
609
598
|
* then `[DONE]`. Deterministic + synchronous so a collector can assert the full sequence.
|
|
610
599
|
*/
|
|
611
|
-
export function streamChat(args: ChatArgs, sink: SseSink, occurredAt?: string,
|
|
612
|
-
const full = buildChatCompletion(args, occurredAt,
|
|
600
|
+
export function streamChat(args: ChatArgs, sink: SseSink, occurredAt?: string, decision?: ScenarioDecision): ChatCompletion {
|
|
601
|
+
const full = buildChatCompletion(args, occurredAt, decision);
|
|
613
602
|
const base = { id: full.id, object: 'chat.completion.chunk' as const, created: full.created, model: full.model, system_fingerprint: SYSTEM_FINGERPRINT };
|
|
614
603
|
for (const choice of full.choices) {
|
|
615
604
|
const idx = choice.index;
|
|
616
605
|
// role chunk
|
|
617
606
|
sink({ data: { ...base, choices: [{ index: idx, delta: { role: 'assistant', content: '' }, logprobs: null, finish_reason: null }] } });
|
|
618
|
-
if (choice.message.tool_calls && choice.message.tool_calls.length)
|
|
619
|
-
|
|
620
|
-
choice.message.tool_calls.forEach((tc, tIdx) => {
|
|
621
|
-
sink({ data: { ...base, choices: [{ index: idx, delta: { tool_calls: [{ index: tIdx, id: tc.id, type: 'function', function: { name: tc.function.name, arguments: '' } }] }, logprobs: null, finish_reason: null }] } });
|
|
622
|
-
sink({ data: { ...base, choices: [{ index: idx, delta: { tool_calls: [{ index: tIdx, function: { arguments: tc.function.arguments } }] }, logprobs: null, finish_reason: null }] } });
|
|
623
|
-
});
|
|
624
|
-
} else {
|
|
607
|
+
if (choice.message.tool_calls && choice.message.tool_calls.length) streamToolCalls(choice.message.tool_calls, idx, base, sink);
|
|
608
|
+
else {
|
|
625
609
|
for (const piece of chunkText(choice.message.content ?? '')) {
|
|
626
610
|
sink({ data: { ...base, choices: [{ index: idx, delta: { content: piece }, logprobs: null, finish_reason: null }] } });
|
|
627
611
|
}
|
|
@@ -637,7 +621,9 @@ export function streamChat(args: ChatArgs, sink: SseSink, occurredAt?: string, s
|
|
|
637
621
|
}
|
|
638
622
|
|
|
639
623
|
// ── Responses API ───────────────────────────────────────────────────────────────────────
|
|
640
|
-
type ResponsesArgs = {
|
|
624
|
+
export type ResponsesArgs = {
|
|
625
|
+
inputItems: Record<string, unknown>[];
|
|
626
|
+
instructions?: string;
|
|
641
627
|
model: string;
|
|
642
628
|
inputText: string;
|
|
643
629
|
messages: ChatMessageParam[];
|
|
@@ -648,31 +634,61 @@ type ResponsesArgs = {
|
|
|
648
634
|
reasoningEffort?: string;
|
|
649
635
|
/** background:true → the response is created `queued` and processed asynchronously (poll + cancel). */
|
|
650
636
|
background: boolean;
|
|
637
|
+
/** The caller's tools, as sent (Responses shape `{type:'function', name, parameters}`); the scenario reads their names. */
|
|
638
|
+
tools?: unknown;
|
|
639
|
+
/** max_output_tokens, when sent — the output cap a scenario handler may key on. */
|
|
640
|
+
maxTokens?: number;
|
|
641
|
+
/** the request's settings a response answers back as sent, or OpenAI's defaults */
|
|
642
|
+
echo: { metadata: unknown; temperature: unknown; top_p: unknown; parallel_tool_calls: unknown; tool_choice: unknown; text: unknown; truncation: unknown; user: unknown; max_tool_calls: unknown; top_logprobs: unknown; service_tier: unknown };
|
|
643
|
+
/** reasoning.summary, when sent: a reasoning item carries a summary only when one is asked for */
|
|
644
|
+
reasoningSummary?: string;
|
|
651
645
|
};
|
|
652
|
-
const REASONING_EFFORTS = new Set(['minimal', 'low', 'medium', 'high']);
|
|
653
646
|
|
|
654
|
-
function
|
|
647
|
+
export function responseMessages(items: Record<string, unknown>[], instructions?: string): ChatMessageParam[] {
|
|
648
|
+
const messages: ChatMessageParam[] = instructions ? [{ role: 'system', content: instructions }] : [];
|
|
649
|
+
const partsText = (content: unknown): string => typeof content === 'string' ? content : Array.isArray(content) ? content.map((c) => c && typeof c === 'object' ? String(c.text ?? JSON.stringify(c)) : String(c)).join('\n') : '';
|
|
650
|
+
for (const item of items) {
|
|
651
|
+
if (item.type === 'function_call') {
|
|
652
|
+
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: String(item.call_id ?? item.id ?? ''), type: 'function', function: { name: String(item.name ?? ''), arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {}) } }] });
|
|
653
|
+
} else if (item.type === 'function_call_output') {
|
|
654
|
+
messages.push({ role: 'tool', content: partsText(item.output), tool_call_id: String(item.call_id ?? '') });
|
|
655
|
+
} else if (item.type === 'message' || item.type === undefined) {
|
|
656
|
+
messages.push({ role: (item.role ?? 'user') as ChatMessageParam['role'], content: partsText(item.content) });
|
|
657
|
+
}
|
|
658
|
+
// Reasoning and other typed items remain in state, without inventing user turns.
|
|
659
|
+
}
|
|
660
|
+
return messages;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** OpenAI's refusal of an `input` list holding something other than input items (objects)
|
|
664
|
+
* (https://platform.openai.com/docs/api-reference/responses/create, `input`). */
|
|
665
|
+
function itemsNotObjects(): { error: OpenAIResponseEnvelope } {
|
|
666
|
+
return { error: invalidRequest("'input' items must be objects", 'input') };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function validateResponses(params: Record<string, unknown>): { args: ResponsesArgs } | { error: OpenAIResponseEnvelope } {
|
|
655
670
|
if (params.model === undefined || params.model === '') return { error: invalidRequest("you must provide a model parameter", 'model') };
|
|
656
671
|
if (typeof params.model !== 'string') return { error: invalidRequest("'model' must be a string", 'model') };
|
|
657
672
|
if (params.input === undefined) return { error: invalidRequest("you must provide an input parameter", 'input') };
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
let inputText = '';
|
|
661
|
-
const messages: ChatMessageParam[] = [];
|
|
673
|
+
// Keep vendor items as state; the messages view is only a projection for the scenario.
|
|
674
|
+
let inputItems: Record<string, unknown>[];
|
|
662
675
|
if (typeof params.input === 'string') {
|
|
663
|
-
|
|
664
|
-
messages.push({ role: 'user', content: params.input });
|
|
676
|
+
inputItems = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: params.input }] }];
|
|
665
677
|
} else if (Array.isArray(params.input)) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
messages.push({ role, content: text });
|
|
672
|
-
}
|
|
678
|
+
if (params.input.some((item) => !item || typeof item !== 'object' || Array.isArray(item))) return itemsNotObjects();
|
|
679
|
+
inputItems = params.input.map((item: Record<string, unknown>) => {
|
|
680
|
+
if (item.type !== undefined && item.type !== 'message') return { ...item };
|
|
681
|
+
return { ...item, type: 'message', role: item.role ?? 'user', content: typeof item.content === 'string' ? [{ type: item.role === 'assistant' ? 'output_text' : 'input_text', text: item.content }] : item.content };
|
|
682
|
+
});
|
|
673
683
|
} else {
|
|
674
684
|
return { error: invalidRequest("'input' must be a string or an array of input items", 'input') };
|
|
675
685
|
}
|
|
686
|
+
const instructions = typeof params.instructions === 'string' ? params.instructions : undefined;
|
|
687
|
+
const messages = responseMessages(inputItems, instructions);
|
|
688
|
+
const inputText = responseMessages(inputItems).map((m) => m.content).filter(Boolean).join('\n');
|
|
689
|
+
if (params.tools !== undefined && !Array.isArray(params.tools)) return { error: invalidRequest("'tools' must be an array", 'tools') };
|
|
690
|
+
const maxOut = params.max_output_tokens;
|
|
691
|
+
if (maxOut !== undefined && (typeof maxOut !== 'number' || !Number.isInteger(maxOut) || maxOut < 1)) return { error: invalidRequest("'max_output_tokens' must be a positive integer", 'max_output_tokens') };
|
|
676
692
|
const prev = params.previous_response_id;
|
|
677
693
|
if (prev !== undefined && (typeof prev !== 'string' || !prev)) return { error: invalidRequest("'previous_response_id' must be a string", 'previous_response_id') };
|
|
678
694
|
// reasoning.effort → the model spends a (stubbed) reasoning budget; the item shape is faithful.
|
|
@@ -681,14 +697,19 @@ function validateResponses(params: Record<string, unknown>): { args: ResponsesAr
|
|
|
681
697
|
const r = params.reasoning;
|
|
682
698
|
if (!r || typeof r !== 'object' || Array.isArray(r)) return { error: invalidRequest("'reasoning' must be an object", 'reasoning') };
|
|
683
699
|
const effort = (r as { effort?: unknown }).effort;
|
|
684
|
-
if (effort !== undefined)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
700
|
+
if (effort !== undefined && effort !== null) reasoningEffort = effort as string;
|
|
701
|
+
}
|
|
702
|
+
// a model refuses what its page says it does not take (openai-models.ts); a reasoning model reasons with the effort
|
|
703
|
+
// asked for, else the spec's default
|
|
704
|
+
const refused = unsupported(params.model, { effort: reasoningEffort, effortParam: 'reasoning.effort', temperature: params.temperature, top_p: params.top_p });
|
|
705
|
+
if (refused) return { error: invalidRequest(refused.message, refused.param, 'unsupported_value') };
|
|
706
|
+
if (reasoningEffort === undefined && reasons(params.model)) reasoningEffort = DEFAULT_EFFORT;
|
|
707
|
+
const reasoningSummary = params.reasoning && typeof (params.reasoning as { summary?: unknown }).summary === 'string' ? String((params.reasoning as { summary: string }).summary) : undefined;
|
|
689
708
|
return {
|
|
690
709
|
args: {
|
|
691
710
|
model: params.model,
|
|
711
|
+
inputItems,
|
|
712
|
+
instructions,
|
|
692
713
|
inputText,
|
|
693
714
|
messages,
|
|
694
715
|
stream: params.stream === true,
|
|
@@ -696,143 +717,205 @@ function validateResponses(params: Record<string, unknown>): { args: ResponsesAr
|
|
|
696
717
|
background: params.background === true,
|
|
697
718
|
...(typeof prev === 'string' ? { previousResponseId: prev } : {}),
|
|
698
719
|
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
|
|
720
|
+
...(Array.isArray(params.tools) ? { tools: params.tools } : {}),
|
|
721
|
+
...(typeof maxOut === 'number' ? { maxTokens: maxOut } : {}),
|
|
722
|
+
...(reasoningSummary !== undefined ? { reasoningSummary } : {}),
|
|
723
|
+
// OpenAI's defaults for what was not sent (the Response schema's): temperature and top_p 1, tools in parallel, auto;
|
|
724
|
+
// plain text out, no truncation, no end user, no tool-call cap, no alternative tokens, the default tier
|
|
725
|
+
// (https://platform.openai.com/docs/api-reference/responses/object)
|
|
726
|
+
echo: {
|
|
727
|
+
metadata: params.metadata ?? {}, temperature: params.temperature ?? 1, top_p: params.top_p ?? 1, parallel_tool_calls: params.parallel_tool_calls ?? true, tool_choice: params.tool_choice ?? 'auto',
|
|
728
|
+
text: params.text ?? { format: { type: 'text' } }, truncation: params.truncation ?? 'disabled', user: params.user ?? null, max_tool_calls: params.max_tool_calls ?? null,
|
|
729
|
+
top_logprobs: params.top_logprobs ?? 0, service_tier: ['flex', 'priority', 'scale'].includes(String(params.service_tier)) ? params.service_tier : 'default',
|
|
730
|
+
},
|
|
699
731
|
},
|
|
700
732
|
};
|
|
701
733
|
}
|
|
702
734
|
|
|
703
|
-
|
|
704
|
-
|
|
735
|
+
/** A tool as a response answers it: as sent, with what OpenAI fills in for what was not. A web search preview searches
|
|
736
|
+
* with `medium` context from the United States unless told otherwise (the spec's WebSearchPreviewTool: "`medium` is
|
|
737
|
+
* the default"; `user_location` "If omitted or null, defaults to the United States"); a function tool sent without
|
|
738
|
+
* `strict` answers strict, as the reference's Functions example answers the tool it sends; a file search tool, its
|
|
739
|
+
* default filter and ranking
|
|
740
|
+
* (https://platform.openai.com/docs/api-reference/responses/create). */
|
|
741
|
+
const TOOL_DEFAULTS: Record<string, (t: Record<string, unknown>) => Record<string, unknown>> = {
|
|
742
|
+
function: (t) => ({ ...t, strict: t.strict ?? true }),
|
|
743
|
+
// a file search sent without filters or ranking answers no filter and the automatic ranker with no threshold, as the
|
|
744
|
+
// reference's File search example answers the tool it sends
|
|
745
|
+
file_search: (t) => ({ ...t, filters: t.filters ?? null, ranking_options: t.ranking_options ?? { ranker: 'auto', score_threshold: 0 } }),
|
|
746
|
+
// and the domains it is limited to, none unless sent, as the reference's Web search example answers `"domains": []`
|
|
747
|
+
web_search_preview: (t) => ({ ...t, domains: t.domains ?? [], search_context_size: t.search_context_size ?? 'medium', user_location: t.user_location ?? { type: 'approximate', city: null, country: 'US', region: null, timezone: null } }),
|
|
748
|
+
};
|
|
749
|
+
TOOL_DEFAULTS.web_search_preview_2025_03_11 = TOOL_DEFAULTS.web_search_preview!;
|
|
750
|
+
function answeredTool(tool: unknown): unknown {
|
|
751
|
+
const t = (tool && typeof tool === 'object' ? tool : {}) as Record<string, unknown>;
|
|
752
|
+
return TOOL_DEFAULTS[String(t.type)]?.(t) ?? tool;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** The built-in tool calls a response makes: a web search for a web search tool, a file search over the named stores for a
|
|
756
|
+
* file search tool, each querying the input's text (its results are not included unless asked for: `results` null). */
|
|
757
|
+
function builtInCalls(args: ResponsesArgs, suffix: string): ResponseOutputItem[] {
|
|
758
|
+
const query = args.inputText.slice(0, 200);
|
|
759
|
+
const out: ResponseOutputItem[] = [];
|
|
760
|
+
for (const [i, tool] of (Array.isArray(args.tools) ? (args.tools as Array<{ type?: unknown }>) : []).entries()) {
|
|
761
|
+
const type = String(tool?.type ?? '');
|
|
762
|
+
if (/^web_search/.test(type)) out.push({ type: 'web_search_call', id: `ws-twin-${suffix}-${i}`, status: 'completed', action: { type: 'search', query } });
|
|
763
|
+
if (type === 'file_search') out.push({ type: 'file_search_call', id: `fs-twin-${suffix}-${i}`, status: 'completed', queries: [query], results: null });
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
705
767
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
768
|
+
/** What every response answers besides its output: the request's settings as sent (or OpenAI's defaults), no error,
|
|
769
|
+
* no program access (https://platform.openai.com/docs/api-reference/responses/object). */
|
|
770
|
+
const responseSettings = (args: ResponsesArgs): Record<string, unknown> => ({
|
|
771
|
+
instructions: args.instructions ?? null, tools: Array.isArray(args.tools) ? args.tools.map(answeredTool) : [], ...args.echo,
|
|
772
|
+
access_programs: null, error: null, incomplete_details: null,
|
|
773
|
+
// whether it is kept: the spec's own Response example answers `"store": true` (spec/patches.json)
|
|
774
|
+
background: args.background, store: args.store, max_output_tokens: args.maxTokens ?? null,
|
|
775
|
+
previous_response_id: args.previousResponseId ?? null,
|
|
776
|
+
reasoning: { effort: args.reasoningEffort ?? null, summary: args.reasoningSummary ?? null },
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// A deterministic reasoning-token budget per effort level (more effort → more reasoning tokens).
|
|
780
|
+
const REASONING_BUDGET: Record<string, number> = { none: 0, minimal: 8, low: 16, medium: 48, high: 128, xhigh: 256, max: 512 };
|
|
781
|
+
|
|
782
|
+
/** The function call an unscripted response makes: when the caller requires one or names one, or on
|
|
783
|
+
* `auto` unless the input ends with a tool's result, which a model answers in words. */
|
|
784
|
+
function responseToolCall(args: ResponsesArgs): ChatToolCall | null {
|
|
785
|
+
const choice = args.echo.tool_choice;
|
|
786
|
+
const named = choice && typeof choice === 'object' ? String((choice as { name?: unknown }).name ?? '') : undefined;
|
|
787
|
+
const answering = args.inputItems.at(-1)?.type === 'function_call_output';
|
|
788
|
+
const functions = (Array.isArray(args.tools) ? args.tools : []).filter((t) => (t as { type?: unknown }).type === 'function');
|
|
789
|
+
if (!functions.length || choice === 'none' || (answering && !named && choice !== 'required')) return null;
|
|
790
|
+
return stubToolCall(functions, 1, named, lastUserText(args.messages));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function buildResponse(args: ResponsesArgs, occurredAt?: string, idSuffix?: string, decision?: ScenarioDecision): OpenAIResponse {
|
|
794
|
+
// The scenario decides the turn exactly as it does for chat completions: a fired handler scripts the
|
|
795
|
+
// text and the tool calls; a miss answers the labeled stub and teaches. The request handler has
|
|
796
|
+
// already honored a fault; only a content decision reaches here.
|
|
710
797
|
const suffix = idSuffix ?? String(nowEpoch(occurredAt));
|
|
798
|
+
const turn = decision && scenarioTurn(decision, `call-twin-${suffix}`);
|
|
799
|
+
const scripted = turn?.scripted;
|
|
800
|
+
let text = scripted ? (scripted.text ?? '') : stubAssistantText(args.messages, args.model) + (turn?.missTeach ?? '');
|
|
801
|
+
const inputTokens = countPromptTokens(args.messages);
|
|
711
802
|
const messageItem: ResponseMessageItem = {
|
|
712
803
|
type: 'message',
|
|
713
804
|
id: `msg-twin-${suffix}`,
|
|
714
805
|
status: 'completed',
|
|
715
806
|
role: 'assistant',
|
|
716
|
-
|
|
807
|
+
// no log probabilities unless asked for (`include: ["message.output_text.logprobs"]`): an empty list
|
|
808
|
+
content: [{ type: 'output_text', text, annotations: [], logprobs: [] }],
|
|
717
809
|
};
|
|
810
|
+
// unscripted, the stub calls a function tool as the chat stub does (buildChoice)
|
|
811
|
+
const stubbed = scripted ? null : responseToolCall(args);
|
|
812
|
+
if (stubbed) text = '';
|
|
813
|
+
const calls: ResponseFunctionCallItem[] = (scripted?.toolCalls ?? (stubbed ? [stubbed] : [])).map((tc, i) => ({ type: 'function_call', id: `fc-twin-${suffix}-${i}`, status: 'completed', call_id: tc.id, name: tc.function.name, arguments: tc.function.arguments }));
|
|
814
|
+
const messageTokens = estimateTokens(text) + estimateTokens(calls.map((c) => c.arguments).join(''));
|
|
718
815
|
const output: ResponseOutputItem[] = [];
|
|
719
|
-
|
|
816
|
+
// usage breaks its counts down as OpenAI's does (https://platform.openai.com/docs/api-reference/responses/object, `usage`):
|
|
817
|
+
// the twin caches nothing, so no input token was read from or written to a cache
|
|
818
|
+
const usage: ResponseUsage = { input_tokens: inputTokens, input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, output_tokens: messageTokens, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: inputTokens + messageTokens };
|
|
720
819
|
// reasoning.effort → a faithful reasoning item (labeled-stub summary) BEFORE the message item,
|
|
721
820
|
// plus output_tokens_details.reasoning_tokens in usage (counted into output_tokens, like the vendor).
|
|
722
821
|
if (args.reasoningEffort) {
|
|
723
822
|
const reasoningTokens = REASONING_BUDGET[args.reasoningEffort] ?? 16;
|
|
823
|
+
// a summary only when one was asked for (`reasoning.summary`); without, OpenAI answers the item's summary empty
|
|
824
|
+
// (https://platform.openai.com/docs/guides/reasoning#reasoning-summaries)
|
|
724
825
|
const reasoningItem: ResponseReasoningItem = {
|
|
725
826
|
type: 'reasoning',
|
|
726
827
|
id: `rs-twin-${suffix}`,
|
|
727
|
-
summary: [{ type: 'summary_text', text: `[twin-stub] reasoning summary (effort=${args.reasoningEffort}); the twin cannot run the model, so the chain-of-thought is not real.` }],
|
|
828
|
+
summary: args.reasoningSummary ? [{ type: 'summary_text', text: `[twin-stub] reasoning summary (effort=${args.reasoningEffort}); the twin cannot run the model, so the chain-of-thought is not real.` }] : [],
|
|
728
829
|
};
|
|
729
|
-
|
|
830
|
+
// only a model that reasons lists its reasoning item (openai-models.ts)
|
|
831
|
+
if (reasons(args.model)) output.push(reasoningItem);
|
|
730
832
|
usage.output_tokens += reasoningTokens;
|
|
731
833
|
usage.total_tokens += reasoningTokens;
|
|
732
834
|
usage.output_tokens_details = { reasoning_tokens: reasoningTokens };
|
|
733
835
|
}
|
|
734
|
-
|
|
836
|
+
// the built-in tools the request offers, which OpenAI runs itself before answering: the placeholder model searches
|
|
837
|
+
// with each one offered, on the input's text, unless `tool_choice` is `none` (the reference's Web search and File search
|
|
838
|
+
// examples answer a `web_search_call` / `file_search_call` item before the message:
|
|
839
|
+
// https://platform.openai.com/docs/guides/tools-web-search, https://platform.openai.com/docs/guides/tools-file-search)
|
|
840
|
+
if (!scripted && args.echo.tool_choice !== 'none') output.push(...builtInCalls(args, suffix));
|
|
841
|
+
// A scripted turn with tool calls and no words carries the calls alone, as the vendor does.
|
|
842
|
+
if (text || !calls.length) output.push(messageItem);
|
|
843
|
+
output.push(...calls);
|
|
735
844
|
return {
|
|
736
845
|
id: `resp-twin-${suffix}`,
|
|
737
846
|
object: 'response',
|
|
738
847
|
created_at: nowEpoch(occurredAt),
|
|
739
848
|
status: 'completed',
|
|
849
|
+
// answered whole at once: completed the instant it was created
|
|
850
|
+
completed_at: nowEpoch(occurredAt),
|
|
740
851
|
model: args.model,
|
|
852
|
+
// no `output_text`: the spec's is an "SDK-only convenience property" the SDKs compute from `output`
|
|
741
853
|
output,
|
|
742
|
-
output_text: text,
|
|
743
854
|
usage,
|
|
744
|
-
...(args
|
|
745
|
-
...(args.previousResponseId ? { previous_response_id: args.previousResponseId } : {}),
|
|
855
|
+
...responseSettings(args),
|
|
746
856
|
};
|
|
747
857
|
}
|
|
748
858
|
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
-
function responseSuffix(args: ResponsesArgs): string {
|
|
859
|
+
// Unstored responses have no state to overwrite. Stored identities are allocated atomically below.
|
|
860
|
+
export function responseSuffix(args: ResponsesArgs): string {
|
|
752
861
|
let h = 0x811c9dc5;
|
|
753
|
-
const s = args.model
|
|
862
|
+
const s = JSON.stringify([args.model, args.inputItems ?? args.messages, args.previousResponseId, args.instructions]);
|
|
754
863
|
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
|
755
864
|
return (h >>> 0).toString(36);
|
|
756
865
|
}
|
|
757
866
|
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
await
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
867
|
+
// Allocate and create under the kernel's cross-process lock. Repeated requests are occurrences;
|
|
868
|
+
// a content hash alone would overwrite an earlier response (and its queued scenario decision).
|
|
869
|
+
export async function createStoredResponse(args: ResponsesArgs, req: OpenAIRequest, decision?: ScenarioDecision): Promise<OpenAIResponse | Record<string, unknown>> {
|
|
870
|
+
const { value } = await applyTwinWriteAtomic<OpenAIResponse | Record<string, unknown>>(SERVICE, (resources) => {
|
|
871
|
+
let ordinal = 0;
|
|
872
|
+
for (const row of resources) {
|
|
873
|
+
if (row.type !== 'response') continue;
|
|
874
|
+
const match = /^resp-twin-(\d+)$/.exec(String(row.id));
|
|
875
|
+
if (match) ordinal = Math.max(ordinal, Number(match[1]));
|
|
876
|
+
}
|
|
877
|
+
const suffix = String(ordinal + 1);
|
|
878
|
+
const inputItems = args.inputItems.map((item, i) => ({ ...item, id: item.id ?? `item-in-${suffix}-${i}` }));
|
|
879
|
+
const resp = args.background ? {
|
|
880
|
+
id: `resp-twin-${suffix}`, object: 'response', created_at: nowEpoch(req.occurredAt),
|
|
881
|
+
status: 'queued', completed_at: null, model: args.model, output: [],
|
|
882
|
+
usage: null, ...responseSettings(args),
|
|
883
|
+
} : buildResponse(args, req.occurredAt, suffix, decision);
|
|
884
|
+
return { kind: 'write', value: resp, write: {
|
|
885
|
+
operation: 'response.create', subjectType: 'response', subjectId: resp.id,
|
|
886
|
+
fields: { ...resp, _stored: true, _input_items: inputItems,
|
|
887
|
+
...(args.background ? { _bg_args: JSON.stringify(args), _bg_suffix: suffix, _bg_decision: decision ? JSON.stringify(decision) : null } : {}),
|
|
888
|
+
},
|
|
889
|
+
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' },
|
|
890
|
+
} };
|
|
768
891
|
}, req.root);
|
|
769
|
-
|
|
770
|
-
function responseView(r: Record<string, unknown>): Record<string, unknown> {
|
|
771
|
-
return { id: r.id, ...strip(r) };
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// ── background responses (background:true → queued → poll → completed | cancel) ───────────
|
|
775
|
-
// Persist a `queued` background response: the faithful queued envelope (no output yet) plus the
|
|
776
|
-
// stashed args so the first poll can compute the real stub output. background implies store.
|
|
777
|
-
async function storeQueuedResponse(args: ResponsesArgs, req: OpenAIRequest, suffix: string): Promise<Record<string, unknown>> {
|
|
778
|
-
const id = `resp-twin-${suffix}`;
|
|
779
|
-
const inputItems = args.messages.map((m, i) => ({ id: `msg-in-${i}`, type: 'message', role: m.role, content: [{ type: 'input_text', text: typeof m.content === 'string' ? m.content : '' }] }));
|
|
780
|
-
const fields = {
|
|
781
|
-
id,
|
|
782
|
-
object: 'response',
|
|
783
|
-
created_at: nowEpoch(req.occurredAt),
|
|
784
|
-
status: 'queued',
|
|
785
|
-
background: true,
|
|
786
|
-
model: args.model,
|
|
787
|
-
output: [],
|
|
788
|
-
output_text: null,
|
|
789
|
-
usage: null,
|
|
790
|
-
error: null,
|
|
791
|
-
incomplete_details: null,
|
|
792
|
-
...(args.previousResponseId ? { previous_response_id: args.previousResponseId } : {}),
|
|
793
|
-
_stored: true,
|
|
794
|
-
_input_items: inputItems,
|
|
795
|
-
_bg_args: JSON.stringify(args),
|
|
796
|
-
_bg_suffix: suffix,
|
|
797
|
-
};
|
|
798
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'response.create', subjectType: 'response', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
799
|
-
return resource;
|
|
800
|
-
}
|
|
801
|
-
// First poll of a queued background response: compute the real stub output + usage, flip the
|
|
802
|
-
// persisted row to `completed`, and record the (now billable) usage exactly once.
|
|
803
|
-
async function completeQueuedResponse(r: Record<string, unknown>, req: OpenAIRequest): Promise<Record<string, unknown>> {
|
|
804
|
-
const args = JSON.parse(String(r._bg_args ?? '{}')) as ResponsesArgs;
|
|
805
|
-
const suffix = String(r._bg_suffix ?? responseSuffix(args));
|
|
806
|
-
const resp = buildResponse(args, req.occurredAt, suffix);
|
|
807
|
-
const fields = { ...resp, background: true, status: 'completed', _bg_args: undefined };
|
|
808
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'response.update', subjectType: 'response', subjectId: String(r.id), fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
809
|
-
await recordUsage(req, 'responses', resp.model, resp.usage.input_tokens, resp.usage.output_tokens);
|
|
810
|
-
return resource;
|
|
892
|
+
return value;
|
|
811
893
|
}
|
|
812
894
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
sink({ data: { type: 'response.created', response: { ...resp, output: [], output_text: '' } } });
|
|
818
|
-
// Emit each output item in order; the message item carries the text deltas at its own index.
|
|
819
|
-
const msgIndex = resp.output.findIndex((o) => o.type === 'message');
|
|
895
|
+
export function emitResponse(resp: OpenAIResponse, sink: SseSink): OpenAIResponse {
|
|
896
|
+
sink({ data: { type: 'response.created', response: { ...resp, output: [] } } });
|
|
897
|
+
// Emit each output item in order, as the vendor streams them: a reasoning item whole, the message
|
|
898
|
+
// item's text as deltas at its own index, a function call's arguments as one delta then done.
|
|
820
899
|
resp.output.forEach((item, i) => {
|
|
821
|
-
sink({ data: { type: 'response.output_item.added', output_index: i, item } });
|
|
822
|
-
if (item.type === '
|
|
900
|
+
sink({ data: { type: 'response.output_item.added', output_index: i, item: item.type === 'function_call' ? { ...item, arguments: '' } : item } });
|
|
901
|
+
if (item.type === 'message') {
|
|
902
|
+
const text = item.content[0]?.text ?? '';
|
|
903
|
+
for (const piece of chunkText(text)) sink({ data: { type: 'response.output_text.delta', output_index: i, content_index: 0, delta: piece } });
|
|
904
|
+
sink({ data: { type: 'response.output_text.done', output_index: i, content_index: 0, text } });
|
|
905
|
+
}
|
|
906
|
+
if (item.type === 'function_call') {
|
|
907
|
+
sink({ data: { type: 'response.function_call_arguments.delta', output_index: i, item_id: item.id, delta: item.arguments } });
|
|
908
|
+
sink({ data: { type: 'response.function_call_arguments.done', output_index: i, item_id: item.id, arguments: item.arguments } });
|
|
909
|
+
}
|
|
910
|
+
sink({ data: { type: 'response.output_item.done', output_index: i, item } });
|
|
823
911
|
});
|
|
824
|
-
const text = resp.output_text ?? '';
|
|
825
|
-
for (const piece of chunkText(text)) {
|
|
826
|
-
sink({ data: { type: 'response.output_text.delta', output_index: msgIndex, content_index: 0, delta: piece } });
|
|
827
|
-
}
|
|
828
|
-
sink({ data: { type: 'response.output_text.done', output_index: msgIndex, content_index: 0, text } });
|
|
829
912
|
sink({ data: { type: 'response.completed', response: resp } });
|
|
830
913
|
sink({ done: true });
|
|
831
914
|
return resp;
|
|
832
915
|
}
|
|
833
916
|
|
|
834
917
|
// ── Embeddings (deterministic pseudo-vectors) ───────────────────────────────────────────
|
|
835
|
-
function handleEmbeddings(params: Record<string, unknown>): OpenAIResponseEnvelope {
|
|
918
|
+
export function handleEmbeddings(params: Record<string, unknown>): OpenAIResponseEnvelope {
|
|
836
919
|
if (params.model === undefined || params.model === '') return invalidRequest("you must provide a model parameter", 'model');
|
|
837
920
|
if (params.input === undefined) return invalidRequest("you must provide an input parameter", 'input');
|
|
838
921
|
const model = String(params.model);
|
|
@@ -872,1229 +955,241 @@ function floatsToBase64(vec: number[]): string {
|
|
|
872
955
|
return btoa(bin);
|
|
873
956
|
}
|
|
874
957
|
|
|
875
|
-
// ── Files (stateful) ────────────────────────────────────────────────────────────────────
|
|
876
|
-
async function createFile(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
877
|
-
// The SDK sends multipart/form-data; the twin's JSON contract accepts { purpose, filename,
|
|
878
|
-
// bytes } (the server adapts multipart → this shape). purpose is required like the vendor.
|
|
879
|
-
const purpose = params.purpose;
|
|
880
|
-
if (purpose === undefined || purpose === '') return invalidRequest("you must provide a purpose parameter", 'purpose');
|
|
881
|
-
const id = nextId('file', 'file', req.root);
|
|
882
|
-
const filename = typeof params.filename === 'string' && params.filename ? params.filename : 'upload.jsonl';
|
|
883
|
-
const bytes = Number(params.bytes ?? (typeof params.content === 'string' ? params.content.length : 0));
|
|
884
|
-
const fields = {
|
|
885
|
-
object: 'file',
|
|
886
|
-
bytes: Number.isFinite(bytes) ? bytes : 0,
|
|
887
|
-
created_at: nowEpoch(req.occurredAt),
|
|
888
|
-
filename,
|
|
889
|
-
purpose: String(purpose),
|
|
890
|
-
status: 'processed',
|
|
891
|
-
// The raw bytes are carved out (openai.files.real_bytes); we keep the supplied content for
|
|
892
|
-
// the /content endpoint when present so round-trips are faithful.
|
|
893
|
-
_content: typeof params.content === 'string' ? params.content : '',
|
|
894
|
-
};
|
|
895
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'file.create', subjectType: 'file', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
896
|
-
return { status: 200, body: fileView(resource) };
|
|
897
|
-
}
|
|
898
|
-
function fileView(r: Record<string, unknown>): Record<string, unknown> {
|
|
899
|
-
const { _content: _c, ...rest } = strip(r);
|
|
900
|
-
return { id: r.id, ...rest };
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// ── Batches (stateful) ──────────────────────────────────────────────────────────────────
|
|
904
|
-
async function createBatch(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
905
|
-
if (params.input_file_id === undefined || params.input_file_id === '') return invalidRequest("you must provide an input_file_id parameter", 'input_file_id');
|
|
906
|
-
if (params.endpoint === undefined || params.endpoint === '') return invalidRequest("you must provide an endpoint parameter", 'endpoint');
|
|
907
|
-
if (params.completion_window === undefined) return invalidRequest("you must provide a completion_window parameter", 'completion_window');
|
|
908
|
-
const id = nextId('batch', 'batch', req.root);
|
|
909
|
-
const created = nowEpoch(req.occurredAt);
|
|
910
|
-
// The twin has nothing to process asynchronously, so a batch completes immediately. The
|
|
911
|
-
// results file id is a deterministic synthetic id.
|
|
912
|
-
const fields = {
|
|
913
|
-
object: 'batch',
|
|
914
|
-
endpoint: String(params.endpoint),
|
|
915
|
-
input_file_id: String(params.input_file_id),
|
|
916
|
-
completion_window: String(params.completion_window),
|
|
917
|
-
status: 'completed',
|
|
918
|
-
output_file_id: `file-twin-batchout-${id}`,
|
|
919
|
-
error_file_id: null,
|
|
920
|
-
created_at: created,
|
|
921
|
-
in_progress_at: created,
|
|
922
|
-
completed_at: created,
|
|
923
|
-
request_counts: { total: 0, completed: 0, failed: 0 },
|
|
924
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : null,
|
|
925
|
-
};
|
|
926
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'batch.create', subjectType: 'batch', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
927
|
-
return { status: 200, body: batchView(resource) };
|
|
928
|
-
}
|
|
929
|
-
function batchView(r: Record<string, unknown>): Record<string, unknown> {
|
|
930
|
-
return { id: r.id, ...strip(r) };
|
|
931
|
-
}
|
|
932
|
-
|
|
933
958
|
// ── Moderations (deterministic) ─────────────────────────────────────────────────────────
|
|
934
|
-
function handleModerations(params: Record<string, unknown>, occurredAt?: string): OpenAIResponseEnvelope {
|
|
959
|
+
export function handleModerations(params: Record<string, unknown>, occurredAt?: string): OpenAIResponseEnvelope {
|
|
935
960
|
if (params.input === undefined) return invalidRequest("you must provide an input parameter", 'input');
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
961
|
+
// an array of strings is several inputs, one result each; an array of text and image parts is ONE multimodal input,
|
|
962
|
+
// one result (https://platform.openai.com/docs/api-reference/moderations/create, `input`)
|
|
963
|
+
const list = Array.isArray(params.input) ? (params.input as unknown[]) : [];
|
|
964
|
+
const parts = list.length > 0 && list.every((x) => x && typeof x === 'object' && !Array.isArray(x));
|
|
965
|
+
const inputs: Array<{ text: string; image: boolean }> = typeof params.input === 'string'
|
|
966
|
+
? [{ text: params.input, image: false }]
|
|
967
|
+
: parts
|
|
968
|
+
? [{ text: (list as Array<{ type?: unknown; text?: unknown }>).filter((x) => x.type === 'text').map((x) => String(x.text ?? '')).join('\n'), image: (list as Array<{ type?: unknown }>).some((x) => x.type === 'image_url') }]
|
|
969
|
+
: list.map((x) => ({ text: typeof x === 'string' ? x : JSON.stringify(x), image: false }));
|
|
941
970
|
if (inputs.length === 0) return invalidRequest("'input' must be a non-empty string or array", 'input');
|
|
942
|
-
const model =
|
|
943
|
-
const results = inputs.map((t) => moderateText(t));
|
|
971
|
+
const model = modelOf('createModeration', params)!;
|
|
972
|
+
const results = inputs.map((t) => moderateText(t.text, t.image));
|
|
944
973
|
return { status: 200, body: { id: `modr-twin-${nowEpoch(occurredAt)}`, model, results } };
|
|
945
974
|
}
|
|
946
975
|
|
|
947
|
-
// ──
|
|
948
|
-
|
|
949
|
-
if (params.model === undefined || params.model === '') return invalidRequest("you must provide a model parameter", 'model');
|
|
950
|
-
if (params.training_file === undefined || params.training_file === '') return invalidRequest("you must provide a training_file parameter", 'training_file');
|
|
951
|
-
const id = nextId('ftjob', 'ftjob', req.root);
|
|
952
|
-
const created = nowEpoch(req.occurredAt);
|
|
953
|
-
// The twin cannot train a model, so the job completes immediately with a synthetic
|
|
954
|
-
// fine-tuned model id (the lifecycle/shape is faithful; the trained model is a stub).
|
|
955
|
-
const fields = {
|
|
956
|
-
object: 'fine_tuning.job',
|
|
957
|
-
model: String(params.model),
|
|
958
|
-
created_at: created,
|
|
959
|
-
finished_at: created,
|
|
960
|
-
fine_tuned_model: `ft:${String(params.model)}:twin::${id}`,
|
|
961
|
-
organization_id: 'org-twin',
|
|
962
|
-
status: 'succeeded',
|
|
963
|
-
training_file: String(params.training_file),
|
|
964
|
-
validation_file: params.validation_file ?? null,
|
|
965
|
-
hyperparameters: (params.hyperparameters && typeof params.hyperparameters === 'object') ? params.hyperparameters : { n_epochs: 'auto' },
|
|
966
|
-
result_files: [],
|
|
967
|
-
trained_tokens: 0,
|
|
968
|
-
error: null,
|
|
969
|
-
seed: Number(params.seed ?? 0),
|
|
970
|
-
};
|
|
971
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'fine_tuning_job.create', subjectType: 'fine_tuning_job', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
972
|
-
return { status: 200, body: ftView(resource) };
|
|
973
|
-
}
|
|
974
|
-
function ftView(r: Record<string, unknown>): Record<string, unknown> {
|
|
975
|
-
return { id: r.id, ...strip(r) };
|
|
976
|
-
}
|
|
977
|
-
/** The fine-tuned model objects minted by succeeded fine-tuning jobs (deletable, owned by the
|
|
978
|
-
* user), minus any that have been deleted via DELETE /v1/models/:id. */
|
|
979
|
-
function fineTunedModels(root?: string): Array<{ id: string; object: 'model'; created: number; owned_by: string }> {
|
|
980
|
-
const deleted = new Set(rows('model', root).filter((r) => r._deleted).map((r) => String(r.id)));
|
|
981
|
-
const out: Array<{ id: string; object: 'model'; created: number; owned_by: string }> = [];
|
|
982
|
-
for (const j of rows('fine_tuning_job', root)) {
|
|
983
|
-
const ftm = (j as { fine_tuned_model?: unknown }).fine_tuned_model;
|
|
984
|
-
if (typeof ftm === 'string' && ftm && !deleted.has(ftm)) {
|
|
985
|
-
out.push({ id: ftm, object: 'model', created: Number(j.created_at ?? 0), owned_by: 'org-twin' });
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
return out;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// ── Vector stores (stateful) ────────────────────────────────────────────────────────────
|
|
992
|
-
async function createVectorStore(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
993
|
-
const id = nextId('vector_store', 'vs', req.root);
|
|
994
|
-
const created = nowEpoch(req.occurredAt);
|
|
995
|
-
const fileIds = Array.isArray(params.file_ids) ? (params.file_ids as unknown[]) : [];
|
|
996
|
-
const fields = {
|
|
997
|
-
object: 'vector_store',
|
|
998
|
-
created_at: created,
|
|
999
|
-
name: typeof params.name === 'string' ? params.name : null,
|
|
1000
|
-
usage_bytes: 0,
|
|
1001
|
-
status: 'completed',
|
|
1002
|
-
file_counts: { in_progress: 0, completed: fileIds.length, failed: 0, cancelled: 0, total: fileIds.length },
|
|
1003
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {},
|
|
1004
|
-
last_active_at: created,
|
|
1005
|
-
};
|
|
1006
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'vector_store.create', subjectType: 'vector_store', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1007
|
-
// Seed any provided file_ids as vector_store_file children.
|
|
1008
|
-
for (const fid of fileIds) {
|
|
1009
|
-
await addVectorStoreFile(id, String(fid), req);
|
|
1010
|
-
}
|
|
1011
|
-
return { status: 200, body: vsView(resource) };
|
|
1012
|
-
}
|
|
1013
|
-
function vsView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1014
|
-
return { id: r.id, ...strip(r) };
|
|
1015
|
-
}
|
|
1016
|
-
async function addVectorStoreFile(storeId: string, fileId: string, req: OpenAIRequest, batchId?: string): Promise<Record<string, unknown>> {
|
|
1017
|
-
const created = nowEpoch(req.occurredAt);
|
|
1018
|
-
const childId = `${storeId}::${fileId}`;
|
|
1019
|
-
const fields = { object: 'vector_store.file', vector_store_id: storeId, file_id: fileId, created_at: created, status: 'completed', usage_bytes: 0, ...(batchId ? { _batch_id: batchId } : {}) };
|
|
1020
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'vector_store_file.create', subjectType: 'vector_store_file', subjectId: childId, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1021
|
-
return vsFileView(resource);
|
|
1022
|
-
}
|
|
1023
|
-
function vsFileView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1024
|
-
const s = strip(r);
|
|
1025
|
-
return { id: String(r.id).split('::')[1] ?? r.id, object: 'vector_store.file', file_id: s.file_id, vector_store_id: s.vector_store_id, created_at: s.created_at, status: s.status, usage_bytes: s.usage_bytes ?? 0 };
|
|
1026
|
-
}
|
|
1027
|
-
function vsBatchView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1028
|
-
const { _file_ids: _f, ...rest } = strip(r);
|
|
1029
|
-
return { id: r.id, ...rest };
|
|
1030
|
-
}
|
|
976
|
+
// ── Images (a real placeholder image, no model: ./openai-media.ts) ─────────────────────────────────
|
|
977
|
+
const gptImage = (model: string): boolean => /^(gpt-image|chatgpt-image)/.test(model);
|
|
1031
978
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
async function createProject(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1038
|
-
if (params.name === undefined || params.name === '') return invalidRequest("you must provide a name parameter", 'name');
|
|
1039
|
-
const id = nextId('project', 'proj', req.root);
|
|
1040
|
-
const created = nowEpoch(req.occurredAt);
|
|
1041
|
-
const fields = { object: 'organization.project', name: String(params.name), created_at: created, archived_at: null, status: 'active' };
|
|
1042
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'project.create', subjectType: 'project', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1043
|
-
return { status: 200, body: idView(resource) };
|
|
979
|
+
/** One answered image: the GPT image models give the image itself as base64, always; `dall-e-2` and `dall-e-3` a URL
|
|
980
|
+
* unless `response_format` is `b64_json`, and `dall-e-3` its revised prompt (developers.openai.com/api/reference/
|
|
981
|
+
* resources/images/methods/generate, `response_format`; the Image object's `b64_json`, `url`, `revised_prompt`). */
|
|
982
|
+
function imageData(params: Record<string, unknown>, model: string, size: { width: number; height: number }, seed: string, kind: string, i: number, occurredAt?: string): Record<string, unknown> {
|
|
983
|
+
return gptImage(model) || params.response_format === 'b64_json' ? { b64_json: base64(placeholderPng(size, `${seed} #${i + 1}`)) } : dalleUrl(model, seed, kind, i, occurredAt);
|
|
1044
984
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
const id = `key_twin_${projectId}_${seq}`;
|
|
1049
|
-
const created = nowEpoch(req.occurredAt);
|
|
1050
|
-
// a SYNTHETIC twin secret — clearly not a real OpenAI key (only shown at creation, like the vendor).
|
|
1051
|
-
const secret = `sk-twin-proj-${projectId}-${seq}`;
|
|
1052
|
-
const fields = { object: 'organization.project.api_key', name: String(params.name), created_at: created, project_id: projectId, redacted_value: `sk-twin-...${seq}`, _secret: secret };
|
|
1053
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'api_key.create', subjectType: 'api_key', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1054
|
-
// creation echoes the one-time secret `value`; subsequent reads only show redacted_value.
|
|
1055
|
-
return { status: 200, body: { ...apiKeyView(resource), value: secret } };
|
|
985
|
+
/** A DALL·E image as its URL, the default for `dall-e-2` and `dall-e-3`. */
|
|
986
|
+
function dalleUrl(model: string, seed: string, kind: string, i: number, occurredAt?: string): Record<string, unknown> {
|
|
987
|
+
return { url: `https://twin.invalid/openai-${kind}-stub/${nowEpoch(occurredAt)}-${i}.png`, ...(model === 'dall-e-3' ? { revised_prompt: `[twin-stub] ${seed}` } : {}) };
|
|
1056
988
|
}
|
|
1057
|
-
function
|
|
1058
|
-
const
|
|
1059
|
-
return
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
const
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
const { _parts: _p, ...rest } = strip(r);
|
|
1082
|
-
return { id: r.id, ...rest };
|
|
1083
|
-
}
|
|
1084
|
-
async function addUploadPart(uploadId: string, params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1085
|
-
const up = getRow('upload', uploadId, req.root);
|
|
1086
|
-
if (!up || up.status !== 'pending') return notFound(`No such Upload object: ${uploadId}`);
|
|
1087
|
-
if (params.data === undefined) return invalidRequest("you must provide a data parameter", 'data');
|
|
1088
|
-
const parts = Array.isArray((up as { _parts?: unknown })._parts) ? [...((up as { _parts: unknown[] })._parts)] : [];
|
|
1089
|
-
const partId = `part-twin-${uploadId}-${parts.length + 1}`;
|
|
1090
|
-
parts.push({ id: partId, data: String(params.data) });
|
|
1091
|
-
await applyTwinWrite(SERVICE, { operation: 'upload.update', subjectType: 'upload', subjectId: uploadId, fields: { _parts: parts }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1092
|
-
return { status: 200, body: { id: partId, object: 'upload.part', created_at: nowEpoch(req.occurredAt), upload_id: uploadId } };
|
|
1093
|
-
}
|
|
1094
|
-
async function completeUpload(uploadId: string, params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1095
|
-
const up = getRow('upload', uploadId, req.root);
|
|
1096
|
-
if (!up || up.status !== 'pending') return notFound(`No such Upload object: ${uploadId}`);
|
|
1097
|
-
if (!Array.isArray(params.part_ids) || params.part_ids.length === 0) return invalidRequest("you must provide a part_ids array", 'part_ids');
|
|
1098
|
-
const parts = Array.isArray((up as { _parts?: unknown })._parts) ? (up as { _parts: Array<{ id: string; data: string }> })._parts : [];
|
|
1099
|
-
const byId = new Map(parts.map((p) => [p.id, p.data]));
|
|
1100
|
-
// assemble the file content from the parts in the caller-specified order.
|
|
1101
|
-
let content = '';
|
|
1102
|
-
for (const pid of params.part_ids as string[]) {
|
|
1103
|
-
if (!byId.has(String(pid))) return invalidRequest(`unknown part_id '${pid}'`, 'part_ids');
|
|
1104
|
-
content += byId.get(String(pid));
|
|
1105
|
-
}
|
|
1106
|
-
// mint a real File object from the assembled content.
|
|
1107
|
-
const fileId = nextId('file', 'file', req.root);
|
|
1108
|
-
const created = nowEpoch(req.occurredAt);
|
|
1109
|
-
const fileFields = {
|
|
1110
|
-
object: 'file', bytes: Number(up.bytes ?? content.length), created_at: created,
|
|
1111
|
-
filename: String(up.filename), purpose: String(up.purpose), status: 'processed', _content: content,
|
|
1112
|
-
};
|
|
1113
|
-
await applyTwinWrite(SERVICE, { operation: 'file.create', subjectType: 'file', subjectId: fileId, fields: fileFields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1114
|
-
const fileObj = fileView({ id: fileId, ...fileFields });
|
|
1115
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'upload.update', subjectType: 'upload', subjectId: uploadId, fields: { status: 'completed', file: fileObj }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1116
|
-
return { status: 200, body: uploadView(resource) };
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
// ── Assistants / Threads / Runs (beta, stateful) ────────────────────────────────────────
|
|
1120
|
-
// The Assistants API is fully stateful CRUD over assistants, threads (+ messages), and runs
|
|
1121
|
-
// (+ run steps). The twin models the OBJECTS + lifecycle faithfully; a run cannot invoke a real
|
|
1122
|
-
// model, so a run completes immediately and any assistant reply message is a clearly-labeled stub.
|
|
1123
|
-
async function createAssistant(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1124
|
-
if (params.model === undefined || params.model === '') return invalidRequest("you must provide a model parameter", 'model');
|
|
1125
|
-
const id = nextId('assistant', 'asst', req.root);
|
|
1126
|
-
const created = nowEpoch(req.occurredAt);
|
|
1127
|
-
const fields = {
|
|
1128
|
-
object: 'assistant', created_at: created,
|
|
1129
|
-
name: params.name ?? null, description: params.description ?? null,
|
|
1130
|
-
model: String(params.model), instructions: params.instructions ?? null,
|
|
1131
|
-
tools: Array.isArray(params.tools) ? params.tools : [],
|
|
1132
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {},
|
|
1133
|
-
temperature: params.temperature ?? null, top_p: params.top_p ?? null,
|
|
1134
|
-
response_format: params.response_format ?? 'auto',
|
|
1135
|
-
};
|
|
1136
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'assistant.create', subjectType: 'assistant', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1137
|
-
return { status: 200, body: idView(resource) };
|
|
1138
|
-
}
|
|
1139
|
-
function idView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1140
|
-
return { id: r.id, ...strip(r) };
|
|
1141
|
-
}
|
|
1142
|
-
/** Pick the updatable fields present in an update body (vendor PATCH-via-POST semantics). */
|
|
1143
|
-
function pickUpdate(params: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
|
1144
|
-
const out: Record<string, unknown> = {};
|
|
1145
|
-
for (const k of keys) if (params[k] !== undefined) out[k] = params[k];
|
|
1146
|
-
return out;
|
|
1147
|
-
}
|
|
1148
|
-
async function createThread(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1149
|
-
const id = nextId('thread', 'thread', req.root);
|
|
1150
|
-
const created = nowEpoch(req.occurredAt);
|
|
1151
|
-
const fields = { object: 'thread', created_at: created, metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {}, tool_resources: params.tool_resources ?? null };
|
|
1152
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'thread.create', subjectType: 'thread', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1153
|
-
// Seed any inline messages provided at thread creation.
|
|
1154
|
-
if (Array.isArray(params.messages)) {
|
|
1155
|
-
for (const m of params.messages as Array<Record<string, unknown>>) await addThreadMessage(id, m, req);
|
|
1156
|
-
}
|
|
1157
|
-
return { status: 200, body: idView(resource) };
|
|
1158
|
-
}
|
|
1159
|
-
async function addThreadMessage(threadId: string, params: Record<string, unknown>, req: OpenAIRequest): Promise<Record<string, unknown>> {
|
|
1160
|
-
const seq = rows('message', req.root).filter((r) => r.thread_id === threadId).length + 1;
|
|
1161
|
-
const id = `msg-twin-${threadId}-${seq}`;
|
|
1162
|
-
const created = nowEpoch(req.occurredAt);
|
|
1163
|
-
const role = typeof params.role === 'string' ? params.role : 'user';
|
|
1164
|
-
const text = typeof params.content === 'string' ? params.content : Array.isArray(params.content) ? (params.content as Array<Record<string, unknown>>).map((p) => String((p as { text?: { value?: unknown } }).text?.value ?? (p as { text?: unknown }).text ?? '')).join('') : '';
|
|
1165
|
-
const fields = {
|
|
1166
|
-
object: 'thread.message', created_at: created, thread_id: threadId, role,
|
|
1167
|
-
content: [{ type: 'text', text: { value: text, annotations: [] } }],
|
|
1168
|
-
assistant_id: params.assistant_id ?? null, run_id: params.run_id ?? null,
|
|
1169
|
-
attachments: Array.isArray(params.attachments) ? params.attachments : [],
|
|
1170
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {},
|
|
1171
|
-
_seq: seq,
|
|
1172
|
-
};
|
|
1173
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'message.create', subjectType: 'message', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1174
|
-
return msgView(resource);
|
|
1175
|
-
}
|
|
1176
|
-
function msgView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1177
|
-
const { _seq: _s, ...rest } = strip(r);
|
|
1178
|
-
return { id: r.id, ...rest };
|
|
1179
|
-
}
|
|
1180
|
-
// A run completes immediately (the twin can't invoke the model): it appends a clearly-labeled
|
|
1181
|
-
// stub assistant message to the thread and records two run steps (message creation lifecycle).
|
|
1182
|
-
async function createRun(threadId: string, params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1183
|
-
if (params.assistant_id === undefined || params.assistant_id === '') return invalidRequest("you must provide an assistant_id parameter", 'assistant_id');
|
|
1184
|
-
const assistantId = String(params.assistant_id);
|
|
1185
|
-
const assistant = getRow('assistant', assistantId, req.root);
|
|
1186
|
-
if (!assistant || assistant._deleted) return notFound(`No assistant found with id '${assistantId}'.`);
|
|
1187
|
-
const id = nextId('run', 'run', req.root);
|
|
1188
|
-
const created = nowEpoch(req.occurredAt);
|
|
1189
|
-
const model = typeof params.model === 'string' && params.model ? params.model : String(assistant.model);
|
|
1190
|
-
// append the stub assistant reply to the thread.
|
|
1191
|
-
const replyText = `[twin-stub:${model}] deterministic assistant run output (no model weights are run)`;
|
|
1192
|
-
const reply = await addThreadMessage(threadId, { role: 'assistant', content: replyText, assistant_id: assistantId, run_id: id }, req);
|
|
1193
|
-
const fields = {
|
|
1194
|
-
object: 'thread.run', created_at: created, thread_id: threadId, assistant_id: assistantId,
|
|
1195
|
-
status: 'completed', model, instructions: params.instructions ?? assistant.instructions ?? null,
|
|
1196
|
-
tools: Array.isArray(params.tools) ? params.tools : (assistant.tools ?? []),
|
|
1197
|
-
started_at: created, completed_at: created, expires_at: null, cancelled_at: null, failed_at: null,
|
|
1198
|
-
required_action: null, last_error: null,
|
|
1199
|
-
usage: { prompt_tokens: 0, completion_tokens: estimateTokens(replyText), total_tokens: estimateTokens(replyText) },
|
|
1200
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {},
|
|
1201
|
-
_reply_msg: reply.id,
|
|
989
|
+
function imageCount(params: Record<string, unknown>): number {
|
|
990
|
+
const n = Number(params.n ?? 1);
|
|
991
|
+
return Number.isInteger(n) && n > 0 ? n : 1;
|
|
992
|
+
}
|
|
993
|
+
/** An image answer, and its events when streamed. */
|
|
994
|
+
export type ImageAnswer = OpenAIResponseEnvelope & { events?: Array<{ event: string; data: unknown }> };
|
|
995
|
+
|
|
996
|
+
/** A GPT image model's answer: the images with the tokens they cost (the image generation guide's 1024x1024
|
|
997
|
+
* medium-quality image is 1056 output tokens, scaled here by area; the twin reads no pixels, so it counts no input image
|
|
998
|
+
* tokens: https://platform.openai.com/docs/guides/image-generation#cost-and-latency), or, with `stream`, the
|
|
999
|
+
* `partial_images` asked (none by default: "When set to 0, the response will be a single image sent in one streaming
|
|
1000
|
+
* event") then the completed image (https://platform.openai.com/docs/api-reference/images-streaming). */
|
|
1001
|
+
function gptImageAnswer(params: Record<string, unknown>, size: { width: number; height: number }, data: Array<Record<string, unknown>>, kind: 'image_generation' | 'image_edit', occurredAt?: string): ImageAnswer {
|
|
1002
|
+
const text = estimateTokens(String(params.prompt ?? ''));
|
|
1003
|
+
const output = Math.ceil((1056 * size.width * size.height) / (1024 * 1024)) * data.length;
|
|
1004
|
+
const usage = { total_tokens: text + output, input_tokens: text, output_tokens: output, input_tokens_details: { text_tokens: text, image_tokens: 0 } };
|
|
1005
|
+
const created = nowEpoch(occurredAt);
|
|
1006
|
+
if (params.stream !== true && params.stream !== 'true') return { status: 200, body: { created, data, usage } };
|
|
1007
|
+
const settings = {
|
|
1008
|
+
created_at: created, size: `${size.width}x${size.height}`,
|
|
1009
|
+
// what `auto` settles on for a placeholder: an opaque PNG of medium quality
|
|
1010
|
+
quality: params.quality && params.quality !== 'auto' ? params.quality : 'medium',
|
|
1011
|
+
background: params.background && params.background !== 'auto' ? params.background : 'opaque',
|
|
1012
|
+
output_format: params.output_format ?? 'png',
|
|
1202
1013
|
};
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
}
|
|
1210
|
-
function runSteps(run: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
1211
|
-
const created = Number(run.created_at ?? 0);
|
|
1212
|
-
const replyMsg = String((run as { _reply_msg?: unknown })._reply_msg ?? '');
|
|
1213
|
-
return [{
|
|
1214
|
-
id: `step-${run.id}-1`, object: 'thread.run.step', created_at: created,
|
|
1215
|
-
run_id: String(run.id), assistant_id: String(run.assistant_id), thread_id: String(run.thread_id),
|
|
1216
|
-
type: 'message_creation', status: 'completed', completed_at: created,
|
|
1217
|
-
step_details: { type: 'message_creation', message_creation: { message_id: replyMsg } },
|
|
1218
|
-
usage: run.usage ?? null,
|
|
1219
|
-
}];
|
|
1014
|
+
const partials = Math.min(3, Math.max(0, Number(params.partial_images ?? 0) || 0));
|
|
1015
|
+
const b64 = String(data[0]?.b64_json ?? '');
|
|
1016
|
+
const events = [
|
|
1017
|
+
...Array.from({ length: partials }, (_, i) => ({ event: `${kind}.partial_image`, data: { type: `${kind}.partial_image`, b64_json: b64, ...settings, partial_image_index: i } })),
|
|
1018
|
+
{ event: `${kind}.completed`, data: { type: `${kind}.completed`, b64_json: b64, ...settings, usage } },
|
|
1019
|
+
];
|
|
1020
|
+
return { status: 200, body: { created, data, usage }, events };
|
|
1220
1021
|
}
|
|
1221
1022
|
|
|
1222
|
-
|
|
1223
|
-
function handleImages(params: Record<string, unknown>, occurredAt?: string): OpenAIResponseEnvelope {
|
|
1023
|
+
export function handleImages(params: Record<string, unknown>, occurredAt?: string): ImageAnswer {
|
|
1224
1024
|
if (params.prompt === undefined || params.prompt === '') return invalidRequest("you must provide a prompt parameter", 'prompt');
|
|
1225
|
-
const
|
|
1226
|
-
const
|
|
1227
|
-
const data = Array.from({ length:
|
|
1228
|
-
|
|
1229
|
-
revised_prompt: `[twin-stub] ${String(params.prompt)}`,
|
|
1230
|
-
}));
|
|
1025
|
+
const model = modelOf('createImage', params)!;
|
|
1026
|
+
const size = requestedSize(params.size);
|
|
1027
|
+
const data = Array.from({ length: imageCount(params) }, (_, i) => imageData(params, model, size, String(params.prompt), 'image', i, occurredAt));
|
|
1028
|
+
if (gptImage(model)) return gptImageAnswer(params, size, data, 'image_generation', occurredAt);
|
|
1231
1029
|
return { status: 200, body: { created: nowEpoch(occurredAt), data } };
|
|
1232
1030
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1031
|
+
/** OpenAI's refusal of an upload that is not an image the model takes. */
|
|
1032
|
+
const badImage = (formats: string): OpenAIResponseEnvelope => invalidRequest(`Invalid image file: 'image' must be ${formats}.`, 'image', 'invalid_image_format');
|
|
1033
|
+
|
|
1034
|
+
// An EDIT takes an image file and a prompt; a VARIATION (dall-e-2 only) a square PNG. Each is told by its bytes.
|
|
1035
|
+
export function handleImageEdit(params: Record<string, unknown>, occurredAt?: string): ImageAnswer {
|
|
1036
|
+
// a GPT image model edits several images sent as `image[]` (the form keeps the last one named so)
|
|
1037
|
+
const image = params.image ?? params['image[]'];
|
|
1038
|
+
if (image === undefined || image === '') return invalidRequest("you must provide an image to edit", 'image');
|
|
1237
1039
|
if (params.prompt === undefined || params.prompt === '') return invalidRequest("you must provide a prompt parameter", 'prompt');
|
|
1238
|
-
const
|
|
1239
|
-
const
|
|
1240
|
-
const
|
|
1040
|
+
const model = modelOf('createImageEdit', params)!;
|
|
1041
|
+
const bytes = partBytes(image);
|
|
1042
|
+
const format = bytes && imageFormat(bytes);
|
|
1043
|
+
if (gptImage(model) ? !format : format !== 'png' || !square(bytes!)) return badImage(gptImage(model) ? 'a png, webp, or jpg file' : 'a square png file');
|
|
1044
|
+
const size = requestedSize(params.size, format === 'png' ? pngSize(bytes!) : undefined);
|
|
1045
|
+
const data = Array.from({ length: imageCount(params) }, (_, i) => imageData(params, model, size, String(params.prompt), 'image-edit', i, occurredAt));
|
|
1046
|
+
if (gptImage(model)) return gptImageAnswer(params, size, data, 'image_edit', occurredAt);
|
|
1241
1047
|
return { status: 200, body: { created: nowEpoch(occurredAt), data } };
|
|
1242
1048
|
}
|
|
1243
|
-
|
|
1049
|
+
const square = (png: Uint8Array): boolean => { const s = pngSize(png); return s.width === s.height; };
|
|
1050
|
+
export function handleImageVariation(params: Record<string, unknown>, occurredAt?: string): OpenAIResponseEnvelope {
|
|
1244
1051
|
if (params.image === undefined || params.image === '') return invalidRequest("you must provide an image", 'image');
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
const
|
|
1052
|
+
const bytes = partBytes(params.image);
|
|
1053
|
+
if (!bytes || imageFormat(bytes) !== 'png' || !square(bytes)) return badImage('a valid square png file');
|
|
1054
|
+
const size = requestedSize(params.size);
|
|
1055
|
+
const data = Array.from({ length: imageCount(params) }, (_, i) => imageData(params, modelOf('createImageVariation', params)!, size, 'variation', 'image-variation', i, occurredAt));
|
|
1248
1056
|
return { status: 200, body: { created: nowEpoch(occurredAt), data } };
|
|
1249
1057
|
}
|
|
1250
1058
|
|
|
1251
|
-
// ── Audio (deterministic stubs;
|
|
1059
|
+
// ── Audio (deterministic stubs; the twin runs no model) ──────────────────────
|
|
1252
1060
|
// Transcription/translation cannot run a real speech model, so the twin returns a clearly
|
|
1253
|
-
// labeled deterministic transcript
|
|
1061
|
+
// labeled deterministic transcript naming the uploaded file. It takes the audio file itself, in a
|
|
1062
|
+
// format OpenAI transcribes (./openai-media.ts), never a file's name. The response SHAPE
|
|
1254
1063
|
// (json / verbose_json / text) is vendor-faithful.
|
|
1255
|
-
|
|
1256
|
-
|
|
1064
|
+
/** A multipart list field (`include[]`, `timestamp_granularities[]`), however the form named it. */
|
|
1065
|
+
function formList(params: Record<string, unknown>, name: string): string[] {
|
|
1066
|
+
const v = params[name] ?? params[`${name}[]`];
|
|
1067
|
+
return (Array.isArray(v) ? v : v === undefined ? [] : [v]).map(String);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** What a transcription is billed on: the GPT-4o transcribe models by tokens, whisper-1 and the diarizing model by the
|
|
1071
|
+
* audio's seconds (https://platform.openai.com/docs/api-reference/audio/json-object, `usage`). The twin counts ten
|
|
1072
|
+
* audio tokens a second and its transcript's text tokens; neither is a model's count. */
|
|
1073
|
+
function transcriptionUsage(model: string, seconds: number, text: string): Record<string, unknown> {
|
|
1074
|
+
if (model.startsWith('whisper') || model.includes('diarize')) return { type: 'duration', seconds: Math.ceil(seconds) };
|
|
1075
|
+
const audio = Math.ceil(seconds * 10);
|
|
1076
|
+
const out = estimateTokens(text);
|
|
1077
|
+
return { type: 'tokens', input_tokens: audio, input_token_details: { text_tokens: 0, audio_tokens: audio }, output_tokens: out, total_tokens: audio + out };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The transcript's words spread evenly over the audio: the twin hears nothing, so its timings are the file's length
|
|
1081
|
+
* shared out, not a model's alignment. */
|
|
1082
|
+
function words(text: string, seconds: number): Array<{ word: string; start: number; end: number }> {
|
|
1083
|
+
const all = text.split(/\s+/).filter(Boolean);
|
|
1084
|
+
const each = seconds / Math.max(1, all.length);
|
|
1085
|
+
return all.map((word, i) => ({ word, start: i * each, end: (i + 1) * each }));
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export type Transcription = { status: 200; body: unknown; seconds: number; events?: Array<{ data: unknown }> } | OpenAIResponseEnvelope;
|
|
1089
|
+
|
|
1090
|
+
/** A transcription (or translation) of the uploaded audio: a labeled transcript naming the file, in the response
|
|
1091
|
+
* format asked (json, text, verbose_json with the word or segment timestamps asked, or diarized_json), with the
|
|
1092
|
+
* token log probabilities when `include[]=logprobs`, and as `transcript.text.delta` events then
|
|
1093
|
+
* `transcript.text.done` when `stream=true` on a model that streams (https://platform.openai.com/docs/api-reference/audio/createTranscription). */
|
|
1094
|
+
export function handleTranscription(params: Record<string, unknown>, translate: boolean): Transcription {
|
|
1257
1095
|
if (params.file === undefined || params.file === '') return invalidRequest("you must provide a file parameter", 'file');
|
|
1258
1096
|
if (params.model === undefined || params.model === '') return invalidRequest("you must provide a model parameter", 'model');
|
|
1259
|
-
const
|
|
1097
|
+
const bytes = partBytes(params.file);
|
|
1098
|
+
if (!bytes || !audioFormat(bytes)) return invalidRequest("Invalid file format. Supported formats: ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']", 'file', 'invalid_value');
|
|
1099
|
+
const filename = String((params.file as { name?: string }).name || 'upload');
|
|
1100
|
+
const model = String(params.model);
|
|
1260
1101
|
const verb = translate ? 'translation' : 'transcription';
|
|
1261
1102
|
const text = `[twin-stub] deterministic ${verb} of ${filename} (no speech model is run)`;
|
|
1262
1103
|
const format = typeof params.response_format === 'string' ? params.response_format : 'json';
|
|
1263
|
-
|
|
1104
|
+
// the file's own length where the twin can read it (a WAV's header), else a labeled second
|
|
1105
|
+
const duration = wavSeconds(bytes) ?? 1.0;
|
|
1106
|
+
if (format === 'text') return { status: 200, body: text, seconds: duration };
|
|
1107
|
+
if (translate) return { status: 200, seconds: duration, body: format === 'verbose_json' ? { task: 'translation', language: 'english', duration, text, segments: [{ id: 0, start: 0, end: duration, text }] } : { text } };
|
|
1108
|
+
const usage = transcriptionUsage(model, duration, text);
|
|
1109
|
+
if (format === 'diarized_json') {
|
|
1110
|
+
// one speaker the twin cannot tell apart: the first name the caller knows, else OpenAI's first label
|
|
1111
|
+
const speaker = formList(params, 'known_speaker_names')[0] ?? 'A';
|
|
1112
|
+
return { status: 200, seconds: duration, body: { task: 'transcribe', duration, text, segments: [{ type: 'transcript.text.segment', id: 'seg_001', start: 0, end: duration, text, speaker }], usage } };
|
|
1113
|
+
}
|
|
1264
1114
|
if (format === 'verbose_json') {
|
|
1265
|
-
|
|
1115
|
+
const granularities = formList(params, 'timestamp_granularities');
|
|
1116
|
+
const segment = { id: 0, seek: 0, start: 0, end: duration, text, tokens: [], temperature: 0, avg_logprob: 0, compression_ratio: 1, no_speech_prob: 0 };
|
|
1117
|
+
return {
|
|
1118
|
+
status: 200,
|
|
1119
|
+
seconds: duration,
|
|
1120
|
+
body: {
|
|
1121
|
+
task: 'transcribe', language: typeof params.language === 'string' ? params.language : 'english', duration, text,
|
|
1122
|
+
...(granularities.includes('word') ? { words: words(text, duration) } : {}),
|
|
1123
|
+
...(!granularities.length || granularities.includes('segment') ? { segments: [segment] } : {}),
|
|
1124
|
+
usage,
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1266
1127
|
}
|
|
1267
|
-
|
|
1268
|
-
}
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1128
|
+
const logprobs = formList(params, 'include').includes('logprobs') ? buildLogprobs(text, 0).content.map(({ token, logprob, bytes: b }) => ({ token, logprob, bytes: b })) : undefined;
|
|
1129
|
+
const body = { text, ...(logprobs ? { logprobs } : {}), usage };
|
|
1130
|
+
// whisper-1 does not stream ("Streaming is not supported for the whisper-1 model and will be ignored")
|
|
1131
|
+
if (String(params.stream) === 'true' && !model.startsWith('whisper')) {
|
|
1132
|
+
const pieces = text.match(/\s*\S+/g) ?? [];
|
|
1133
|
+
const events = pieces.map((delta) => ({ data: { type: 'transcript.text.delta', delta, ...(logprobs ? { logprobs: buildLogprobs(delta, 0).content.map(({ token, logprob, bytes: b }) => ({ token, logprob, bytes: b })) } : {}) } }));
|
|
1134
|
+
events.push({ data: { type: 'transcript.text.done', text, ...(logprobs ? { logprobs } : {}), usage } as never });
|
|
1135
|
+
return { status: 200, body, seconds: duration, events };
|
|
1136
|
+
}
|
|
1137
|
+
return { status: 200, body, seconds: duration };
|
|
1138
|
+
}
|
|
1139
|
+
// Text-to-speech: runs no speech model, but answers the audio file itself in the requested format, as OpenAI does
|
|
1140
|
+
// (a second of silence: ./openai-media.ts), with that format's Content-Type.
|
|
1141
|
+
const SPEECH: Record<string, { type: string; file: () => Uint8Array }> = {
|
|
1142
|
+
mp3: { type: 'audio/mpeg', file: silentMp3 },
|
|
1143
|
+
opus: { type: 'audio/opus', file: silentOpus },
|
|
1144
|
+
aac: { type: 'audio/aac', file: silentAac },
|
|
1145
|
+
flac: { type: 'audio/flac', file: silentFlac },
|
|
1146
|
+
wav: { type: 'audio/wav', file: silentWav },
|
|
1147
|
+
pcm: { type: 'audio/pcm', file: silentPcm },
|
|
1148
|
+
};
|
|
1149
|
+
export function handleSpeech(params: Record<string, unknown>): OpenAIResponseEnvelope | { status: 200; audio: Uint8Array; type: string } {
|
|
1272
1150
|
if (params.model === undefined || params.model === '') return invalidRequest("you must provide a model parameter", 'model');
|
|
1273
1151
|
if (params.input === undefined || params.input === '') return invalidRequest("you must provide an input parameter", 'input');
|
|
1274
1152
|
if (params.voice === undefined || params.voice === '') return invalidRequest("you must provide a voice parameter", 'voice');
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
return { status: 200,
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
// ──
|
|
1281
|
-
// The
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
return (h >>> 0) / 0xffffffff;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// ── Evals API (stateful: eval config → runs → output items) ──────────────────────────────
|
|
1292
|
-
// The Evals API systematically evaluates model output. The twin cannot run a real grader, so a
|
|
1293
|
-
// run completes immediately with a deterministic synthetic result + one output item per datasource
|
|
1294
|
-
// item (passed, since the twin's stub output is graded by a deterministic stub grader). The eval/
|
|
1295
|
-
// run/output-item SHAPES are vendor-faithful; the grading values are clearly-deterministic stubs.
|
|
1296
|
-
async function createEval(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1297
|
-
if (params.data_source_config === undefined) return invalidRequest("you must provide a data_source_config parameter", 'data_source_config');
|
|
1298
|
-
if (params.testing_criteria === undefined || !Array.isArray(params.testing_criteria)) return invalidRequest("you must provide a testing_criteria parameter (array)", 'testing_criteria');
|
|
1299
|
-
const id = nextId('eval', 'eval', req.root);
|
|
1300
|
-
const fields = {
|
|
1301
|
-
object: 'eval',
|
|
1302
|
-
name: typeof params.name === 'string' ? params.name : `eval-${id}`,
|
|
1303
|
-
created_at: nowEpoch(req.occurredAt),
|
|
1304
|
-
data_source_config: params.data_source_config,
|
|
1305
|
-
testing_criteria: params.testing_criteria,
|
|
1306
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {},
|
|
1307
|
-
};
|
|
1308
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'eval.create', subjectType: 'eval', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1309
|
-
return { status: 200, body: idView(resource) };
|
|
1310
|
-
}
|
|
1311
|
-
function evalView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1312
|
-
return { id: r.id, ...strip(r) };
|
|
1313
|
-
}
|
|
1314
|
-
// A run records a synthetic per-model usage + a result_counts of passed items. The twin grades a
|
|
1315
|
-
// fixed single datasource item (the twin cannot fetch a real dataset) → 1 passed item.
|
|
1316
|
-
async function createEvalRun(evalId: string, params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1317
|
-
if (params.data_source === undefined) return invalidRequest("you must provide a data_source parameter", 'data_source');
|
|
1318
|
-
const id = nextId('evalrun', 'evalrun', req.root);
|
|
1319
|
-
const created = nowEpoch(req.occurredAt);
|
|
1320
|
-
const model = typeof (params.data_source as { model?: unknown })?.model === 'string' ? String((params.data_source as { model?: unknown }).model) : 'gpt-4o';
|
|
1321
|
-
const fields = {
|
|
1322
|
-
object: 'eval.run',
|
|
1323
|
-
eval_id: evalId,
|
|
1324
|
-
name: typeof params.name === 'string' ? params.name : `run-${id}`,
|
|
1325
|
-
created_at: created,
|
|
1326
|
-
status: 'completed',
|
|
1327
|
-
model,
|
|
1328
|
-
data_source: params.data_source,
|
|
1329
|
-
result_counts: { total: 1, errored: 0, failed: 0, passed: 1 },
|
|
1330
|
-
per_model_usage: [{ model_name: model, invocation_count: 1, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, cached_tokens: 0 }],
|
|
1331
|
-
per_testing_criteria_results: [{ testing_criteria: 'twin-stub-grader', passed: 1, failed: 0 }],
|
|
1332
|
-
report_url: `https://twin.invalid/evals/${evalId}/runs/${id}`,
|
|
1333
|
-
metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {},
|
|
1334
|
-
error: null,
|
|
1335
|
-
};
|
|
1336
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'eval_run.create', subjectType: 'eval_run', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1337
|
-
return { status: 200, body: evalRunView(resource) };
|
|
1338
|
-
}
|
|
1339
|
-
function evalRunView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1340
|
-
return { id: r.id, ...strip(r) };
|
|
1341
|
-
}
|
|
1342
|
-
// The output items for a completed run: a single deterministic passed item (the twin's stub
|
|
1343
|
-
// output graded by a deterministic stub grader). Shape: object:'eval.run.output_item'.
|
|
1344
|
-
function evalRunOutputItems(run: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
1345
|
-
const created = Number(run.created_at ?? 0);
|
|
1346
|
-
return [{
|
|
1347
|
-
id: `evalitem-${run.id}-1`,
|
|
1348
|
-
object: 'eval.run.output_item',
|
|
1349
|
-
created_at: created,
|
|
1350
|
-
run_id: String(run.id),
|
|
1351
|
-
eval_id: String(run.eval_id),
|
|
1352
|
-
status: 'pass',
|
|
1353
|
-
datasource_item_id: 0,
|
|
1354
|
-
datasource_item: { item: { input: '[twin-stub] datasource item' } },
|
|
1355
|
-
results: [{ name: 'twin-stub-grader', passed: true, score: 1.0 }],
|
|
1356
|
-
sample: { input: [], output: [{ role: 'assistant', content: '[twin-stub] eval sample output (no real model run)' }], finish_reason: 'stop', model: String(run.model ?? 'gpt-4o'), usage: { total_tokens: 0, completion_tokens: 0, prompt_tokens: 0, cached_tokens: 0 }, error: null },
|
|
1357
|
-
}];
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// ── Containers API (code-interpreter sandboxes: container → container files) ──────────────
|
|
1361
|
-
// A container is an isolated sandbox for the code_interpreter tool. The twin cannot run a real
|
|
1362
|
-
// sandbox, so a container is created `running` and its files are stored faithfully (metadata +
|
|
1363
|
-
// supplied text content, like the Files API). The SHAPES are vendor-faithful.
|
|
1364
|
-
async function createContainer(params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1365
|
-
if (params.name === undefined || params.name === '') return invalidRequest("you must provide a name parameter", 'name');
|
|
1366
|
-
const id = nextId('container', 'cntr', req.root);
|
|
1367
|
-
const created = nowEpoch(req.occurredAt);
|
|
1368
|
-
const fields = {
|
|
1369
|
-
object: 'container',
|
|
1370
|
-
name: String(params.name),
|
|
1371
|
-
created_at: created,
|
|
1372
|
-
status: 'running',
|
|
1373
|
-
last_active_at: created,
|
|
1374
|
-
expires_after: (params.expires_after && typeof params.expires_after === 'object') ? params.expires_after : { anchor: 'last_active_at', minutes: 20 },
|
|
1375
|
-
};
|
|
1376
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'container.create', subjectType: 'container', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1377
|
-
return { status: 200, body: containerView(resource) };
|
|
1378
|
-
}
|
|
1379
|
-
function containerView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1380
|
-
return { id: r.id, ...strip(r) };
|
|
1381
|
-
}
|
|
1382
|
-
async function createContainerFile(containerId: string, params: Record<string, unknown>, req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1383
|
-
// The server adapts a multipart upload (or a JSON { file_id } reference) into JSON; the twin
|
|
1384
|
-
// accepts either an inline `content`+`path` (text) or a `file_id` referencing a stored File.
|
|
1385
|
-
const seq = rows('container_file', req.root).filter((r) => r.container_id === containerId).length + 1;
|
|
1386
|
-
const id = `cfile-twin-${containerId}-${seq}`;
|
|
1387
|
-
const created = nowEpoch(req.occurredAt);
|
|
1388
|
-
let content = '';
|
|
1389
|
-
let source = 'user';
|
|
1390
|
-
if (typeof params.file_id === 'string' && params.file_id) {
|
|
1391
|
-
const f = getRow('file', params.file_id, req.root);
|
|
1392
|
-
if (!f || f._deleted) return notFound(`No such File object: ${String(params.file_id)}`);
|
|
1393
|
-
content = String(f._content ?? '');
|
|
1394
|
-
source = 'file_id';
|
|
1395
|
-
} else if (typeof params.content === 'string') {
|
|
1396
|
-
content = params.content;
|
|
1397
|
-
}
|
|
1398
|
-
const path = typeof params.path === 'string' && params.path ? params.path : `/mnt/data/${id}`;
|
|
1399
|
-
const fields = {
|
|
1400
|
-
object: 'container.file',
|
|
1401
|
-
container_id: containerId,
|
|
1402
|
-
created_at: created,
|
|
1403
|
-
bytes: content.length,
|
|
1404
|
-
path,
|
|
1405
|
-
source,
|
|
1406
|
-
_content: content,
|
|
1407
|
-
};
|
|
1408
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'container_file.create', subjectType: 'container_file', subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1409
|
-
return { status: 200, body: containerFileView(resource) };
|
|
1410
|
-
}
|
|
1411
|
-
function containerFileView(r: Record<string, unknown>): Record<string, unknown> {
|
|
1412
|
-
return { id: r.id, ...strip(r) };
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// ── public entry: cross-cutting protocol (auth / rate-limit / idempotency) then route ─────
|
|
1416
|
-
// The HTTP server passes request headers, so on the live wire every call is auth/rate-limit/
|
|
1417
|
-
// idempotency-checked; in-process trusted calls (capability verify, connector) omit `headers`/
|
|
1418
|
-
// `apiKey` and are NOT gated. Idempotency-Key dedup wraps the WHOLE request so any successful
|
|
1419
|
-
// mutation is replayed verbatim on a re-issue with the same key.
|
|
1153
|
+
const format = SPEECH[String(params.response_format ?? 'mp3')];
|
|
1154
|
+
if (!format) return invalidRequest("'response_format' must be one of 'mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'", 'response_format');
|
|
1155
|
+
return { status: 200, audio: format.file(), type: format.type };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// ── public entry: cross-cutting protocol (auth / rate-limit) then route ─────────────────────
|
|
1159
|
+
// The HTTP server passes request headers, so on the live wire every call is auth/rate-limit-
|
|
1160
|
+
// checked; in-process trusted calls (capability verify, connector) omit `headers`/`apiKey` and are
|
|
1161
|
+
// NOT gated.
|
|
1162
|
+
/** OpenAI's API as one call: the request goes through the pack's own dispatch (the derived dispatch over its
|
|
1163
|
+
* semantics, its derived core and the hand-written routes below), so a caller that holds no HTTP
|
|
1164
|
+
* server reaches exactly what an SDK does. A caller that presents no credential is a trusted
|
|
1165
|
+
* in-process caller and is not held to the auth rule; `sseSink` receives a stream's events. */
|
|
1420
1166
|
export async function handleOpenAITwinRequest(req: OpenAIRequest): Promise<OpenAIResponseEnvelope> {
|
|
1167
|
+
const twin = createOpenAITwinFetch({
|
|
1168
|
+
...(req.root !== undefined ? { root: req.root } : {}),
|
|
1169
|
+
readOnly: req.readOnly ?? false,
|
|
1170
|
+
...(req.scenarioEngine ? { scenarioEngine: req.scenarioEngine } : {}),
|
|
1171
|
+
...(req.occurredAt ? { clock: () => req.occurredAt! } : {}),
|
|
1172
|
+
});
|
|
1173
|
+
const headers: Record<string, string> = { ...(req.headers ?? {}) };
|
|
1174
|
+
if (req.apiKey !== undefined) headers.authorization = `Bearer ${req.apiKey}`;
|
|
1175
|
+
else if (req.headers === undefined) headers.authorization = 'Bearer sk-twin-in-process';
|
|
1421
1176
|
const method = req.method.toUpperCase();
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
if (
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
const cached = getIdempotentResult(idemKey, req.root);
|
|
1436
|
-
if (cached) return cached;
|
|
1437
|
-
const result = await routeOpenAI(req, method);
|
|
1438
|
-
if (result.status >= 200 && result.status < 300) await storeIdempotentResult(idemKey, result, req);
|
|
1439
|
-
return result;
|
|
1440
|
-
}
|
|
1441
|
-
return routeOpenAI(req, method);
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
// ── router ──────────────────────────────────────────────────────────────────────────────
|
|
1445
|
-
async function routeOpenAI(req: OpenAIRequest, method: string): Promise<OpenAIResponseEnvelope> {
|
|
1446
|
-
const path = (req.path.split('?')[0] ?? '/').replace(/\/+$/, '') || '/';
|
|
1447
|
-
const seg = path.replace(/^\/+/, '').split('/'); // ["v1","chat","completions",...]
|
|
1448
|
-
const params = parseJson(req.body);
|
|
1449
|
-
const dec = (s: string) => decodeURIComponent(s);
|
|
1450
|
-
|
|
1451
|
-
// D3: a read-only twin rejects any mutation with a vendor-shaped error.
|
|
1452
|
-
if (req.readOnly && method !== 'GET') {
|
|
1453
|
-
return { status: 405, body: errBody('invalid_request_error', 'twin is read-only; omit readOnly to accept writes', 'method_not_allowed') };
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
// ---- models (static catalog + fine-tuned models minted by fine-tuning jobs) ----
|
|
1457
|
-
if (path === '/v1/models' && method === 'GET') {
|
|
1458
|
-
// List the static catalog plus every (non-deleted) fine-tuned model produced by a job.
|
|
1459
|
-
return { status: 200, body: { object: 'list', data: [...OPENAI_MODELS, ...fineTunedModels(req.root)] } };
|
|
1460
|
-
}
|
|
1461
|
-
if (seg[1] === 'models' && seg.length === 3 && method === 'GET') {
|
|
1462
|
-
const mid = dec(seg[2]!);
|
|
1463
|
-
const m = findModel(mid) ?? fineTunedModels(req.root).find((fm) => fm.id === mid);
|
|
1464
|
-
return m ? { status: 200, body: m } : notFound(`The model '${mid}' does not exist`, 'model_not_found');
|
|
1465
|
-
}
|
|
1466
|
-
// Delete a fine-tuned model (only models you own — base catalog models can't be deleted).
|
|
1467
|
-
if (seg[1] === 'models' && seg.length === 3 && method === 'DELETE') {
|
|
1468
|
-
const mid = dec(seg[2]!);
|
|
1469
|
-
if (findModel(mid)) return invalidRequest(`The model '${mid}' cannot be deleted`, 'model', 'model_not_deletable');
|
|
1470
|
-
const ft = fineTunedModels(req.root).find((fm) => fm.id === mid);
|
|
1471
|
-
if (!ft) return notFound(`The model '${mid}' does not exist`, 'model_not_found');
|
|
1472
|
-
await applyTwinWrite(SERVICE, { operation: 'model.delete', subjectType: 'model', subjectId: mid, fields: { _deleted: true, object: 'model' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1473
|
-
return { status: 200, body: { id: mid, object: 'model', deleted: true } };
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
// ---- stored chat completions (store:true) — retrieve / list / messages / delete ----
|
|
1477
|
-
// (must precede the generic create route below; these are GET/DELETE on the same prefix.)
|
|
1478
|
-
if (path === '/v1/chat/completions' && method === 'GET') {
|
|
1479
|
-
const items = rows('chat_completion', req.root).filter((r) => !r._deleted).map(storedChatView).sort((a, b) => Number(b.created) - Number(a.created));
|
|
1480
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1481
|
-
}
|
|
1482
|
-
if (seg[1] === 'chat' && seg[2] === 'completions' && seg.length === 4 && method === 'GET') {
|
|
1483
|
-
const c = getRow('chat_completion', dec(seg[3]!), req.root);
|
|
1484
|
-
return c && !c._deleted ? { status: 200, body: storedChatView(c) } : notFound(`No chat completion found with id '${dec(seg[3]!)}'.`);
|
|
1485
|
-
}
|
|
1486
|
-
if (seg[1] === 'chat' && seg[2] === 'completions' && seg.length === 5 && seg[4] === 'messages' && method === 'GET') {
|
|
1487
|
-
const c = getRow('chat_completion', dec(seg[3]!), req.root);
|
|
1488
|
-
if (!c || c._deleted) return notFound(`No chat completion found with id '${dec(seg[3]!)}'.`);
|
|
1489
|
-
const msgs = ((c as { _input_messages?: unknown })._input_messages as unknown[]) ?? [];
|
|
1490
|
-
return { status: 200, body: paginate(msgs as Array<Record<string, unknown>>, req.path) };
|
|
1491
|
-
}
|
|
1492
|
-
if (seg[1] === 'chat' && seg[2] === 'completions' && seg.length === 4 && method === 'DELETE') {
|
|
1493
|
-
const cid = dec(seg[3]!);
|
|
1494
|
-
const c = getRow('chat_completion', cid, req.root);
|
|
1495
|
-
if (!c || c._deleted) return notFound(`No chat completion found with id '${cid}'.`);
|
|
1496
|
-
await applyTwinWrite(SERVICE, { operation: 'chat_completion.delete', subjectType: 'chat_completion', subjectId: cid, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1497
|
-
return { status: 200, body: { id: cid, object: 'chat.completion.deleted', deleted: true } };
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
// ---- chat completions (the generative stub; envelope is faithful) ----
|
|
1501
|
-
if (path === '/v1/chat/completions' && method === 'POST') {
|
|
1502
|
-
const validated = validateChat(params);
|
|
1503
|
-
if ('error' in validated) return validated.error;
|
|
1504
|
-
const args = validated.args;
|
|
1505
|
-
let result: ChatCompletion;
|
|
1506
|
-
if (args.stream && req.sseSink) result = streamChat(args, req.sseSink, req.occurredAt, req.scenarioEngine);
|
|
1507
|
-
else result = buildChatCompletion(args, req.occurredAt, req.scenarioEngine);
|
|
1508
|
-
// store:true → persist the completion so it can be retrieved/listed later (stored completions).
|
|
1509
|
-
if (args.store) await storeChatCompletion(result, args, req);
|
|
1510
|
-
await recordUsage(req, 'completions', result.model, result.usage.prompt_tokens, result.usage.completion_tokens);
|
|
1511
|
-
return { status: 200, body: result };
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// ---- responses API ----
|
|
1515
|
-
if (path === '/v1/responses' && method === 'POST') {
|
|
1516
|
-
const validated = validateResponses(params);
|
|
1517
|
-
if ('error' in validated) return validated.error;
|
|
1518
|
-
const args = validated.args;
|
|
1519
|
-
// previous_response_id chaining: the referenced response must exist; its assistant output is
|
|
1520
|
-
// folded into the prompt context (so usage reflects the chained history — server-side state).
|
|
1521
|
-
if (args.previousResponseId) {
|
|
1522
|
-
const prior = getRow('response', args.previousResponseId, req.root);
|
|
1523
|
-
if (!prior || prior._deleted) return notFound(`Response with id '${args.previousResponseId}' not found.`);
|
|
1524
|
-
const priorText = String((prior as { output_text?: unknown }).output_text ?? '');
|
|
1525
|
-
args.messages = [{ role: 'assistant', content: priorText }, ...args.messages];
|
|
1526
|
-
}
|
|
1527
|
-
// Deterministic stored-response id (hash of model+input+chain) so retrieval is assertable.
|
|
1528
|
-
const suffix = responseSuffix(args);
|
|
1529
|
-
// background:true → the response is created `queued` and processed asynchronously. The twin has
|
|
1530
|
-
// nothing to run in the background, so it persists a queued response that the FIRST poll
|
|
1531
|
-
// (GET /v1/responses/:id) transitions to `completed` (computing the real stub output then). The
|
|
1532
|
-
// queued envelope carries no output yet; background implies store (so it can be polled/cancelled).
|
|
1533
|
-
if (args.background) {
|
|
1534
|
-
const queued = await storeQueuedResponse(args, req, suffix);
|
|
1535
|
-
return { status: 200, body: responseView(queued) };
|
|
1536
|
-
}
|
|
1537
|
-
if (args.stream) {
|
|
1538
|
-
const resp = req.sseSink ? streamResponse(args, req.sseSink, req.occurredAt, suffix) : buildResponse(args, req.occurredAt, suffix);
|
|
1539
|
-
if (args.store) await storeResponse(resp, args, req);
|
|
1540
|
-
await recordUsage(req, 'responses', resp.model, resp.usage.input_tokens, resp.usage.output_tokens);
|
|
1541
|
-
return { status: 200, body: resp };
|
|
1542
|
-
}
|
|
1543
|
-
const resp = buildResponse(args, req.occurredAt, suffix);
|
|
1544
|
-
if (args.store) await storeResponse(resp, args, req);
|
|
1545
|
-
await recordUsage(req, 'responses', resp.model, resp.usage.input_tokens, resp.usage.output_tokens);
|
|
1546
|
-
return { status: 200, body: resp };
|
|
1547
|
-
}
|
|
1548
|
-
// Responses: retrieve / delete a stored response by id.
|
|
1549
|
-
if (seg[1] === 'responses' && seg.length === 3 && method === 'GET') {
|
|
1550
|
-
const r = getRow('response', dec(seg[2]!), req.root);
|
|
1551
|
-
if (!r || r._deleted) return notFound(`Response with id '${dec(seg[2]!)}' not found.`);
|
|
1552
|
-
// A queued background response is processed on first poll: transition queued → completed,
|
|
1553
|
-
// computing the real stub output + usage at that point (then record billable usage once).
|
|
1554
|
-
if (r.status === 'queued') {
|
|
1555
|
-
const completed = await completeQueuedResponse(r, req);
|
|
1556
|
-
return { status: 200, body: responseView(completed) };
|
|
1557
|
-
}
|
|
1558
|
-
return { status: 200, body: responseView(r) };
|
|
1559
|
-
}
|
|
1560
|
-
// Responses: cancel a background response (only a queued/in_progress one can be cancelled).
|
|
1561
|
-
if (seg[1] === 'responses' && seg.length === 4 && seg[3] === 'cancel' && method === 'POST') {
|
|
1562
|
-
const rid = dec(seg[2]!);
|
|
1563
|
-
const r = getRow('response', rid, req.root);
|
|
1564
|
-
if (!r || r._deleted) return notFound(`Response with id '${rid}' not found.`);
|
|
1565
|
-
if (r.status !== 'queued' && r.status !== 'in_progress') {
|
|
1566
|
-
return invalidRequest(`Cannot cancel a response with status '${String(r.status)}'.`, null, 'invalid_status');
|
|
1567
|
-
}
|
|
1568
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'response.cancel', subjectType: 'response', subjectId: rid, fields: { status: 'cancelled' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1569
|
-
return { status: 200, body: responseView(resource) };
|
|
1570
|
-
}
|
|
1571
|
-
if (seg[1] === 'responses' && seg.length === 3 && method === 'DELETE') {
|
|
1572
|
-
const rid = dec(seg[2]!);
|
|
1573
|
-
const r = getRow('response', rid, req.root);
|
|
1574
|
-
if (!r || r._deleted) return notFound(`Response with id '${rid}' not found.`);
|
|
1575
|
-
await applyTwinWrite(SERVICE, { operation: 'response.delete', subjectType: 'response', subjectId: rid, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1576
|
-
return { status: 200, body: { id: rid, object: 'response.deleted', deleted: true } };
|
|
1577
|
-
}
|
|
1578
|
-
// Responses: list the input items of a stored response (the user turns folded into it).
|
|
1579
|
-
if (seg[1] === 'responses' && seg.length === 4 && seg[3] === 'input_items' && method === 'GET') {
|
|
1580
|
-
const r = getRow('response', dec(seg[2]!), req.root);
|
|
1581
|
-
if (!r || r._deleted) return notFound(`Response with id '${dec(seg[2]!)}' not found.`);
|
|
1582
|
-
const items = ((r as { _input_items?: unknown })._input_items as unknown[]) ?? [];
|
|
1583
|
-
return { status: 200, body: { object: 'list', data: items, has_more: false } };
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
// ---- embeddings ----
|
|
1587
|
-
if (path === '/v1/embeddings' && method === 'POST') {
|
|
1588
|
-
const res = handleEmbeddings(params);
|
|
1589
|
-
if (res.status === 200) {
|
|
1590
|
-
const b = res.body as EmbeddingResponse;
|
|
1591
|
-
await recordUsage(req, 'embeddings', b.model, b.usage.prompt_tokens, 0);
|
|
1592
|
-
}
|
|
1593
|
-
return res;
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
// ---- moderations ----
|
|
1597
|
-
if (path === '/v1/moderations' && method === 'POST') {
|
|
1598
|
-
return handleModerations(params, req.occurredAt);
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
// ---- images ----
|
|
1602
|
-
if (path === '/v1/images/generations' && method === 'POST') {
|
|
1603
|
-
return handleImages(params, req.occurredAt);
|
|
1604
|
-
}
|
|
1605
|
-
if (path === '/v1/images/edits' && method === 'POST') return handleImageEdit(params, req.occurredAt);
|
|
1606
|
-
if (path === '/v1/images/variations' && method === 'POST') return handleImageVariation(params, req.occurredAt);
|
|
1607
|
-
|
|
1608
|
-
// ---- audio ----
|
|
1609
|
-
if (path === '/v1/audio/transcriptions' && method === 'POST') return handleTranscription(params, false);
|
|
1610
|
-
if (path === '/v1/audio/translations' && method === 'POST') return handleTranscription(params, true);
|
|
1611
|
-
if (path === '/v1/audio/speech' && method === 'POST') return handleSpeech(params);
|
|
1612
|
-
|
|
1613
|
-
// ---- files (stateful) ----
|
|
1614
|
-
if (path === '/v1/files' && method === 'POST') return createFile(params, req);
|
|
1615
|
-
if (path === '/v1/files' && method === 'GET') {
|
|
1616
|
-
const items = rows('file', req.root).filter((r) => !r._deleted).map(fileView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1617
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1618
|
-
}
|
|
1619
|
-
if (seg[1] === 'files' && seg.length === 3 && method === 'GET') {
|
|
1620
|
-
const f = getRow('file', dec(seg[2]!), req.root);
|
|
1621
|
-
return f && !f._deleted ? { status: 200, body: fileView(f) } : notFound(`No such File object: ${dec(seg[2]!)}`);
|
|
1622
|
-
}
|
|
1623
|
-
if (seg[1] === 'files' && seg.length === 4 && seg[3] === 'content' && method === 'GET') {
|
|
1624
|
-
const f = getRow('file', dec(seg[2]!), req.root);
|
|
1625
|
-
if (!f || f._deleted) return notFound(`No such File object: ${dec(seg[2]!)}`);
|
|
1626
|
-
return { status: 200, body: (f._content as string) ?? '' };
|
|
1627
|
-
}
|
|
1628
|
-
if (seg[1] === 'files' && seg.length === 3 && method === 'DELETE') {
|
|
1629
|
-
const id = dec(seg[2]!);
|
|
1630
|
-
const f = getRow('file', id, req.root);
|
|
1631
|
-
if (!f) return notFound(`No such File object: ${id}`);
|
|
1632
|
-
await applyTwinWrite(SERVICE, { operation: 'file.delete', subjectType: 'file', subjectId: id, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1633
|
-
return { status: 200, body: { id, object: 'file', deleted: true } };
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
// ---- uploads (multipart large-file parts, stateful) ----
|
|
1637
|
-
if (path === '/v1/uploads' && method === 'POST') return createUpload(params, req);
|
|
1638
|
-
if (seg[1] === 'uploads' && seg.length === 4 && seg[3] === 'parts' && method === 'POST') return addUploadPart(dec(seg[2]!), params, req);
|
|
1639
|
-
if (seg[1] === 'uploads' && seg.length === 4 && seg[3] === 'complete' && method === 'POST') return completeUpload(dec(seg[2]!), params, req);
|
|
1640
|
-
if (seg[1] === 'uploads' && seg.length === 4 && seg[3] === 'cancel' && method === 'POST') {
|
|
1641
|
-
const uid = dec(seg[2]!);
|
|
1642
|
-
const up = getRow('upload', uid, req.root);
|
|
1643
|
-
if (!up || up.status !== 'pending') return notFound(`No such Upload object: ${uid}`);
|
|
1644
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'upload.cancel', subjectType: 'upload', subjectId: uid, fields: { status: 'cancelled' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1645
|
-
return { status: 200, body: uploadView(resource) };
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// ---- batches (stateful) ----
|
|
1649
|
-
if (path === '/v1/batches' && method === 'POST') return createBatch(params, req);
|
|
1650
|
-
if (path === '/v1/batches' && method === 'GET') {
|
|
1651
|
-
const items = rows('batch', req.root).map(batchView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1652
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1653
|
-
}
|
|
1654
|
-
if (seg[1] === 'batches' && seg.length === 3 && method === 'GET') {
|
|
1655
|
-
const b = getRow('batch', dec(seg[2]!), req.root);
|
|
1656
|
-
return b ? { status: 200, body: batchView(b) } : notFound(`No such Batch object: ${dec(seg[2]!)}`);
|
|
1657
|
-
}
|
|
1658
|
-
if (seg[1] === 'batches' && seg.length === 4 && seg[3] === 'cancel' && method === 'POST') {
|
|
1659
|
-
const id = dec(seg[2]!);
|
|
1660
|
-
const b = getRow('batch', id, req.root);
|
|
1661
|
-
if (!b) return notFound(`No such Batch object: ${id}`);
|
|
1662
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'batch.cancel', subjectType: 'batch', subjectId: id, fields: { status: 'cancelled', cancelled_at: nowEpoch(req.occurredAt) }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1663
|
-
return { status: 200, body: batchView(resource) };
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
// ---- moderations handled above ----
|
|
1667
|
-
|
|
1668
|
-
// ---- fine-tuning jobs (stateful) ----
|
|
1669
|
-
if (path === '/v1/fine_tuning/jobs' && method === 'POST') return createFineTune(params, req);
|
|
1670
|
-
if (path === '/v1/fine_tuning/jobs' && method === 'GET') {
|
|
1671
|
-
const items = rows('fine_tuning_job', req.root).map(ftView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1672
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1673
|
-
}
|
|
1674
|
-
if (seg[1] === 'fine_tuning' && seg[2] === 'jobs' && seg.length === 4 && method === 'GET') {
|
|
1675
|
-
const j = getRow('fine_tuning_job', dec(seg[3]!), req.root);
|
|
1676
|
-
return j ? { status: 200, body: ftView(j) } : notFound(`No such fine-tuning job: ${dec(seg[3]!)}`);
|
|
1677
|
-
}
|
|
1678
|
-
if (seg[1] === 'fine_tuning' && seg[2] === 'jobs' && seg.length === 5 && seg[4] === 'events' && method === 'GET') {
|
|
1679
|
-
const j = getRow('fine_tuning_job', dec(seg[3]!), req.root);
|
|
1680
|
-
if (!j) return notFound(`No such fine-tuning job: ${dec(seg[3]!)}`);
|
|
1681
|
-
// Deterministic synthetic event stream for a completed twin job.
|
|
1682
|
-
const created = Number(j.created_at ?? 0);
|
|
1683
|
-
const events = [
|
|
1684
|
-
{ object: 'fine_tuning.job.event', id: `ftevent-${j.id}-1`, created_at: created, level: 'info', message: 'Created fine-tuning job', type: 'message' },
|
|
1685
|
-
{ object: 'fine_tuning.job.event', id: `ftevent-${j.id}-2`, created_at: created, level: 'info', message: 'Fine-tuning job successfully completed (twin stub)', type: 'message' },
|
|
1686
|
-
];
|
|
1687
|
-
return { status: 200, body: { object: 'list', data: events, has_more: false } };
|
|
1688
|
-
}
|
|
1689
|
-
// Fine-tuning checkpoints: a completed job exposes its training checkpoints (one per epoch). The
|
|
1690
|
-
// twin synthesizes a deterministic checkpoint per succeeded job (shape faithful; metrics are stubs).
|
|
1691
|
-
if (seg[1] === 'fine_tuning' && seg[2] === 'jobs' && seg.length === 5 && seg[4] === 'checkpoints' && method === 'GET') {
|
|
1692
|
-
const j = getRow('fine_tuning_job', dec(seg[3]!), req.root);
|
|
1693
|
-
if (!j) return notFound(`No such fine-tuning job: ${dec(seg[3]!)}`);
|
|
1694
|
-
const created = Number(j.created_at ?? 0);
|
|
1695
|
-
// succeeded jobs have a final checkpoint pointing at the fine-tuned model; cancelled jobs have none.
|
|
1696
|
-
const checkpoints = j.status === 'succeeded'
|
|
1697
|
-
? [{
|
|
1698
|
-
object: 'fine_tuning.job.checkpoint',
|
|
1699
|
-
id: `ftckpt-${j.id}-1`,
|
|
1700
|
-
created_at: created,
|
|
1701
|
-
fine_tuned_model_checkpoint: String(j.fine_tuned_model ?? `ft:twin::${j.id}:ckpt-step-1`),
|
|
1702
|
-
fine_tuning_job_id: String(j.id),
|
|
1703
|
-
metrics: { step: 1, train_loss: 0, train_mean_token_accuracy: 1, full_valid_loss: 0, full_valid_mean_token_accuracy: 1 },
|
|
1704
|
-
step_number: 1,
|
|
1705
|
-
}]
|
|
1706
|
-
: [];
|
|
1707
|
-
return { status: 200, body: { object: 'list', data: checkpoints, has_more: false, first_id: checkpoints[0]?.id ?? null, last_id: checkpoints[0]?.id ?? null } };
|
|
1708
|
-
}
|
|
1709
|
-
if (seg[1] === 'fine_tuning' && seg[2] === 'jobs' && seg.length === 5 && seg[4] === 'cancel' && method === 'POST') {
|
|
1710
|
-
const id = dec(seg[3]!);
|
|
1711
|
-
const j = getRow('fine_tuning_job', id, req.root);
|
|
1712
|
-
if (!j) return notFound(`No such fine-tuning job: ${id}`);
|
|
1713
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'fine_tuning_job.cancel', subjectType: 'fine_tuning_job', subjectId: id, fields: { status: 'cancelled' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1714
|
-
return { status: 200, body: ftView(resource) };
|
|
1715
|
-
}
|
|
1716
|
-
// Pause a fine-tuning job → status 'paused' (a deployable checkpoint is preserved). Only a
|
|
1717
|
-
// running/queued/succeeded job can be paused; a cancelled job can't (vendor 400).
|
|
1718
|
-
if (seg[1] === 'fine_tuning' && seg[2] === 'jobs' && seg.length === 5 && seg[4] === 'pause' && method === 'POST') {
|
|
1719
|
-
const id = dec(seg[3]!);
|
|
1720
|
-
const j = getRow('fine_tuning_job', id, req.root);
|
|
1721
|
-
if (!j) return notFound(`No such fine-tuning job: ${id}`);
|
|
1722
|
-
if (j.status === 'cancelled' || j.status === 'failed') return invalidRequest(`Cannot pause a job with status '${String(j.status)}'.`, null, 'invalid_status');
|
|
1723
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'fine_tuning_job.pause', subjectType: 'fine_tuning_job', subjectId: id, fields: { status: 'paused' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1724
|
-
return { status: 200, body: ftView(resource) };
|
|
1725
|
-
}
|
|
1726
|
-
// Resume a paused fine-tuning job → status back to 'succeeded' (the twin's terminal state, since
|
|
1727
|
-
// it cannot actually train). Only a paused job can be resumed (vendor 400 otherwise).
|
|
1728
|
-
if (seg[1] === 'fine_tuning' && seg[2] === 'jobs' && seg.length === 5 && seg[4] === 'resume' && method === 'POST') {
|
|
1729
|
-
const id = dec(seg[3]!);
|
|
1730
|
-
const j = getRow('fine_tuning_job', id, req.root);
|
|
1731
|
-
if (!j) return notFound(`No such fine-tuning job: ${id}`);
|
|
1732
|
-
if (j.status !== 'paused') return invalidRequest(`Cannot resume a job with status '${String(j.status)}'.`, null, 'invalid_status');
|
|
1733
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'fine_tuning_job.resume', subjectType: 'fine_tuning_job', subjectId: id, fields: { status: 'succeeded' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1734
|
-
return { status: 200, body: ftView(resource) };
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
// ---- vector stores (stateful) ----
|
|
1738
|
-
if (path === '/v1/vector_stores' && method === 'POST') return createVectorStore(params, req);
|
|
1739
|
-
if (path === '/v1/vector_stores' && method === 'GET') {
|
|
1740
|
-
const items = rows('vector_store', req.root).filter((r) => !r._deleted).map(vsView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1741
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1742
|
-
}
|
|
1743
|
-
if (seg[1] === 'vector_stores' && seg.length === 3 && method === 'GET') {
|
|
1744
|
-
const v = getRow('vector_store', dec(seg[2]!), req.root);
|
|
1745
|
-
return v && !v._deleted ? { status: 200, body: vsView(v) } : notFound(`No such vector store: ${dec(seg[2]!)}`);
|
|
1746
|
-
}
|
|
1747
|
-
if (seg[1] === 'vector_stores' && seg.length === 3 && method === 'DELETE') {
|
|
1748
|
-
const id = dec(seg[2]!);
|
|
1749
|
-
const v = getRow('vector_store', id, req.root);
|
|
1750
|
-
if (!v) return notFound(`No such vector store: ${id}`);
|
|
1751
|
-
await applyTwinWrite(SERVICE, { operation: 'vector_store.delete', subjectType: 'vector_store', subjectId: id, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1752
|
-
return { status: 200, body: { id, object: 'vector_store.deleted', deleted: true } };
|
|
1753
|
-
}
|
|
1754
|
-
if (seg[1] === 'vector_stores' && seg.length === 4 && seg[3] === 'files' && method === 'POST') {
|
|
1755
|
-
const storeId = dec(seg[2]!);
|
|
1756
|
-
if (!getRow('vector_store', storeId, req.root)) return notFound(`No such vector store: ${storeId}`);
|
|
1757
|
-
if (params.file_id === undefined || params.file_id === '') return invalidRequest("you must provide a file_id parameter", 'file_id');
|
|
1758
|
-
const view = await addVectorStoreFile(storeId, String(params.file_id), req);
|
|
1759
|
-
return { status: 200, body: view };
|
|
1760
|
-
}
|
|
1761
|
-
if (seg[1] === 'vector_stores' && seg.length === 4 && seg[3] === 'files' && method === 'GET') {
|
|
1762
|
-
const storeId = dec(seg[2]!);
|
|
1763
|
-
if (!getRow('vector_store', storeId, req.root)) return notFound(`No such vector store: ${storeId}`);
|
|
1764
|
-
const all = rows('vector_store_file', req.root).filter((r) => r.vector_store_id === storeId && !r._deleted).map(vsFileView);
|
|
1765
|
-
return { status: 200, body: paginate(all, req.path) };
|
|
1766
|
-
}
|
|
1767
|
-
// Vector store FILE BATCHES: bulk-attach a list of file_ids to a store in one call. The twin
|
|
1768
|
-
// attaches each file as a vector_store.file child, then returns a batch object (status completed,
|
|
1769
|
-
// since the twin has nothing to process async). Retrieve + list-files re-read the batch.
|
|
1770
|
-
if (seg[1] === 'vector_stores' && seg.length === 4 && seg[3] === 'file_batches' && method === 'POST') {
|
|
1771
|
-
const storeId = dec(seg[2]!);
|
|
1772
|
-
if (!getRow('vector_store', storeId, req.root)) return notFound(`No such vector store: ${storeId}`);
|
|
1773
|
-
if (!Array.isArray(params.file_ids) || params.file_ids.length === 0) return invalidRequest("you must provide a non-empty file_ids array", 'file_ids');
|
|
1774
|
-
const fileIds = (params.file_ids as unknown[]).map(String);
|
|
1775
|
-
const batchId = nextId('vector_store_file_batch', 'vsfb', req.root);
|
|
1776
|
-
for (const fid of fileIds) await addVectorStoreFile(storeId, fid, req, batchId);
|
|
1777
|
-
const created = nowEpoch(req.occurredAt);
|
|
1778
|
-
const fields = {
|
|
1779
|
-
object: 'vector_store.file_batch',
|
|
1780
|
-
vector_store_id: storeId,
|
|
1781
|
-
created_at: created,
|
|
1782
|
-
status: 'completed',
|
|
1783
|
-
file_counts: { in_progress: 0, completed: fileIds.length, failed: 0, cancelled: 0, total: fileIds.length },
|
|
1784
|
-
_file_ids: fileIds,
|
|
1785
|
-
};
|
|
1786
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'vector_store_file_batch.create', subjectType: 'vector_store_file_batch', subjectId: batchId, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1787
|
-
return { status: 200, body: vsBatchView(resource) };
|
|
1788
|
-
}
|
|
1789
|
-
if (seg[1] === 'vector_stores' && seg.length === 5 && seg[3] === 'file_batches' && method === 'GET') {
|
|
1790
|
-
const storeId = dec(seg[2]!);
|
|
1791
|
-
if (!getRow('vector_store', storeId, req.root)) return notFound(`No such vector store: ${storeId}`);
|
|
1792
|
-
const b = getRow('vector_store_file_batch', dec(seg[4]!), req.root);
|
|
1793
|
-
return b && b.vector_store_id === storeId ? { status: 200, body: vsBatchView(b) } : notFound(`No such file batch: ${dec(seg[4]!)}`);
|
|
1794
|
-
}
|
|
1795
|
-
if (seg[1] === 'vector_stores' && seg.length === 6 && seg[3] === 'file_batches' && seg[5] === 'files' && method === 'GET') {
|
|
1796
|
-
const storeId = dec(seg[2]!);
|
|
1797
|
-
if (!getRow('vector_store', storeId, req.root)) return notFound(`No such vector store: ${storeId}`);
|
|
1798
|
-
const batchId = dec(seg[4]!);
|
|
1799
|
-
if (!getRow('vector_store_file_batch', batchId, req.root)) return notFound(`No such file batch: ${batchId}`);
|
|
1800
|
-
const all = rows('vector_store_file', req.root).filter((r) => r.vector_store_id === storeId && r._batch_id === batchId && !r._deleted).map(vsFileView);
|
|
1801
|
-
return { status: 200, body: paginate(all, req.path) };
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
// Vector store search: deterministic ranked results over the store's attached files.
|
|
1805
|
-
if (seg[1] === 'vector_stores' && seg.length === 4 && seg[3] === 'search' && method === 'POST') {
|
|
1806
|
-
const storeId = dec(seg[2]!);
|
|
1807
|
-
if (!getRow('vector_store', storeId, req.root)) return notFound(`No such vector store: ${storeId}`);
|
|
1808
|
-
if (params.query === undefined || params.query === '') return invalidRequest("you must provide a query parameter", 'query');
|
|
1809
|
-
const query = typeof params.query === 'string' ? params.query : JSON.stringify(params.query);
|
|
1810
|
-
const files = rows('vector_store_file', req.root).filter((r) => r.vector_store_id === storeId && !r._deleted);
|
|
1811
|
-
const maxResults = Number.isInteger(Number(params.max_num_results)) ? Number(params.max_num_results) : 10;
|
|
1812
|
-
const ranked = files
|
|
1813
|
-
.map((f) => ({ file_id: String((f as { file_id?: unknown }).file_id ?? ''), score: searchScore(query, String((f as { file_id?: unknown }).file_id ?? '')) }))
|
|
1814
|
-
.sort((a, b) => b.score - a.score)
|
|
1815
|
-
.slice(0, Math.max(1, maxResults))
|
|
1816
|
-
.map((r) => ({ file_id: r.file_id, filename: r.file_id, score: r.score, attributes: {}, content: [{ type: 'text', text: `[twin-stub] deterministic pseudo-match for "${query}" in ${r.file_id} (no real semantic search)` }] }));
|
|
1817
|
-
return { status: 200, body: { object: 'vector_store.search_results.page', search_query: query, data: ranked, has_more: false, next_page: null } };
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
// ---- assistants (beta, stateful) ----
|
|
1821
|
-
if (path === '/v1/assistants' && method === 'POST') return createAssistant(params, req);
|
|
1822
|
-
if (path === '/v1/assistants' && method === 'GET') {
|
|
1823
|
-
const items = rows('assistant', req.root).filter((r) => !r._deleted).map(idView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1824
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1825
|
-
}
|
|
1826
|
-
if (seg[1] === 'assistants' && seg.length === 3 && method === 'GET') {
|
|
1827
|
-
const a = getRow('assistant', dec(seg[2]!), req.root);
|
|
1828
|
-
return a && !a._deleted ? { status: 200, body: idView(a) } : notFound(`No assistant found with id '${dec(seg[2]!)}'.`);
|
|
1829
|
-
}
|
|
1830
|
-
if (seg[1] === 'assistants' && seg.length === 3 && method === 'POST') {
|
|
1831
|
-
const aid = dec(seg[2]!);
|
|
1832
|
-
const a = getRow('assistant', aid, req.root);
|
|
1833
|
-
if (!a || a._deleted) return notFound(`No assistant found with id '${aid}'.`);
|
|
1834
|
-
const fields = pickUpdate(params, ['name', 'description', 'model', 'instructions', 'tools', 'metadata', 'temperature', 'top_p', 'response_format']);
|
|
1835
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'assistant.update', subjectType: 'assistant', subjectId: aid, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1836
|
-
return { status: 200, body: idView(resource) };
|
|
1837
|
-
}
|
|
1838
|
-
if (seg[1] === 'assistants' && seg.length === 3 && method === 'DELETE') {
|
|
1839
|
-
const aid = dec(seg[2]!);
|
|
1840
|
-
const a = getRow('assistant', aid, req.root);
|
|
1841
|
-
if (!a || a._deleted) return notFound(`No assistant found with id '${aid}'.`);
|
|
1842
|
-
await applyTwinWrite(SERVICE, { operation: 'assistant.delete', subjectType: 'assistant', subjectId: aid, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1843
|
-
return { status: 200, body: { id: aid, object: 'assistant.deleted', deleted: true } };
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
// ---- threads (+ messages) (beta, stateful) ----
|
|
1847
|
-
if (path === '/v1/threads' && method === 'POST') return createThread(params, req);
|
|
1848
|
-
if (seg[1] === 'threads' && seg.length === 3 && method === 'GET') {
|
|
1849
|
-
const t = getRow('thread', dec(seg[2]!), req.root);
|
|
1850
|
-
return t && !t._deleted ? { status: 200, body: idView(t) } : notFound(`No thread found with id '${dec(seg[2]!)}'.`);
|
|
1851
|
-
}
|
|
1852
|
-
if (seg[1] === 'threads' && seg.length === 3 && method === 'POST') {
|
|
1853
|
-
const tid = dec(seg[2]!);
|
|
1854
|
-
const t = getRow('thread', tid, req.root);
|
|
1855
|
-
if (!t || t._deleted) return notFound(`No thread found with id '${tid}'.`);
|
|
1856
|
-
const fields = pickUpdate(params, ['metadata', 'tool_resources']);
|
|
1857
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'thread.update', subjectType: 'thread', subjectId: tid, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1858
|
-
return { status: 200, body: idView(resource) };
|
|
1859
|
-
}
|
|
1860
|
-
if (seg[1] === 'threads' && seg.length === 3 && method === 'DELETE') {
|
|
1861
|
-
const tid = dec(seg[2]!);
|
|
1862
|
-
const t = getRow('thread', tid, req.root);
|
|
1863
|
-
if (!t || t._deleted) return notFound(`No thread found with id '${tid}'.`);
|
|
1864
|
-
await applyTwinWrite(SERVICE, { operation: 'thread.delete', subjectType: 'thread', subjectId: tid, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1865
|
-
return { status: 200, body: { id: tid, object: 'thread.deleted', deleted: true } };
|
|
1866
|
-
}
|
|
1867
|
-
if (seg[1] === 'threads' && seg.length === 4 && seg[3] === 'messages' && method === 'POST') {
|
|
1868
|
-
const tid = dec(seg[2]!);
|
|
1869
|
-
if (!getRow('thread', tid, req.root)) return notFound(`No thread found with id '${tid}'.`);
|
|
1870
|
-
if (params.content === undefined) return invalidRequest("you must provide a content parameter", 'content');
|
|
1871
|
-
const view = await addThreadMessage(tid, params, req);
|
|
1872
|
-
return { status: 200, body: view };
|
|
1873
|
-
}
|
|
1874
|
-
if (seg[1] === 'threads' && seg.length === 4 && seg[3] === 'messages' && method === 'GET') {
|
|
1875
|
-
const tid = dec(seg[2]!);
|
|
1876
|
-
if (!getRow('thread', tid, req.root)) return notFound(`No thread found with id '${tid}'.`);
|
|
1877
|
-
const all = rows('message', req.root).filter((r) => r.thread_id === tid && !r._deleted).map(msgView).sort((a, b) => Number(b.created_at) - Number(a.created_at) || String(b.id).localeCompare(String(a.id)));
|
|
1878
|
-
return { status: 200, body: paginate(all, req.path) };
|
|
1879
|
-
}
|
|
1880
|
-
if (seg[1] === 'threads' && seg.length === 5 && seg[3] === 'messages' && method === 'GET') {
|
|
1881
|
-
const tid = dec(seg[2]!);
|
|
1882
|
-
if (!getRow('thread', tid, req.root)) return notFound(`No thread found with id '${tid}'.`);
|
|
1883
|
-
const m = getRow('message', dec(seg[4]!), req.root);
|
|
1884
|
-
return m && m.thread_id === tid && !m._deleted ? { status: 200, body: msgView(m) } : notFound(`No message found with id '${dec(seg[4]!)}'.`);
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
// ---- runs (+ run steps) (beta, stateful) ----
|
|
1888
|
-
if (seg[1] === 'threads' && seg.length === 4 && seg[3] === 'runs' && method === 'POST') {
|
|
1889
|
-
const tid = dec(seg[2]!);
|
|
1890
|
-
if (!getRow('thread', tid, req.root)) return notFound(`No thread found with id '${tid}'.`);
|
|
1891
|
-
return createRun(tid, params, req);
|
|
1892
|
-
}
|
|
1893
|
-
if (seg[1] === 'threads' && seg.length === 4 && seg[3] === 'runs' && method === 'GET') {
|
|
1894
|
-
const tid = dec(seg[2]!);
|
|
1895
|
-
if (!getRow('thread', tid, req.root)) return notFound(`No thread found with id '${tid}'.`);
|
|
1896
|
-
const all = rows('run', req.root).filter((r) => r.thread_id === tid && !r._deleted).map(runView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1897
|
-
return { status: 200, body: paginate(all, req.path) };
|
|
1898
|
-
}
|
|
1899
|
-
if (seg[1] === 'threads' && seg.length === 6 && seg[3] === 'runs' && seg[5] === 'steps' && method === 'GET') {
|
|
1900
|
-
const tid = dec(seg[2]!);
|
|
1901
|
-
const r = getRow('run', dec(seg[4]!), req.root);
|
|
1902
|
-
if (!r || r.thread_id !== tid || r._deleted) return notFound(`No run found with id '${dec(seg[4]!)}'.`);
|
|
1903
|
-
return { status: 200, body: { object: 'list', data: runSteps(r), has_more: false, first_id: `step-${r.id}-1`, last_id: `step-${r.id}-1` } };
|
|
1904
|
-
}
|
|
1905
|
-
if (seg[1] === 'threads' && seg.length === 7 && seg[3] === 'runs' && seg[5] === 'steps' && method === 'GET') {
|
|
1906
|
-
const tid = dec(seg[2]!);
|
|
1907
|
-
const r = getRow('run', dec(seg[4]!), req.root);
|
|
1908
|
-
if (!r || r.thread_id !== tid || r._deleted) return notFound(`No run found with id '${dec(seg[4]!)}'.`);
|
|
1909
|
-
const step = runSteps(r).find((s) => s.id === dec(seg[6]!));
|
|
1910
|
-
return step ? { status: 200, body: step } : notFound(`No run step found with id '${dec(seg[6]!)}'.`);
|
|
1911
|
-
}
|
|
1912
|
-
if (seg[1] === 'threads' && seg.length === 5 && seg[3] === 'runs' && method === 'GET') {
|
|
1913
|
-
const tid = dec(seg[2]!);
|
|
1914
|
-
const r = getRow('run', dec(seg[4]!), req.root);
|
|
1915
|
-
return r && r.thread_id === tid && !r._deleted ? { status: 200, body: runView(r) } : notFound(`No run found with id '${dec(seg[4]!)}'.`);
|
|
1916
|
-
}
|
|
1917
|
-
if (seg[1] === 'threads' && seg.length === 6 && seg[3] === 'runs' && seg[5] === 'cancel' && method === 'POST') {
|
|
1918
|
-
const tid = dec(seg[2]!);
|
|
1919
|
-
const r = getRow('run', dec(seg[4]!), req.root);
|
|
1920
|
-
if (!r || r.thread_id !== tid || r._deleted) return notFound(`No run found with id '${dec(seg[4]!)}'.`);
|
|
1921
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'run.cancel', subjectType: 'run', subjectId: dec(seg[4]!), fields: { status: 'cancelled', cancelled_at: nowEpoch(req.occurredAt) }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1922
|
-
return { status: 200, body: runView(resource) };
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
// ---- admin: usage + costs reporting (computed over REAL recorded usage rows) ----
|
|
1926
|
-
// The vendor's Usage/Costs API returns time-bucketed aggregates. The twin records a usage row per
|
|
1927
|
-
// billable inference call (recordUsage) and aggregates them here — nothing is hardcoded; with no
|
|
1928
|
-
// traffic the report is genuinely empty. A single bucket covers [0, now] (the twin is offline +
|
|
1929
|
-
// time-frozen), grouped by model, faithful to the vendor's bucket/result shape.
|
|
1930
|
-
if (seg[1] === 'organization' && seg[2] === 'usage' && (seg.length === 3 || seg.length === 4) && method === 'GET') {
|
|
1931
|
-
const kindFilter = seg.length === 4 ? dec(seg[3]!) : undefined; // 'completions' | 'embeddings' | undefined (all)
|
|
1932
|
-
const all = rows('usage_record', req.root).filter((r) => kindFilter === undefined || r.kind === kindFilter);
|
|
1933
|
-
// group by model → one result object per model
|
|
1934
|
-
const byModel = new Map<string, { input: number; output: number; requests: number }>();
|
|
1935
|
-
for (const r of all) {
|
|
1936
|
-
const m = String(r.model);
|
|
1937
|
-
const agg = byModel.get(m) ?? { input: 0, output: 0, requests: 0 };
|
|
1938
|
-
agg.input += Number(r.input_tokens ?? 0);
|
|
1939
|
-
agg.output += Number(r.output_tokens ?? 0);
|
|
1940
|
-
agg.requests += Number(r.num_model_requests ?? 0);
|
|
1941
|
-
byModel.set(m, agg);
|
|
1177
|
+
if (typeof req.body === 'string' && method !== 'GET' && method !== 'HEAD' && !headers['content-type']) headers['content-type'] = 'application/json';
|
|
1178
|
+
const path = req.path.startsWith('/') ? req.path : `/${req.path}`;
|
|
1179
|
+
const response = await twin(new Request(`https://api.openai.com${path}`, { method, headers, ...(req.body !== undefined && method !== 'GET' && method !== 'HEAD' ? { body: req.body } : {}) }));
|
|
1180
|
+
const answerHeaders: Record<string, string> = {};
|
|
1181
|
+
response.headers.forEach((v, k) => { if (k !== 'content-type') answerHeaders[k] = v; });
|
|
1182
|
+
const type = response.headers.get('content-type') ?? '';
|
|
1183
|
+
const text = await response.text();
|
|
1184
|
+
if (type.includes('text/event-stream')) {
|
|
1185
|
+
// the frames back into events, for a caller that collects them
|
|
1186
|
+
for (const frame of text.split('\n\n')) {
|
|
1187
|
+
const data = frame.split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6)).join('\n');
|
|
1188
|
+
if (!data) continue;
|
|
1189
|
+
req.sseSink?.(data === '[DONE]' ? { done: true } : { data: JSON.parse(data) as Record<string, unknown> });
|
|
1942
1190
|
}
|
|
1943
|
-
|
|
1944
|
-
const results = [...byModel.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([model, agg]) => ({
|
|
1945
|
-
object: resultObject,
|
|
1946
|
-
input_tokens: agg.input,
|
|
1947
|
-
output_tokens: agg.output,
|
|
1948
|
-
num_model_requests: agg.requests,
|
|
1949
|
-
model,
|
|
1950
|
-
project_id: null,
|
|
1951
|
-
}));
|
|
1952
|
-
const buckets = results.length === 0 ? [] : [{ object: 'bucket', start_time: 0, end_time: nowEpoch(req.occurredAt), results }];
|
|
1953
|
-
return { status: 200, body: { object: 'page', data: buckets, has_more: false, next_page: null } };
|
|
1954
|
-
}
|
|
1955
|
-
if (path === '/v1/organization/costs' && method === 'GET') {
|
|
1956
|
-
const all = rows('usage_record', req.root);
|
|
1957
|
-
// group cost by line_item (the usage kind, vendor-style) → one result per line item
|
|
1958
|
-
const byLine = new Map<string, number>();
|
|
1959
|
-
for (const r of all) {
|
|
1960
|
-
const line = String(r.kind);
|
|
1961
|
-
byLine.set(line, (byLine.get(line) ?? 0) + Number(r.cost_usd ?? 0));
|
|
1962
|
-
}
|
|
1963
|
-
const results = [...byLine.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([line, amount]) => ({
|
|
1964
|
-
object: 'organization.costs.result',
|
|
1965
|
-
amount: { value: amount, currency: 'usd' },
|
|
1966
|
-
line_item: line,
|
|
1967
|
-
project_id: null,
|
|
1968
|
-
}));
|
|
1969
|
-
const buckets = results.length === 0 ? [] : [{ object: 'bucket', start_time: 0, end_time: nowEpoch(req.occurredAt), results }];
|
|
1970
|
-
return { status: 200, body: { object: 'page', data: buckets, has_more: false, next_page: null } };
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
// ---- admin: organization projects (stateful) ----
|
|
1974
|
-
if (path === '/v1/organization/projects' && method === 'POST') return createProject(params, req);
|
|
1975
|
-
if (path === '/v1/organization/projects' && method === 'GET') {
|
|
1976
|
-
const includeArchived = new URLSearchParams(req.path.split('?')[1] ?? '').get('include_archived') === 'true';
|
|
1977
|
-
const items = rows('project', req.root).filter((r) => includeArchived || r.status !== 'archived').map(idView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
1978
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
1979
|
-
}
|
|
1980
|
-
if (seg[1] === 'organization' && seg[2] === 'projects' && seg.length === 4 && method === 'GET') {
|
|
1981
|
-
const p = getRow('project', dec(seg[3]!), req.root);
|
|
1982
|
-
return p ? { status: 200, body: idView(p) } : notFound(`Project ${dec(seg[3]!)} not found`);
|
|
1983
|
-
}
|
|
1984
|
-
if (seg[1] === 'organization' && seg[2] === 'projects' && seg.length === 4 && method === 'POST') {
|
|
1985
|
-
const pid = dec(seg[3]!);
|
|
1986
|
-
if (!getRow('project', pid, req.root)) return notFound(`Project ${pid} not found`);
|
|
1987
|
-
const fields = pickUpdate(params, ['name']);
|
|
1988
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'project.update', subjectType: 'project', subjectId: pid, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1989
|
-
return { status: 200, body: idView(resource) };
|
|
1191
|
+
return { status: response.status, body: null, headers: answerHeaders };
|
|
1990
1192
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
if (!getRow('project', pid, req.root)) return notFound(`Project ${pid} not found`);
|
|
1994
|
-
const { resource } = await applyTwinWrite(SERVICE, { operation: 'project.archive', subjectType: 'project', subjectId: pid, fields: { status: 'archived', archived_at: nowEpoch(req.occurredAt) }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
1995
|
-
return { status: 200, body: idView(resource) };
|
|
1996
|
-
}
|
|
1997
|
-
// ---- admin: project API keys (stateful; synthetic twin keys) ----
|
|
1998
|
-
if (seg[1] === 'organization' && seg[2] === 'projects' && seg.length === 5 && seg[4] === 'api_keys' && method === 'POST') {
|
|
1999
|
-
const pid = dec(seg[3]!);
|
|
2000
|
-
if (!getRow('project', pid, req.root)) return notFound(`Project ${pid} not found`);
|
|
2001
|
-
return createProjectApiKey(pid, params, req);
|
|
2002
|
-
}
|
|
2003
|
-
if (seg[1] === 'organization' && seg[2] === 'projects' && seg.length === 5 && seg[4] === 'api_keys' && method === 'GET') {
|
|
2004
|
-
const pid = dec(seg[3]!);
|
|
2005
|
-
if (!getRow('project', pid, req.root)) return notFound(`Project ${pid} not found`);
|
|
2006
|
-
const items = rows('api_key', req.root).filter((r) => r.project_id === pid && !r._deleted).map(apiKeyView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
2007
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
2008
|
-
}
|
|
2009
|
-
if (seg[1] === 'organization' && seg[2] === 'projects' && seg.length === 6 && seg[4] === 'api_keys' && method === 'GET') {
|
|
2010
|
-
const k = getRow('api_key', dec(seg[5]!), req.root);
|
|
2011
|
-
return k && k.project_id === dec(seg[3]!) && !k._deleted ? { status: 200, body: apiKeyView(k) } : notFound(`API key ${dec(seg[5]!)} not found`);
|
|
2012
|
-
}
|
|
2013
|
-
if (seg[1] === 'organization' && seg[2] === 'projects' && seg.length === 6 && seg[4] === 'api_keys' && method === 'DELETE') {
|
|
2014
|
-
const kid = dec(seg[5]!);
|
|
2015
|
-
const k = getRow('api_key', kid, req.root);
|
|
2016
|
-
if (!k || k.project_id !== dec(seg[3]!) || k._deleted) return notFound(`API key ${kid} not found`);
|
|
2017
|
-
await applyTwinWrite(SERVICE, { operation: 'api_key.delete', subjectType: 'api_key', subjectId: kid, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
2018
|
-
return { status: 200, body: { id: kid, object: 'organization.project.api_key.deleted', deleted: true } };
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
// ---- evals (stateful: eval config → runs → output items) ----
|
|
2022
|
-
if (path === '/v1/evals' && method === 'POST') return createEval(params, req);
|
|
2023
|
-
if (path === '/v1/evals' && method === 'GET') {
|
|
2024
|
-
const items = rows('eval', req.root).map(evalView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
2025
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
2026
|
-
}
|
|
2027
|
-
if (seg[1] === 'evals' && seg.length === 3 && method === 'GET') {
|
|
2028
|
-
const e = getRow('eval', dec(seg[2]!), req.root);
|
|
2029
|
-
return e ? { status: 200, body: evalView(e) } : notFound(`Eval ${dec(seg[2]!)} not found`);
|
|
2030
|
-
}
|
|
2031
|
-
if (seg[1] === 'evals' && seg.length === 4 && seg[3] === 'runs' && method === 'POST') {
|
|
2032
|
-
const evalId = dec(seg[2]!);
|
|
2033
|
-
if (!getRow('eval', evalId, req.root)) return notFound(`Eval ${evalId} not found`);
|
|
2034
|
-
return createEvalRun(evalId, params, req);
|
|
2035
|
-
}
|
|
2036
|
-
if (seg[1] === 'evals' && seg.length === 4 && seg[3] === 'runs' && method === 'GET') {
|
|
2037
|
-
const evalId = dec(seg[2]!);
|
|
2038
|
-
if (!getRow('eval', evalId, req.root)) return notFound(`Eval ${evalId} not found`);
|
|
2039
|
-
const items = rows('eval_run', req.root).filter((r) => r.eval_id === evalId).map(evalRunView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
2040
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
2041
|
-
}
|
|
2042
|
-
if (seg[1] === 'evals' && seg.length === 5 && seg[3] === 'runs' && method === 'GET') {
|
|
2043
|
-
const run = getRow('eval_run', dec(seg[4]!), req.root);
|
|
2044
|
-
return run && run.eval_id === dec(seg[2]!) ? { status: 200, body: evalRunView(run) } : notFound(`Eval run ${dec(seg[4]!)} not found`);
|
|
2045
|
-
}
|
|
2046
|
-
if (seg[1] === 'evals' && seg.length === 6 && seg[3] === 'runs' && seg[5] === 'output_items' && method === 'GET') {
|
|
2047
|
-
const run = getRow('eval_run', dec(seg[4]!), req.root);
|
|
2048
|
-
if (!run || run.eval_id !== dec(seg[2]!)) return notFound(`Eval run ${dec(seg[4]!)} not found`);
|
|
2049
|
-
return { status: 200, body: paginate(evalRunOutputItems(run), req.path) };
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
// ---- containers (code-interpreter sandboxes) → container files ----
|
|
2053
|
-
if (path === '/v1/containers' && method === 'POST') return createContainer(params, req);
|
|
2054
|
-
if (path === '/v1/containers' && method === 'GET') {
|
|
2055
|
-
const items = rows('container', req.root).filter((r) => !r._deleted).map(containerView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
2056
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
2057
|
-
}
|
|
2058
|
-
if (seg[1] === 'containers' && seg.length === 3 && method === 'GET') {
|
|
2059
|
-
const c = getRow('container', dec(seg[2]!), req.root);
|
|
2060
|
-
return c && !c._deleted ? { status: 200, body: containerView(c) } : notFound(`Container ${dec(seg[2]!)} not found`);
|
|
2061
|
-
}
|
|
2062
|
-
if (seg[1] === 'containers' && seg.length === 3 && method === 'DELETE') {
|
|
2063
|
-
const cid = dec(seg[2]!);
|
|
2064
|
-
const c = getRow('container', cid, req.root);
|
|
2065
|
-
if (!c || c._deleted) return notFound(`Container ${cid} not found`);
|
|
2066
|
-
await applyTwinWrite(SERVICE, { operation: 'container.delete', subjectType: 'container', subjectId: cid, fields: { _deleted: true, status: 'deleted' }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
2067
|
-
return { status: 200, body: { id: cid, object: 'container.deleted', deleted: true } };
|
|
2068
|
-
}
|
|
2069
|
-
if (seg[1] === 'containers' && seg.length === 4 && seg[3] === 'files' && method === 'POST') {
|
|
2070
|
-
const cid = dec(seg[2]!);
|
|
2071
|
-
const c = getRow('container', cid, req.root);
|
|
2072
|
-
if (!c || c._deleted) return notFound(`Container ${cid} not found`);
|
|
2073
|
-
return createContainerFile(cid, params, req);
|
|
2074
|
-
}
|
|
2075
|
-
if (seg[1] === 'containers' && seg.length === 4 && seg[3] === 'files' && method === 'GET') {
|
|
2076
|
-
const cid = dec(seg[2]!);
|
|
2077
|
-
if (!getRow('container', cid, req.root)) return notFound(`Container ${cid} not found`);
|
|
2078
|
-
const items = rows('container_file', req.root).filter((r) => r.container_id === cid && !r._deleted).map(containerFileView).sort((a, b) => Number(b.created_at) - Number(a.created_at));
|
|
2079
|
-
return { status: 200, body: paginate(items, req.path) };
|
|
2080
|
-
}
|
|
2081
|
-
if (seg[1] === 'containers' && seg.length === 5 && seg[3] === 'files' && method === 'GET') {
|
|
2082
|
-
const cf = getRow('container_file', dec(seg[4]!), req.root);
|
|
2083
|
-
return cf && cf.container_id === dec(seg[2]!) && !cf._deleted ? { status: 200, body: containerFileView(cf) } : notFound(`Container file ${dec(seg[4]!)} not found`);
|
|
2084
|
-
}
|
|
2085
|
-
if (seg[1] === 'containers' && seg.length === 6 && seg[3] === 'files' && seg[5] === 'content' && method === 'GET') {
|
|
2086
|
-
const cf = getRow('container_file', dec(seg[4]!), req.root);
|
|
2087
|
-
if (!cf || cf.container_id !== dec(seg[2]!) || cf._deleted) return notFound(`Container file ${dec(seg[4]!)} not found`);
|
|
2088
|
-
return { status: 200, body: (cf._content as string) ?? '' };
|
|
2089
|
-
}
|
|
2090
|
-
if (seg[1] === 'containers' && seg.length === 5 && seg[3] === 'files' && method === 'DELETE') {
|
|
2091
|
-
const fid = dec(seg[4]!);
|
|
2092
|
-
const cf = getRow('container_file', fid, req.root);
|
|
2093
|
-
if (!cf || cf.container_id !== dec(seg[2]!) || cf._deleted) return notFound(`Container file ${fid} not found`);
|
|
2094
|
-
await applyTwinWrite(SERVICE, { operation: 'container_file.delete', subjectType: 'container_file', subjectId: fid, fields: { _deleted: true }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
2095
|
-
return { status: 200, body: { id: fid, object: 'container.file.deleted', deleted: true } };
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
// Unknown route → vendor-faithful 404 (never a fabricated success — D2).
|
|
2099
|
-
return notFound(`Unknown request URL: ${method} ${path}. Please check the URL for typos.`);
|
|
1193
|
+
const body = type.includes('json') ? (text ? JSON.parse(text) : null) : text;
|
|
1194
|
+
return { status: response.status, body, headers: answerHeaders };
|
|
2100
1195
|
}
|