ag-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/client/adapters/cluster.d.ts +12 -0
- package/dist/client/adapters/cluster.js +567 -0
- package/dist/client/adapters/google.d.ts +12 -0
- package/dist/client/adapters/google.js +478 -0
- package/dist/client/client.d.ts +11 -0
- package/dist/client/client.js +192 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +18 -0
- package/dist/client/quota.d.ts +55 -0
- package/dist/client/quota.js +148 -0
- package/dist/client/types.d.ts +124 -0
- package/dist/client/types.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/internal/apikey.d.ts +5 -0
- package/dist/internal/apikey.js +51 -0
- package/dist/internal/array.d.ts +2 -0
- package/dist/internal/array.js +6 -0
- package/dist/internal/log.d.ts +9 -0
- package/dist/internal/log.js +92 -0
- package/dist/internal/node-cache.d.ts +71 -0
- package/dist/internal/node-cache.js +104 -0
- package/dist/internal/retryOnError.d.ts +26 -0
- package/dist/internal/retryOnError.js +123 -0
- package/dist/internal/string/redact.d.ts +2 -0
- package/dist/internal/string/redact.js +94 -0
- package/dist/internal/string/truncate.d.ts +8 -0
- package/dist/internal/string/truncate.js +16 -0
- package/dist/k8s/assets/network-init.sh +52 -0
- package/dist/k8s/common.d.ts +35 -0
- package/dist/k8s/common.js +56 -0
- package/dist/k8s/index.d.ts +9 -0
- package/dist/k8s/index.js +33 -0
- package/dist/k8s/opencode.d.ts +4 -0
- package/dist/k8s/opencode.js +55 -0
- package/dist/k8s/runner.d.ts +4 -0
- package/dist/k8s/runner.js +247 -0
- package/dist/k8s/t3code.d.ts +4 -0
- package/dist/k8s/t3code.js +60 -0
- package/package.json +56 -0
- package/runtime/bridge.mjs +1093 -0
- package/runtime/executor/opencode.mjs +430 -0
- package/runtime/image/adapter.mjs +213 -0
- package/runtime/index.mjs +53 -0
- package/runtime/protocol.mjs +384 -0
- package/runtime/server.mjs +621 -0
- package/runtime/store.mjs +568 -0
- package/runtime/telemetry.mjs +137 -0
- package/runtime/tests/bridge.test.mjs +554 -0
- package/runtime/tests/image-adapter.test.mjs +148 -0
- package/runtime/tests/protocol.test.mjs +156 -0
- package/runtime/tests/server.test.mjs +231 -0
- package/runtime/tests/store.test.mjs +114 -0
- package/runtime/tests/telemetry.test.mjs +138 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { timingSafeEqual, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
/* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
GenerationProtocolError,
|
|
8
|
+
normalizeGenerationRequest,
|
|
9
|
+
normalizeIdempotencyKey,
|
|
10
|
+
publicModelCatalog,
|
|
11
|
+
requestDigest,
|
|
12
|
+
idempotencyDigest,
|
|
13
|
+
} from './protocol.mjs';
|
|
14
|
+
import { GenerationStoreError } from './store.mjs';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_GENERATION_PORT = 4501;
|
|
17
|
+
const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
const MAX_MODEL_RESPONSE_BYTES = 512 * 1024;
|
|
20
|
+
const JOB_LEASE_MS = 31 * 60 * 1000;
|
|
21
|
+
const LOCAL_RESPONSE_PREFIX = 'generation/';
|
|
22
|
+
const MAX_CONCURRENT_BODY_READS = 2;
|
|
23
|
+
|
|
24
|
+
const noopAnalytics = { emit: () => Promise.resolve(true) };
|
|
25
|
+
|
|
26
|
+
const asObject = (value) => (value && typeof value === 'object' ? value : null);
|
|
27
|
+
|
|
28
|
+
const httpError = (status, code, message) => new GenerationProtocolError(status, code, message);
|
|
29
|
+
|
|
30
|
+
const errorPayload = (error) => {
|
|
31
|
+
if (error instanceof GenerationProtocolError) {
|
|
32
|
+
return { status: error.status, body: { error: { code: error.code, message: error.message } } };
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof GenerationStoreError) {
|
|
35
|
+
const status =
|
|
36
|
+
error.code === 'not_found'
|
|
37
|
+
? 404
|
|
38
|
+
: error.code === 'result_expired'
|
|
39
|
+
? 410
|
|
40
|
+
: error.code === 'idempotency_conflict'
|
|
41
|
+
? 409
|
|
42
|
+
: error.code === 'queue_full'
|
|
43
|
+
? 429
|
|
44
|
+
: 500;
|
|
45
|
+
return { status, body: { error: { code: error.code, message: error.message } } };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
status: 500,
|
|
49
|
+
body: { error: { code: 'internal_error', message: 'generation request failed' } },
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const isPrivateIPv4 = (address) => {
|
|
54
|
+
if (net.isIP(address) !== 4) return false;
|
|
55
|
+
const octets = address.split('.').map(Number);
|
|
56
|
+
return (
|
|
57
|
+
octets[0] === 127 ||
|
|
58
|
+
octets[0] === 10 ||
|
|
59
|
+
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
60
|
+
(octets[0] === 192 && octets[1] === 168)
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const normalizeIPv4 = (address) => {
|
|
65
|
+
if (typeof address !== 'string') return undefined;
|
|
66
|
+
if (address.startsWith('::ffff:')) return address.slice('::ffff:'.length);
|
|
67
|
+
return address;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isAllowedPeer = (address, allowedPrefix) => {
|
|
71
|
+
const normalized = normalizeIPv4(address);
|
|
72
|
+
if (!normalized || net.isIP(normalized) !== 4) return address === '::1';
|
|
73
|
+
if (normalized.startsWith('127.')) return true;
|
|
74
|
+
if (!isPrivateIPv4(normalized)) return false;
|
|
75
|
+
return allowedPrefix === undefined ? true : normalized.startsWith(allowedPrefix);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const normalizeBindHost = (host) => {
|
|
79
|
+
if (typeof host !== 'string' || net.isIP(host) !== 4 || !isPrivateIPv4(host)) {
|
|
80
|
+
throw new GenerationProtocolError(
|
|
81
|
+
500,
|
|
82
|
+
'unsafe_configuration',
|
|
83
|
+
'generation host must be a private IPv4 address',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return host;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const normalizePort = (port) => {
|
|
90
|
+
if (port === undefined || port === null || port === '') return DEFAULT_GENERATION_PORT;
|
|
91
|
+
const value = Number(port);
|
|
92
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
93
|
+
throw new GenerationProtocolError(500, 'unsafe_configuration', 'generation port is invalid');
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const tokenMatches = (header, token) => {
|
|
99
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
|
|
100
|
+
const supplied = Buffer.from(header.slice('Bearer '.length));
|
|
101
|
+
const expected = Buffer.from(token);
|
|
102
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const writeJson = (response, status, value, maximum = MAX_RESPONSE_BYTES) => {
|
|
106
|
+
const bytes = Buffer.from(JSON.stringify(value));
|
|
107
|
+
if (bytes.length > maximum) {
|
|
108
|
+
response.statusCode = 500;
|
|
109
|
+
response.setHeader('content-type', 'application/json');
|
|
110
|
+
response.end(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
error: { code: 'response_too_large', message: 'response exceeds its size limit' },
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
response.statusCode = status;
|
|
118
|
+
response.setHeader('cache-control', 'no-store');
|
|
119
|
+
response.setHeader('content-type', 'application/json');
|
|
120
|
+
response.setHeader('content-length', bytes.length);
|
|
121
|
+
response.end(bytes);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const readBody = (request) =>
|
|
125
|
+
new Promise((resolve, reject) => {
|
|
126
|
+
const declared = Number(request.headers['content-length']);
|
|
127
|
+
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) {
|
|
128
|
+
reject(httpError(413, 'request_too_large', 'request exceeds its size limit'));
|
|
129
|
+
request.resume();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const chunks = [];
|
|
133
|
+
let length = 0;
|
|
134
|
+
let settled = false;
|
|
135
|
+
const fail = (error) => {
|
|
136
|
+
if (settled) return;
|
|
137
|
+
settled = true;
|
|
138
|
+
reject(error);
|
|
139
|
+
request.destroy();
|
|
140
|
+
};
|
|
141
|
+
request.on('data', (chunk) => {
|
|
142
|
+
length += chunk.length;
|
|
143
|
+
if (length > MAX_REQUEST_BYTES) {
|
|
144
|
+
fail(httpError(413, 'request_too_large', 'request exceeds its size limit'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
chunks.push(chunk);
|
|
148
|
+
});
|
|
149
|
+
request.on('end', () => {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
settled = true;
|
|
152
|
+
resolve(Buffer.concat(chunks, length));
|
|
153
|
+
});
|
|
154
|
+
request.on('error', (error) => fail(error));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const parseBody = async (request) => {
|
|
158
|
+
const bytes = await readBody(request);
|
|
159
|
+
if (bytes.length === 0) {
|
|
160
|
+
throw httpError(400, 'invalid_request', 'request body is required');
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
throw httpError(400, 'invalid_request', 'request body is not valid JSON');
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const safeHeader = (value) => (Array.isArray(value) ? value[0] : value);
|
|
170
|
+
|
|
171
|
+
const requestRoute = (request) => {
|
|
172
|
+
try {
|
|
173
|
+
const segments = new URL(request.url ?? '/', 'http://generation.local').pathname
|
|
174
|
+
.split('/')
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
if (segments.length === 2 && segments[0] === 'v1' && segments[1] === 'models') {
|
|
177
|
+
return 'models';
|
|
178
|
+
}
|
|
179
|
+
if (segments.length === 2 && segments[0] === 'v1' && segments[1] === 'jobs') {
|
|
180
|
+
return 'jobs-create';
|
|
181
|
+
}
|
|
182
|
+
if (segments.length === 3 && segments[0] === 'v1' && segments[1] === 'jobs') {
|
|
183
|
+
return 'jobs-read';
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
segments.length === 4 &&
|
|
187
|
+
segments[0] === 'v1' &&
|
|
188
|
+
segments[1] === 'jobs' &&
|
|
189
|
+
segments[3] === 'cancel'
|
|
190
|
+
) {
|
|
191
|
+
return 'jobs-cancel';
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
return 'unknown';
|
|
195
|
+
}
|
|
196
|
+
return 'unknown';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const validUsage = (value) => {
|
|
200
|
+
const usage = asObject(value);
|
|
201
|
+
if (!usage || Array.isArray(usage)) return undefined;
|
|
202
|
+
const inputTokens = usage.inputTokens;
|
|
203
|
+
const outputTokens = usage.outputTokens;
|
|
204
|
+
const totalTokens = usage.totalTokens;
|
|
205
|
+
if (
|
|
206
|
+
![inputTokens, outputTokens, totalTokens].every(
|
|
207
|
+
(item) => Number.isInteger(item) && item >= 0 && item <= 10_000_000,
|
|
208
|
+
)
|
|
209
|
+
) {
|
|
210
|
+
throw httpError(500, 'invalid_response', 'generation executor returned invalid usage metadata');
|
|
211
|
+
}
|
|
212
|
+
return { inputTokens, outputTokens, totalTokens };
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const genericResult = (record, responseBytes, imageBytes) => {
|
|
216
|
+
let response;
|
|
217
|
+
try {
|
|
218
|
+
response = JSON.parse(responseBytes.toString('utf8'));
|
|
219
|
+
} catch {
|
|
220
|
+
throw httpError(
|
|
221
|
+
500,
|
|
222
|
+
'invalid_response',
|
|
223
|
+
'generation executor returned invalid result metadata',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
!response ||
|
|
228
|
+
typeof response !== 'object' ||
|
|
229
|
+
!Number.isInteger(response.generatedAt) ||
|
|
230
|
+
response.generatedAt < 0 ||
|
|
231
|
+
typeof response.model !== 'string' ||
|
|
232
|
+
response.model !== record.model ||
|
|
233
|
+
!Array.isArray(response.output) ||
|
|
234
|
+
response.output.length > 64
|
|
235
|
+
) {
|
|
236
|
+
throw httpError(
|
|
237
|
+
500,
|
|
238
|
+
'invalid_response',
|
|
239
|
+
'generation executor returned invalid result metadata',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
Object.keys(response).some((key) => !['generatedAt', 'model', 'output', 'usage'].includes(key))
|
|
244
|
+
) {
|
|
245
|
+
throw httpError(
|
|
246
|
+
500,
|
|
247
|
+
'invalid_response',
|
|
248
|
+
'generation executor returned unsupported result metadata',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const output = [];
|
|
252
|
+
const referencedImages = new Set();
|
|
253
|
+
const requestedOutput = new Set(record.request.output);
|
|
254
|
+
for (const part of response.output) {
|
|
255
|
+
if (!part || typeof part !== 'object') {
|
|
256
|
+
throw httpError(
|
|
257
|
+
500,
|
|
258
|
+
'invalid_response',
|
|
259
|
+
'generation executor returned an invalid output part',
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (part.type === 'text') {
|
|
263
|
+
if (Object.keys(part).some((key) => !['type', 'text'].includes(key))) {
|
|
264
|
+
throw httpError(
|
|
265
|
+
500,
|
|
266
|
+
'invalid_response',
|
|
267
|
+
'generation executor returned an invalid text output',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (!requestedOutput.has('text')) {
|
|
271
|
+
throw httpError(
|
|
272
|
+
500,
|
|
273
|
+
'invalid_response',
|
|
274
|
+
'generation executor returned unrequested text output',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (typeof part.text !== 'string' || Buffer.byteLength(part.text) > 2 * 1024 * 1024) {
|
|
278
|
+
throw httpError(
|
|
279
|
+
500,
|
|
280
|
+
'invalid_response',
|
|
281
|
+
'generation executor returned invalid text output',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
output.push({ type: 'text', text: part.text });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (part.type !== 'image' || part.mimeType !== 'image/png') {
|
|
288
|
+
throw httpError(
|
|
289
|
+
500,
|
|
290
|
+
'invalid_response',
|
|
291
|
+
'generation executor returned an unsupported output part',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (Object.keys(part).some((key) => !['type', 'mimeType', 'name'].includes(key))) {
|
|
295
|
+
throw httpError(
|
|
296
|
+
500,
|
|
297
|
+
'invalid_response',
|
|
298
|
+
'generation executor returned an invalid image output',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (!requestedOutput.has('image')) {
|
|
302
|
+
throw httpError(
|
|
303
|
+
500,
|
|
304
|
+
'invalid_response',
|
|
305
|
+
'generation executor returned unrequested image output',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
typeof part.name !== 'string' ||
|
|
310
|
+
!/^image-[0-3]\.png$/.test(part.name) ||
|
|
311
|
+
referencedImages.has(part.name)
|
|
312
|
+
) {
|
|
313
|
+
throw httpError(
|
|
314
|
+
500,
|
|
315
|
+
'invalid_response',
|
|
316
|
+
'generation executor returned an invalid image reference',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const bytes = imageBytes.get(part.name);
|
|
320
|
+
if (!bytes) {
|
|
321
|
+
throw httpError(
|
|
322
|
+
500,
|
|
323
|
+
'invalid_response',
|
|
324
|
+
'generation executor returned a missing image artifact',
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
referencedImages.add(part.name);
|
|
328
|
+
output.push({
|
|
329
|
+
type: 'image',
|
|
330
|
+
mimeType: 'image/png',
|
|
331
|
+
data: bytes.toString('base64'),
|
|
332
|
+
name: part.name,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
for (const name of imageBytes.keys()) {
|
|
336
|
+
if (!referencedImages.has(name)) {
|
|
337
|
+
throw httpError(
|
|
338
|
+
500,
|
|
339
|
+
'invalid_response',
|
|
340
|
+
'generation executor uploaded an unreferenced image artifact',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const result = {
|
|
345
|
+
output,
|
|
346
|
+
model: response.model,
|
|
347
|
+
generatedAt: response.generatedAt,
|
|
348
|
+
};
|
|
349
|
+
const usage = validUsage(response.usage);
|
|
350
|
+
if (usage) result.usage = usage;
|
|
351
|
+
return result;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const createLocalRunner = ({ store, record }) => {
|
|
355
|
+
const leaseToken = ['local', randomUUID()].join('-');
|
|
356
|
+
const leaseExpiresAt = Date.now() + JOB_LEASE_MS;
|
|
357
|
+
const uploads = new Map();
|
|
358
|
+
const job = {
|
|
359
|
+
id: record.id,
|
|
360
|
+
runId: record.id,
|
|
361
|
+
leaseToken,
|
|
362
|
+
leaseExpiresAt,
|
|
363
|
+
model: record.model,
|
|
364
|
+
reasoningEffort: record.reasoningEffort,
|
|
365
|
+
};
|
|
366
|
+
return {
|
|
367
|
+
job,
|
|
368
|
+
input() {
|
|
369
|
+
return Promise.resolve(record.request);
|
|
370
|
+
},
|
|
371
|
+
leaseHeartbeat() {
|
|
372
|
+
return store.isCancellationRequested(record.id).then((cancelRequested) => ({
|
|
373
|
+
cancelRequested,
|
|
374
|
+
leaseExpiresAt,
|
|
375
|
+
}));
|
|
376
|
+
},
|
|
377
|
+
upload(_jobId, _token, bytes, name) {
|
|
378
|
+
if (!(bytes instanceof Buffer))
|
|
379
|
+
return Promise.reject(new Error('generation upload must be bytes'));
|
|
380
|
+
if (name !== 'response.json' && !/^image-[0-3]\.png$/.test(name)) {
|
|
381
|
+
return Promise.reject(new Error('generation upload name is invalid'));
|
|
382
|
+
}
|
|
383
|
+
uploads.set(name, Buffer.from(bytes));
|
|
384
|
+
return Promise.resolve({ key: `${LOCAL_RESPONSE_PREFIX}${record.id}/${name}` });
|
|
385
|
+
},
|
|
386
|
+
complete() {
|
|
387
|
+
const response = uploads.get('response.json');
|
|
388
|
+
if (!response) return Promise.reject(new Error('generation response upload is missing'));
|
|
389
|
+
const images = new Map(
|
|
390
|
+
[...uploads.entries()]
|
|
391
|
+
.filter(([name]) => /^image-[0-3]\.png$/.test(name))
|
|
392
|
+
.map(([name, bytes]) => [name, bytes]),
|
|
393
|
+
);
|
|
394
|
+
return store.complete(record.id, genericResult(record, response, images));
|
|
395
|
+
},
|
|
396
|
+
fail(_jobId, _token, message, outcome) {
|
|
397
|
+
return store.fail(record.id, message, outcome);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
class GenerationServer {
|
|
403
|
+
constructor({
|
|
404
|
+
host,
|
|
405
|
+
port,
|
|
406
|
+
token,
|
|
407
|
+
store,
|
|
408
|
+
getModels,
|
|
409
|
+
isReady = () => true,
|
|
410
|
+
onQueued,
|
|
411
|
+
analytics,
|
|
412
|
+
allowedPeerPrefix,
|
|
413
|
+
} = {}) {
|
|
414
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
415
|
+
throw new GenerationProtocolError(
|
|
416
|
+
500,
|
|
417
|
+
'unsafe_configuration',
|
|
418
|
+
'generation token is required',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
this.host = normalizeBindHost(host);
|
|
422
|
+
this.port = normalizePort(port);
|
|
423
|
+
this.token = String(token);
|
|
424
|
+
this.store = store;
|
|
425
|
+
this.getModels = getModels ?? (() => []);
|
|
426
|
+
this.isReady = isReady;
|
|
427
|
+
this.onQueued = onQueued;
|
|
428
|
+
this.analytics = analytics ?? noopAnalytics;
|
|
429
|
+
this.allowedPeerPrefix = allowedPeerPrefix;
|
|
430
|
+
this.activeBodyReads = 0;
|
|
431
|
+
this.server = createServer((request, response) => {
|
|
432
|
+
void this.handle(request, response);
|
|
433
|
+
});
|
|
434
|
+
this.listening = false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async handle(request, response) {
|
|
438
|
+
const route = requestRoute(request);
|
|
439
|
+
void this.analytics.emit('http', route, 'received');
|
|
440
|
+
response.once('finish', () => {
|
|
441
|
+
void this.analytics.emit('http', route, 'responded', response.statusCode);
|
|
442
|
+
});
|
|
443
|
+
response.setHeader('cache-control', 'no-store');
|
|
444
|
+
response.setHeader('x-content-type-options', 'nosniff');
|
|
445
|
+
if (!isAllowedPeer(request.socket.remoteAddress, this.allowedPeerPrefix)) {
|
|
446
|
+
void this.analytics.emit('http', route, 'peer-rejected');
|
|
447
|
+
writeJson(response, 403, {
|
|
448
|
+
error: { code: 'forbidden', message: 'peer is outside the private LAN' },
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
void this.analytics.emit('http', route, 'peer-accepted');
|
|
453
|
+
if (!tokenMatches(request.headers.authorization, this.token)) {
|
|
454
|
+
void this.analytics.emit('http', route, 'authentication-rejected');
|
|
455
|
+
writeJson(response, 401, {
|
|
456
|
+
error: { code: 'unauthorized', message: 'invalid generation credential' },
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
void this.analytics.emit('http', route, 'authenticated');
|
|
461
|
+
try {
|
|
462
|
+
const parsed = new URL(request.url ?? '/', 'http://generation.local');
|
|
463
|
+
if (parsed.search || parsed.hash) {
|
|
464
|
+
throw httpError(404, 'not_found', 'endpoint was not found');
|
|
465
|
+
}
|
|
466
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
467
|
+
if (segments.length === 2 && segments[0] === 'v1' && segments[1] === 'models') {
|
|
468
|
+
if (request.method !== 'GET')
|
|
469
|
+
throw httpError(405, 'method_not_allowed', 'method is not allowed');
|
|
470
|
+
const models = await this.getModels();
|
|
471
|
+
void this.analytics.emit('http', route, 'model-catalog-loaded');
|
|
472
|
+
writeJson(
|
|
473
|
+
response,
|
|
474
|
+
200,
|
|
475
|
+
publicModelCatalog(models, { ready: this.isReady() }),
|
|
476
|
+
MAX_MODEL_RESPONSE_BYTES,
|
|
477
|
+
);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (segments.length === 2 && segments[0] === 'v1' && segments[1] === 'jobs') {
|
|
481
|
+
if (request.method !== 'POST')
|
|
482
|
+
throw httpError(405, 'method_not_allowed', 'method is not allowed');
|
|
483
|
+
await this.createJob(request, response);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (segments.length === 3 && segments[0] === 'v1' && segments[1] === 'jobs') {
|
|
487
|
+
if (request.method !== 'GET')
|
|
488
|
+
throw httpError(405, 'method_not_allowed', 'method is not allowed');
|
|
489
|
+
const record = await this.store.get(segments[2]);
|
|
490
|
+
if (!record) throw httpError(404, 'not_found', 'generation job was not found');
|
|
491
|
+
if (record.bodyPresent === false && record.status === 'completed') {
|
|
492
|
+
throw new GenerationStoreError('generation result has expired', 'result_expired');
|
|
493
|
+
}
|
|
494
|
+
void this.analytics.emit('http', route, 'job-loaded', record.status);
|
|
495
|
+
writeJson(response, 200, this.store.publicJob(record));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (
|
|
499
|
+
segments.length === 4 &&
|
|
500
|
+
segments[0] === 'v1' &&
|
|
501
|
+
segments[1] === 'jobs' &&
|
|
502
|
+
segments[3] === 'cancel'
|
|
503
|
+
) {
|
|
504
|
+
if (request.method !== 'POST')
|
|
505
|
+
throw httpError(405, 'method_not_allowed', 'method is not allowed');
|
|
506
|
+
const record = await this.store.cancel(segments[2]);
|
|
507
|
+
if (!record) throw httpError(404, 'not_found', 'generation job was not found');
|
|
508
|
+
void this.analytics.emit('http', route, 'cancellation-recorded', record.status);
|
|
509
|
+
writeJson(response, 200, this.store.publicJob(record));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
throw httpError(404, 'not_found', 'endpoint was not found');
|
|
513
|
+
} catch (error) {
|
|
514
|
+
const payload = errorPayload(error);
|
|
515
|
+
void this.analytics.emit('http', route, 'failed', payload.body.error.code);
|
|
516
|
+
writeJson(response, payload.status, payload.body);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async createJob(request, response) {
|
|
521
|
+
const key = normalizeIdempotencyKey(safeHeader(request.headers['idempotency-key']));
|
|
522
|
+
if (this.activeBodyReads >= MAX_CONCURRENT_BODY_READS) {
|
|
523
|
+
request.resume();
|
|
524
|
+
throw new GenerationProtocolError(429, 'overloaded', 'generation request capacity is full');
|
|
525
|
+
}
|
|
526
|
+
this.activeBodyReads += 1;
|
|
527
|
+
try {
|
|
528
|
+
void this.analytics.emit('job', 'local', 'body-read-started');
|
|
529
|
+
const body = await parseBody(request);
|
|
530
|
+
void this.analytics.emit('job', 'local', 'body-read');
|
|
531
|
+
const idempotencyKeyHash = idempotencyDigest(key);
|
|
532
|
+
const digest = requestDigest(body);
|
|
533
|
+
const existing = await this.store.findByIdempotencyKeyHash(idempotencyKeyHash);
|
|
534
|
+
void this.analytics.emit('job', 'local', 'idempotency-checked');
|
|
535
|
+
if (existing) {
|
|
536
|
+
if (existing.requestHash !== digest) {
|
|
537
|
+
throw new GenerationStoreError(
|
|
538
|
+
'idempotency key was already used for a different request',
|
|
539
|
+
'idempotency_conflict',
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
void this.analytics.emit('job', 'local', 'idempotency-reused', existing.status);
|
|
543
|
+
writeJson(response, 200, { id: existing.id, status: existing.status });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const models = await this.getModels();
|
|
547
|
+
void this.analytics.emit('job', 'local', 'model-catalog-loaded');
|
|
548
|
+
const normalized = normalizeGenerationRequest(body, models);
|
|
549
|
+
void this.analytics.emit('job', 'local', 'request-validated');
|
|
550
|
+
const created = await this.store.create({
|
|
551
|
+
idempotencyKeyHash,
|
|
552
|
+
requestHash: digest,
|
|
553
|
+
request: normalized,
|
|
554
|
+
model: normalized.model,
|
|
555
|
+
reasoningEffort: normalized.reasoningEffort,
|
|
556
|
+
});
|
|
557
|
+
void this.analytics.emit(
|
|
558
|
+
'job',
|
|
559
|
+
'local',
|
|
560
|
+
created.created ? 'queued' : 'idempotency-reused-after-race',
|
|
561
|
+
created.record.status,
|
|
562
|
+
);
|
|
563
|
+
if (created.created) this.onQueued?.();
|
|
564
|
+
writeJson(response, created.created ? 202 : 200, {
|
|
565
|
+
id: created.record.id,
|
|
566
|
+
status: created.record.status,
|
|
567
|
+
});
|
|
568
|
+
} finally {
|
|
569
|
+
this.activeBodyReads -= 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async listen() {
|
|
574
|
+
if (this.listening) return this.address();
|
|
575
|
+
await new Promise((resolve, reject) => {
|
|
576
|
+
const onError = (error) => {
|
|
577
|
+
this.server.off('listening', onListening);
|
|
578
|
+
reject(error);
|
|
579
|
+
};
|
|
580
|
+
const onListening = () => {
|
|
581
|
+
this.server.off('error', onError);
|
|
582
|
+
this.listening = true;
|
|
583
|
+
resolve();
|
|
584
|
+
};
|
|
585
|
+
this.server.once('error', onError);
|
|
586
|
+
this.server.once('listening', onListening);
|
|
587
|
+
this.server.listen(this.port, this.host);
|
|
588
|
+
});
|
|
589
|
+
return this.address();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
address() {
|
|
593
|
+
const address = this.server.address();
|
|
594
|
+
return typeof address === 'object' && address
|
|
595
|
+
? { address: address.address, port: address.port }
|
|
596
|
+
: undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async close() {
|
|
600
|
+
if (!this.listening) return;
|
|
601
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
602
|
+
this.listening = false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const startGenerationServer = async (options) => {
|
|
607
|
+
const server = new GenerationServer(options);
|
|
608
|
+
await server.store.init();
|
|
609
|
+
await server.listen();
|
|
610
|
+
return server;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
export {
|
|
614
|
+
DEFAULT_GENERATION_PORT,
|
|
615
|
+
GenerationServer,
|
|
616
|
+
MAX_REQUEST_BYTES,
|
|
617
|
+
createLocalRunner,
|
|
618
|
+
isAllowedPeer,
|
|
619
|
+
normalizeBindHost,
|
|
620
|
+
startGenerationServer,
|
|
621
|
+
};
|