@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,151 @@
|
|
|
1
|
+
// Containers API semantics: code-interpreter sandboxes and the files in them. Delete of a container is
|
|
2
|
+
// the derived core's, and so are the reads once expiry is observed. The twin runs no sandbox, so a
|
|
3
|
+
// container is created running; it expires once idle past its `expires_after` (by default 20 minutes
|
|
4
|
+
// after `last_active_at`), which a read or a use observes by the World clock (the machine's time
|
|
5
|
+
// move in ../manifest.ts; a read-only twin never writes it). Adding or removing a file is activity and
|
|
6
|
+
// moves `last_active_at`; an expired container's files cannot be used. A file keeps its text
|
|
7
|
+
// (`_content`) as the Files API does, from an upload or a stored File.
|
|
8
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
9
|
+
import { coreAnswer, readOnly } from './progress.ts';
|
|
10
|
+
import { at, epoch, invalid, objectOr, recordUsage, type Row } from './shared.ts';
|
|
11
|
+
|
|
12
|
+
const CONTAINER = 'ContainerResource';
|
|
13
|
+
const FILE = 'ContainerFileResource';
|
|
14
|
+
|
|
15
|
+
/** Whether a running container has sat idle past its expiry window at this instant. */
|
|
16
|
+
function idle(row: Row, now: number): boolean {
|
|
17
|
+
const after = (row.expires_after ?? {}) as { anchor?: unknown; minutes?: unknown };
|
|
18
|
+
const minutes = Number(after.minutes ?? 20);
|
|
19
|
+
const anchor = Number((after.anchor === 'created_at' ? row.created_at : row.last_active_at) ?? row.created_at ?? 0);
|
|
20
|
+
return row.status === 'running' && Number.isFinite(minutes) && now > anchor + minutes * 60;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Observe a container's expiry (the time move), deciding and writing at once. */
|
|
24
|
+
async function observe(ctx: SemanticsContext, id: string): Promise<void> {
|
|
25
|
+
if (readOnly(ctx)) return;
|
|
26
|
+
const now = epoch(ctx);
|
|
27
|
+
await ctx.atomically((rows) => {
|
|
28
|
+
const current = rows(CONTAINER).find((r) => r.id === id);
|
|
29
|
+
if (!current || !idle(current, now)) return { value: undefined };
|
|
30
|
+
if (ctx.legal(CONTAINER, 'status', ctx.call.operation.id, current.status, 'expired', id, 'time')) return { value: undefined };
|
|
31
|
+
return { value: undefined, write: { resource: CONTAINER, id, fields: { status: 'expired' }, operation: 'container.update' } };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The container a file operation names, observed and allowed to be used, or OpenAI's answer. */
|
|
36
|
+
async function usable(ctx: SemanticsContext): Promise<{ id: string } | { answer: Response }> {
|
|
37
|
+
const id = at(ctx, 'container_id');
|
|
38
|
+
await observe(ctx, id);
|
|
39
|
+
const row = ctx.get(CONTAINER, id);
|
|
40
|
+
if (!row) return { answer: ctx.notFound(CONTAINER, id) };
|
|
41
|
+
const refusal = ctx.legal(CONTAINER, 'status', ctx.call.operation.id, row.status, undefined, id);
|
|
42
|
+
return refusal ? { answer: ctx.refuse(refusal) } : { id };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Using a container (a file added or removed) is activity: it restarts the idle window. */
|
|
46
|
+
const touch = (ctx: SemanticsContext, id: string): Promise<Row> => ctx.write(CONTAINER, id, { last_active_at: epoch(ctx) }, 'container.update');
|
|
47
|
+
|
|
48
|
+
const create: Semantics = async (ctx) => {
|
|
49
|
+
const p = ctx.params;
|
|
50
|
+
if (p.name === undefined || p.name === '') return invalid(ctx, 'you must provide a name parameter', 'name');
|
|
51
|
+
const created = epoch(ctx);
|
|
52
|
+
const fields = {
|
|
53
|
+
object: 'container',
|
|
54
|
+
name: String(p.name),
|
|
55
|
+
created_at: created,
|
|
56
|
+
status: 'running',
|
|
57
|
+
last_active_at: created,
|
|
58
|
+
expires_after: objectOr(p.expires_after, { anchor: 'last_active_at', minutes: 20 }),
|
|
59
|
+
// its memory, "Defaults to \"1g\"", and the network policy it was given (the spec's CreateContainerBody;
|
|
60
|
+
// https://platform.openai.com/docs/api-reference/containers/createContainers)
|
|
61
|
+
memory_limit: typeof p.memory_limit === 'string' && p.memory_limit ? p.memory_limit : '1g',
|
|
62
|
+
...(p.network_policy && typeof p.network_policy === 'object' ? { network_policy: p.network_policy } : {}),
|
|
63
|
+
};
|
|
64
|
+
const written = await ctx.write(CONTAINER, ctx.mint(CONTAINER), fields, 'container.create');
|
|
65
|
+
// a container is a code interpreter session, billed as one (the usage report's `num_sessions`)
|
|
66
|
+
await recordUsage(ctx, 'code_interpreter_sessions', 'code-interpreter', 0, 0, { num_sessions: 1 });
|
|
67
|
+
return ctx.reply(written);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// A file comes from a stored File (a JSON `file_id`) or a multipart upload (the part `file`), the two
|
|
71
|
+
// forms OpenAI takes (https://platform.openai.com/docs/api-reference/container-files/createContainerFile:
|
|
72
|
+
// "either a multipart/form-data request with the raw file content, or a JSON request with a file ID");
|
|
73
|
+
// its id is the container's next.
|
|
74
|
+
const createFile: Semantics = async (ctx) => {
|
|
75
|
+
const found = await usable(ctx);
|
|
76
|
+
if ('answer' in found) return found.answer;
|
|
77
|
+
const containerId = found.id;
|
|
78
|
+
const p = ctx.params;
|
|
79
|
+
const seq = ctx.rowsRaw(FILE, { withDeleted: true }).filter((r) => r.container_id === containerId).length + 1;
|
|
80
|
+
const id = `cfile-twin-${containerId}-${seq}`;
|
|
81
|
+
let content = '';
|
|
82
|
+
let source = 'user';
|
|
83
|
+
let name = '';
|
|
84
|
+
if (typeof p.file_id === 'string' && p.file_id) {
|
|
85
|
+
const file = ctx.row('OpenAIFile', p.file_id);
|
|
86
|
+
if (!file) return ctx.notFound('OpenAIFile', p.file_id);
|
|
87
|
+
content = String(file._content ?? '');
|
|
88
|
+
source = 'file_id';
|
|
89
|
+
} else if (p.file && typeof p.file === 'object') {
|
|
90
|
+
const upload = p.file as { name?: unknown; content?: unknown };
|
|
91
|
+
content = String(upload.content ?? '');
|
|
92
|
+
name = typeof upload.name === 'string' && upload.name ? upload.name : '';
|
|
93
|
+
}
|
|
94
|
+
const fields = {
|
|
95
|
+
object: 'container.file',
|
|
96
|
+
container_id: containerId,
|
|
97
|
+
created_at: epoch(ctx),
|
|
98
|
+
bytes: content.length,
|
|
99
|
+
path: name ? `/mnt/data/${name}` : `/mnt/data/${id}`,
|
|
100
|
+
source,
|
|
101
|
+
_content: content,
|
|
102
|
+
};
|
|
103
|
+
const written = await ctx.write(FILE, id, fields, 'container_file.create');
|
|
104
|
+
await touch(ctx, containerId);
|
|
105
|
+
return ctx.reply(written);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// the file's bytes as kept
|
|
109
|
+
const content: Semantics = async (ctx) => {
|
|
110
|
+
const found = await usable(ctx);
|
|
111
|
+
if ('answer' in found) return found.answer;
|
|
112
|
+
const id = at(ctx, 'file_id');
|
|
113
|
+
const file = ctx.row(FILE, id);
|
|
114
|
+
if (!file || file.container_id !== at(ctx, 'container_id')) return ctx.notFound(FILE, id);
|
|
115
|
+
return ctx.raw(String(file._content ?? ''), { headers: { 'content-type': 'application/octet-stream' } });
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const containers: Record<string, Semantics> = {
|
|
119
|
+
CreateContainer: create,
|
|
120
|
+
CreateContainerFile: createFile,
|
|
121
|
+
RetrieveContainerFileContent: content,
|
|
122
|
+
// a container's reads observe its expiry first
|
|
123
|
+
RetrieveContainer: async (ctx) => {
|
|
124
|
+
await observe(ctx, at(ctx, 'container_id'));
|
|
125
|
+
return coreAnswer(ctx);
|
|
126
|
+
},
|
|
127
|
+
ListContainers: async (ctx) => {
|
|
128
|
+
for (const r of ctx.rowsRaw(CONTAINER)) await observe(ctx, String(r.id));
|
|
129
|
+
return coreAnswer(ctx);
|
|
130
|
+
},
|
|
131
|
+
// its files' reads, only while it can be used
|
|
132
|
+
ListContainerFiles: async (ctx) => {
|
|
133
|
+
const found = await usable(ctx);
|
|
134
|
+
return 'answer' in found ? found.answer : coreAnswer(ctx);
|
|
135
|
+
},
|
|
136
|
+
RetrieveContainerFile: async (ctx) => {
|
|
137
|
+
const found = await usable(ctx);
|
|
138
|
+
return 'answer' in found ? found.answer : coreAnswer(ctx);
|
|
139
|
+
},
|
|
140
|
+
// removing a file is activity too
|
|
141
|
+
DeleteContainerFile: async (ctx) => {
|
|
142
|
+
const found = await usable(ctx);
|
|
143
|
+
if ('answer' in found) return found.answer;
|
|
144
|
+
const id = at(ctx, 'file_id');
|
|
145
|
+
const file = ctx.get(FILE, id);
|
|
146
|
+
if (!file || file.container_id !== found.id) return ctx.notFound(FILE, id);
|
|
147
|
+
await ctx.write(FILE, id, { deleted: true }, 'container_file.delete');
|
|
148
|
+
await touch(ctx, found.id);
|
|
149
|
+
return ctx.reply({ id, object: 'container.file.deleted', deleted: true });
|
|
150
|
+
},
|
|
151
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Embeddings: deterministic pseudo-vectors from the pack's stub, never a model's. Each call records
|
|
2
|
+
// its usage.
|
|
3
|
+
import type { Semantics } from '@volter/world-core';
|
|
4
|
+
import { handleEmbeddings } from '../openai-twin.ts';
|
|
5
|
+
import type { EmbeddingResponse } from '../openai-types.ts';
|
|
6
|
+
import { recordUsage, send } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
const create: Semantics = async (ctx) => {
|
|
9
|
+
const answer = handleEmbeddings(ctx.params);
|
|
10
|
+
if (answer.status === 200) {
|
|
11
|
+
const body = answer.body as EmbeddingResponse;
|
|
12
|
+
await recordUsage(ctx, 'embeddings', body.model, body.usage.prompt_tokens, 0);
|
|
13
|
+
}
|
|
14
|
+
return send(ctx, answer);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const embeddings: Record<string, Semantics> = {
|
|
18
|
+
createEmbedding: create,
|
|
19
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Evals API semantics: an eval's configuration, its runs, and a run's output items. List and
|
|
2
|
+
// retrieve of evals are the derived core's, and so are an eval's runs (a run lives under its eval)
|
|
3
|
+
// once the vendor's work is observed. A run is created `queued`, as OpenAI creates it; the twin runs
|
|
4
|
+
// no grader, so when a read first looks (./progress.ts) it completes with one passed item graded by
|
|
5
|
+
// a deterministic stub grader; the output items are read off the run, never stored.
|
|
6
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { coreAnswer, progress, progressAll } from './progress.ts';
|
|
8
|
+
import { at, epoch, invalid, objectOr, page, type Row } from './shared.ts';
|
|
9
|
+
|
|
10
|
+
const EVAL = 'Eval';
|
|
11
|
+
const RUN = 'EvalRun';
|
|
12
|
+
|
|
13
|
+
/** The items a run grades: those its data source holds inline (`file_content`), else one the twin stands in for (it
|
|
14
|
+
* fetches no dataset and reads no stored completions). */
|
|
15
|
+
function items(run: Row): Row[] {
|
|
16
|
+
const source = ((run.data_source ?? {}) as { source?: { type?: unknown; content?: unknown } }).source;
|
|
17
|
+
const inline = source?.type === 'file_content' && Array.isArray(source.content) ? (source.content as Array<{ item?: unknown }>).map((c) => (c?.item ?? {}) as Row) : [];
|
|
18
|
+
return inline.length ? inline : [{ input: '[twin-stub] datasource item' }];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The names the eval's testing criteria are graded under: each criterion's id. */
|
|
22
|
+
const criteria = (ctx: SemanticsContext, run: Row): string[] => {
|
|
23
|
+
const list = (ctx.get(EVAL, String(run.eval_id))?.testing_criteria ?? []) as Array<{ id?: unknown }>;
|
|
24
|
+
return list.length ? list.map((c) => String(c.id)) : ['twin-stub-grader'];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What a graded run carries: each of its items passed by the stub grader, under each criterion. */
|
|
28
|
+
const graded = (ctx: SemanticsContext) => (row: Row, end: string): Row => {
|
|
29
|
+
if (end !== 'completed') return {};
|
|
30
|
+
const n = items(row).length;
|
|
31
|
+
return {
|
|
32
|
+
result_counts: { total: n, errored: 0, failed: 0, passed: n },
|
|
33
|
+
per_model_usage: row.model == null ? [] : [{ model_name: String(row.model), invocation_count: n, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, cached_tokens: 0 }],
|
|
34
|
+
per_testing_criteria_results: criteria(ctx, row).map((testing_criteria) => ({ testing_criteria, passed: n, failed: 0 })),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A chat message as an eval answers it: a typed message whose text content is an `input_text` part (the reference's
|
|
39
|
+
* createEval and createEvalRun examples answer the messages they were sent so). */
|
|
40
|
+
function message(m: unknown): unknown {
|
|
41
|
+
const x = m as { type?: unknown; role?: unknown; content?: unknown };
|
|
42
|
+
if (!x || typeof x !== 'object' || x.type !== undefined && x.type !== 'message') return m;
|
|
43
|
+
return { type: 'message', ...x, content: typeof x.content === 'string' ? { type: 'input_text', text: x.content } : x.content };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The schema of the items (and samples) an eval reads: a stored-completions eval's item and sample objects, a custom
|
|
47
|
+
* eval's item schema as sent (the reference's createEval and getEval examples;
|
|
48
|
+
* https://platform.openai.com/docs/api-reference/evals/object, `data_source_config.schema`). */
|
|
49
|
+
function dataSourceConfig(config: Row): Row {
|
|
50
|
+
if (config.type === 'stored_completions') return { type: 'stored_completions', metadata: config.metadata ?? {}, schema: { type: 'object', properties: { item: { type: 'object' }, sample: { type: 'object' } }, required: ['item', 'sample'] } };
|
|
51
|
+
// a custom eval's item schema (and the sample's, when asked for); any other source's configuration as sent
|
|
52
|
+
const sample = config.include_sample_schema === true;
|
|
53
|
+
return config.type === 'custom' && config.item_schema
|
|
54
|
+
? { type: 'custom', schema: { type: 'object', properties: { item: config.item_schema, ...(sample ? { sample: { type: 'object' } } : {}) }, required: sample ? ['item', 'sample'] : ['item'] } }
|
|
55
|
+
: config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const createEval: Semantics = async (ctx) => {
|
|
59
|
+
const p = ctx.params;
|
|
60
|
+
if (p.data_source_config === undefined) return invalid(ctx, 'you must provide a data_source_config parameter', 'data_source_config');
|
|
61
|
+
if (p.testing_criteria === undefined || !Array.isArray(p.testing_criteria)) return invalid(ctx, 'you must provide a testing_criteria parameter (array)', 'testing_criteria');
|
|
62
|
+
const id = ctx.mint(EVAL);
|
|
63
|
+
// each criterion is named and given an id of its own (its name and the eval's), its model's messages typed
|
|
64
|
+
// a label model grader samples as its model does unless told: `sampling_params: null` (the reference's listEvals
|
|
65
|
+
// example; spec/patches.json)
|
|
66
|
+
const testing = (p.testing_criteria as Row[]).map((c, i) => ({ ...c, id: `${String(c.name ?? c.type)}-${id}-${i + 1}`, ...(Array.isArray(c.input) ? { input: c.input.map(message) } : {}), ...(c.type === 'label_model' ? { sampling_params: c.sampling_params ?? null } : {}) }));
|
|
67
|
+
const fields = {
|
|
68
|
+
object: 'eval',
|
|
69
|
+
name: typeof p.name === 'string' ? p.name : `eval-${id}`,
|
|
70
|
+
created_at: epoch(ctx),
|
|
71
|
+
data_source_config: dataSourceConfig((p.data_source_config ?? {}) as Row),
|
|
72
|
+
testing_criteria: testing,
|
|
73
|
+
metadata: objectOr(p.metadata, {}),
|
|
74
|
+
};
|
|
75
|
+
return ctx.reply(await ctx.write(EVAL, id, fields, 'eval.create'));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// the twin grades a single datasource item (it fetches no dataset): one item, passed
|
|
79
|
+
const createRun: Semantics = async (ctx) => {
|
|
80
|
+
const evalId = at(ctx, 'eval_id');
|
|
81
|
+
if (!ctx.get(EVAL, evalId)) return ctx.notFound(EVAL, evalId);
|
|
82
|
+
const p = ctx.params;
|
|
83
|
+
if (p.data_source === undefined) return invalid(ctx, 'you must provide a data_source parameter', 'data_source');
|
|
84
|
+
const id = ctx.mint(RUN);
|
|
85
|
+
const source = p.data_source as { model?: unknown; input_messages?: { template?: unknown } };
|
|
86
|
+
// a data source that names no model samples none: the run's `model` is "The model that is evaluated, if applicable"
|
|
87
|
+
// (EvalRun), and the spec gives the sources no default; with none, nothing is sampled and nothing billed (the twin's
|
|
88
|
+
// reading of "if applicable")
|
|
89
|
+
const model = typeof source?.model === 'string' ? source.model : null;
|
|
90
|
+
const template = source?.input_messages?.template;
|
|
91
|
+
const dataSource = Array.isArray(template) ? { ...source, input_messages: { ...source.input_messages, template: template.map(message) } } : source;
|
|
92
|
+
const fields = {
|
|
93
|
+
object: 'eval.run',
|
|
94
|
+
eval_id: evalId,
|
|
95
|
+
name: typeof p.name === 'string' ? p.name : `run-${id}`,
|
|
96
|
+
created_at: epoch(ctx),
|
|
97
|
+
status: 'queued',
|
|
98
|
+
model,
|
|
99
|
+
data_source: dataSource,
|
|
100
|
+
// nothing graded yet: no usage and no criterion's results (the reference's createEvalRun example)
|
|
101
|
+
result_counts: { total: 0, errored: 0, failed: 0, passed: 0 },
|
|
102
|
+
per_model_usage: null,
|
|
103
|
+
per_testing_criteria_results: null,
|
|
104
|
+
report_url: `https://twin.invalid/evals/${evalId}/runs/${id}`,
|
|
105
|
+
metadata: objectOr(p.metadata, {}),
|
|
106
|
+
error: null,
|
|
107
|
+
};
|
|
108
|
+
return ctx.reply(await ctx.write(RUN, id, fields, 'eval_run.create'));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The messages the model was sent for an item: the run's template, each `{{item.x}}` filled from the item. */
|
|
112
|
+
function rendered(run: Row, item: Row): Row[] {
|
|
113
|
+
const template = ((((run.data_source ?? {}) as Row).input_messages ?? {}) as { template?: unknown }).template;
|
|
114
|
+
const fill = (t: string): string => t.replace(/\{\{\s*item\.([A-Za-z0-9_]+)\s*\}\}/g, (_m, k: string) => String(item[k] ?? ''));
|
|
115
|
+
return (Array.isArray(template) ? template : []).map((m) => {
|
|
116
|
+
const x = m as { role?: unknown; content?: unknown };
|
|
117
|
+
const text = typeof x.content === 'string' ? x.content : String((x.content as { text?: unknown } | undefined)?.text ?? '');
|
|
118
|
+
return { role: x.role, content: fill(text), tool_call_id: null, tool_calls: null, function_call: null };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A completed run's output: an item for each datasource item it graded, passed by the stub grader under each
|
|
123
|
+
* criterion, with the sample the model answered (https://platform.openai.com/docs/api-reference/evals/run-output-item-object);
|
|
124
|
+
* others have none yet. */
|
|
125
|
+
function outputItems(ctx: SemanticsContext, run: Row): Row[] {
|
|
126
|
+
if (run.status !== 'completed') return [];
|
|
127
|
+
const sampling = (((run.data_source ?? {}) as Row).sampling_params ?? {}) as { max_completions_tokens?: unknown; temperature?: unknown; top_p?: unknown; seed?: unknown };
|
|
128
|
+
const names = criteria(ctx, run);
|
|
129
|
+
return items(run).map((item, i) => ({
|
|
130
|
+
id: `evalitem-${run.id}-${i + 1}`,
|
|
131
|
+
object: 'eval.run.output_item',
|
|
132
|
+
created_at: Number(run.created_at ?? 0),
|
|
133
|
+
run_id: String(run.id),
|
|
134
|
+
eval_id: String(run.eval_id),
|
|
135
|
+
status: 'pass',
|
|
136
|
+
datasource_item_id: i,
|
|
137
|
+
datasource_item: item,
|
|
138
|
+
results: names.map((name) => ({ name, sample: null, passed: true, score: 1.0 })),
|
|
139
|
+
sample: run.model == null ? null : {
|
|
140
|
+
input: rendered(run, item), output: [{ role: 'assistant', content: '[twin-stub] eval sample output (no real model run)', tool_call_id: null, tool_calls: null, function_call: null }],
|
|
141
|
+
finish_reason: 'stop', model: String(run.model), usage: { total_tokens: 0, completion_tokens: 0, prompt_tokens: 0, cached_tokens: 0 }, error: null,
|
|
142
|
+
temperature: sampling.temperature ?? 1, max_completion_tokens: sampling.max_completions_tokens ?? null, top_p: sampling.top_p ?? 1, seed: sampling.seed ?? 0,
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const listOutputItems: Semantics = async (ctx) => {
|
|
148
|
+
const id = at(ctx, 'run_id');
|
|
149
|
+
await progress(ctx, RUN, id, graded(ctx));
|
|
150
|
+
const run = ctx.get(RUN, id);
|
|
151
|
+
if (!run || run.eval_id !== at(ctx, 'eval_id')) return ctx.notFound(RUN, id);
|
|
152
|
+
return page(ctx, outputItems(ctx, run));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// cancelling stops a run still being graded
|
|
156
|
+
const cancelRun: Semantics = async (ctx) => {
|
|
157
|
+
const id = at(ctx, 'run_id');
|
|
158
|
+
const run = ctx.row(RUN, id);
|
|
159
|
+
if (!run || run.eval_id !== at(ctx, 'eval_id')) return ctx.notFound(RUN, id);
|
|
160
|
+
const refusal = ctx.legal(RUN, 'status', 'cancelEvalRun', run.status, 'canceled');
|
|
161
|
+
if (refusal) return ctx.refuse(refusal);
|
|
162
|
+
return ctx.reply(await ctx.write(RUN, id, { status: 'canceled' }, 'eval_run.cancel'));
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// an eval's runs, each one's grading observed first
|
|
166
|
+
const listRuns: Semantics = async (ctx) => {
|
|
167
|
+
const evalId = at(ctx, 'eval_id');
|
|
168
|
+
await progressAll(ctx, RUN, graded(ctx), (r) => r.eval_id === evalId);
|
|
169
|
+
return coreAnswer(ctx);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const evals: Record<string, Semantics> = {
|
|
173
|
+
createEval,
|
|
174
|
+
createEvalRun: createRun,
|
|
175
|
+
getEvalRunOutputItems: listOutputItems,
|
|
176
|
+
cancelEvalRun: cancelRun,
|
|
177
|
+
getEvalRun: async (ctx) => {
|
|
178
|
+
await progress(ctx, RUN, at(ctx, 'run_id'), graded(ctx));
|
|
179
|
+
return coreAnswer(ctx);
|
|
180
|
+
},
|
|
181
|
+
getEvalRuns: listRuns,
|
|
182
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// File semantics. List, retrieve and delete are the derived core's; upload and download are here, with
|
|
2
|
+
// a file's expiry. The twin keeps an uploaded file's text (`_content`) so a download returns what was uploaded.
|
|
3
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
4
|
+
import { epoch, invalid, type Row } from './shared.ts';
|
|
5
|
+
|
|
6
|
+
const THIRTY_DAYS = 30 * 24 * 3600;
|
|
7
|
+
|
|
8
|
+
/** When a file expires (its `expires_at`): `expires_after.seconds` past its creation when the upload set one, else
|
|
9
|
+
* thirty days for a batch file, else never. "By default, files with `purpose=batch` expire after 30 days and all
|
|
10
|
+
* other files are persisted until they are manually deleted"; `seconds` "must be between 3600 (1 hour) and 2592000
|
|
11
|
+
* (30 days)" (developers.openai.com/api/reference/resources/files/methods/create, `expires_after`). Undefined for a
|
|
12
|
+
* policy OpenAI refuses. */
|
|
13
|
+
export function expiresAt(purpose: string, created: number, after: unknown): number | null | undefined {
|
|
14
|
+
return after !== undefined ? expiresAfter(created, after) : purpose === 'batch' ? created + THIRTY_DAYS : null;
|
|
15
|
+
}
|
|
16
|
+
/** The expiry an upload's own `expires_after` sets, or undefined for one out of range. */
|
|
17
|
+
function expiresAfter(created: number, after: unknown): number | undefined {
|
|
18
|
+
const a = (after && typeof after === 'object' ? after : {}) as Row;
|
|
19
|
+
const seconds = Number(a.seconds);
|
|
20
|
+
return a.anchor === 'created_at' && Number.isInteger(seconds) && seconds >= 3600 && seconds <= THIRTY_DAYS ? created + seconds : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Files whose time is up are gone, each deleted at the moment it expired, not when a later request looks: a batch
|
|
24
|
+
* input thirty days after its upload (above), a batch's output "automatically deleted 30 days after the batch is
|
|
25
|
+
* complete" (developers.openai.com/api/docs/guides/batch). A get, download or delete of one then answers OpenAI's
|
|
26
|
+
* 404 for a file that does not exist. */
|
|
27
|
+
export async function expireFiles(ctx: SemanticsContext): Promise<void> {
|
|
28
|
+
const now = epoch(ctx);
|
|
29
|
+
const due = ctx.rowsRaw('OpenAIFile').filter((f) => typeof f.expires_at === 'number' && f.expires_at <= now);
|
|
30
|
+
for (const f of due.sort((a, b) => Number(a.expires_at) - Number(b.expires_at))) {
|
|
31
|
+
const at = await ctx.at(new Date(Number(f.expires_at) * 1000).toISOString());
|
|
32
|
+
await at.write('OpenAIFile', String(f.id), { deleted: true }, 'file.delete');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Upload = { name?: string; size?: number; content?: string };
|
|
37
|
+
|
|
38
|
+
// The SDK sends multipart/form-data (the part `file`); an in-process caller may send JSON
|
|
39
|
+
// { purpose, filename, content, bytes } instead.
|
|
40
|
+
const create: Semantics = async (ctx) => {
|
|
41
|
+
const p = ctx.params;
|
|
42
|
+
if (p.purpose === undefined || p.purpose === '') return invalid(ctx, 'you must provide a purpose parameter', 'purpose');
|
|
43
|
+
const upload = p.file && typeof p.file === 'object' ? (p.file as Upload) : undefined;
|
|
44
|
+
const content = upload ? String(upload.content ?? '') : typeof p.content === 'string' ? p.content : '';
|
|
45
|
+
const filename = upload ? upload.name || 'upload' : typeof p.filename === 'string' && p.filename ? p.filename : 'upload.jsonl';
|
|
46
|
+
const bytes = upload ? upload.size || content.length : Number(p.bytes ?? (typeof p.content === 'string' ? p.content.length : 0));
|
|
47
|
+
// the SDK sends the policy as the form fields expires_after[anchor] and expires_after[seconds]
|
|
48
|
+
const after = p.expires_after ?? (p['expires_after[anchor]'] === undefined ? undefined : { anchor: p['expires_after[anchor]'], seconds: p['expires_after[seconds]'] });
|
|
49
|
+
const expires = expiresAt(String(p.purpose), epoch(ctx), after);
|
|
50
|
+
if (expires === undefined) return invalid(ctx, "'expires_after' must have anchor 'created_at' and seconds between 3600 and 2592000", 'expires_after');
|
|
51
|
+
const id = ctx.mint('OpenAIFile');
|
|
52
|
+
const fields = { object: 'file', bytes: Number.isFinite(bytes) ? bytes : 0, created_at: epoch(ctx), expires_at: expires, filename, purpose: String(p.purpose), status: 'processed', _content: content };
|
|
53
|
+
return ctx.reply(await ctx.write('OpenAIFile', id, fields, 'file.create'));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// the file's bytes as uploaded, not JSON
|
|
57
|
+
const download: Semantics = async (ctx) => {
|
|
58
|
+
const id = String(ctx.id);
|
|
59
|
+
const row = ctx.row('OpenAIFile', id);
|
|
60
|
+
if (!row) return ctx.notFound('OpenAIFile', id);
|
|
61
|
+
return ctx.raw(String(row._content ?? ''), { headers: { 'content-type': 'application/octet-stream' } });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const files: Record<string, Semantics> = {
|
|
65
|
+
createFile: create,
|
|
66
|
+
downloadFile: download,
|
|
67
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Fine-tuning semantics. Cancel, pause and resume are the derived core's: the machine in
|
|
2
|
+
// ../manifest.ts moves `status` and refuses what OpenAI refuses. A job is created
|
|
3
|
+
// `validating_files`, as OpenAI creates it; the twin cannot train, so when a read first looks it
|
|
4
|
+
// finishes the job (./progress.ts) with a synthetic fine-tuned model id, unless it is paused or
|
|
5
|
+
// cancelled. Its events and checkpoints are read off the job.
|
|
6
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { fineTuningClosed } from '../openai-models.ts';
|
|
8
|
+
import { estimateTokens } from '../openai-stub.ts';
|
|
9
|
+
import { coreAnswer, inFlight, progress } from './progress.ts';
|
|
10
|
+
import { epoch, invalid, page, type Row } from './shared.ts';
|
|
11
|
+
|
|
12
|
+
const JOB = 'FineTuningJob';
|
|
13
|
+
|
|
14
|
+
/** The text of a file the job names, or '' when there is none. */
|
|
15
|
+
const fileText = (ctx: SemanticsContext, id: unknown): string => String(ctx.row('OpenAIFile', String(id ?? ''), { withDeleted: true })?._content ?? '');
|
|
16
|
+
|
|
17
|
+
/** The epochs a job trains: its method's n_epochs, one when left to OpenAI ("auto"). */
|
|
18
|
+
function epochs(row: Row): number {
|
|
19
|
+
const m = (row.method ?? {}) as Record<string, { hyperparameters?: { n_epochs?: unknown } } | undefined>;
|
|
20
|
+
const n = Number(m[String((row.method as { type?: unknown } | undefined)?.type)]?.hyperparameters?.n_epochs);
|
|
21
|
+
return Number.isInteger(n) && n > 0 ? n : 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What a finished job carries: the model it trained, the tokens it trained on (its training file's, once an epoch),
|
|
25
|
+
* and its result file, the training metrics OpenAI writes for a succeeded job
|
|
26
|
+
* (https://platform.openai.com/docs/api-reference/fine-tuning/object, `result_files`, `trained_tokens`). */
|
|
27
|
+
const finish = (ctx: SemanticsContext) => (row: Row, end: string): Row => (end === 'succeeded'
|
|
28
|
+
? { fine_tuned_model: `ft:${String(row.model)}:twin::${String(row.id)}`, trained_tokens: estimateTokens(fileText(ctx, row.training_file)) * epochs(row), result_files: [`file-twin-ftresult-${String(row.id)}`], ...resolved(row) }
|
|
29
|
+
: {});
|
|
30
|
+
|
|
31
|
+
/** A trained job's hyperparameters as it trained with them: each `auto` resolved to a value, as a finished job answers
|
|
32
|
+
* them (the reference's retrieve example: `"hyperparameters": {"n_epochs": 4, "batch_size": 1, "learning_rate_multiplier": 1}`).
|
|
33
|
+
* The twin trains one example a step at the base rate, for the epochs asked or one. */
|
|
34
|
+
function resolved(row: Row): Row {
|
|
35
|
+
const method = row.method as Row | undefined;
|
|
36
|
+
const type = String(method?.type);
|
|
37
|
+
const body = method?.[type] as Row | undefined;
|
|
38
|
+
if (!method || !body || (type !== 'supervised' && type !== 'dpo')) return {};
|
|
39
|
+
const h = (body.hyperparameters ?? {}) as Row;
|
|
40
|
+
const auto = (v: unknown, n: number): unknown => (v === 'auto' || v === undefined ? n : v);
|
|
41
|
+
const hyperparameters = { ...h, batch_size: auto(h.batch_size, 1), learning_rate_multiplier: auto(h.learning_rate_multiplier, 1), n_epochs: epochs(row) };
|
|
42
|
+
return { method: { ...method, [type]: { ...body, hyperparameters } }, ...(type === 'supervised' ? { hyperparameters } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Observe OpenAI's training of a job; the read that finishes it writes its result file (step metrics, stubs). */
|
|
46
|
+
async function train(ctx: SemanticsContext, id: string): Promise<void> {
|
|
47
|
+
const moved = await progress(ctx, JOB, id, finish(ctx));
|
|
48
|
+
if (moved?.to !== 'succeeded') return;
|
|
49
|
+
const content = 'step,train_loss,train_accuracy,valid_loss,valid_mean_token_accuracy\n1,0,1,,\n';
|
|
50
|
+
await ctx.write('OpenAIFile', `file-twin-ftresult-${id}`, { object: 'file', bytes: content.length, created_at: epoch(ctx), expires_at: null, filename: 'step_metrics.csv', purpose: 'fine-tune-results', status: 'processed', _content: content }, 'file.create');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The job the path names, its training observed, or OpenAI's answer that there is none. */
|
|
54
|
+
async function job(ctx: SemanticsContext): Promise<{ job: Row } | { answer: Response }> {
|
|
55
|
+
const id = String(ctx.id);
|
|
56
|
+
await train(ctx, id);
|
|
57
|
+
const found = ctx.get(JOB, id);
|
|
58
|
+
return found ? { job: found } : { answer: ctx.notFound(JOB, id) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A method's hyperparameters with OpenAI's `auto` for each one not sent (the job object's `method`:
|
|
62
|
+
* https://platform.openai.com/docs/api-reference/fine-tuning/object); a reinforcement method's are its own. */
|
|
63
|
+
function withDefaults(method: Row): Row {
|
|
64
|
+
const type = String(method.type);
|
|
65
|
+
if (type !== 'supervised' && type !== 'dpo' && type !== 'reinforcement') return method;
|
|
66
|
+
const body = (method[type] && typeof method[type] === 'object' ? method[type] : {}) as Row;
|
|
67
|
+
const sent = (body.hyperparameters && typeof body.hyperparameters === 'object' ? body.hyperparameters : {}) as Row;
|
|
68
|
+
// a reinforcement job also evaluates on its own schedule and budget, and answers its response format, null unless
|
|
69
|
+
// set (the reference's Reinforcement example)
|
|
70
|
+
const own = type === 'dpo' ? { beta: 'auto' } : type === 'reinforcement' ? { eval_interval: 'auto', eval_samples: 'auto', compute_multiplier: 'auto', reasoning_effort: 'default' } : {};
|
|
71
|
+
const hyperparameters = { batch_size: 'auto', learning_rate_multiplier: 'auto', n_epochs: 'auto', ...own, ...sent };
|
|
72
|
+
return { ...method, [type]: { ...body, hyperparameters, ...(type === 'reinforcement' ? { response_format: body.response_format ?? null } : {}) } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const create: Semantics = async (ctx) => {
|
|
76
|
+
const p = ctx.params;
|
|
77
|
+
if (p.model === undefined || p.model === '') return invalid(ctx, 'you must provide a model parameter', 'model');
|
|
78
|
+
if (p.training_file === undefined || p.training_file === '') return invalid(ctx, 'you must provide a training_file parameter', 'training_file');
|
|
79
|
+
// fine-tuning's dated availability (../openai-models.ts): whether the organization has fine-tuned before, and when it
|
|
80
|
+
// last ran inference on a fine-tuned model
|
|
81
|
+
const inference = ctx.rowsRaw('_usage_record').filter((r) => String(r.model).startsWith('ft:')).map((r) => Number(r.created_at));
|
|
82
|
+
const closed = fineTuningClosed(epoch(ctx), { everFineTuned: ctx.rowsRaw(JOB, { withDeleted: true }).length > 0, ...(inference.length ? { lastFineTunedInference: Math.max(...inference) } : {}) });
|
|
83
|
+
if (closed) return ctx.refuse({ status: 403, message: closed, code: 'fine_tuning_unavailable' });
|
|
84
|
+
const id = ctx.mint(JOB);
|
|
85
|
+
const created = epoch(ctx);
|
|
86
|
+
// the method trained with, supervised by default; the deprecated top-level hyperparameters are a supervised job's
|
|
87
|
+
// method's, and null for any other method (the reference's Epochs and DPO examples)
|
|
88
|
+
const method = withDefaults(p.method && typeof p.method === 'object' ? (p.method as Row) : { type: 'supervised', supervised: { hyperparameters: p.hyperparameters ?? {} } });
|
|
89
|
+
const hyperparameters = method.type === 'supervised' ? ((method.supervised as Row).hyperparameters as Row) : null;
|
|
90
|
+
// a Weights & Biases run is named by the job unless the request names it (the spec's FineTuningIntegration: "If not
|
|
91
|
+
// set, we will use the Job ID as the name"); its entity is the W&B default unless set
|
|
92
|
+
const integrations = Array.isArray(p.integrations)
|
|
93
|
+
? (p.integrations as Row[]).map((i) => (i && i.type === 'wandb' ? { ...i, wandb: { entity: null, ...(i.wandb as Row), run_id: id } } : i))
|
|
94
|
+
: [];
|
|
95
|
+
const fields = {
|
|
96
|
+
object: 'fine_tuning.job',
|
|
97
|
+
model: String(p.model),
|
|
98
|
+
created_at: created,
|
|
99
|
+
finished_at: null,
|
|
100
|
+
fine_tuned_model: null,
|
|
101
|
+
organization_id: 'org-twin',
|
|
102
|
+
status: 'validating_files',
|
|
103
|
+
training_file: String(p.training_file),
|
|
104
|
+
validation_file: p.validation_file ?? null,
|
|
105
|
+
hyperparameters,
|
|
106
|
+
method,
|
|
107
|
+
integrations,
|
|
108
|
+
metadata: p.metadata && typeof p.metadata === 'object' ? p.metadata : null,
|
|
109
|
+
result_files: [],
|
|
110
|
+
trained_tokens: null,
|
|
111
|
+
// no failure, as the reference's create examples answer it
|
|
112
|
+
error: { code: null, message: null, param: null },
|
|
113
|
+
estimated_finish: null,
|
|
114
|
+
// the suffix it was given, its usage (none until it trains) and whether its data is shared with OpenAI, as the
|
|
115
|
+
// reference's create examples answer them (spec/patches.json adds them to the job object)
|
|
116
|
+
user_provided_suffix: typeof p.suffix === 'string' ? p.suffix : null,
|
|
117
|
+
usage_metrics: null,
|
|
118
|
+
shared_with_openai: false,
|
|
119
|
+
seed: Number(p.seed ?? 0),
|
|
120
|
+
};
|
|
121
|
+
return ctx.reply(await ctx.write(JOB, id, fields, 'fine_tuning_job.create'));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// the job's event stream: created, then completed once it has succeeded
|
|
125
|
+
const events: Semantics = async (ctx) => {
|
|
126
|
+
const found = await job(ctx);
|
|
127
|
+
if ('answer' in found) return found.answer;
|
|
128
|
+
const j = found.job;
|
|
129
|
+
const created = Number(j.created_at ?? 0);
|
|
130
|
+
const data = [
|
|
131
|
+
// a message event carries no data (https://platform.openai.com/docs/api-reference/fine-tuning/event-object, `data`)
|
|
132
|
+
{ object: 'fine_tuning.job.event', id: `ftevent-${j.id}-1`, created_at: created, level: 'info', message: 'Created fine-tuning job', data: null, type: 'message' },
|
|
133
|
+
...(j.status === 'succeeded' ? [{ object: 'fine_tuning.job.event', id: `ftevent-${j.id}-2`, created_at: Number(j.finished_at ?? created), level: 'info', message: 'Fine-tuning job successfully completed (twin stub)', data: null, type: 'message' }] : []),
|
|
134
|
+
];
|
|
135
|
+
return ctx.reply({ object: 'list', data, has_more: false });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// a succeeded job has one final checkpoint naming its fine-tuned model (metrics are stubs); any
|
|
139
|
+
// other job has none
|
|
140
|
+
const checkpoints: Semantics = async (ctx) => {
|
|
141
|
+
const found = await job(ctx);
|
|
142
|
+
if ('answer' in found) return found.answer;
|
|
143
|
+
const j = found.job;
|
|
144
|
+
const data = j.status === 'succeeded'
|
|
145
|
+
? [{
|
|
146
|
+
object: 'fine_tuning.job.checkpoint',
|
|
147
|
+
id: `ftckpt-${j.id}-1`,
|
|
148
|
+
created_at: Number(j.created_at ?? 0),
|
|
149
|
+
fine_tuned_model_checkpoint: String(j.fine_tuned_model ?? `ft:twin::${j.id}:ckpt-step-1`),
|
|
150
|
+
fine_tuning_job_id: String(j.id),
|
|
151
|
+
metrics: { step: 1, train_loss: 0, train_mean_token_accuracy: 1, full_valid_loss: 0, full_valid_mean_token_accuracy: 1 },
|
|
152
|
+
step_number: 1,
|
|
153
|
+
}]
|
|
154
|
+
: [];
|
|
155
|
+
return ctx.reply({ object: 'list', data, has_more: false, first_id: data[0]?.id ?? null, last_id: data[0]?.id ?? null });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// the organization's jobs, each one's training observed first, newest first; `metadata[k]=v` keeps the jobs whose
|
|
159
|
+
// metadata holds each pair (https://platform.openai.com/docs/api-reference/fine-tuning/list, `metadata`)
|
|
160
|
+
const listJobs: Semantics = async (ctx) => {
|
|
161
|
+
for (const r of ctx.rowsRaw(JOB)) if (inFlight(JOB, r)) await train(ctx, String(r.id));
|
|
162
|
+
const wanted = Object.entries(ctx.params).flatMap(([k, v]) => { const m = /^metadata\[(.+)\]$/.exec(k); return m ? [[m[1]!, String(v)] as const] : []; });
|
|
163
|
+
const nested = ctx.params.metadata && typeof ctx.params.metadata === 'object' ? Object.entries(ctx.params.metadata as Row).map(([k, v]) => [k, String(v)] as const) : [];
|
|
164
|
+
const pairs = [...wanted, ...nested];
|
|
165
|
+
if (!pairs.length) return coreAnswer(ctx);
|
|
166
|
+
const jobs = ctx.rows(JOB).filter((r) => pairs.every(([k, v]) => (r.metadata as Row | null)?.[k] === v));
|
|
167
|
+
// newest first, ties by mint order as the derived core lists (a job minted later is newer)
|
|
168
|
+
return page(ctx, jobs.sort((a, b) => Number(b.created_at) - Number(a.created_at) || String(b.id).localeCompare(String(a.id), undefined, { numeric: true })));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const fineTuning: Record<string, Semantics> = {
|
|
172
|
+
createFineTuningJob: create,
|
|
173
|
+
listFineTuningEvents: events,
|
|
174
|
+
listFineTuningJobCheckpoints: checkpoints,
|
|
175
|
+
retrieveFineTuningJob: async (ctx) => {
|
|
176
|
+
await train(ctx, String(ctx.id));
|
|
177
|
+
return coreAnswer(ctx);
|
|
178
|
+
},
|
|
179
|
+
listPaginatedFineTuningJobs: listJobs,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Every job OpenAI is still training, observed (a read observes all the vendor's work before it answers). */
|
|
183
|
+
export async function observeJobs(ctx: SemanticsContext): Promise<void> {
|
|
184
|
+
for (const row of ctx.rowsRaw(JOB)) if (inFlight(JOB, row)) await train(ctx, String(row.id));
|
|
185
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Images: a real placeholder image (base64 for the GPT image models, a URL for DALL·E); the twin renders no
|
|
2
|
+
// pixels. An edit or a variation takes its image as a multipart file of a format the endpoint takes.
|
|
3
|
+
import type { Semantics } from '@volter/world-core';
|
|
4
|
+
import { handleImageEdit, handleImages, handleImageVariation, type ImageAnswer } from '../openai-twin.ts';
|
|
5
|
+
import { modelOf } from '../openai-models.ts';
|
|
6
|
+
import { recordUsage, send } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
/** The images, or their events when the request streamed them; each call made is billed by its images, of the size
|
|
9
|
+
* asked, from its source (the usage report's `images`, `size`, `source`). */
|
|
10
|
+
async function answer(ctx: Parameters<Semantics>[0], r: ImageAnswer, source: string): Promise<Response> {
|
|
11
|
+
if (r.status === 200) {
|
|
12
|
+
const n = ((r.body as { data?: unknown[] }).data ?? []).length;
|
|
13
|
+
const model = modelOf(ctx.call.operation.id, ctx.params as { model?: unknown })!;
|
|
14
|
+
await recordUsage(ctx, 'images', model, 0, 0, { images: n, size: typeof ctx.params.size === 'string' ? ctx.params.size : '1024x1024', source });
|
|
15
|
+
}
|
|
16
|
+
return r.events ? ctx.sse(r.events) : send(ctx, r);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const images: Record<string, Semantics> = {
|
|
20
|
+
createImage: async (ctx) => answer(ctx, handleImages(ctx.params, ctx.occurredAt), 'image.generation'),
|
|
21
|
+
createImageEdit: async (ctx) => answer(ctx, handleImageEdit(ctx.params, ctx.occurredAt), 'image.edit'),
|
|
22
|
+
createImageVariation: async (ctx) => answer(ctx, handleImageVariation(ctx.params, ctx.occurredAt), 'image.variation'),
|
|
23
|
+
};
|