@playcraft/cli 0.1.86 → 0.1.87

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.
@@ -1,29 +1,50 @@
1
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { Readable, Transform } from 'node:stream';
4
- import { pipeline } from 'node:stream/promises';
4
+ import { finished, pipeline } from 'node:stream/promises';
5
5
  import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
- import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint, workspaceExecutionTraceOriginSchema } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint, workspaceExecutionTraceOriginSchema, } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
10
10
  import { WorkspaceRemixReferencesBroker } from './remix-references-broker.js';
11
11
  import { WorkspaceExecutionContextBroker } from './execution-context-broker.js';
12
12
  import { WorkspaceSkillDiscoveryBroker } from './skill-discovery-broker.js';
13
+ import { ModelBrokerDiagnostics } from './model-broker-diagnostics.js';
14
+ import { MODEL_REQUEST_BUDGET_MS, ModelBrokerRequestError, modelBrokerFailure, readModelRequestBody, } from './model-broker-request.js';
15
+ import { WorkspaceModelCapture, captureSubmittedResponse, } from './model-capture.js';
13
16
  const credentialSchema = z.object({
14
- token: z.string().min(1), expiresAt: z.string().datetime(),
15
- configId: z.string().uuid(), model: z.string().min(1).max(128),
17
+ token: z.string().min(1),
18
+ expiresAt: z.string().datetime(),
19
+ configId: z.string().uuid(),
20
+ model: z.string().min(1).max(128),
16
21
  supportsReasoning: z.boolean().optional(),
17
22
  claims: z.object({
18
- environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
19
- generation: z.number().int(), runtimeEpoch: z.number().int(),
20
- traceOrigin: z.custom(value => workspaceExecutionTraceOriginSchema.safeParse(value).success).optional(),
23
+ environment: z.string(),
24
+ userId: z.string(),
25
+ workspaceId: z.string(),
26
+ runtimeId: z.string(),
27
+ generation: z.number().int(),
28
+ runtimeEpoch: z.number().int(),
29
+ traceOrigin: z
30
+ .custom((value) => workspaceExecutionTraceOriginSchema.safeParse(value).success)
31
+ .optional(),
32
+ capturePolicy: z
33
+ .object({
34
+ enabled: z.literal(true),
35
+ policyVersion: z.literal('workspace-model-capture-v1'),
36
+ maxRequestBytes: z.number().int().positive(),
37
+ maxResponseBytes: z.number().int().positive(),
38
+ expiresAt: z.string().datetime(),
39
+ })
40
+ .optional(),
21
41
  }),
22
42
  });
23
43
  const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
24
44
  /** Keeps renewable execution credentials in control memory, outside Codex and project files. */
25
45
  export class WorkspaceModelBroker {
26
46
  state;
47
+ requestBudgetMs;
27
48
  server;
28
49
  baseUrl = '';
29
50
  entries = new Map();
@@ -35,12 +56,15 @@ export class WorkspaceModelBroker {
35
56
  references;
36
57
  executionContext;
37
58
  skills;
38
- constructor(state) {
59
+ capture;
60
+ constructor(state, requestBudgetMs = MODEL_REQUEST_BUDGET_MS) {
39
61
  this.state = state;
62
+ this.requestBudgetMs = requestBudgetMs;
40
63
  this.generation = new WorkspaceGenerationProvenanceBroker(state);
41
64
  this.references = new WorkspaceRemixReferencesBroker(state);
42
65
  this.executionContext = new WorkspaceExecutionContextBroker(state);
43
66
  this.skills = new WorkspaceSkillDiscoveryBroker(state);
67
+ this.capture = new WorkspaceModelCapture(state);
44
68
  }
45
69
  async start() {
46
70
  if (this.server)
@@ -57,6 +81,7 @@ export class WorkspaceModelBroker {
57
81
  if (!address || typeof address === 'string')
58
82
  throw new Error('model_broker_unavailable');
59
83
  this.baseUrl = `http://127.0.0.1:${address.port}`;
84
+ await this.capture.start();
60
85
  }
61
86
  async threadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
62
87
  const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort);
@@ -69,24 +94,44 @@ export class WorkspaceModelBroker {
69
94
  const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
70
95
  if (modelConfigId !== undefined && !z.string().uuid().safeParse(modelConfigId).success)
71
96
  throw new Error('model_config_denied');
72
- const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
97
+ const routeKey = sessionId
98
+ ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`
99
+ : workspaceId;
73
100
  let route = this.routes.get(routeKey);
74
- const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
101
+ const selected = modelConfigId ??
102
+ (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
75
103
  const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}:${sessionId ?? ''}:${clientRequestId ?? ''}`;
76
- let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
77
- && item.sessionId === sessionId && item.clientRequestId === clientRequestId
78
- && item.reasoningEffort === reasoningEffort);
104
+ let entry = this.entries.get(cacheKey) ??
105
+ [...this.entries.values()].find((item) => item.workspaceId === workspaceId &&
106
+ item.epoch === this.state.runtimeEpoch &&
107
+ item.modelConfigId === selected &&
108
+ item.sessionId === sessionId &&
109
+ item.clientRequestId === clientRequestId &&
110
+ item.reasoningEffort === reasoningEffort);
79
111
  if (!entry || entry.epoch !== this.state.runtimeEpoch) {
80
- entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort, sessionId, clientRequestId };
112
+ entry = {
113
+ workspaceId,
114
+ epoch: this.state.runtimeEpoch,
115
+ cacheKey,
116
+ modelConfigId: selected,
117
+ reasoningEffort,
118
+ sessionId,
119
+ clientRequestId,
120
+ };
81
121
  this.entries.set(cacheKey, entry);
82
122
  }
83
123
  // Resolve current model metadata and permission at every native turn boundary.
84
124
  const credential = await this.credential(entry, true);
85
125
  const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
86
126
  if (!route || route.epoch !== this.state.runtimeEpoch) {
87
- route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
88
- generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url'),
89
- skillsCapability: randomBytes(32).toString('base64url') };
127
+ route = {
128
+ workspaceId,
129
+ epoch: this.state.runtimeEpoch,
130
+ capability: randomBytes(32).toString('base64url'),
131
+ generationCapability: randomBytes(32).toString('base64url'),
132
+ referencesCapability: randomBytes(32).toString('base64url'),
133
+ skillsCapability: randomBytes(32).toString('base64url'),
134
+ };
90
135
  this.routes.set(routeKey, route);
91
136
  }
92
137
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
@@ -95,50 +140,87 @@ export class WorkspaceModelBroker {
95
140
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
96
141
  throw new Error('model_access_denied');
97
142
  route.lastAdmissionReason = undefined;
98
- route.selected = { entry, model: credential.model,
99
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, readOnly,
100
- ...(originCwd ? { originCwd } : {}) } } : {}) };
143
+ route.selected = {
144
+ entry,
145
+ model: credential.model,
146
+ ...(sessionId && clientRequestId
147
+ ? {
148
+ feedback: {
149
+ sessionId,
150
+ clientRequestId,
151
+ cwd,
152
+ boundaryId,
153
+ readOnly,
154
+ ...(originCwd ? { originCwd } : {}),
155
+ },
156
+ }
157
+ : {}),
158
+ };
101
159
  },
102
160
  model: credential.model,
103
161
  modelConfigId: credential.configId,
104
162
  modelProvider: 'playcraft_workspace',
105
163
  config: {
106
- ...(sessionId ? { 'shell_environment_policy.set': {
107
- PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
108
- PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
109
- PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
110
- PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
111
- PLAYCRAFT_SKILLS_ENDPOINT: `${this.baseUrl}/${routeKey}/skill-discovery`,
112
- PLAYCRAFT_SKILLS_CAPABILITY: route.skillsCapability,
113
- } } : {}),
164
+ ...(sessionId
165
+ ? {
166
+ 'shell_environment_policy.set': {
167
+ PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
168
+ PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
169
+ PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
170
+ PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
171
+ PLAYCRAFT_SKILLS_ENDPOINT: `${this.baseUrl}/${routeKey}/skill-discovery`,
172
+ PLAYCRAFT_SKILLS_CAPABILITY: route.skillsCapability,
173
+ },
174
+ }
175
+ : {}),
114
176
  'model_providers.playcraft_workspace': {
115
- name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
116
- wire_api: 'responses', requires_openai_auth: false,
177
+ name: 'PlayCraft Workspace',
178
+ base_url: `${this.baseUrl}/${routeKey}`,
179
+ wire_api: 'responses',
180
+ requires_openai_auth: false,
181
+ request_max_retries: 0,
182
+ stream_max_retries: 0,
183
+ stream_idle_timeout_ms: MODEL_REQUEST_BUDGET_MS,
184
+ supports_websockets: false,
117
185
  http_headers: { Authorization: `Bearer ${route.capability}` },
118
186
  },
187
+ 'features.unbounded_connection_retries': false,
119
188
  },
120
189
  };
121
190
  }
122
191
  valid(entry) {
123
- return Boolean(this.server && !this.state.journal.destroyed()
124
- && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
125
- && this.entries.get(entry.cacheKey) === entry);
192
+ return Boolean(this.server &&
193
+ !this.state.journal.destroyed() &&
194
+ this.state.runtimeEpoch === entry.epoch &&
195
+ this.state.journal.getWorkspace(entry.workspaceId) &&
196
+ this.entries.get(entry.cacheKey) === entry);
126
197
  }
127
198
  /** Called immediately before the native turn/start write, within this broker's process clock. */
128
199
  markCodexDispatched(cwd, sessionId, clientRequestId) {
129
- const workspaceId = /^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd);
200
+ const workspaceId = /^(basic|remix-)/.test(basename(cwd))
201
+ ? basename(dirname(cwd))
202
+ : basename(cwd);
130
203
  const route = this.routes.get(`${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`);
131
204
  const entry = route?.selected?.entry;
132
- if (!entry || !this.valid(entry) || entry.sessionId !== sessionId || entry.clientRequestId !== clientRequestId)
205
+ if (!entry ||
206
+ !this.valid(entry) ||
207
+ entry.sessionId !== sessionId ||
208
+ entry.clientRequestId !== clientRequestId)
133
209
  return;
134
210
  entry.codexDispatchedAt = new Date().toISOString();
135
211
  entry.codexDispatchMark = performance.now();
136
212
  }
137
- async credential(entry, refresh = false) {
213
+ async credential(entry, refresh = false, signal) {
214
+ signal?.throwIfAborted();
138
215
  if (!this.valid(entry))
139
216
  throw new Error('model_access_denied');
140
- if (!refresh && entry.credential && Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
217
+ if (!refresh &&
218
+ entry.credential &&
219
+ Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
141
220
  return entry.credential;
221
+ // A model request owns its renewal cancellation; it must not abort another caller's renewal.
222
+ if (signal)
223
+ return this.renew(entry, signal);
142
224
  let pending = this.pending.get(entry);
143
225
  if (!pending) {
144
226
  pending = this.renew(entry).finally(() => this.pending.delete(entry));
@@ -146,34 +228,76 @@ export class WorkspaceModelBroker {
146
228
  }
147
229
  return pending;
148
230
  }
149
- async renew(entry) {
231
+ async renew(entry, signal) {
150
232
  const config = this.state.config;
151
233
  const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.modelCredentials);
152
- const response = await fetch(endpoint, {
153
- method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
154
- headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
155
- body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
156
- runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
157
- ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
158
- ...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
159
- ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
160
- ...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}) }),
161
- });
162
- if (!response.ok) {
163
- const error = await response.json().catch(() => ({}));
164
- throw new Error(['model_config_required', 'model_config_denied', 'reasoning_effort_denied', 'reasoning_effort_unsupported'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
234
+ const timeout = new AbortController();
235
+ const timer = setTimeout(() => timeout.abort(new ModelBrokerRequestError(504, 'model_credential_timeout')), 10_000);
236
+ timer.unref();
237
+ try {
238
+ const response = await fetch(endpoint, {
239
+ method: 'POST',
240
+ redirect: 'error',
241
+ signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
242
+ headers: {
243
+ authorization: `Bearer ${config.controlToken}`,
244
+ 'content-type': 'application/json',
245
+ 'playcraft-env': config.environment,
246
+ ...(this.capture.supported ? { 'x-playcraft-model-capture-protocol': '1' } : {}),
247
+ },
248
+ body: JSON.stringify({
249
+ runtimeId: config.runtimeId,
250
+ generation: config.generation,
251
+ runtimeEpoch: entry.epoch,
252
+ workspaceId: entry.workspaceId,
253
+ ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
254
+ ...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
255
+ ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
256
+ ...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}),
257
+ }),
258
+ });
259
+ if (!response.ok) {
260
+ const error = (await response.json().catch(() => ({})));
261
+ signal?.throwIfAborted();
262
+ timeout.signal.throwIfAborted();
263
+ throw new ModelBrokerRequestError(response.status, [
264
+ 'model_config_required',
265
+ 'model_config_denied',
266
+ 'reasoning_effort_denied',
267
+ 'reasoning_effort_unsupported',
268
+ ].includes(error.reason ?? '')
269
+ ? error.reason
270
+ : 'model_credential_unavailable');
271
+ }
272
+ const parsed = credentialSchema.safeParse(await response.json());
273
+ if (!parsed.success)
274
+ throw new ModelBrokerRequestError(502, 'model_credential_invalid');
275
+ const credential = parsed.data;
276
+ const claims = credential.claims;
277
+ if (!this.valid(entry) ||
278
+ claims.workspaceId !== entry.workspaceId ||
279
+ claims.runtimeId !== config.runtimeId ||
280
+ claims.userId !== config.userId ||
281
+ claims.environment !== config.environment ||
282
+ claims.generation !== config.generation ||
283
+ claims.runtimeEpoch !== entry.epoch ||
284
+ (entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId) ||
285
+ Date.parse(credential.expiresAt) <= Date.now() + 1000)
286
+ throw new ModelBrokerRequestError(403, 'model_credential_scope_mismatch');
287
+ entry.modelConfigId = credential.configId;
288
+ entry.credential = credential;
289
+ return credential;
290
+ }
291
+ catch (error) {
292
+ signal?.throwIfAborted();
293
+ timeout.signal.throwIfAborted();
294
+ if (error instanceof ModelBrokerRequestError)
295
+ throw error;
296
+ throw new ModelBrokerRequestError(503, 'model_credential_unavailable', error);
297
+ }
298
+ finally {
299
+ clearTimeout(timer);
165
300
  }
166
- const credential = credentialSchema.parse(await response.json());
167
- const claims = credential.claims;
168
- if (!this.valid(entry) || claims.workspaceId !== entry.workspaceId || claims.runtimeId !== config.runtimeId
169
- || claims.userId !== config.userId || claims.environment !== config.environment
170
- || claims.generation !== config.generation || claims.runtimeEpoch !== entry.epoch
171
- || (entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId)
172
- || Date.parse(credential.expiresAt) <= Date.now() + 1000)
173
- throw new Error('model_credential_scope_mismatch');
174
- entry.modelConfigId = credential.configId;
175
- entry.credential = credential;
176
- return credential;
177
301
  }
178
302
  async forward(request, response) {
179
303
  const receivedMark = performance.now();
@@ -182,7 +306,19 @@ export class WorkspaceModelBroker {
182
306
  const abort = new AbortController();
183
307
  this.requests.add(abort);
184
308
  let activeRoute;
185
- const cancel = () => abort.abort();
309
+ let capturedRequest;
310
+ let capturedResponse;
311
+ let requestEnded = false;
312
+ let diagnostics;
313
+ let failure;
314
+ let deadlineTimer;
315
+ let credentialTimer;
316
+ const cancel = () => {
317
+ if (response.writableFinished)
318
+ return;
319
+ diagnostics?.abort('downstream_closed');
320
+ abort.abort();
321
+ };
186
322
  response.once('close', cancel);
187
323
  try {
188
324
  const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context|skill-discovery\/(?:match|selection|artifact|activate))$/.exec(request.url ?? '');
@@ -194,10 +330,21 @@ export class WorkspaceModelBroker {
194
330
  const contextRequest = match?.[2] === 'execution-context';
195
331
  const referencesRequest = match?.[2] === 'remix-references';
196
332
  const skillsRequest = match?.[2]?.startsWith('skill-discovery/');
197
- const expected = Buffer.from(route ? `Bearer ${generationRequest || contextRequest ? route.generationCapability
198
- : referencesRequest ? route.referencesCapability : skillsRequest ? route.skillsCapability : route.capability}` : '');
199
- if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
200
- || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
333
+ const expected = Buffer.from(route
334
+ ? `Bearer ${generationRequest || contextRequest
335
+ ? route.generationCapability
336
+ : referencesRequest
337
+ ? route.referencesCapability
338
+ : skillsRequest
339
+ ? route.skillsCapability
340
+ : route.capability}`
341
+ : '');
342
+ if (request.method !== 'POST' ||
343
+ !entry ||
344
+ !expected.length ||
345
+ presented.length !== expected.length ||
346
+ !timingSafeEqual(presented, expected) ||
347
+ !this.valid(entry)) {
201
348
  response.writeHead(403);
202
349
  response.end();
203
350
  return;
@@ -239,13 +386,50 @@ export class WorkspaceModelBroker {
239
386
  await this.skills.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected, action);
240
387
  return;
241
388
  }
389
+ const modelCallId = randomUUID();
390
+ diagnostics = new ModelBrokerDiagnostics({
391
+ environment: this.state.config.environment,
392
+ runtimeId: this.state.config.runtimeId,
393
+ generation: this.state.config.generation,
394
+ runtimeEpoch: entry.epoch,
395
+ workspaceId: entry.workspaceId,
396
+ sessionId: entry.sessionId,
397
+ clientRequestId: entry.clientRequestId,
398
+ configId: entry.modelConfigId,
399
+ modelCallId,
400
+ requestBudgetMs: this.requestBudgetMs,
401
+ });
402
+ const expire = () => {
403
+ if (abort.signal.aborted)
404
+ return;
405
+ diagnostics?.abort('deadline_exceeded');
406
+ abort.abort(new ModelBrokerRequestError(504, 'model_request_deadline_exceeded'));
407
+ };
408
+ const checkDeadline = () => {
409
+ if (performance.now() - receivedMark >= this.requestBudgetMs)
410
+ expire();
411
+ abort.signal.throwIfAborted();
412
+ };
413
+ deadlineTimer = setTimeout(expire, Math.max(0, this.requestBudgetMs - (performance.now() - receivedMark)));
414
+ deadlineTimer.unref();
242
415
  const rejectOversizedBody = () => {
243
- response.writeHead(413, { 'content-type': 'application/json', 'cache-control': 'no-store' });
244
- response.end(JSON.stringify({ error: { message: 'model_request_too_large',
245
- type: 'invalid_request_error', code: 'model_request_too_large', maxBytes: MAX_MODEL_REQUEST_BYTES } }));
416
+ response.writeHead(413, {
417
+ 'content-type': 'application/json',
418
+ 'cache-control': 'no-store',
419
+ });
420
+ response.end(JSON.stringify({
421
+ error: {
422
+ message: 'model_request_too_large',
423
+ type: 'invalid_request_error',
424
+ code: 'model_request_too_large',
425
+ maxBytes: MAX_MODEL_REQUEST_BYTES,
426
+ },
427
+ }));
246
428
  };
247
429
  const declaredLength = request.headers['content-length'];
248
- if (declaredLength !== undefined && /^\d+$/.test(declaredLength) && Number(declaredLength) > MAX_MODEL_REQUEST_BYTES) {
430
+ if (declaredLength !== undefined &&
431
+ /^\d+$/.test(declaredLength) &&
432
+ Number(declaredLength) > MAX_MODEL_REQUEST_BYTES) {
249
433
  rejectOversizedBody();
250
434
  return;
251
435
  }
@@ -254,23 +438,52 @@ export class WorkspaceModelBroker {
254
438
  active.add(abort);
255
439
  this.sessionRequests.set(route, active);
256
440
  const credentialMark = performance.now();
257
- const credential = await this.credential(entry);
441
+ const credential = await this.credential(entry, false, abort.signal);
442
+ checkDeadline();
258
443
  const credentialWaitMs = elapsed(credentialMark);
444
+ const candidateOrigin = credential.claims.traceOrigin;
445
+ const origin = candidateOrigin?.sessionId === entry.sessionId &&
446
+ candidateOrigin?.clientRequestId === entry.clientRequestId
447
+ ? candidateOrigin
448
+ : undefined;
449
+ diagnostics.bind({
450
+ configId: credential.configId,
451
+ runtimeOperationId: origin?.runtimeOperationId,
452
+ });
453
+ const captured = origin ? this.capture.begin(modelCallId, credential) : undefined;
454
+ capturedRequest = captured?.artifact('codex_request', request.headers['content-type'] ?? 'application/json');
455
+ capturedResponse = captured?.artifact('codex_response');
456
+ if (capturedResponse)
457
+ captureSubmittedResponse(response, capturedResponse);
458
+ diagnostics.enter('request_body');
259
459
  const requestReadMark = performance.now();
260
460
  const chunks = [];
261
461
  let size = 0;
262
- for await (const chunk of request) {
462
+ await readModelRequestBody(request, abort.signal, (chunk) => {
463
+ diagnostics?.requestChunk(chunk.length);
464
+ capturedRequest?.append(chunk);
263
465
  size += chunk.length;
264
466
  if (size > MAX_MODEL_REQUEST_BYTES) {
265
- rejectOversizedBody();
266
- return;
467
+ throw new ModelBrokerRequestError(413, 'model_request_too_large');
267
468
  }
268
469
  chunks.push(Buffer.from(chunk));
269
- }
470
+ });
471
+ checkDeadline();
270
472
  const requestReadMs = elapsed(requestReadMark);
473
+ capturedRequest?.finish({ sourceEnd: 'eof', protocolTerminal: 'not_applicable' });
474
+ requestEnded = true;
475
+ diagnostics.enter('prepare');
271
476
  if (!this.valid(entry))
272
477
  throw new Error('model_access_denied');
273
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
478
+ let body;
479
+ try {
480
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
481
+ }
482
+ catch {
483
+ checkDeadline();
484
+ throw new ModelBrokerRequestError(400, 'model_request_invalid_json');
485
+ }
486
+ checkDeadline();
274
487
  if (!body || typeof body !== 'object' || Array.isArray(body)) {
275
488
  response.writeHead(400);
276
489
  response.end();
@@ -288,41 +501,82 @@ export class WorkspaceModelBroker {
288
501
  const reasoning = body.reasoning;
289
502
  const modelName = selected.model.split('/').pop() ?? selected.model;
290
503
  const effort = entry.reasoningEffort === 'max' && /^o\d|gpt-[5-9]/i.test(modelName)
291
- ? 'xhigh' : entry.reasoningEffort;
504
+ ? 'xhigh'
505
+ : entry.reasoningEffort;
292
506
  body.reasoning = {
293
- ...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning) ? reasoning : {}),
507
+ ...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)
508
+ ? reasoning
509
+ : {}),
294
510
  effort,
295
511
  };
296
512
  }
297
- const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
513
+ credentialTimer = setTimeout(() => {
514
+ if (abort.signal.aborted)
515
+ return;
516
+ diagnostics?.abort('credential_expired');
517
+ abort.abort(new ModelBrokerRequestError(401, 'model_credential_expired'));
518
+ }, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
519
+ credentialTimer.unref();
298
520
  const dispatchedAt = new Date().toISOString();
299
521
  const dispatchedMark = performance.now();
300
522
  const beforeDispatchMs = elapsed(receivedMark);
301
- const candidateOrigin = credential.claims.traceOrigin;
302
- const origin = candidateOrigin?.sessionId === entry.sessionId && candidateOrigin?.clientRequestId === entry.clientRequestId
303
- ? candidateOrigin : undefined;
304
- const broker = { receivedAt, dispatchedAt,
305
- credentialWaitMs, requestReadMs, beforeDispatchMs };
523
+ const broker = {
524
+ receivedAt,
525
+ dispatchedAt,
526
+ credentialWaitMs,
527
+ requestReadMs,
528
+ beforeDispatchMs,
529
+ };
306
530
  let attemptId;
307
531
  let outcome = 'error';
308
532
  try {
533
+ const requestBody = JSON.stringify(body);
534
+ checkDeadline();
535
+ diagnostics.enter('upstream_headers');
309
536
  const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
310
- method: 'POST', redirect: 'error', signal: abort.signal,
311
- headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json',
312
- ...(origin ? { 'x-playcraft-model-journey': JSON.stringify({ version: 1,
313
- runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId, clientRequestId: origin.clientRequestId,
314
- ...(entry.codexDispatchedAt ? { codexDispatchedAt: entry.codexDispatchedAt } : {}),
315
- brokerReceivedAt: receivedAt, brokerDispatchedAt: dispatchedAt, credentialWaitMs, requestReadMs, beforeDispatchMs,
316
- ...(entry.codexDispatchMark === undefined ? {} : { codexDispatchToBrokerMs: Math.max(0, Math.round(receivedMark - entry.codexDispatchMark)) }),
317
- }) } : {}),
318
- ...(selected?.feedback ? {
319
- 'x-playcraft-cloud-session-id': selected.feedback.sessionId,
320
- 'x-playcraft-client-request-id': selected.feedback.clientRequestId,
321
- } : {}) },
322
- body: JSON.stringify(body),
537
+ method: 'POST',
538
+ redirect: 'error',
539
+ signal: abort.signal,
540
+ headers: {
541
+ authorization: `Bearer ${credential.token}`,
542
+ 'content-type': 'application/json',
543
+ 'x-playcraft-model-call-id': modelCallId,
544
+ 'x-request-id': modelCallId,
545
+ ...(origin
546
+ ? {
547
+ 'x-playcraft-model-journey': JSON.stringify({
548
+ version: 1,
549
+ runtimeOperationId: origin.runtimeOperationId,
550
+ sessionId: origin.sessionId,
551
+ clientRequestId: origin.clientRequestId,
552
+ ...(entry.codexDispatchedAt
553
+ ? { codexDispatchedAt: entry.codexDispatchedAt }
554
+ : {}),
555
+ brokerReceivedAt: receivedAt,
556
+ brokerDispatchedAt: dispatchedAt,
557
+ credentialWaitMs,
558
+ requestReadMs,
559
+ beforeDispatchMs,
560
+ ...(entry.codexDispatchMark === undefined
561
+ ? {}
562
+ : {
563
+ codexDispatchToBrokerMs: Math.max(0, Math.round(receivedMark - entry.codexDispatchMark)),
564
+ }),
565
+ }),
566
+ }
567
+ : {}),
568
+ ...(selected?.feedback
569
+ ? {
570
+ 'x-playcraft-cloud-session-id': selected.feedback.sessionId,
571
+ 'x-playcraft-client-request-id': selected.feedback.clientRequestId,
572
+ }
573
+ : {}),
574
+ },
575
+ body: requestBody,
323
576
  });
324
577
  broker.responseHeadersAt = new Date().toISOString();
325
578
  broker.proxyResponseHeadersMs = elapsed(dispatchedMark);
579
+ diagnostics.headers(upstream.status, upstream.headers);
326
580
  const reference = upstream.headers.get('x-model-attempt-id');
327
581
  if (reference && z.string().uuid().safeParse(reference).success)
328
582
  attemptId = reference;
@@ -334,24 +588,51 @@ export class WorkspaceModelBroker {
334
588
  route.lastAdmissionReason = undefined;
335
589
  else if (admissionReason)
336
590
  route.lastAdmissionReason = admissionReason;
337
- response.writeHead(upstream.status, {
338
- 'content-type': upstream.headers.get('content-type') ?? 'application/json',
339
- 'cache-control': 'no-store',
340
- ...(admissionReason ? { 'x-model-admission-reason': admissionReason } : {}),
341
- ...(retryAfter ? { 'retry-after': retryAfter } : {}),
342
- });
343
- if (upstream.body)
344
- await pipeline(Readable.fromWeb(upstream.body), new Transform({
591
+ const forwardHeaders = () => {
592
+ if (response.headersSent)
593
+ return;
594
+ response.writeHead(upstream.status, {
595
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json',
596
+ 'cache-control': 'no-store',
597
+ ...(admissionReason ? { 'x-model-admission-reason': admissionReason } : {}),
598
+ ...(retryAfter ? { 'retry-after': retryAfter } : {}),
599
+ });
600
+ };
601
+ const requestDiagnostics = diagnostics;
602
+ if (upstream.body) {
603
+ const observed = new Transform({
345
604
  transform(chunk, _encoding, callback) {
605
+ forwardHeaders();
606
+ requestDiagnostics.responseChunk(chunk.length);
607
+ capturedResponse?.upstreamObserved(chunk.length);
346
608
  if (chunk.length && broker.firstBodyByteAt === undefined) {
347
609
  broker.firstBodyByteAt = new Date().toISOString();
348
610
  broker.proxyFirstBodyByteMs = elapsed(dispatchedMark);
349
611
  }
350
612
  callback(null, chunk);
351
613
  },
352
- }), response);
353
- else
614
+ });
615
+ // Keep an uncommitted HTTP response writable so a pre-body deadline can return 504.
616
+ const drained = finished(response, { cleanup: true });
617
+ const source = Readable.fromWeb(upstream.body);
618
+ const transferred = pipeline(source, observed, {
619
+ signal: abort.signal,
620
+ });
621
+ observed.once('end', forwardHeaders);
622
+ observed.pipe(response);
623
+ try {
624
+ await Promise.all([transferred, drained]);
625
+ }
626
+ catch (error) {
627
+ source.destroy();
628
+ observed.destroy();
629
+ throw error;
630
+ }
631
+ }
632
+ else {
633
+ forwardHeaders();
354
634
  response.end();
635
+ }
355
636
  outcome = 'completed';
356
637
  }
357
638
  catch (error) {
@@ -359,26 +640,63 @@ export class WorkspaceModelBroker {
359
640
  throw error;
360
641
  }
361
642
  finally {
362
- clearTimeout(expires);
643
+ clearTimeout(credentialTimer);
363
644
  broker.completedAt = new Date().toISOString();
364
645
  broker.proxyResponseDurationMs = elapsed(dispatchedMark);
365
646
  if (origin && attemptId) {
366
647
  // Telemetry is sent after transfer and cannot delay or change the Codex model response.
367
- void this.reportObservation(credential.token, { version: 1, attemptId,
368
- runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId,
369
- clientRequestId: origin.clientRequestId, broker, outcome });
648
+ void this.reportObservation(credential.token, {
649
+ version: 1,
650
+ attemptId,
651
+ runtimeOperationId: origin.runtimeOperationId,
652
+ sessionId: origin.sessionId,
653
+ clientRequestId: origin.clientRequestId,
654
+ broker,
655
+ outcome,
656
+ });
370
657
  }
371
658
  }
372
659
  }
373
- catch {
374
- if (!response.headersSent) {
375
- response.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
376
- response.end(JSON.stringify({ error: { message: 'model_credential_unavailable', type: 'server_error' } }));
660
+ catch (error) {
661
+ if (deadlineTimer &&
662
+ !abort.signal.aborted &&
663
+ performance.now() - receivedMark >= this.requestBudgetMs) {
664
+ diagnostics?.abort('deadline_exceeded');
665
+ abort.abort(new ModelBrokerRequestError(504, 'model_request_deadline_exceeded'));
666
+ }
667
+ failure = error;
668
+ const rejected = modelBrokerFailure(error, abort.signal);
669
+ if (!response.headersSent && !response.destroyed) {
670
+ response.writeHead(rejected.status, {
671
+ 'content-type': 'application/json',
672
+ 'cache-control': 'no-store',
673
+ ...(!request.complete ? { connection: 'close' } : {}),
674
+ });
675
+ response.end(JSON.stringify({
676
+ error: {
677
+ message: rejected.code,
678
+ type: rejected.status === 499
679
+ ? 'request_cancelled'
680
+ : rejected.status < 500
681
+ ? 'invalid_request_error'
682
+ : 'server_error',
683
+ code: rejected.code,
684
+ ...(rejected.status === 413 ? { maxBytes: MAX_MODEL_REQUEST_BYTES } : {}),
685
+ },
686
+ }));
377
687
  }
378
688
  else
379
689
  response.destroy();
380
690
  }
381
691
  finally {
692
+ clearTimeout(deadlineTimer);
693
+ clearTimeout(credentialTimer);
694
+ diagnostics?.finish(response.statusCode, response.writableFinished, abort.signal.aborted, failure);
695
+ if (!requestEnded)
696
+ capturedRequest?.finish({
697
+ sourceEnd: abort.signal.aborted ? 'aborted' : 'io_error',
698
+ protocolTerminal: 'unknown',
699
+ });
382
700
  response.off('close', cancel);
383
701
  this.requests.delete(abort);
384
702
  if (activeRoute) {
@@ -392,12 +710,17 @@ export class WorkspaceModelBroker {
392
710
  async reportObservation(token, input) {
393
711
  try {
394
712
  const response = await fetch(platformEndpoint(this.state.config.publicBaseUrl, '/api/llm-proxy/runtime-observations'), {
395
- method: 'POST', redirect: 'error', signal: AbortSignal.timeout(3_000),
396
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(input),
713
+ method: 'POST',
714
+ redirect: 'error',
715
+ signal: AbortSignal.timeout(3_000),
716
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
717
+ body: JSON.stringify(input),
397
718
  });
398
719
  await response.body?.cancel();
399
720
  }
400
- catch { /* Missing observations remain unknown; never replay a model request for telemetry. */ }
721
+ catch {
722
+ /* Missing observations remain unknown; never replay a model request for telemetry. */
723
+ }
401
724
  }
402
725
  cancelSession(cwd, sessionId) {
403
726
  const routeKey = `${/^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
@@ -424,7 +747,8 @@ export class WorkspaceModelBroker {
424
747
  request.abort();
425
748
  if (server) {
426
749
  server.closeAllConnections();
427
- await new Promise(resolve => server.close(() => resolve()));
750
+ await new Promise((resolve) => server.close(() => resolve()));
428
751
  }
752
+ await this.capture.close();
429
753
  }
430
754
  }