@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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// OpenAI semantics, by operationId. Families move here from openai-twin.ts's hand-written router;
|
|
2
|
+
// the generative answers (chat, responses, embeddings) come from the pack's behaviour: its
|
|
3
|
+
// deterministic stub or a scripted scenario, never a model.
|
|
4
|
+
import type { Semantics } from '@volter/world-core';
|
|
5
|
+
import type { OpenAIScenarioEngine } from '../openai-scenario.ts';
|
|
6
|
+
import { assistants } from './assistants.ts';
|
|
7
|
+
import { audio } from './audio.ts';
|
|
8
|
+
import { batches } from './batches.ts';
|
|
9
|
+
import { chatCompletions } from './chat-completions.ts';
|
|
10
|
+
import { containers } from './containers.ts';
|
|
11
|
+
import { embeddings } from './embeddings.ts';
|
|
12
|
+
import { evals } from './evals.ts';
|
|
13
|
+
import { files } from './files.ts';
|
|
14
|
+
import { fineTuning } from './fine-tuning.ts';
|
|
15
|
+
import { images } from './images.ts';
|
|
16
|
+
import { models } from './models.ts';
|
|
17
|
+
import { moderations } from './moderations.ts';
|
|
18
|
+
import { organization } from './organization.ts';
|
|
19
|
+
import { live, modelOf, removedAt } from '../openai-models.ts';
|
|
20
|
+
import { READ_ONLY } from './progress.ts';
|
|
21
|
+
import { epoch } from './shared.ts';
|
|
22
|
+
import { responses } from './responses.ts';
|
|
23
|
+
import { uploads } from './uploads.ts';
|
|
24
|
+
import { vectorStores } from './vector-stores.ts';
|
|
25
|
+
|
|
26
|
+
/** `readOnly`: served by a read-only (mirror) twin, whose reads never write (./progress.ts). */
|
|
27
|
+
|
|
28
|
+
export type OpenAISemanticsOptions = { scenarioEngine?: OpenAIScenarioEngine; readOnly?: boolean };
|
|
29
|
+
|
|
30
|
+
export function openaiSemantics(options: OpenAISemanticsOptions): Record<string, Semantics> {
|
|
31
|
+
const all: Record<string, Semantics> = { ...chatCompletions(options.scenarioEngine), ...responses(options.scenarioEngine), ...models, ...files, ...uploads, ...batches, ...fineTuning, ...vectorStores, ...embeddings, ...moderations, ...images, ...audio, ...assistants, ...organization, ...evals, ...containers };
|
|
32
|
+
// OpenAI's timeline (../openai-models.ts), at the World's instant: an operation of a removed API family answers as
|
|
33
|
+
// removed; a request naming a model that is not live (not in the catalog, not yet released, or shut down) answers
|
|
34
|
+
// OpenAI's model_not_found, as for any model that does not exist. A fine-tuned model is live while the job that made
|
|
35
|
+
// it holds it and it is not deleted. An operation that names no model runs on its default (DEFAULT_MODEL). The models'
|
|
36
|
+
// own operations list and read the catalog as it stands (./models.ts).
|
|
37
|
+
for (const [id, h] of Object.entries(all)) {
|
|
38
|
+
all[id] = (async (ctx) => {
|
|
39
|
+
const now = epoch(ctx);
|
|
40
|
+
const removed = removedAt(id, now);
|
|
41
|
+
if (removed) return ctx.refuse({ status: removed.status, message: removed.message });
|
|
42
|
+
if (id in models) return h(ctx);
|
|
43
|
+
const p = ctx.params as { model?: unknown; data_source?: { model?: unknown } };
|
|
44
|
+
const named = typeof p.data_source?.model === 'string' && typeof p.model !== 'string' ? p.data_source.model : modelOf(id, p);
|
|
45
|
+
if (!named) return h(ctx);
|
|
46
|
+
const tuned = named.startsWith('ft:') && ctx.rowsRaw('FineTuningJob', { withDeleted: true }).some((j) => j.fine_tuned_model === named) && !ctx.rowsRaw('Model', { withDeleted: true }).some((m) => m.id === named && m.deleted);
|
|
47
|
+
return tuned || live(named, now) ? h(ctx) : ctx.notFound('Model', named);
|
|
48
|
+
}) as Semantics;
|
|
49
|
+
}
|
|
50
|
+
if (!options.readOnly) return all;
|
|
51
|
+
return Object.fromEntries(Object.entries(all).map(([id, h]) => [id, ((ctx) => h(Object.assign(ctx, { [READ_ONLY]: true }))) as Semantics]));
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Model semantics: the static catalog plus the fine-tuned models succeeded jobs minted. The catalog is
|
|
2
|
+
// data, not state, so every operation is a handler; only a fine-tuned model can be deleted.
|
|
3
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
4
|
+
import { findModel, live, OPENAI_MODELS } from '../openai-models.ts';
|
|
5
|
+
import { epoch, invalid } from './shared.ts';
|
|
6
|
+
|
|
7
|
+
type FineTunedModel = { id: string; object: 'model'; created: number; owned_by: string; shutdown_date: string | null };
|
|
8
|
+
|
|
9
|
+
/** The models succeeded fine-tuning jobs minted, less those deleted (a deletion is a Model tombstone). */
|
|
10
|
+
function fineTunedModels(ctx: SemanticsContext): FineTunedModel[] {
|
|
11
|
+
const deleted = new Set(ctx.rowsRaw('Model', { withDeleted: true }).filter((r) => r.deleted).map((r) => String(r.id)));
|
|
12
|
+
const out: FineTunedModel[] = [];
|
|
13
|
+
for (const job of ctx.rowsRaw('FineTuningJob', { withDeleted: true })) {
|
|
14
|
+
const id = job.fine_tuned_model;
|
|
15
|
+
if (typeof id === 'string' && id && !deleted.has(id)) out.push({ id, object: 'model', created: Number(job.created_at ?? 0), owned_by: 'org-twin', shutdown_date: null });
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// the catalog as it stands at the World's instant: a model not yet released, or shut down, is not there
|
|
21
|
+
const list: Semantics = async (ctx) => ctx.reply({ object: 'list', data: [...OPENAI_MODELS.filter((m) => live(m.id, epoch(ctx))), ...fineTunedModels(ctx)] });
|
|
22
|
+
|
|
23
|
+
const retrieve: Semantics = async (ctx) => {
|
|
24
|
+
const id = String(ctx.id);
|
|
25
|
+
const model = (live(id, epoch(ctx)) ? findModel(id) : undefined) ?? fineTunedModels(ctx).find((m) => m.id === id);
|
|
26
|
+
return model ? ctx.reply(model) : ctx.notFound('Model', id);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const remove: Semantics = async (ctx) => {
|
|
30
|
+
const id = String(ctx.id);
|
|
31
|
+
if (findModel(id)) return invalid(ctx, `The model '${id}' cannot be deleted`, 'model', 'model_not_deletable');
|
|
32
|
+
if (!fineTunedModels(ctx).some((m) => m.id === id)) return ctx.notFound('Model', id);
|
|
33
|
+
await ctx.write('Model', id, { deleted: true, object: 'model' }, 'model.delete');
|
|
34
|
+
return ctx.reply({ id, object: 'model', deleted: true });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const models: Record<string, Semantics> = {
|
|
38
|
+
listModels: list,
|
|
39
|
+
retrieveModel: retrieve,
|
|
40
|
+
deleteModel: remove,
|
|
41
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Moderations: the pack's deterministic classifier, never a model's.
|
|
2
|
+
import type { Semantics } from '@volter/world-core';
|
|
3
|
+
import { handleModerations } from '../openai-twin.ts';
|
|
4
|
+
import { estimateTokens } from '../openai-stub.ts';
|
|
5
|
+
import { recordUsage, send } from './shared.ts';
|
|
6
|
+
|
|
7
|
+
export const moderations: Record<string, Semantics> = {
|
|
8
|
+
// a moderation is metered by the tokens of its text (the usage report's `input_tokens`)
|
|
9
|
+
createModeration: async (ctx) => {
|
|
10
|
+
const r = handleModerations(ctx.params, ctx.occurredAt);
|
|
11
|
+
if (r.status === 200) await recordUsage(ctx, 'moderations', String((r.body as { model: string }).model), estimateTokens(JSON.stringify(ctx.params.input ?? '')), 0);
|
|
12
|
+
return send(ctx, r);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Organization (Admin API) semantics: the usage and costs reports over the usage every billable call
|
|
2
|
+
// recorded, projects, and a project's API keys. Retrieve and archive of a project and a project's
|
|
3
|
+
// keys (under their project) are the derived core's; the machine in ../manifest.ts moves a
|
|
4
|
+
// project's `status`. Keys are the twin's own, never OpenAI credentials.
|
|
5
|
+
import type { Semantics } from '@volter/world-core';
|
|
6
|
+
import { bucketed, costsReport, reportQuery, usageReport } from '../openai-twin.ts';
|
|
7
|
+
import { readOnly } from './progress.ts';
|
|
8
|
+
import { epoch, invalid, newest, page, pick } from './shared.ts';
|
|
9
|
+
|
|
10
|
+
const PROJECT = 'Project';
|
|
11
|
+
|
|
12
|
+
/** Every organization has a Default project, where the keys made before any other project live and to
|
|
13
|
+
* which the Admin API's own acts are attributed (https://platform.openai.com/docs/api-reference/audit-logs/object:
|
|
14
|
+
* "any admin actions taken via Admin API keys are associated with the default project"). The twin makes
|
|
15
|
+
* it when the organization's projects are first looked at, dated by the organization's first billed call
|
|
16
|
+
* when there was one (the twin keeps no signup date); a read-only twin holds the vendor's own. */
|
|
17
|
+
async function defaultProject(ctx: Parameters<Semantics>[0]): Promise<void> {
|
|
18
|
+
if (readOnly(ctx) || ctx.rowsRaw(PROJECT, { withDeleted: true }).some((p) => p._default === true)) return;
|
|
19
|
+
await ctx.write(PROJECT, ctx.mint(PROJECT), { object: 'organization.project', name: 'Default project', created_at: Math.min(epoch(ctx), ...ctx.rowsRaw('_usage_record').map((r) => Number(r.created_at))), archived_at: null, status: 'active', _default: true }, 'project.create');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// one report per kind the spec publishes; the operation's path names the kind
|
|
23
|
+
const usage: Semantics = async (ctx) => {
|
|
24
|
+
if (ctx.params.start_time === undefined) return invalid(ctx, 'you must provide a start_time parameter', 'start_time');
|
|
25
|
+
const kind = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '').split('/').at(-1);
|
|
26
|
+
const q = reportQuery(ctx.params, epoch(ctx), false);
|
|
27
|
+
// vector store usage is storage, not calls: the bytes the organization's stores held over each bucket, for a bucket in
|
|
28
|
+
// which any store existed (https://platform.openai.com/docs/api-reference/usage/vector_stores_object)
|
|
29
|
+
if (kind === 'vector_stores') {
|
|
30
|
+
const stores = ctx.rowsRaw('VectorStoreObject', { withDeleted: true }).map((r) => ({ from: Number(r.created_at), until: r.deleted ? Date.parse(String(r.updatedAt)) / 1000 : Infinity, bytes: Number(r.usage_bytes ?? 0) }));
|
|
31
|
+
return ctx.reply(bucketed([], q, (_rows, at, until) => {
|
|
32
|
+
const held = stores.filter((v) => v.from < until && v.until >= at);
|
|
33
|
+
return held.length ? [{ object: 'organization.usage.vector_stores.result', usage_bytes: held.reduce((n, v) => n + v.bytes, 0), project_id: null }] : [];
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
return ctx.reply(usageReport(ctx.rowsRaw('_usage_record'), kind, q));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const costs: Semantics = async (ctx) => ctx.params.start_time === undefined ? invalid(ctx, 'you must provide a start_time parameter', 'start_time') : ctx.reply(costsReport(ctx.rowsRaw('_usage_record'), reportQuery(ctx.params, epoch(ctx), true)));
|
|
40
|
+
|
|
41
|
+
// archived projects are left out unless asked for
|
|
42
|
+
const listProjects: Semantics = async (ctx) => {
|
|
43
|
+
await defaultProject(ctx);
|
|
44
|
+
const all = ctx.params.include_archived === 'true';
|
|
45
|
+
return page(ctx, newest(ctx.rows(PROJECT).filter((p) => all || p.status !== 'archived')));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const createProject: Semantics = async (ctx) => {
|
|
49
|
+
await defaultProject(ctx);
|
|
50
|
+
if (ctx.params.name === undefined || ctx.params.name === '') return invalid(ctx, 'you must provide a name parameter', 'name');
|
|
51
|
+
const fields = { object: 'organization.project', name: String(ctx.params.name), created_at: epoch(ctx), archived_at: null, status: 'active' };
|
|
52
|
+
return ctx.reply(await ctx.write(PROJECT, ctx.mint(PROJECT), fields, 'project.create'));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const modifyProject: Semantics = async (ctx) => {
|
|
56
|
+
const id = String(ctx.id);
|
|
57
|
+
if (!ctx.get(PROJECT, id)) return ctx.notFound(PROJECT, id);
|
|
58
|
+
return ctx.reply(await ctx.write(PROJECT, id, pick(ctx.params, ['name']), 'project.update'));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const organization: Record<string, Semantics> = {
|
|
62
|
+
'usage-audio-speeches': usage,
|
|
63
|
+
'usage-audio-transcriptions': usage,
|
|
64
|
+
'usage-code-interpreter-sessions': usage,
|
|
65
|
+
'usage-completions': usage,
|
|
66
|
+
'usage-embeddings': usage,
|
|
67
|
+
'usage-file-search-calls': usage,
|
|
68
|
+
'usage-images': usage,
|
|
69
|
+
'usage-moderations': usage,
|
|
70
|
+
'usage-vector-stores': usage,
|
|
71
|
+
'usage-web-search-calls': usage,
|
|
72
|
+
'usage-costs': costs,
|
|
73
|
+
'list-projects': listProjects,
|
|
74
|
+
'create-project': createProject,
|
|
75
|
+
'modify-project': modifyProject,
|
|
76
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The work OpenAI does on its own. A batch, a fine-tuning job, a run, an eval run and a vector store's
|
|
2
|
+
// files are created where OpenAI starts them (validating, queued, in progress) and OpenAI moves them
|
|
3
|
+
// on; the twin does that work when a read first looks, walking the vendor's declared moves in
|
|
4
|
+
// ../manifest.ts (actor 'vendor') to where they end, deciding and writing at once so concurrent reads
|
|
5
|
+
// move a subject once. A subject the caller holds (a paused job) or that is already done stays put.
|
|
6
|
+
// A read-only (mirror) twin answers stored state as it is and never moves anything.
|
|
7
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
8
|
+
import { manifest } from '../manifest.ts';
|
|
9
|
+
import type { Row } from './shared.ts';
|
|
10
|
+
|
|
11
|
+
/** Marks a context served by a read-only (mirror) twin: its subjects come from the real vendor, so it
|
|
12
|
+
* has no vendor progress to simulate and a read never writes. */
|
|
13
|
+
export const READ_ONLY = Symbol('openai.readOnly');
|
|
14
|
+
|
|
15
|
+
/** Whether this request is served by a read-only twin. */
|
|
16
|
+
export const readOnly = (ctx: SemanticsContext): boolean => (ctx as unknown as Record<symbol, unknown>)[READ_ONLY] === true;
|
|
17
|
+
|
|
18
|
+
/** Where the vendor takes each in-flight state next, per resource. */
|
|
19
|
+
export const PROGRESS: Record<string, Record<string, string>> = {
|
|
20
|
+
Batch: { validating: 'in_progress', in_progress: 'finalizing', finalizing: 'completed', cancelling: 'cancelled' },
|
|
21
|
+
FineTuningJob: { validating_files: 'queued', queued: 'running', running: 'succeeded' },
|
|
22
|
+
RunObject: { queued: 'in_progress', in_progress: 'completed', cancelling: 'cancelled' },
|
|
23
|
+
EvalRun: { queued: 'in_progress', in_progress: 'completed' },
|
|
24
|
+
VectorStoreFileObject: { in_progress: 'completed' },
|
|
25
|
+
VectorStoreFileBatchObject: { in_progress: 'completed' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Whether the vendor still has work to do on a stored subject. */
|
|
29
|
+
export const inFlight = (resource: string, row: Row): boolean => String(row.status) in (PROGRESS[resource] ?? {});
|
|
30
|
+
|
|
31
|
+
/** The effects the manifest declares on the vendor's move `from` → `to`, as values. */
|
|
32
|
+
function effects(ctx: SemanticsContext, resource: string, from: string, to: string): Row {
|
|
33
|
+
const t = manifest.resources[resource]?.state?.status?.transitions.find((x) => x.actor === 'vendor' && x.to === to && x.from !== '*' && x.from.includes(from));
|
|
34
|
+
const out: Row = {};
|
|
35
|
+
for (const [k, rule] of Object.entries(t?.effects ?? {})) out[k] = 'now' in rule ? ctx.now() : 'value' in rule ? rule.value : null;
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Walk one subject to where the vendor leaves it. `finish` adds what its end state carries (an
|
|
40
|
+
* output file, a fine-tuned model). Answers the end state when this read is the one that moved it. */
|
|
41
|
+
export async function progress(ctx: SemanticsContext, resource: string, id: string, finish?: (row: Row, end: string) => Row): Promise<{ from: string; to: string; row: Row } | undefined> {
|
|
42
|
+
if (readOnly(ctx)) return undefined;
|
|
43
|
+
const storedAs = manifest.resources[resource]?.storedAs ?? resource;
|
|
44
|
+
return ctx.atomically<{ from: string; to: string; row: Row } | undefined>((rows) => {
|
|
45
|
+
const current = rows(resource).find((r) => r.id === id);
|
|
46
|
+
if (!current || !inFlight(resource, current)) return { value: undefined };
|
|
47
|
+
const path = PROGRESS[resource]!;
|
|
48
|
+
const from = String(current.status);
|
|
49
|
+
let state = from;
|
|
50
|
+
const fields: Row = {};
|
|
51
|
+
while (state in path) {
|
|
52
|
+
const to = path[state]!;
|
|
53
|
+
// the vendor's move, never the caller's: the machine must declare it
|
|
54
|
+
if (ctx.legal(resource, 'status', ctx.call.operation.id, state, to, id, 'vendor')) break;
|
|
55
|
+
Object.assign(fields, effects(ctx, resource, state, to));
|
|
56
|
+
state = to;
|
|
57
|
+
}
|
|
58
|
+
Object.assign(fields, { status: state }, finish?.(current, state) ?? {});
|
|
59
|
+
return { value: { from, to: state, row: { ...current, ...fields } }, write: { resource, id, fields, operation: `${storedAs}.update` } };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every in-flight subject a read is about to show, walked on (a list observes as a get does). */
|
|
64
|
+
export async function progressAll(ctx: SemanticsContext, resource: string, finish?: (row: Row, end: string) => Row, only: (row: Row) => boolean = () => true): Promise<void> {
|
|
65
|
+
if (readOnly(ctx)) return;
|
|
66
|
+
for (const row of ctx.rowsRaw(resource)) if (only(row) && inFlight(resource, row)) await progress(ctx, resource, String(row.id), finish);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The derived core's answer for this read, once the vendor's work is observed. */
|
|
70
|
+
export async function coreAnswer(ctx: SemanticsContext): Promise<Response> {
|
|
71
|
+
return (await ctx.core()) ?? ctx.refuse({ status: 404, message: `Unknown request URL: ${ctx.call.request.method} ${new URL(ctx.call.request.url).pathname}. Please check the URL for typos.` });
|
|
72
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Responses API semantics. A response is the pack's behaviour, never a model: a scripted scenario's
|
|
2
|
+
// turn when one is loaded and a handler matches, else the labeled deterministic stub. A stored
|
|
3
|
+
// response (the default) reads back with its input items; `previous_response_id` continues one; a
|
|
4
|
+
// background response is created queued and processed on its first poll. The machine in
|
|
5
|
+
// ../manifest.ts declares `status` (the vendor's work a poll observes, cancel) and refuses what OpenAI refuses; delete is the
|
|
6
|
+
// derived core's. Every operation has a beta twin (`?beta=true`) with the same semantics.
|
|
7
|
+
import type { ScenarioDecision, Semantics } from '@volter/world-core';
|
|
8
|
+
import { type OpenAIScenarioEngine, serveScenario } from '../openai-scenario.ts';
|
|
9
|
+
import { buildResponse, createStoredResponse, emitResponse, responseMessages, responseSuffix, validateResponses, type ResponsesArgs } from '../openai-twin.ts';
|
|
10
|
+
import type { OpenAIResponse } from '../openai-types.ts';
|
|
11
|
+
import { readOnly } from './progress.ts';
|
|
12
|
+
import { epoch, recordUsage, req, send, vendorView, type Row } from './shared.ts';
|
|
13
|
+
|
|
14
|
+
type Ops = { resource: string; get: string; cancel: string };
|
|
15
|
+
const GA: Ops = { resource: 'Response', get: 'getResponse', cancel: 'cancelResponse' };
|
|
16
|
+
const BETA: Ops = { resource: 'BetaResponse', get: 'beta_getResponse', cancel: 'beta_cancelResponse' };
|
|
17
|
+
|
|
18
|
+
/** Each built-in tool call a response made is billed as one (the usage reports' web search and file search calls). */
|
|
19
|
+
async function recordToolCalls(ctx: Parameters<Semantics>[0], resp: OpenAIResponse): Promise<void> {
|
|
20
|
+
for (const item of resp.output) {
|
|
21
|
+
if (item.type === 'web_search_call') await recordUsage(ctx, 'web_search_calls', resp.model, 0, 0);
|
|
22
|
+
if (item.type === 'file_search_call') await recordUsage(ctx, 'file_search_calls', resp.model, 0, 0);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const create = (engine: OpenAIScenarioEngine | undefined, ops: Ops): Semantics => async (ctx) => {
|
|
27
|
+
const validated = validateResponses(ctx.params);
|
|
28
|
+
if ('error' in validated) return send(ctx, validated.error);
|
|
29
|
+
const args = validated.args;
|
|
30
|
+
// each stored input already holds its ancestry: continuing appends the prior output once
|
|
31
|
+
if (args.previousResponseId) {
|
|
32
|
+
const prior = ctx.row(ops.resource, args.previousResponseId);
|
|
33
|
+
if (!prior) return ctx.notFound(ops.resource, args.previousResponseId);
|
|
34
|
+
args.inputItems = [...((prior._input_items ?? []) as Row[]), ...((prior.output ?? []) as Row[]), ...args.inputItems];
|
|
35
|
+
args.messages = responseMessages(args.inputItems, args.instructions);
|
|
36
|
+
}
|
|
37
|
+
// the scenario decides the turn and honors a fault before any response exists, as on chat
|
|
38
|
+
const decision = engine ? await serveScenario(engine, { model: args.model, messages: args.messages, tools: args.tools, maxTokens: args.maxTokens }, new URL(ctx.call.request.url).pathname) : undefined;
|
|
39
|
+
if (decision?.kind === 'fault') return send(ctx, decision.result);
|
|
40
|
+
// a background response keeps its decision until its first poll, and streams nothing now
|
|
41
|
+
if (args.background) {
|
|
42
|
+
const queued = await createStoredResponse(args, req(ctx), decision);
|
|
43
|
+
return args.stream ? ctx.sse([]) : ctx.reply(queued);
|
|
44
|
+
}
|
|
45
|
+
const resp = args.store ? ((await createStoredResponse(args, req(ctx), decision)) as OpenAIResponse) : buildResponse(args, ctx.occurredAt, responseSuffix(args), decision);
|
|
46
|
+
const events: Array<{ event?: string; data: unknown }> = [];
|
|
47
|
+
if (args.stream) emitResponse(resp, (e) => { if (e.data) events.push({ event: String((e.data as { type?: unknown }).type ?? ''), data: e.data }); });
|
|
48
|
+
await recordUsage(ctx, 'responses', resp.model, resp.usage.input_tokens, resp.usage.output_tokens);
|
|
49
|
+
await recordToolCalls(ctx, resp);
|
|
50
|
+
return args.stream ? ctx.sse(events) : ctx.reply(resp);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// A background response is OpenAI's work, done over time: it is `queued`, then `in_progress` as its output is written,
|
|
54
|
+
// then `completed` (https://platform.openai.com/docs/guides/background: "you can poll the response object... while the
|
|
55
|
+
// status is `queued` or `in_progress`"). Extrapolation, the twin's own timing: a response waits a second in the queue, then
|
|
56
|
+
// writes its output at 50 tokens a second; a poll in between sees the text written so far in a message still in progress.
|
|
57
|
+
const QUEUED_SECONDS = 1;
|
|
58
|
+
const TOKENS_PER_SECOND = 50;
|
|
59
|
+
|
|
60
|
+
/** Where a background response has got to at `now`: its status and, in progress, the output written so far. */
|
|
61
|
+
function backgroundAt(current: Row, now: number, occurredAt: string | undefined): { status: 'queued' } | { status: 'in_progress'; output: unknown[] } | { status: 'completed'; resp: OpenAIResponse; at: number } {
|
|
62
|
+
const created = Number(current.created_at);
|
|
63
|
+
const start = created + QUEUED_SECONDS;
|
|
64
|
+
if (now < start) return { status: 'queued' };
|
|
65
|
+
const args = JSON.parse(String(current._bg_args ?? '{}')) as ResponsesArgs;
|
|
66
|
+
const decision = current._bg_decision ? (JSON.parse(String(current._bg_decision)) as ScenarioDecision) : undefined;
|
|
67
|
+
const resp = buildResponse(args, occurredAt, String(current._bg_suffix ?? responseSuffix(args)), decision);
|
|
68
|
+
const finish = start + Math.max(1, Math.ceil(resp.usage.output_tokens / TOKENS_PER_SECOND));
|
|
69
|
+
if (now >= finish) return { status: 'completed', resp: { ...resp, created_at: created, completed_at: finish }, at: finish };
|
|
70
|
+
const share = (now - start) / (finish - start);
|
|
71
|
+
const output = resp.output.flatMap((item): unknown[] => {
|
|
72
|
+
if (item.type !== 'message') return item.type === 'reasoning' ? [item] : [];
|
|
73
|
+
const whole = item.content[0]?.text ?? '';
|
|
74
|
+
return [{ ...item, status: 'in_progress', content: [{ ...item.content[0], text: whole.slice(0, Math.floor(whole.length * share)) }] }];
|
|
75
|
+
});
|
|
76
|
+
return { status: 'in_progress', output };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Observe OpenAI's work on a background response up to now, deciding and writing at once so concurrent reads move it
|
|
80
|
+
* once (and record its usage once, when it completes). */
|
|
81
|
+
async function observe(ctx: Parameters<Semantics>[0], ops: Ops, id: string): Promise<Row | undefined> {
|
|
82
|
+
const now = epoch(ctx);
|
|
83
|
+
const polled = await ctx.atomically<{ row?: Row; completed?: OpenAIResponse }>((rows) => {
|
|
84
|
+
const current = rows(ops.resource).find((r) => r.id === id);
|
|
85
|
+
if (!current) return { value: {} };
|
|
86
|
+
if (current.status !== 'queued' && current.status !== 'in_progress') return { value: { row: current } };
|
|
87
|
+
const at = backgroundAt(current, now, ctx.occurredAt);
|
|
88
|
+
if (at.status === current.status && at.status === 'queued') return { value: { row: current } };
|
|
89
|
+
// the vendor's move, not the caller's: a poll finds it moved on
|
|
90
|
+
// (further text in a response already in progress is no move of its status)
|
|
91
|
+
const refusal = at.status === current.status ? undefined : ctx.legal(ops.resource, 'status', ops.get, current.status, at.status, id, 'vendor');
|
|
92
|
+
if (refusal) return { value: { row: current } };
|
|
93
|
+
const fields = at.status === 'completed'
|
|
94
|
+
? { ...at.resp, background: true, _bg_args: null, _bg_suffix: null, _bg_decision: null }
|
|
95
|
+
: { status: 'in_progress', output: (at as { output: unknown[] }).output };
|
|
96
|
+
return { value: { row: { ...current, ...fields }, ...(at.status === 'completed' ? { completed: at.resp } : {}) }, write: { resource: ops.resource, id, fields, operation: 'response.update' } };
|
|
97
|
+
});
|
|
98
|
+
if (polled.completed) {
|
|
99
|
+
await recordUsage(ctx, 'responses', polled.completed.model, polled.completed.usage.input_tokens, polled.completed.usage.output_tokens);
|
|
100
|
+
await recordToolCalls(ctx, polled.completed);
|
|
101
|
+
}
|
|
102
|
+
return polled.row;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const retrieve = (ops: Ops): Semantics => async (ctx) => {
|
|
106
|
+
const id = String(ctx.id);
|
|
107
|
+
const row = ctx.row(ops.resource, id);
|
|
108
|
+
if (!row) return ctx.notFound(ops.resource, id);
|
|
109
|
+
// a read-only (mirror) twin holds the vendor's own state: it answers it as stored
|
|
110
|
+
if ((row.status !== 'queued' && row.status !== 'in_progress') || readOnly(ctx)) return ctx.reply(vendorView(row));
|
|
111
|
+
const seen = await observe(ctx, ops, id);
|
|
112
|
+
return seen ? ctx.reply(vendorView(seen)) : ctx.notFound(ops.resource, id);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// cancelling stops the response where OpenAI's work has got to: a queued one with no output, one in progress with the
|
|
116
|
+
// output written so far, its message still `in_progress` (the reference's cancel example); a finished one is refused
|
|
117
|
+
const cancel = (ops: Ops): Semantics => async (ctx) => {
|
|
118
|
+
const id = String(ctx.id);
|
|
119
|
+
let row = ctx.row(ops.resource, id);
|
|
120
|
+
if (!row) return ctx.notFound(ops.resource, id);
|
|
121
|
+
if ((row.status === 'queued' || row.status === 'in_progress') && !readOnly(ctx)) row = (await observe(ctx, ops, id)) ?? row;
|
|
122
|
+
const refusal = ctx.legal(ops.resource, 'status', ops.cancel, row.status, 'cancelled');
|
|
123
|
+
if (refusal) return ctx.refuse(refusal);
|
|
124
|
+
// cancelling a cancelled response again answers it as it is
|
|
125
|
+
if (row.status === 'cancelled') return ctx.reply(vendorView(row));
|
|
126
|
+
return ctx.reply(await ctx.write(ops.resource, id, { status: 'cancelled', _bg_args: null, _bg_suffix: null, _bg_decision: null }, 'response.cancel'));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// the input items folded into a stored response (its ancestry included)
|
|
130
|
+
const inputItems = (ops: Ops): Semantics => async (ctx) => {
|
|
131
|
+
const id = String(ctx.id);
|
|
132
|
+
const row = ctx.row(ops.resource, id);
|
|
133
|
+
if (!row) return ctx.notFound(ops.resource, id);
|
|
134
|
+
// the list page names its first and last items, as every OpenAI list does
|
|
135
|
+
// (https://platform.openai.com/docs/api-reference/responses/input-items)
|
|
136
|
+
const data = (row._input_items as Row[] | undefined) ?? [];
|
|
137
|
+
return ctx.reply({ object: 'list', data, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null, has_more: false });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function responses(engine: OpenAIScenarioEngine | undefined): Record<string, Semantics> {
|
|
141
|
+
return {
|
|
142
|
+
createResponse: create(engine, GA),
|
|
143
|
+
getResponse: retrieve(GA),
|
|
144
|
+
cancelResponse: cancel(GA),
|
|
145
|
+
listInputItems: inputItems(GA),
|
|
146
|
+
beta_createResponse: create(engine, BETA),
|
|
147
|
+
beta_getResponse: retrieve(BETA),
|
|
148
|
+
beta_cancelResponse: cancel(BETA),
|
|
149
|
+
beta_listInputItems: inputItems(BETA),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// What every OpenAI family's semantics share: its error answers, its cursor page, its update pick,
|
|
2
|
+
// the usage ledger every billable call writes, and the request shape the pack's helpers in
|
|
3
|
+
// ../openai-twin.ts take. All of it is over the SemanticsContext.
|
|
4
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
5
|
+
import { usageCost, type OpenAIRequest, type OpenAIResponseEnvelope } from '../openai-twin.ts';
|
|
6
|
+
|
|
7
|
+
export type Row = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
/** The request shape the pack's shared helpers take. */
|
|
10
|
+
export function req(ctx: SemanticsContext): OpenAIRequest {
|
|
11
|
+
const url = new URL(ctx.call.request.url);
|
|
12
|
+
return { method: ctx.call.request.method, path: url.pathname + url.search, occurredAt: ctx.occurredAt, ...(ctx.root !== undefined ? { root: ctx.root } : {}) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** An answer a pack helper built, with the headers it carries (a scripted fault's rate-limit family). */
|
|
16
|
+
export function send(ctx: SemanticsContext, r: OpenAIResponseEnvelope): Response {
|
|
17
|
+
if (!r.headers) return ctx.reply(r.body, r.status);
|
|
18
|
+
return new Response(JSON.stringify(r.body), { status: r.status, headers: { 'content-type': 'application/json', ...r.headers } });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** OpenAI's invalid_request_error: 400 with the parameter it names and the code when there is one. */
|
|
22
|
+
export const invalid = (ctx: SemanticsContext, message: string, param?: string, code?: string): Response =>
|
|
23
|
+
ctx.refuse({ status: 400, message, ...(param ? { param } : {}), ...(code ? { code } : {}) });
|
|
24
|
+
|
|
25
|
+
/** A 404 in OpenAI's error shape, with the message the family uses. */
|
|
26
|
+
export const missing = (ctx: SemanticsContext, message: string, code?: string): Response => ctx.refuse({ status: 404, message, ...(code ? { code } : {}) });
|
|
27
|
+
|
|
28
|
+
/** The world clock as OpenAI's unix seconds. */
|
|
29
|
+
export const epoch = (ctx: SemanticsContext): number => Number(ctx.now());
|
|
30
|
+
|
|
31
|
+
/** A path parameter, by the name the spec gives it. */
|
|
32
|
+
export const at = (ctx: SemanticsContext, name: string): string => ctx.call.params[name] ?? '';
|
|
33
|
+
|
|
34
|
+
/** An object parameter, or the fallback when the caller sent none. */
|
|
35
|
+
export const objectOr = (v: unknown, fallback: unknown): unknown => (v && typeof v === 'object' ? v : fallback);
|
|
36
|
+
|
|
37
|
+
/** Only the updatable fields present in the request (OpenAI's modify-by-POST). */
|
|
38
|
+
export function pick(params: Row, keys: string[]): Row {
|
|
39
|
+
const out: Row = {};
|
|
40
|
+
for (const k of keys) if (params[k] !== undefined) out[k] = params[k];
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a page starts after the cursor `after`: past that id, or past the end for an unknown one. */
|
|
45
|
+
function startAfter(items: Row[], after: string): number {
|
|
46
|
+
const i = items.findIndex((it) => it.id === after);
|
|
47
|
+
return i >= 0 ? i + 1 : items.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** OpenAI's cursor page over items already in list order: `after` starts past that id (an unknown
|
|
51
|
+
* one past the end), `limit` defaults to 20 and caps at 100. */
|
|
52
|
+
export function page(ctx: SemanticsContext, items: Row[]): Response {
|
|
53
|
+
const after = typeof ctx.params.after === 'string' ? ctx.params.after : '';
|
|
54
|
+
let limit = Number(ctx.params.limit);
|
|
55
|
+
if (!Number.isInteger(limit) || limit < 1) limit = 20;
|
|
56
|
+
if (limit > 100) limit = 100;
|
|
57
|
+
const start = after ? startAfter(items, after) : 0;
|
|
58
|
+
const data = items.slice(start, start + limit);
|
|
59
|
+
return ctx.reply({ object: 'list', data, has_more: start + limit < items.length, first_id: data[0]?.id ?? null, last_id: data[data.length - 1]?.id ?? null });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A stored row as OpenAI shows it, from the raw row a handler read for its bookkeeping: its id
|
|
63
|
+
* first, the kernel's fields and the twin's own (`_`-prefixed) left out. */
|
|
64
|
+
export function vendorView(r: Row): Row {
|
|
65
|
+
const out: Row = { id: r.id };
|
|
66
|
+
for (const [k, v] of Object.entries(r)) if (k !== 'type' && k !== 'updatedAt' && !k.startsWith('_')) out[k] = v;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Newest first by `created_at`, rows of one instant by mint order (`proj-twin-10` newer than `proj-twin-9`), as the
|
|
71
|
+
* derived core lists. */
|
|
72
|
+
export const newest = (items: Row[], field = 'created_at'): Row[] => [...items].sort((a, b) => Number(b[field]) - Number(a[field]) || String(b.id).localeCompare(String(a.id), undefined, { numeric: true }));
|
|
73
|
+
|
|
74
|
+
/** A billable call's usage, priced per model: what the organization usage and costs reports read. `extra` is what the
|
|
75
|
+
* call's own report counts besides tokens (a speech's characters, a transcription's seconds, an image call's images,
|
|
76
|
+
* size and source, a container's session). */
|
|
77
|
+
export async function recordUsage(ctx: SemanticsContext, kind: string, model: string, inputTokens: number, outputTokens: number, extra: Row = {}): Promise<void> {
|
|
78
|
+
await ctx.record('_usage_record', {
|
|
79
|
+
object: '_usage_record', kind, model, input_tokens: inputTokens, output_tokens: outputTokens,
|
|
80
|
+
num_model_requests: 1, cost_usd: usageCost(model, inputTokens, outputTokens), created_at: epoch(ctx), ...extra,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Upload semantics: a large file sent in parts. Create an Upload, add parts, then complete it (the
|
|
2
|
+
// parts, in the order the caller names, become one File) or cancel it. An Upload is stored as
|
|
3
|
+
// 'upload' beside the File it becomes; its parts are bookkeeping on it (`_parts`). The spec names no
|
|
4
|
+
// Upload resource, so the manifest declares one for its id and its `status` machine.
|
|
5
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { expiresAt } from './files.ts';
|
|
7
|
+
import { epoch, invalid, type Row } from './shared.ts';
|
|
8
|
+
|
|
9
|
+
const UPLOAD = 'Upload';
|
|
10
|
+
|
|
11
|
+
/** The Upload the path names, when the machine in ../manifest.ts lets `operationId` act on it (only a
|
|
12
|
+
* pending one takes parts, completes or cancels), or OpenAI's answer. */
|
|
13
|
+
async function pending(ctx: SemanticsContext, operationId: string, to?: string): Promise<{ upload: Row } | { answer: Response }> {
|
|
14
|
+
const id = String(ctx.id);
|
|
15
|
+
let upload = ctx.row(UPLOAD, id, { withDeleted: true });
|
|
16
|
+
if (!upload) return { answer: ctx.notFound(UPLOAD, id) };
|
|
17
|
+
// a pending Upload expires an hour after it was created (its expires_at): the clock's move, seen when a use looks
|
|
18
|
+
if (upload.status === 'pending' && Number(upload.expires_at) <= epoch(ctx) && !ctx.legal(UPLOAD, 'status', operationId, 'pending', 'expired', id, 'time')) {
|
|
19
|
+
upload = { ...upload, ...(await ctx.write(UPLOAD, id, { status: 'expired' }, 'upload.update')) };
|
|
20
|
+
}
|
|
21
|
+
const refusal = ctx.legal(UPLOAD, 'status', operationId, upload.status, to, id);
|
|
22
|
+
return refusal ? { answer: ctx.refuse(refusal) } : { upload };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const create: Semantics = async (ctx) => {
|
|
26
|
+
const p = ctx.params;
|
|
27
|
+
for (const k of ['filename', 'purpose', 'bytes', 'mime_type']) {
|
|
28
|
+
if (p[k] === undefined || p[k] === '') return invalid(ctx, `you must provide a ${k} parameter`, k);
|
|
29
|
+
}
|
|
30
|
+
const created = epoch(ctx);
|
|
31
|
+
const fields = {
|
|
32
|
+
object: 'upload', created_at: created, filename: String(p.filename), bytes: Number(p.bytes),
|
|
33
|
+
purpose: String(p.purpose), mime_type: String(p.mime_type), status: 'pending',
|
|
34
|
+
expires_at: created + 3600, file: null,
|
|
35
|
+
};
|
|
36
|
+
return ctx.reply(await ctx.write(UPLOAD, ctx.mint(UPLOAD), fields, 'upload.create'));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// A part's `data` is a multipart file from the SDK, or a string from an in-process caller.
|
|
40
|
+
const addPart: Semantics = async (ctx) => {
|
|
41
|
+
const found = await pending(ctx, 'addUploadPart');
|
|
42
|
+
if ('answer' in found) return found.answer;
|
|
43
|
+
const data = ctx.params.data;
|
|
44
|
+
if (data === undefined) return invalid(ctx, 'you must provide a data parameter', 'data');
|
|
45
|
+
const id = String(found.upload.id);
|
|
46
|
+
const parts = Array.isArray(found.upload._parts) ? [...(found.upload._parts as unknown[])] : [];
|
|
47
|
+
const partId = `part-twin-${id}-${parts.length + 1}`;
|
|
48
|
+
parts.push({ id: partId, data: data && typeof data === 'object' ? String((data as { content?: unknown }).content ?? '') : String(data) });
|
|
49
|
+
await ctx.write(UPLOAD, id, { _parts: parts }, 'upload.update');
|
|
50
|
+
return ctx.reply({ id: partId, object: 'upload.part', created_at: epoch(ctx), upload_id: id });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const complete: Semantics = async (ctx) => {
|
|
54
|
+
const found = await pending(ctx, 'completeUpload', 'completed');
|
|
55
|
+
if ('answer' in found) return found.answer;
|
|
56
|
+
const upload = found.upload;
|
|
57
|
+
const partIds = ctx.params.part_ids;
|
|
58
|
+
if (!Array.isArray(partIds) || partIds.length === 0) return invalid(ctx, 'you must provide a part_ids array', 'part_ids');
|
|
59
|
+
const parts = Array.isArray(upload._parts) ? (upload._parts as Array<{ id: string; data: string }>) : [];
|
|
60
|
+
const byId = new Map(parts.map((part) => [part.id, part.data]));
|
|
61
|
+
let content = '';
|
|
62
|
+
for (const pid of partIds) {
|
|
63
|
+
if (!byId.has(String(pid))) return invalid(ctx, `unknown part_id '${pid}'`, 'part_ids');
|
|
64
|
+
content += byId.get(String(pid));
|
|
65
|
+
}
|
|
66
|
+
// OpenAI refuses to assemble a file whose parts do not add up to the bytes declared at creation
|
|
67
|
+
// (https://platform.openai.com/docs/api-reference/uploads/complete). Extrapolation: the wording is
|
|
68
|
+
// not sourced from a recording.
|
|
69
|
+
const sent = new TextEncoder().encode(content).length;
|
|
70
|
+
const declared = Number(upload.bytes);
|
|
71
|
+
if (sent !== declared) return invalid(ctx, `The number of bytes uploaded (${sent}) does not match the number of bytes specified when the Upload was created (${declared}).`, 'bytes');
|
|
72
|
+
const file = await ctx.write(
|
|
73
|
+
'OpenAIFile',
|
|
74
|
+
ctx.mint('OpenAIFile'),
|
|
75
|
+
{ object: 'file', bytes: Number(upload.bytes ?? content.length), created_at: epoch(ctx), expires_at: expiresAt(String(upload.purpose), epoch(ctx), undefined), filename: String(upload.filename), purpose: String(upload.purpose), status: 'processed', _content: content },
|
|
76
|
+
'file.create',
|
|
77
|
+
);
|
|
78
|
+
return ctx.reply(await ctx.write(UPLOAD, String(upload.id), { status: 'completed', file }, 'upload.update'));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const cancel: Semantics = async (ctx) => {
|
|
82
|
+
const found = await pending(ctx, 'cancelUpload', 'cancelled');
|
|
83
|
+
if ('answer' in found) return found.answer;
|
|
84
|
+
return ctx.reply(await ctx.write(UPLOAD, String(found.upload.id), { status: 'cancelled' }, 'upload.cancel'));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const uploads: Record<string, Semantics> = {
|
|
88
|
+
createUpload: create,
|
|
89
|
+
addUploadPart: addPart,
|
|
90
|
+
completeUpload: complete,
|
|
91
|
+
cancelUpload: cancel,
|
|
92
|
+
};
|