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