agl 41.0.1 → 41.2.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.
Files changed (87) hide show
  1. package/changelog.md +24 -0
  2. package/dist_serve/bundle.js +1448 -1448
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +7 -3
  6. package/dist_ts/classes.authmodels.d.ts +15 -0
  7. package/dist_ts/classes.authmodels.js +78 -7
  8. package/dist_ts/classes.authstore.js +23 -6
  9. package/dist_ts/classes.cli.js +8 -3
  10. package/dist_ts/classes.controller.d.ts +13 -0
  11. package/dist_ts/classes.controller.js +51 -14
  12. package/dist_ts/classes.controllermanagement.d.ts +53 -6
  13. package/dist_ts/classes.controllermanagement.js +180 -72
  14. package/dist_ts/classes.inferencegateway.d.ts +6 -6
  15. package/dist_ts/classes.inferencegateway.js +28 -15
  16. package/dist_ts/classes.inferenceusage.d.ts +19 -7
  17. package/dist_ts/classes.inferenceusage.js +49 -13
  18. package/dist_ts/classes.inferenceusagetap.d.ts +15 -3
  19. package/dist_ts/classes.inferenceusagetap.js +16 -9
  20. package/dist_ts/classes.processinspection.d.ts +3 -1
  21. package/dist_ts/classes.processinspection.js +4 -2
  22. package/dist_ts/classes.proxycontrol.d.ts +22 -7
  23. package/dist_ts/classes.proxycontrol.js +5 -3
  24. package/dist_ts/classes.proxycontrolclient.d.ts +4 -3
  25. package/dist_ts/classes.proxycontrolclient.js +7 -5
  26. package/dist_ts/classes.proxydaemon.d.ts +6 -4
  27. package/dist_ts/classes.proxydaemon.js +42 -17
  28. package/dist_ts/classes.proxyleaseholder.d.ts +11 -2
  29. package/dist_ts/classes.proxyleaseholder.js +25 -2
  30. package/dist_ts/classes.proxystore.d.ts +3 -2
  31. package/dist_ts/classes.proxystore.js +5 -2
  32. package/dist_ts/classes.upgradecoordinator.d.ts +2 -1
  33. package/dist_ts/classes.upgradecoordinator.js +13 -10
  34. package/dist_ts/classes.upgradetransaction.d.ts +6 -0
  35. package/dist_ts/classes.upgradetransaction.js +38 -13
  36. package/dist_ts/classes.usagefeed.js +3 -2
  37. package/dist_ts/functions.controllermanagementsurface.d.ts +37 -0
  38. package/dist_ts/functions.controllermanagementsurface.js +110 -0
  39. package/dist_ts/functions.inferencecli.d.ts +6 -3
  40. package/dist_ts/functions.inferencecli.js +9 -5
  41. package/dist_ts/functions.inferencecliarguments.d.ts +1 -1
  42. package/dist_ts/functions.inferencecliarguments.js +19 -7
  43. package/dist_ts/functions.inferencelaunch.d.ts +30 -9
  44. package/dist_ts/functions.inferencelaunch.js +49 -23
  45. package/dist_ts/interfaces.auth.d.ts +14 -3
  46. package/dist_ts/interfaces.auth.js +4 -3
  47. package/dist_ts_interfaces/index.d.ts +1 -0
  48. package/dist_ts_interfaces/index.js +2 -1
  49. package/dist_ts_interfaces/interfaces.d.ts +6 -1
  50. package/dist_ts_interfaces/interfaces.js +2 -2
  51. package/dist_ts_interfaces/management.d.ts +33 -0
  52. package/dist_ts_interfaces/management.js +35 -0
  53. package/dist_ts_interfaces/metrics.d.ts +29 -7
  54. package/dist_ts_interfaces/metrics.js +14 -1
  55. package/package.json +6 -5
  56. package/readme.md +123 -28
  57. package/ts/00_commitinfo_data.ts +1 -1
  58. package/ts/aglapi/aglapisurface.document.ts +6 -2
  59. package/ts/classes.authmodels.ts +74 -5
  60. package/ts/classes.authstore.ts +24 -4
  61. package/ts/classes.cli.ts +9 -2
  62. package/ts/classes.controller.ts +98 -59
  63. package/ts/classes.controllermanagement.ts +284 -107
  64. package/ts/classes.inferencegateway.ts +31 -19
  65. package/ts/classes.inferenceusage.ts +58 -19
  66. package/ts/classes.inferenceusagetap.ts +27 -8
  67. package/ts/classes.processinspection.ts +2 -1
  68. package/ts/classes.proxycontrol.ts +25 -9
  69. package/ts/classes.proxycontrolclient.ts +7 -4
  70. package/ts/classes.proxydaemon.ts +43 -17
  71. package/ts/classes.proxyleaseholder.ts +36 -7
  72. package/ts/classes.proxystore.ts +8 -3
  73. package/ts/classes.upgradecoordinator.ts +20 -11
  74. package/ts/classes.upgradetransaction.ts +53 -2
  75. package/ts/classes.usagefeed.ts +2 -1
  76. package/ts/functions.controllermanagementsurface.ts +149 -0
  77. package/ts/functions.inferencecli.ts +15 -7
  78. package/ts/functions.inferencecliarguments.ts +16 -6
  79. package/ts/functions.inferencelaunch.ts +57 -23
  80. package/ts/interfaces.auth.ts +14 -3
  81. package/ts_interfaces/index.ts +1 -0
  82. package/ts_interfaces/interfaces.ts +6 -1
  83. package/ts_interfaces/management.ts +52 -0
  84. package/ts_interfaces/metrics.ts +34 -7
  85. package/ts_web/00_commitinfo_data.ts +1 -1
  86. package/ts_web/classes.controllersocketclient.ts +5 -2
  87. package/ts_web/elements.metricsboard.ts +68 -18
@@ -1,6 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { parseUpgradeBlockers } from './classes.upgradecoordinator.js';
3
3
  import {
4
+ controllerManagementResponseMaximumBytes,
5
+ controllerManagementTypedRequestPath,
4
6
  controllerPackageName,
5
7
  controllerProtocolVersion,
6
8
  controllerServiceUnitName,
@@ -9,6 +11,7 @@ import {
9
11
  type IReq_ControllerUpgradeLaunch,
10
12
  type IReq_ControllerUpgradePrepareBegin,
11
13
  type IReq_ControllerUpgradeFinalize,
14
+ type TControllerManagementTypedRequest,
12
15
  type TControllerSessionHarnessId,
13
16
  } from '../ts_interfaces/index.js';
14
17
  import {
@@ -27,6 +30,33 @@ export interface IControllerStatusExpectation {
27
30
  upgradeManagementVersion: number;
28
31
  }
29
32
 
33
+ /**
34
+ * A declared management protocol (`controllerManagementProtocolVersion` in a package, `managementProtocolVersion`
35
+ * in a status): a safe integer of 1 or more.
36
+ */
37
+ export const isControllerManagementProtocolVersion = (valueArg: unknown): valueArg is number => (
38
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= 1
39
+ );
40
+
41
+ /** A controller a management call is addressed to, by what it declares about its management endpoint. */
42
+ export interface IControllerManagementTarget {
43
+ /**
44
+ * The management protocol the controller declares (`controllerManagementProtocolVersion`): the status's own
45
+ * `managementProtocolVersion` once the controller runs, or what the installed package that runs it declares in
46
+ * its `package.json`. Absent when it declares none, as every release older than the endpoint.
47
+ */
48
+ managementProtocolVersion?: number;
49
+ }
50
+
51
+ /**
52
+ * A status read: the identity the status must report, and the declaration that decides how it is read, which comes
53
+ * from the package expected to run the controller, since there is no status yet. Every caller states it, absent
54
+ * included.
55
+ */
56
+ export interface IControllerStatusQuery extends IControllerStatusExpectation {
57
+ managementProtocolVersion: number | undefined;
58
+ }
59
+
30
60
  const harnessStates = ['starting', 'ready', 'stopping', 'stopped', 'failed'] as const;
31
61
  /** The controller protocol version that added each harness to the status document. */
32
62
  const controllerHarnessIntroductions: Record<TControllerSessionHarnessId, number> = {
@@ -165,6 +195,8 @@ export const assertControllerStatus = (
165
195
  || (expectationArg.protocolVersion !== undefined
166
196
  && status.protocolVersion !== expectationArg.protocolVersion)
167
197
  || status.upgradeManagementVersion !== expectationArg.upgradeManagementVersion
198
+ || (status.managementProtocolVersion !== undefined
199
+ && !isControllerManagementProtocolVersion(status.managementProtocolVersion))
168
200
  || !Number.isSafeInteger(status.controllerPid)
169
201
  || !Number.isSafeInteger(status.processGroupId)
170
202
  || typeof status.processFingerprint !== 'string'
@@ -192,109 +224,273 @@ export const assertControllerStatus = (
192
224
  return readableStatus as IControllerStatus;
193
225
  };
194
226
 
195
- export const requestControllerUpgradePrepareBegin = async (
227
+ /**
228
+ * How this build reaches a controller's management methods, decided by the controller's declaration alone: over its
229
+ * loopback management endpoint when it declares a management protocol, which every later release keeps whatever
230
+ * transport its browser speaks, and over its controller socket when it declares none (a release older than the
231
+ * endpoint, such as a source an upgrade recovery restores), which serves them in every release up to this one.
232
+ * There is no second attempt over the other transport.
233
+ */
234
+ export const controllerManagementTransport = (
235
+ targetArg: IControllerManagementTarget,
236
+ ): 'http' | 'socket' => (
237
+ isControllerManagementProtocolVersion(targetArg.managementProtocolVersion) ? 'http' : 'socket'
238
+ );
239
+
240
+ /** A management request the controller answered with an error, or whose answer this build cannot read. */
241
+ export class ControllerManagementError extends Error {
242
+ constructor(messageArg: string, public readonly code?: string) {
243
+ super(messageArg);
244
+ this.name = 'ControllerManagementError';
245
+ }
246
+ }
247
+
248
+ const maximumManagementErrorTextBytes = 4_096;
249
+
250
+ const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
251
+ typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg)
252
+ );
253
+
254
+ /**
255
+ * Reads one answer of the management endpoint: the envelope must answer this very request (method, request
256
+ * instance and correlation) and carry `response`; an `error` is the controller's refusal. Keys this build does not
257
+ * read are left alone, so a later release may add some.
258
+ */
259
+ const readControllerManagementAnswer = (
260
+ answerArg: unknown,
261
+ requestArg: { method: string; requestInstanceId: string; correlationId: string },
262
+ ): unknown => {
263
+ if (
264
+ !isPlainRecord(answerArg)
265
+ || answerArg.method !== requestArg.method
266
+ || answerArg.requestInstanceId !== requestArg.requestInstanceId
267
+ || !isPlainRecord(answerArg.correlation)
268
+ || answerArg.correlation.id !== requestArg.correlationId
269
+ || answerArg.correlation.phase !== 'response'
270
+ || !Object.hasOwn(answerArg, 'response')
271
+ ) throw new ControllerManagementError('The controller returned an invalid management answer.', 'protocol_mismatch');
272
+ if (answerArg.error === undefined) return answerArg.response;
273
+ if (!isPlainRecord(answerArg.error) || typeof answerArg.error.text !== 'string') {
274
+ throw new ControllerManagementError('The controller returned an invalid management answer.', 'protocol_mismatch');
275
+ }
276
+ const data = answerArg.error.data;
277
+ const code = isPlainRecord(data) && typeof data.code === 'string' && data.code.length <= 128 ? data.code : undefined;
278
+ const text = Buffer.byteLength(answerArg.error.text, 'utf8') <= maximumManagementErrorTextBytes
279
+ ? answerArg.error.text
280
+ : 'The controller refused the management request.';
281
+ throw new ControllerManagementError(text, code);
282
+ };
283
+
284
+ /** Sends one envelope to the management endpoint on 127.0.0.1 and returns the answer's bytes. */
285
+ const postControllerManagementEnvelope = (
196
286
  portArg: number,
197
- tokenArg: string,
198
- targetVersionArg: string,
199
- gracePeriodMsArg: number,
200
- ): Promise<IReq_ControllerUpgradePrepareBegin['response']> => {
201
- const abortController = new AbortController();
202
- const timeout = setTimeout(
203
- () => abortController.abort(),
204
- controllerUpgradePrepareBeginRequestTimeoutMs,
205
- );
287
+ bodyArg: Buffer,
288
+ signalArg: AbortSignal,
289
+ ): Promise<Buffer> => new Promise<Buffer>((resolve, reject) => {
290
+ const request = plugins.http.request({
291
+ protocol: 'http:',
292
+ hostname: '127.0.0.1',
293
+ port: portArg,
294
+ path: controllerManagementTypedRequestPath,
295
+ method: 'POST',
296
+ agent: false,
297
+ signal: signalArg,
298
+ headers: {
299
+ Host: `127.0.0.1:${portArg}`,
300
+ 'Content-Type': 'application/json',
301
+ Accept: 'application/json',
302
+ 'Content-Length': String(bodyArg.byteLength),
303
+ Connection: 'close',
304
+ },
305
+ }, (responseArg) => {
306
+ const statusCode = responseArg.statusCode ?? 0;
307
+ const contentType = responseArg.headers['content-type'];
308
+ if (statusCode !== 200) {
309
+ responseArg.resume();
310
+ reject(new ControllerManagementError(
311
+ `The controller refused the management request with HTTP ${statusCode}.`,
312
+ 'http_refused',
313
+ ));
314
+ return;
315
+ }
316
+ if (typeof contentType !== 'string' || !/^application\/json(?:;|$)/iu.test(contentType)) {
317
+ responseArg.resume();
318
+ reject(new ControllerManagementError('The controller returned an invalid management answer.', 'protocol_mismatch'));
319
+ return;
320
+ }
321
+ const chunks: Buffer[] = [];
322
+ let byteLength = 0;
323
+ responseArg.on('data', (chunkArg: Buffer | string) => {
324
+ const chunk = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg);
325
+ byteLength += chunk.byteLength;
326
+ if (byteLength > controllerManagementResponseMaximumBytes) {
327
+ responseArg.destroy();
328
+ reject(new ControllerManagementError('The controller management answer exceeded its size limit.', 'response_limit'));
329
+ return;
330
+ }
331
+ chunks.push(chunk);
332
+ });
333
+ responseArg.once('end', () => resolve(Buffer.concat(chunks, byteLength)));
334
+ responseArg.once('error', reject);
335
+ });
336
+ request.once('error', reject);
337
+ request.end(bodyArg);
338
+ });
339
+
340
+ /**
341
+ * Sends one management request over loopback HTTP: a TypedRequest envelope of exactly `method`, `request`,
342
+ * `response: null`, `correlation` and `requestInstanceId`, as a TypedRequest client sends it, with no credential
343
+ * beside what the request itself carries.
344
+ */
345
+ export const requestControllerManagement = async <T extends TControllerManagementTypedRequest>(
346
+ portArg: number,
347
+ methodArg: T['method'],
348
+ requestArg: T['request'],
349
+ optionsArg: { timeoutMs: number },
350
+ ): Promise<unknown> => {
351
+ const identity = {
352
+ method: methodArg,
353
+ requestInstanceId: plugins.crypto.randomUUID(),
354
+ correlationId: plugins.crypto.randomUUID(),
355
+ };
356
+ const body = Buffer.from(JSON.stringify({
357
+ method: methodArg,
358
+ request: requestArg,
359
+ response: null,
360
+ correlation: { id: identity.correlationId, phase: 'request' },
361
+ requestInstanceId: identity.requestInstanceId,
362
+ }), 'utf8');
363
+ const timeoutSignal = AbortSignal.timeout(optionsArg.timeoutMs);
364
+ let answerBytes: Buffer;
365
+ try {
366
+ answerBytes = await postControllerManagementEnvelope(portArg, body, timeoutSignal);
367
+ } catch (errorArg) {
368
+ if (timeoutSignal.aborted && !(errorArg instanceof ControllerManagementError)) {
369
+ throw new ControllerManagementError(
370
+ `The controller did not answer ${methodArg} within ${optionsArg.timeoutMs} ms.`,
371
+ 'timeout',
372
+ );
373
+ }
374
+ throw errorArg;
375
+ }
376
+ let answer: unknown;
377
+ try {
378
+ const text = answerBytes.toString('utf8');
379
+ if (!Buffer.from(text, 'utf8').equals(answerBytes)) throw new Error('invalid UTF-8');
380
+ answer = JSON.parse(text);
381
+ } catch {
382
+ throw new ControllerManagementError('The controller returned an invalid management answer.', 'protocol_mismatch');
383
+ }
384
+ return readControllerManagementAnswer(answer, identity);
385
+ };
386
+
387
+ /** Fires one management request over the controller socket, for a controller that declares no management protocol. */
388
+ const requestControllerManagementOverSocket = async <T extends TControllerManagementTypedRequest>(
389
+ portArg: number,
390
+ methodArg: T['method'],
391
+ requestArg: T['request'],
392
+ optionsArg: { timeoutMs: number },
393
+ ): Promise<unknown> => {
394
+ const signal = AbortSignal.timeout(optionsArg.timeoutMs);
206
395
  let socket: plugins.typedsocket.TypedSocket | undefined;
207
396
  try {
208
397
  socket = await plugins.typedsocket.TypedSocket.createClient(
209
398
  new plugins.typedrequest.TypedRouter(),
210
399
  `http://127.0.0.1:${portArg}`,
211
- {
212
- autoReconnect: false,
213
- maxRetries: 0,
214
- abortSignal: abortController.signal,
215
- },
400
+ { autoReconnect: false, maxRetries: 0, abortSignal: signal },
216
401
  );
217
- const response = await socket.createTypedRequest<IReq_ControllerUpgradePrepareBegin>(
218
- 'controller.upgrade.prepare.begin',
402
+ return await socket.createTypedRequest<T>(
403
+ methodArg,
219
404
  undefined,
220
- {
221
- timeoutMs: controllerUpgradePrepareBeginRequestTimeoutMs,
222
- abortSignal: abortController.signal,
223
- },
224
- ).fire({ token: tokenArg, targetVersion: targetVersionArg, gracePeriodMs: gracePeriodMsArg });
225
- if (
226
- !response
227
- || typeof response !== 'object'
228
- || Array.isArray(response)
229
- || Object.keys(response).length !== 1
230
- || response.accepted !== true
231
- ) throw new Error('The controller returned an invalid upgrade preparation response.');
232
- return response;
405
+ { timeoutMs: optionsArg.timeoutMs, abortSignal: signal },
406
+ ).fire(requestArg);
233
407
  } finally {
234
- clearTimeout(timeout);
235
408
  await socket?.stop();
236
409
  }
237
410
  };
238
411
 
412
+ const requestManagedController = <T extends TControllerManagementTypedRequest>(
413
+ portArg: number,
414
+ controllerArg: IControllerManagementTarget,
415
+ methodArg: T['method'],
416
+ requestArg: T['request'],
417
+ optionsArg: { timeoutMs: number },
418
+ ): Promise<unknown> => controllerManagementTransport(controllerArg) === 'http'
419
+ ? requestControllerManagement<T>(portArg, methodArg, requestArg, optionsArg)
420
+ : requestControllerManagementOverSocket<T>(portArg, methodArg, requestArg, optionsArg);
421
+
422
+ export const requestControllerUpgradePrepareBegin = async (
423
+ portArg: number,
424
+ controllerArg: IControllerManagementTarget,
425
+ tokenArg: string,
426
+ targetVersionArg: string,
427
+ gracePeriodMsArg: number,
428
+ ): Promise<IReq_ControllerUpgradePrepareBegin['response']> => {
429
+ const response = await requestManagedController<IReq_ControllerUpgradePrepareBegin>(
430
+ portArg,
431
+ controllerArg,
432
+ 'controller.upgrade.prepare.begin',
433
+ { token: tokenArg, targetVersion: targetVersionArg, gracePeriodMs: gracePeriodMsArg },
434
+ { timeoutMs: controllerUpgradePrepareBeginRequestTimeoutMs },
435
+ );
436
+ if (
437
+ !isPlainRecord(response)
438
+ || Object.keys(response).length !== 1
439
+ || response.accepted !== true
440
+ ) throw new Error('The controller returned an invalid upgrade preparation response.');
441
+ return { accepted: true };
442
+ };
443
+
239
444
  export const requestControllerUpgradeFinalize = async (
240
445
  portArg: number,
446
+ controllerArg: IControllerManagementTarget,
241
447
  tokenArg: string,
242
448
  modeArg: IReq_ControllerUpgradeFinalize['request']['mode'],
243
449
  timeoutMsArg = 60_000,
244
450
  ): Promise<IReq_ControllerUpgradeFinalize['response']> => {
245
- const socket = await plugins.typedsocket.TypedSocket.createClient(
246
- new plugins.typedrequest.TypedRouter(),
247
- `http://127.0.0.1:${portArg}`,
248
- { autoReconnect: false, maxRetries: 0 },
451
+ const response = await requestManagedController<IReq_ControllerUpgradeFinalize>(
452
+ portArg,
453
+ controllerArg,
454
+ 'controller.upgrade.finalize',
455
+ { token: tokenArg, mode: modeArg },
456
+ { timeoutMs: timeoutMsArg },
249
457
  );
250
- try {
251
- return await socket.createTypedRequest<IReq_ControllerUpgradeFinalize>(
252
- 'controller.upgrade.finalize',
253
- undefined,
254
- { timeoutMs: timeoutMsArg },
255
- ).fire({ token: tokenArg, mode: modeArg });
256
- } finally {
257
- await socket.stop();
458
+ if (!isPlainRecord(response) || response.finalized !== true) {
459
+ throw new Error('The controller returned an invalid upgrade finalization response.');
258
460
  }
461
+ const { continued, failed } = response;
462
+ if (
463
+ typeof continued !== 'number' || !Number.isSafeInteger(continued) || continued < 0
464
+ || typeof failed !== 'number' || !Number.isSafeInteger(failed) || failed < 0
465
+ ) throw new Error('The controller returned an invalid upgrade finalization response.');
466
+ return { finalized: true, continued, failed };
259
467
  };
260
468
 
469
+ /**
470
+ * Reads a controller's status over the transport the query's declaration picks. The upgrade calls that follow take
471
+ * the status itself as their target, so from then on the controller's own declaration decides.
472
+ */
261
473
  export const queryControllerStatus = async (
262
474
  portArg: number,
263
- expectationArg: IControllerStatusExpectation,
475
+ queryArg: IControllerStatusQuery,
264
476
  timeoutMsArg = 2_500,
265
- ): Promise<IControllerStatus> => {
266
- const abortController = new AbortController();
267
- const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
268
- let socket: plugins.typedsocket.TypedSocket | undefined;
269
- try {
270
- const router = new plugins.typedrequest.TypedRouter();
271
- socket = await plugins.typedsocket.TypedSocket.createClient(
272
- router,
273
- `http://127.0.0.1:${portArg}`,
274
- {
275
- autoReconnect: false,
276
- maxRetries: 0,
277
- abortSignal: abortController.signal,
278
- },
279
- );
280
- const request = socket.createTypedRequest<IReq_ControllerStatus>(
281
- 'controller.status',
282
- undefined,
283
- { timeoutMs: timeoutMsArg, abortSignal: abortController.signal },
284
- );
285
- return assertControllerStatus(await request.fire({}), expectationArg);
286
- } finally {
287
- clearTimeout(timeout);
288
- await socket?.stop();
289
- }
290
- };
477
+ ): Promise<IControllerStatus> => assertControllerStatus(
478
+ await requestManagedController<IReq_ControllerStatus>(
479
+ portArg,
480
+ queryArg,
481
+ 'controller.status',
482
+ {},
483
+ { timeoutMs: timeoutMsArg },
484
+ ),
485
+ queryArg,
486
+ );
291
487
 
292
488
  export const tryQueryControllerStatus = async (
293
489
  portArg: number,
294
- expectationArg: IControllerStatusExpectation,
490
+ queryArg: IControllerStatusQuery,
295
491
  ): Promise<IControllerStatus | undefined> => {
296
492
  try {
297
- return await queryControllerStatus(portArg, expectationArg);
493
+ return await queryControllerStatus(portArg, queryArg);
298
494
  } catch {
299
495
  return undefined;
300
496
  }
@@ -322,44 +518,25 @@ export const isLoopbackPortListening = async (
322
518
 
323
519
  export const requestControllerUpgradeLaunch = async (
324
520
  portArg: number,
521
+ controllerArg: IControllerManagementTarget,
325
522
  tokenArg: string,
326
523
  timeoutMsArg = 15_000,
327
524
  ): Promise<IReq_ControllerUpgradeLaunch['response']> => {
328
- const abortController = new AbortController();
329
- const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
330
- let socket: plugins.typedsocket.TypedSocket | undefined;
331
- try {
332
- const router = new plugins.typedrequest.TypedRouter();
333
- socket = await plugins.typedsocket.TypedSocket.createClient(
334
- router,
335
- `http://127.0.0.1:${portArg}`,
336
- {
337
- autoReconnect: false,
338
- maxRetries: 0,
339
- abortSignal: abortController.signal,
340
- },
341
- );
342
- const response = await socket.createTypedRequest<IReq_ControllerUpgradeLaunch>(
343
- 'controller.upgrade.launch',
344
- undefined,
345
- { timeoutMs: timeoutMsArg, abortSignal: abortController.signal },
346
- ).fire({ token: tokenArg });
347
- if (
348
- !response
349
- || typeof response !== 'object'
350
- || response.accepted !== true
351
- || !Number.isSafeInteger(response.workerPid)
352
- || response.workerPid < 2
353
- || typeof response.logFilePath !== 'string'
354
- || !plugins.path.isAbsolute(response.logFilePath)
355
- ) {
356
- throw new Error(`The controller returned an invalid ${currentCliName} upgrade launch response.`);
357
- }
358
- return response;
359
- } finally {
360
- clearTimeout(timeout);
361
- await socket?.stop();
362
- }
525
+ const response = await requestManagedController<IReq_ControllerUpgradeLaunch>(
526
+ portArg,
527
+ controllerArg,
528
+ 'controller.upgrade.launch',
529
+ { token: tokenArg },
530
+ { timeoutMs: timeoutMsArg },
531
+ );
532
+ const invalid = new Error(`The controller returned an invalid ${currentCliName} upgrade launch response.`);
533
+ if (!isPlainRecord(response) || response.accepted !== true) throw invalid;
534
+ const { workerPid, logFilePath } = response;
535
+ if (
536
+ typeof workerPid !== 'number' || !Number.isSafeInteger(workerPid) || workerPid < 2
537
+ || typeof logFilePath !== 'string' || !plugins.path.isAbsolute(logFilePath)
538
+ ) throw invalid;
539
+ return { accepted: true, workerPid, logFilePath };
363
540
  };
364
541
 
365
542
  export const waitUntilControllerExited = async (
@@ -66,9 +66,9 @@ export interface IInferenceGatewayOptions {
66
66
  usageLedger: IInferenceUsageLedger;
67
67
  /**
68
68
  * The confined listener's Unix socket, bound beside the loopback port and made this user's alone (0600, in a private
69
- * directory that holds nothing else). It accepts confined credentials alone and serves them the Claude pool's three
70
- * endpoints Claude Code uses (`confinedEndpoints`), exactly as the loopback listener serves them; unset, the gateway has
71
- * no confined listener and admits no confined credential.
69
+ * directory that holds nothing else). It accepts confined credentials alone and serves them the three endpoints Claude
70
+ * Code uses (`confinedEndpoints`) of their pool, the Claude or the ChatGPT pool, exactly as the loopback listener serves
71
+ * Claude Code on that pool; unset, the gateway has no confined listener and admits no confined credential.
72
72
  */
73
73
  confinedSocketPath?: string;
74
74
  }
@@ -159,8 +159,8 @@ export interface IInferenceGatewayClientOptions {
159
159
  */
160
160
  sessionScope?: string;
161
161
  /**
162
- * Where the credential is accepted; `loopback` when unset. A confined credential is a lease of the Claude pool, accepted
163
- * on the confined listener alone.
162
+ * Where the credential is accepted; `loopback` when unset. A confined credential is a lease of the Claude or the ChatGPT
163
+ * pool, accepted on the confined listener alone, and stays on the pool it was issued for.
164
164
  */
165
165
  route?: TInferenceGatewayRoute;
166
166
  }
@@ -233,8 +233,9 @@ const maximumErrorBodyBytes = 64 * 1024;
233
233
  const catalogTag = (modelsArg: unknown): string =>
234
234
  `"${plugins.crypto.createHash('sha256').update(JSON.stringify(modelsArg)).digest('base64url')}"`;
235
235
  /**
236
- * What the confined listener serves, by method and path: the Claude pool's endpoints Claude Code uses. Every other
237
- * method, path and pool is refused there before anything is read or routed.
236
+ * What the confined listener serves, by method and path: the endpoints Claude Code uses, of the Claude pool or, translated,
237
+ * of the ChatGPT pool. Every other method and path, every `/openai/` path among them, is refused there before anything is
238
+ * read or routed.
238
239
  */
239
240
  const confinedEndpoints: ReadonlySet<string> = new Set(['GET /anthropic/v1/models', 'POST /anthropic/v1/messages',
240
241
  'POST /anthropic/v1/messages/count_tokens']);
@@ -476,18 +477,24 @@ export const assertInferenceRoutingPolicy = (policyArg: IInferenceRoutingPolicy)
476
477
  }
477
478
  };
478
479
 
479
- /** A confined credential is a lease of the Claude pool, admitted only by a gateway with a confined listener. */
480
+ /**
481
+ * A confined credential is a lease of the Claude or the ChatGPT pool, admitted only by a gateway with a confined listener;
482
+ * `issuedArg` is the policy it was issued with, which a new policy may not move to another pool.
483
+ */
480
484
  const assertConfinedClient = (policyArg: IInferenceRoutingPolicy, nameArg: string | undefined,
481
- socketPathArg: string | undefined): void => {
485
+ socketPathArg: string | undefined, issuedArg?: IInferenceRoutingPolicy): void => {
482
486
  if (socketPathArg === undefined) throw new Error('The gateway has no confined listener.');
483
- if (nameArg !== undefined || policyArg.provider !== 'anthropic') {
484
- throw new Error('A confined credential is a lease of the Claude pool.');
487
+ if (nameArg !== undefined || (policyArg.provider !== 'anthropic' && policyArg.provider !== 'openai')) {
488
+ throw new Error('A confined credential is a lease of the Claude or the ChatGPT pool.');
489
+ }
490
+ if (issuedArg !== undefined && policyArg.provider !== issuedArg.provider) {
491
+ throw new Error('A confined credential stays on the pool it was issued for.');
485
492
  }
486
493
  };
487
494
 
488
495
  /**
489
496
  * Authenticated Responses and Messages endpoints with bounded, cancelable streaming, on a loopback listener and, for
490
- * confined credentials, a Unix socket that serves the Claude pool alone.
497
+ * confined credentials, a Unix socket that serves Claude Code's endpoints alone.
491
498
  */
492
499
  export class InferenceGateway {
493
500
  private readonly clients = new Set<IClient>();
@@ -583,7 +590,7 @@ export class InferenceGateway {
583
590
  this.clients.add(client);
584
591
  return { baseUrl: `http://${this.host}`, digest: digestArg.toString('hex'), setPolicy: (nextArg) => {
585
592
  assertInferenceRoutingPolicy(nextArg);
586
- if (routeArg === 'confined') assertConfinedClient(nextArg, nameArg, this.options.confinedSocketPath);
593
+ if (routeArg === 'confined') assertConfinedClient(nextArg, nameArg, this.options.confinedSocketPath, client.policy);
587
594
  client.policy = structuredClone(nextArg);
588
595
  }, close: () => {
589
596
  this.clients.delete(client);
@@ -713,11 +720,15 @@ export class InferenceGateway {
713
720
  try {
714
721
  if (routeArg === 'confined') protocol = 'claude';
715
722
  client = this.authenticate(requestArg, routeArg);
716
- if (routeArg === 'confined' && (client.policy.provider !== 'anthropic'
717
- || !confinedEndpoints.has(`${requestArg.method} ${new URL(requestArg.url).pathname}`))) {
718
- throw new InferenceRoutingError(404, 'not_found', 'The confined listener serves the Claude pool\'s '
719
- + 'GET /anthropic/v1/models, POST /anthropic/v1/messages and POST /anthropic/v1/messages/count_tokens alone.');
723
+ // On either pool the confined listener serves Claude Code's three endpoints alone, as the loopback listener serves
724
+ // Claude Code on that pool; a ChatGPT-pool run never reaches a Responses or OpenAI models endpoint.
725
+ if (routeArg === 'confined' && !confinedEndpoints.has(`${requestArg.method} ${new URL(requestArg.url).pathname}`)) {
726
+ throw new InferenceRoutingError(404, 'not_found', 'The confined listener serves GET /anthropic/v1/models, '
727
+ + 'POST /anthropic/v1/messages and POST /anthropic/v1/messages/count_tokens alone.');
720
728
  }
729
+ // From here on a confined request is answered exactly as the loopback listener answers Claude Code on its pool, its
730
+ // errors included; only the refusals above are the confined listener's own, in Claude's shape.
731
+ if (routeArg === 'confined' && client.policy.provider !== 'anthropic') protocol = undefined;
721
732
  if (this.inFlight >= this.limits.total) throw new InferenceRoutingError(429, 'concurrency_limit', 'The gateway has too many requests in flight.', 1);
722
733
  if (client.pending >= this.limits.perClient) throw new InferenceRoutingError(429, 'concurrency_limit', 'This gateway client has too many requests.', 1);
723
734
  client.pending += 1;
@@ -998,10 +1009,11 @@ export class InferenceGateway {
998
1009
  if (spent) this.options.router.recordSpend(accountId, route.loginId, spent);
999
1010
  else this.options.router.uncountedSpend(accountId, answeredArg.model ?? route.model.modelId);
1000
1011
  } : undefined)
1001
- : new InferenceClaudeUsageTap(answerFormat, (usageArg) => {
1012
+ : new InferenceClaudeUsageTap(answerFormat, (usageArg, cacheStatedArg) => {
1002
1013
  if (route.provider === 'anthropic') this.options.router.countTokens(accountId, usageArg);
1003
1014
  recordUsage({ inputTokens: usageArg.inputTokens, outputTokens: usageArg.outputTokens,
1004
- cacheReadTokens: usageArg.cacheReadTokens, cacheWriteTokens: usageArg.cacheWrite5mTokens + usageArg.cacheWrite1hTokens });
1015
+ cacheReadTokens: cacheStatedArg.reads ? usageArg.cacheReadTokens : null,
1016
+ cacheWriteTokens: cacheStatedArg.writes ? usageArg.cacheWrite5mTokens + usageArg.cacheWrite1hTokens : null });
1005
1017
  // An API key's answer is priced as it ends, and its spend counts against the key's limit from then on.
1006
1018
  }, route.provider === 'anthropic' && route.credential === 'api_key' ? (answeredArg) => {
1007
1019
  const spent = inferenceAnthropicSpend(answeredArg, route.model.modelId, Date.now(), inferenceAnthropicServerToolTypes(body));