@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.
- package/dist/commands/tools-generation.js +102 -46
- package/dist/commands/tools-utils.js +15 -17
- package/dist/utils/image-asset-context.js +92 -42
- package/dist/workspace-runtime/codex/capture-isolation.js +10 -0
- package/dist/workspace-runtime/codex/config-toml.js +3 -2
- package/dist/workspace-runtime/codex/model-broker-diagnostics.js +149 -0
- package/dist/workspace-runtime/codex/model-broker-request.js +59 -0
- package/dist/workspace-runtime/codex/model-broker.js +445 -121
- package/dist/workspace-runtime/codex/model-capture.js +724 -0
- package/package.json +4 -3
- package/templates/preview-runtime/v1/manifest.json +1 -1
- package/templates/preview-runtime/v1/runtime.mjs +1 -1
|
@@ -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),
|
|
15
|
-
|
|
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(),
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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) ??
|
|
77
|
-
|
|
78
|
-
|
|
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 = {
|
|
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 = {
|
|
88
|
-
|
|
89
|
-
|
|
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 = {
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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',
|
|
116
|
-
|
|
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 &&
|
|
124
|
-
|
|
125
|
-
|
|
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))
|
|
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 ||
|
|
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 &&
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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, {
|
|
244
|
-
|
|
245
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
504
|
+
? 'xhigh'
|
|
505
|
+
: entry.reasoningEffort;
|
|
292
506
|
body.reasoning = {
|
|
293
|
-
...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)
|
|
507
|
+
...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)
|
|
508
|
+
? reasoning
|
|
509
|
+
: {}),
|
|
294
510
|
effort,
|
|
295
511
|
};
|
|
296
512
|
}
|
|
297
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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',
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
...(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
})
|
|
353
|
-
|
|
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(
|
|
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, {
|
|
368
|
-
|
|
369
|
-
|
|
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 (
|
|
375
|
-
|
|
376
|
-
|
|
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',
|
|
396
|
-
|
|
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 {
|
|
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
|
}
|