@mono-agent/operator-adapter 0.20.11 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tui/errors.d.ts +1 -1
- package/dist/tui/errors.d.ts.map +1 -1
- package/dist/tui/errors.js.map +1 -1
- package/dist/tui/index.d.ts +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/server.d.ts +85 -9
- package/dist/tui/server.d.ts.map +1 -1
- package/dist/tui/server.js +766 -20
- package/dist/tui/server.js.map +1 -1
- package/package.json +3 -3
package/dist/tui/server.js
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { isAbsolute } from "node:path";
|
|
4
|
-
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, MCP_APP_RESOURCE_MIME_TYPE, MCP_APP_SUPPORTED_VERSIONS, MAX_AGENT_REPLY_PARTS, BoundedHttpResponseWriter, agentAttachmentKindFromMimeType, closeServerBounded, createChannelUserCancelReason, decodeAgentAttachmentText, isAgentResponseCancelledError, parseProcessJobProjection, parseProcessJobProjections, serializeAgentStreamFrame, } from "@mono-agent/agent-contracts";
|
|
5
|
-
import { assertSafeBind, bearerTokensEqual, hostForUrl, isLoopbackHost, listen, normalizeOptionalString, parseCronOperatorOverview, parseCronOperatorRunDetail, parseCronOperatorRunPage, readAuthorizationBearer, } from "@mono-agent/agent-contracts";
|
|
4
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_CONTEXT_IMPORT_MAX_CONVERSATION_ID_BYTES, AGENT_CONTEXT_IMPORT_MAX_IDEMPOTENCY_KEY_BYTES, AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES, AGENT_CONTEXT_IMPORT_VERSION, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, MCP_APP_RESOURCE_MIME_TYPE, MCP_APP_SUPPORTED_VERSIONS, MAX_AGENT_REPLY_PARTS, MAX_PROVIDER_AUTH_BODY_BYTES, ProviderAuthOperationError, BoundedHttpResponseWriter, agentAttachmentKindFromMimeType, closeServerBounded, createChannelUserCancelReason, decodeAgentAttachmentText, isAgentResponseCancelledError, parseMonitorProjection, parseMonitorProjections, parseProcessJobProjection, parseProcessJobProjections, parseProviderAuthSessionInput, parseProviderAuthSessionSnapshot, parseProviderAuthSessionStartInput, parseProviderAuthCheckSessionSnapshot, parseProviderAuthCheckStartInput, parseProviderAuthStatusSnapshot, serializeAgentStreamFrame, } from "@mono-agent/agent-contracts";
|
|
5
|
+
import { assertSafeBind, bearerTokensEqual, hostForUrl, isLoopbackHost, listen, MAX_INFO_BODY_BYTES, normalizeOptionalString, parseCronOperatorOverview, parseCronOperatorRunDetail, parseCronOperatorRunPage, readAuthorizationBearer, } from "@mono-agent/agent-contracts";
|
|
6
6
|
import express, {} from "express";
|
|
7
|
+
const TARGET_WAITER_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
8
|
+
const MAX_TARGET_WAITERS_PER_OPERATION = 100;
|
|
9
|
+
const MAX_TARGET_WAITERS_GLOBAL = 1_000;
|
|
10
|
+
function liveInputTargetKey(conversationId, turnId) {
|
|
11
|
+
return `${conversationId.length}:${conversationId}${turnId}`;
|
|
12
|
+
}
|
|
13
|
+
function settleLiveInputOffer(res, offer) {
|
|
14
|
+
if (offer.status === "unavailable") {
|
|
15
|
+
res.status(200).json(offer);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
void offer.settled.then((settlement) => {
|
|
19
|
+
if (!res.writableEnded)
|
|
20
|
+
res.status(200).json(settlement);
|
|
21
|
+
}).catch(() => {
|
|
22
|
+
if (!res.writableEnded)
|
|
23
|
+
res.status(200).json({ status: "uncertain", reason: "delivery_uncertain" });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
7
26
|
import { DEFAULT_BASE_PATH, DEFAULT_HOST, DEFAULT_PORT, MAX_FRAME_BYTES, TUI_WIRE_SCHEMA } from "./constants.js";
|
|
8
27
|
import { CronOperatorError, MAX_CRON_OPERATOR_RESPONSE_BYTES, MAX_CRON_OPERATOR_RUN_PAGE, } from "./cron.js";
|
|
9
28
|
import { TuiAdapterError } from "./errors.js";
|
|
10
29
|
const MAX_TURN_BODY_BYTES = 96 * 1024 * 1024;
|
|
30
|
+
const MAX_MODEL_CATALOG_PAGE_SIZE = 200;
|
|
31
|
+
const DEFAULT_MODEL_CATALOG_PAGE_SIZE = 100;
|
|
32
|
+
const MAX_MODEL_CATALOG_RESPONSE_BYTES = 1024 * 1024;
|
|
33
|
+
const MAX_MODEL_CATALOG_PROVIDER_BYTES = 256;
|
|
34
|
+
const MAX_MODEL_CATALOG_QUERY_BYTES = 512;
|
|
35
|
+
const MAX_MODEL_CATALOG_CURSOR_BYTES = 4 * 1024;
|
|
11
36
|
const MAX_VERBATIM_BODY_BYTES = 2 * 1024 * 1024;
|
|
37
|
+
// `{"idempotencyKey":"","text":""}` is 31 bytes. Each legal decoded
|
|
38
|
+
// text/key byte can require a six-byte JSON escape; 31 + 6*(32768+512).
|
|
39
|
+
const MAX_CONTEXT_IMPORT_BODY_BYTES = 199_711;
|
|
12
40
|
const MAX_VERBATIM_TEXT_CHARACTERS = 200_000;
|
|
13
41
|
const MAX_VERBATIM_TEXT_BYTES = 1024 * 1024;
|
|
14
42
|
const MAX_LIVE_INPUT_BODY_BYTES = 32 * 1024;
|
|
@@ -38,6 +66,10 @@ export async function startTuiAdapter(options) {
|
|
|
38
66
|
if ((options.processJobs === undefined) !== (processJobsBearer === undefined)) {
|
|
39
67
|
throw new TuiAdapterError("invalid_config", "processJobs and processJobsBearer must be configured together.");
|
|
40
68
|
}
|
|
69
|
+
const monitorsBearer = normalizeOptionalString(options.monitorsBearer);
|
|
70
|
+
if ((options.monitors === undefined) !== (monitorsBearer === undefined)) {
|
|
71
|
+
throw new TuiAdapterError("invalid_config", "monitors and monitorsBearer must be configured together.");
|
|
72
|
+
}
|
|
41
73
|
if (options.requestToolEnvironment !== undefined && !isLoopbackHost(host)) {
|
|
42
74
|
throw new TuiAdapterError("unsafe_host", "Request tool environment requires a loopback-only TUI adapter bind.", { host });
|
|
43
75
|
}
|
|
@@ -45,12 +77,25 @@ export async function startTuiAdapter(options) {
|
|
|
45
77
|
const app = express();
|
|
46
78
|
const server = createServer(app);
|
|
47
79
|
const activeTurns = new Set();
|
|
80
|
+
const liveInputTargets = new Map();
|
|
81
|
+
let pendingTargetWaiters = 0;
|
|
82
|
+
const settleTarget = (target, runId) => {
|
|
83
|
+
target.state = runId === undefined ? "closed" : "ready";
|
|
84
|
+
if (runId === undefined)
|
|
85
|
+
delete target.runId;
|
|
86
|
+
else
|
|
87
|
+
target.runId = runId;
|
|
88
|
+
for (const waiter of [...target.waiters])
|
|
89
|
+
waiter(runId);
|
|
90
|
+
};
|
|
48
91
|
let stopping = false;
|
|
49
92
|
let stopPromise;
|
|
50
93
|
const infoPath = `${basePath}/v1/info`;
|
|
94
|
+
const modelsPath = `${basePath}/v1/models`;
|
|
51
95
|
const turnsPath = `${basePath}/v1/turns`;
|
|
52
96
|
const cancelPath = `${basePath}/v1/conversations/:conversationId/cancel`;
|
|
53
97
|
const verbatimPath = `${basePath}/v1/conversations/:conversationId/verbatim`;
|
|
98
|
+
const contextImportPath = `${basePath}/v1/conversations/:conversationId/context-imports`;
|
|
54
99
|
const liveInputPath = `${basePath}/v1/conversations/:conversationId/live-input`;
|
|
55
100
|
const replyArtifactPath = `${basePath}/v1/conversations/:conversationId/reply-artifacts/:artifactId`;
|
|
56
101
|
const mcpAppPath = `${basePath}/v1/conversations/:conversationId/mcp-apps/:invocationId`;
|
|
@@ -66,6 +111,15 @@ export async function startTuiAdapter(options) {
|
|
|
66
111
|
const jobsPath = `${basePath}/v1/jobs`;
|
|
67
112
|
const jobPath = `${basePath}/v1/jobs/:jobId`;
|
|
68
113
|
const jobCancelPath = `${basePath}/v1/jobs/:jobId/cancel`;
|
|
114
|
+
const monitorsPath = `${basePath}/v1/monitors`;
|
|
115
|
+
const monitorPath = `${basePath}/v1/monitors/:monitorId`;
|
|
116
|
+
const monitorCancelPath = `${basePath}/v1/monitors/:monitorId/cancel`;
|
|
117
|
+
const providerAuthPath = `${basePath}/v1/provider-auth`;
|
|
118
|
+
const providerAuthSessionsPath = `${providerAuthPath}/sessions`;
|
|
119
|
+
const providerAuthSessionPath = `${providerAuthSessionsPath}/:sessionId`;
|
|
120
|
+
const providerAuthInputPath = `${providerAuthSessionPath}/input`;
|
|
121
|
+
const providerAuthChecksPath = `${providerAuthPath}/checks`;
|
|
122
|
+
const providerAuthCheckPath = `${providerAuthChecksPath}/:checkId`;
|
|
69
123
|
app.get(infoPath, (req, res) => {
|
|
70
124
|
if (!authorize(req, res, apiKey)) {
|
|
71
125
|
return;
|
|
@@ -86,7 +140,7 @@ export async function startTuiAdapter(options) {
|
|
|
86
140
|
});
|
|
87
141
|
void Promise.all([resolveInfo(options.info), cronInfo])
|
|
88
142
|
.then(([info, cronState]) => {
|
|
89
|
-
res
|
|
143
|
+
sendBoundedInfo(res, {
|
|
90
144
|
schema: TUI_WIRE_SCHEMA,
|
|
91
145
|
pid: process.pid,
|
|
92
146
|
capabilities: {
|
|
@@ -105,7 +159,14 @@ export async function startTuiAdapter(options) {
|
|
|
105
159
|
}
|
|
106
160
|
: {}),
|
|
107
161
|
...(typeof options.responder.offerLiveInput === "function" ? { liveInput: true } : {}),
|
|
162
|
+
...(typeof options.responder.offerLiveInput === "function"
|
|
163
|
+
&& options.responder.liveInputOwnership?.version === 1
|
|
164
|
+
? { liveInputTargeting: { version: 1 } }
|
|
165
|
+
: {}),
|
|
108
166
|
...(typeof options.responder.deliverVerbatim === "function" ? { historyAppend: true } : {}),
|
|
167
|
+
...(typeof options.responder.importContext === "function"
|
|
168
|
+
? { contextImport: { version: AGENT_CONTEXT_IMPORT_VERSION, maxTextBytes: AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES } }
|
|
169
|
+
: {}),
|
|
109
170
|
...(options.interaction === undefined ? {} : { askUser: true }),
|
|
110
171
|
...(typeof options.interaction?.getAsk === "function" ? { askById: true } : {}),
|
|
111
172
|
...(cronState.kind === "absent"
|
|
@@ -122,7 +183,19 @@ export async function startTuiAdapter(options) {
|
|
|
122
183
|
},
|
|
123
184
|
}),
|
|
124
185
|
...(options.processJobs === undefined || processJobsBearer === undefined ? {} : { jobs: true }),
|
|
186
|
+
...(options.monitors === undefined || monitorsBearer === undefined ? {} : { monitors: true }),
|
|
125
187
|
...(options.requestToolEnvironment === undefined ? {} : { toolEnvironment: true }),
|
|
188
|
+
...(options.modelCatalog === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { modelCatalog: { version: 1, maxPageSize: MAX_MODEL_CATALOG_PAGE_SIZE } }),
|
|
191
|
+
...(options.providerAuth === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: {
|
|
194
|
+
providerAuth: {
|
|
195
|
+
version: 1,
|
|
196
|
+
...(options.providerAuth.checks === undefined ? {} : { checks: { version: 1 } }),
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
126
199
|
},
|
|
127
200
|
...(info?.label === undefined ? {} : { label: info.label }),
|
|
128
201
|
...(info?.model === undefined ? {} : { model: info.model }),
|
|
@@ -132,13 +205,48 @@ export async function startTuiAdapter(options) {
|
|
|
132
205
|
? {}
|
|
133
206
|
: { modelOptions: info.modelOptions }),
|
|
134
207
|
...(info?.skills === undefined ? {} : { skills: info.skills }),
|
|
135
|
-
|
|
208
|
+
...(info?.providers === undefined || info.providers.length === 0 ? {} : { providers: info.providers }),
|
|
209
|
+
}, options.logger);
|
|
136
210
|
})
|
|
137
211
|
.catch((error) => {
|
|
138
212
|
options.logger?.error?.("TUI info provider failed.", { error: errorToMessage(error) });
|
|
139
213
|
sendJsonError(res, 500, error);
|
|
140
214
|
});
|
|
141
215
|
});
|
|
216
|
+
app.get(modelsPath, (req, res, next) => {
|
|
217
|
+
if (!authorize(req, res, apiKey))
|
|
218
|
+
return;
|
|
219
|
+
if (options.modelCatalog === undefined) {
|
|
220
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "The model catalog is unavailable."));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let request;
|
|
224
|
+
try {
|
|
225
|
+
request = normalizeModelCatalogRequest(req);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
next(error);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
let page;
|
|
232
|
+
try {
|
|
233
|
+
// The catalog is a trust boundary only in the sense that the provider is
|
|
234
|
+
// host-owned; a throwing supplier must still fail as a server error
|
|
235
|
+
// rather than silently serving an empty catalog (see the "totality"
|
|
236
|
+
// contract in the composition layer — that layer never throws).
|
|
237
|
+
page = options.modelCatalog(request);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
next(error);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
sendBoundedModelCatalog(res, page);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
next(error);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
142
250
|
app.get(jobsPath, (req, res, next) => {
|
|
143
251
|
if (!authorize(req, res, processJobsBearer))
|
|
144
252
|
return;
|
|
@@ -202,12 +310,81 @@ export async function startTuiAdapter(options) {
|
|
|
202
310
|
}
|
|
203
311
|
});
|
|
204
312
|
});
|
|
313
|
+
app.get(monitorsPath, (req, res, next) => {
|
|
314
|
+
if (!authorize(req, res, monitorsBearer))
|
|
315
|
+
return;
|
|
316
|
+
if (options.monitors === undefined || monitorsBearer === undefined) {
|
|
317
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Monitors are unavailable."));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
void options.monitors.list()
|
|
321
|
+
.then((monitors) => {
|
|
322
|
+
res.status(200).json({ monitors: parseMonitorProjections(monitors) });
|
|
323
|
+
})
|
|
324
|
+
.catch(next);
|
|
325
|
+
});
|
|
326
|
+
app.get(monitorPath, (req, res, next) => {
|
|
327
|
+
if (!authorize(req, res, monitorsBearer))
|
|
328
|
+
return;
|
|
329
|
+
if (options.monitors === undefined || monitorsBearer === undefined) {
|
|
330
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Monitors are unavailable."));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const monitorId = boundedMonitorId(req.params.monitorId);
|
|
334
|
+
if (monitorId === undefined) {
|
|
335
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A bounded monitorId is required."));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
void options.monitors.get(monitorId)
|
|
339
|
+
.then((monitor) => {
|
|
340
|
+
if (monitor === undefined) {
|
|
341
|
+
res.status(404).json({ error: { code: "monitor_not_found", message: "Monitor was not found." } });
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
res.status(200).json(parseMonitorProjection(monitor));
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
.catch(next);
|
|
348
|
+
});
|
|
349
|
+
app.post(monitorCancelPath, (req, res, next) => {
|
|
350
|
+
if (!authorize(req, res, monitorsBearer))
|
|
351
|
+
return;
|
|
352
|
+
if (options.monitors === undefined || monitorsBearer === undefined) {
|
|
353
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Monitors are unavailable."));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const monitorId = boundedMonitorId(req.params.monitorId);
|
|
357
|
+
if (monitorId === undefined) {
|
|
358
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A bounded monitorId is required."));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
void options.monitors.cancel(monitorId)
|
|
362
|
+
.then((monitor) => {
|
|
363
|
+
res.status(200).json(parseMonitorProjection(monitor));
|
|
364
|
+
})
|
|
365
|
+
.catch((error) => {
|
|
366
|
+
const code = typeof error === "object" && error !== null
|
|
367
|
+
? error.code
|
|
368
|
+
: undefined;
|
|
369
|
+
if (code === "monitor_not_found") {
|
|
370
|
+
res.status(404).json({ error: { code, message: errorToMessage(error) } });
|
|
371
|
+
}
|
|
372
|
+
else if (code === "monitor_conflict") {
|
|
373
|
+
res.status(409).json({ error: { code, message: errorToMessage(error) } });
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
next(error);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
205
380
|
// Keep the enlarged parser scoped to turn submission. 64 MiB of decoded
|
|
206
381
|
// files expands to about 85.4 MiB in base64, while info/cancel stay bodyless.
|
|
207
382
|
app.post(turnsPath, express.json({ limit: MAX_TURN_BODY_BYTES }), (req, res) => {
|
|
208
383
|
if (!authorize(req, res, apiKey)) {
|
|
209
384
|
return;
|
|
210
385
|
}
|
|
386
|
+
if (!authorizeMonitorWake(req, res, req.body?.processJobWakeDeliveryKey, monitorsBearer))
|
|
387
|
+
return;
|
|
211
388
|
void handleTurn(req, res).catch((error) => {
|
|
212
389
|
options.logger?.error?.("TUI turn failed before response.", { error: errorToMessage(error) });
|
|
213
390
|
if (!res.headersSent) {
|
|
@@ -350,6 +527,36 @@ export async function startTuiAdapter(options) {
|
|
|
350
527
|
res.status(200).json({ recorded: true, conversationId: body.conversationId });
|
|
351
528
|
}).catch(next);
|
|
352
529
|
});
|
|
530
|
+
app.post(contextImportPath, (_req, res, next) => {
|
|
531
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
532
|
+
next();
|
|
533
|
+
}, express.json({ limit: MAX_CONTEXT_IMPORT_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
534
|
+
if (!authorize(req, res, apiKey))
|
|
535
|
+
return;
|
|
536
|
+
if (typeof options.responder.importContext !== "function") {
|
|
537
|
+
sendContextImportError(res, 501, "context_import_unsupported", "This responder does not support canonical context import.", "unsupported");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
let normalized;
|
|
541
|
+
try {
|
|
542
|
+
normalized = normalizeContextImportBody(req.params.conversationId, req.body);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
next(error);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
void options.responder.importContext(normalized.conversationId, normalized.request).then((result) => {
|
|
549
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
550
|
+
if (result.status === "conflict") {
|
|
551
|
+
sendContextImportError(res, 409, "context_import_conflict", "Canonical context import conflicts with existing history.", result.reason);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
res.status(200).json({ imported: true, status: result.status, conversationId: normalized.conversationId });
|
|
555
|
+
}).catch((error) => {
|
|
556
|
+
options.logger?.error?.("TUI context import failed.", { error: errorToMessage(error) });
|
|
557
|
+
sendContextImportError(res, 500, "context_import_failed", "Canonical context import failed.", "operation_failed");
|
|
558
|
+
});
|
|
559
|
+
});
|
|
353
560
|
app.post(liveInputPath, express.json({ limit: MAX_LIVE_INPUT_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
354
561
|
if (!authorize(req, res, apiKey))
|
|
355
562
|
return;
|
|
@@ -368,14 +575,86 @@ export async function startTuiAdapter(options) {
|
|
|
368
575
|
|| (body.deliveryKey !== undefined
|
|
369
576
|
&& (typeof body.deliveryKey !== "string"
|
|
370
577
|
|| body.deliveryKey.trim().length === 0
|
|
371
|
-
|| body.deliveryKey.length > 1_024))
|
|
578
|
+
|| body.deliveryKey.length > 1_024))
|
|
579
|
+
|| (body.targetTurnId !== undefined
|
|
580
|
+
&& (typeof body.targetTurnId !== "string" || body.targetTurnId.trim().length === 0 || body.targetTurnId.length > 4_096))
|
|
581
|
+
|| (body.targetRunId !== undefined
|
|
582
|
+
&& (typeof body.targetRunId !== "string" || body.targetRunId.trim().length === 0 || body.targetRunId.length > 4_096))) {
|
|
372
583
|
next(new TuiAdapterError("invalid_request", `Live input requires id, receivedAt, and 1-${String(AGENT_LIVE_INPUT_MAX_CHARACTERS)} text characters.`));
|
|
373
584
|
return;
|
|
374
585
|
}
|
|
586
|
+
if (!authorizeMonitorWake(req, res, body.deliveryKey, monitorsBearer))
|
|
587
|
+
return;
|
|
375
588
|
if (typeof options.responder.offerLiveInput !== "function") {
|
|
376
589
|
res.status(200).json({ status: "unavailable", reason: "unsupported" });
|
|
377
590
|
return;
|
|
378
591
|
}
|
|
592
|
+
const inputId = body.id;
|
|
593
|
+
const inputText = body.text;
|
|
594
|
+
const receivedAt = body.receivedAt;
|
|
595
|
+
const targetTurnId = typeof body.targetTurnId === "string" ? body.targetTurnId : undefined;
|
|
596
|
+
const explicitRunId = typeof body.targetRunId === "string" ? body.targetRunId : undefined;
|
|
597
|
+
if (targetTurnId !== undefined) {
|
|
598
|
+
const target = liveInputTargets.get(liveInputTargetKey(conversationId, targetTurnId));
|
|
599
|
+
if (target === undefined || target.state === "closed") {
|
|
600
|
+
res.status(200).json({ status: "unavailable", reason: "inactive" });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const offerToTarget = (runId) => {
|
|
604
|
+
if (res.writableEnded || res.destroyed)
|
|
605
|
+
return;
|
|
606
|
+
if (runId === undefined || (explicitRunId !== undefined && explicitRunId !== runId)) {
|
|
607
|
+
res.status(200).json({ status: "unavailable", reason: "inactive" });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
settleLiveInputOffer(res, options.responder.offerLiveInput({
|
|
612
|
+
conversationId,
|
|
613
|
+
id: inputId,
|
|
614
|
+
text: inputText,
|
|
615
|
+
receivedAt,
|
|
616
|
+
targetRunId: runId,
|
|
617
|
+
...(typeof body.deliveryKey === "string" ? { deliveryKey: body.deliveryKey } : {}),
|
|
618
|
+
}));
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
next(error);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
if (target.state === "ready") {
|
|
625
|
+
offerToTarget(target.runId);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (target.waiters.size >= MAX_TARGET_WAITERS_PER_OPERATION || pendingTargetWaiters >= MAX_TARGET_WAITERS_GLOBAL) {
|
|
629
|
+
res.status(200).json({ status: "unavailable", reason: "full" });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
pendingTargetWaiters += 1;
|
|
633
|
+
let detached = false;
|
|
634
|
+
let timer;
|
|
635
|
+
const abort = () => { detach(); };
|
|
636
|
+
const settle = (runId) => {
|
|
637
|
+
detach();
|
|
638
|
+
offerToTarget(runId);
|
|
639
|
+
};
|
|
640
|
+
const detach = () => {
|
|
641
|
+
if (detached)
|
|
642
|
+
return;
|
|
643
|
+
detached = true;
|
|
644
|
+
if (timer !== undefined)
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
req.off("aborted", abort);
|
|
647
|
+
res.off("close", abort);
|
|
648
|
+
target.waiters.delete(settle);
|
|
649
|
+
pendingTargetWaiters -= 1;
|
|
650
|
+
};
|
|
651
|
+
target.waiters.add(settle);
|
|
652
|
+
timer = setTimeout(() => { settle(undefined); }, TARGET_WAITER_TIMEOUT_MS);
|
|
653
|
+
timer.unref?.();
|
|
654
|
+
req.once("aborted", abort);
|
|
655
|
+
res.once("close", abort);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
379
658
|
let offer;
|
|
380
659
|
try {
|
|
381
660
|
offer = options.responder.offerLiveInput({
|
|
@@ -383,6 +662,7 @@ export async function startTuiAdapter(options) {
|
|
|
383
662
|
id: body.id,
|
|
384
663
|
text: body.text,
|
|
385
664
|
receivedAt: body.receivedAt,
|
|
665
|
+
...(explicitRunId === undefined ? {} : { targetRunId: explicitRunId }),
|
|
386
666
|
...(typeof body.deliveryKey === "string" ? { deliveryKey: body.deliveryKey } : {}),
|
|
387
667
|
});
|
|
388
668
|
}
|
|
@@ -390,13 +670,7 @@ export async function startTuiAdapter(options) {
|
|
|
390
670
|
next(error);
|
|
391
671
|
return;
|
|
392
672
|
}
|
|
393
|
-
|
|
394
|
-
res.status(200).json(offer);
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
void offer.settled.then((settlement) => {
|
|
398
|
-
res.status(200).json(settlement);
|
|
399
|
-
}).catch(next);
|
|
673
|
+
settleLiveInputOffer(res, offer);
|
|
400
674
|
});
|
|
401
675
|
app.get(askPath, (req, res) => {
|
|
402
676
|
if (!authorize(req, res, apiKey))
|
|
@@ -552,6 +826,158 @@ export async function startTuiAdapter(options) {
|
|
|
552
826
|
next(error);
|
|
553
827
|
}
|
|
554
828
|
});
|
|
829
|
+
const requireProviderAuth = (req, res) => {
|
|
830
|
+
if (options.providerAuth === undefined) {
|
|
831
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Provider authentication is unavailable."));
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
return authorize(req, res, apiKey) ? options.providerAuth : undefined;
|
|
835
|
+
};
|
|
836
|
+
const sendProviderAuth = (res, status, body) => {
|
|
837
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
838
|
+
res.status(status).json(body);
|
|
839
|
+
};
|
|
840
|
+
app.use(providerAuthPath, (_req, res, next) => {
|
|
841
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
842
|
+
next();
|
|
843
|
+
});
|
|
844
|
+
app.get(providerAuthPath, (req, res, next) => {
|
|
845
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
846
|
+
if (providerAuth === undefined)
|
|
847
|
+
return;
|
|
848
|
+
void providerAuth.status()
|
|
849
|
+
.then((snapshot) => sendProviderAuth(res, 200, parseProviderAuthStatusSnapshot(snapshot)))
|
|
850
|
+
.catch(next);
|
|
851
|
+
});
|
|
852
|
+
app.post(providerAuthSessionsPath, express.json({ limit: MAX_PROVIDER_AUTH_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
853
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
854
|
+
if (providerAuth === undefined)
|
|
855
|
+
return;
|
|
856
|
+
let input;
|
|
857
|
+
try {
|
|
858
|
+
input = parseProviderAuthSessionStartInput(req.body);
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
next(error);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
void providerAuth.start(input)
|
|
865
|
+
.then((snapshot) => sendProviderAuth(res, 201, parseProviderAuthSessionSnapshot(snapshot)))
|
|
866
|
+
.catch(next);
|
|
867
|
+
});
|
|
868
|
+
app.get(providerAuthSessionPath, (req, res, next) => {
|
|
869
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
870
|
+
if (providerAuth === undefined)
|
|
871
|
+
return;
|
|
872
|
+
const sessionId = boundedProviderAuthSessionId(req.params.sessionId);
|
|
873
|
+
if (sessionId === undefined) {
|
|
874
|
+
next(new TuiAdapterError("invalid_request", "A bounded provider auth session id is required."));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
void providerAuth.get(sessionId).then((snapshot) => {
|
|
878
|
+
if (snapshot === undefined) {
|
|
879
|
+
sendJsonError(res, 404, new ProviderAuthOperationError("provider_auth_not_found", "Provider authentication session was not found.", 404));
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
sendProviderAuth(res, 200, parseProviderAuthSessionSnapshot(snapshot));
|
|
883
|
+
}
|
|
884
|
+
}).catch(next);
|
|
885
|
+
});
|
|
886
|
+
app.post(providerAuthInputPath, express.json({ limit: MAX_PROVIDER_AUTH_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
887
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
888
|
+
if (providerAuth === undefined)
|
|
889
|
+
return;
|
|
890
|
+
const sessionId = boundedProviderAuthSessionId(req.params.sessionId);
|
|
891
|
+
if (sessionId === undefined) {
|
|
892
|
+
next(new TuiAdapterError("invalid_request", "A bounded provider auth session id is required."));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
let input;
|
|
896
|
+
try {
|
|
897
|
+
input = parseProviderAuthSessionInput(req.body);
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
next(error);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
void providerAuth.submit(sessionId, input)
|
|
904
|
+
.then((snapshot) => sendProviderAuth(res, 200, parseProviderAuthSessionSnapshot(snapshot)))
|
|
905
|
+
.catch(next);
|
|
906
|
+
});
|
|
907
|
+
app.delete(providerAuthSessionPath, (req, res, next) => {
|
|
908
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
909
|
+
if (providerAuth === undefined)
|
|
910
|
+
return;
|
|
911
|
+
const sessionId = boundedProviderAuthSessionId(req.params.sessionId);
|
|
912
|
+
if (sessionId === undefined) {
|
|
913
|
+
next(new TuiAdapterError("invalid_request", "A bounded provider auth session id is required."));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
void providerAuth.cancel(sessionId).then(() => {
|
|
917
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
918
|
+
res.status(204).end();
|
|
919
|
+
}).catch(next);
|
|
920
|
+
});
|
|
921
|
+
app.post(providerAuthChecksPath, express.json({ limit: MAX_PROVIDER_AUTH_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
922
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
923
|
+
if (providerAuth === undefined)
|
|
924
|
+
return;
|
|
925
|
+
if (providerAuth.checks === undefined) {
|
|
926
|
+
next(new ProviderAuthOperationError("provider_auth_unavailable", "Provider checks are unavailable.", 503));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
let input;
|
|
930
|
+
try {
|
|
931
|
+
input = parseProviderAuthCheckStartInput(req.body);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
next(error);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
void providerAuth.checks.start(input)
|
|
938
|
+
.then((snapshot) => sendProviderAuth(res, 201, parseProviderAuthCheckSessionSnapshot(snapshot)))
|
|
939
|
+
.catch(next);
|
|
940
|
+
});
|
|
941
|
+
app.get(providerAuthCheckPath, (req, res, next) => {
|
|
942
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
943
|
+
if (providerAuth === undefined)
|
|
944
|
+
return;
|
|
945
|
+
if (providerAuth.checks === undefined) {
|
|
946
|
+
next(new ProviderAuthOperationError("provider_auth_unavailable", "Provider checks are unavailable.", 503));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const checkId = boundedProviderAuthSessionId(req.params.checkId);
|
|
950
|
+
if (checkId === undefined) {
|
|
951
|
+
next(new TuiAdapterError("invalid_request", "A bounded provider auth check id is required."));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
void providerAuth.checks.get(checkId).then((snapshot) => {
|
|
955
|
+
if (snapshot === undefined) {
|
|
956
|
+
sendJsonError(res, 404, new ProviderAuthOperationError("provider_auth_not_found", "Provider auth check was not found.", 404));
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
sendProviderAuth(res, 200, parseProviderAuthCheckSessionSnapshot(snapshot));
|
|
960
|
+
}
|
|
961
|
+
}).catch(next);
|
|
962
|
+
});
|
|
963
|
+
app.delete(providerAuthCheckPath, (req, res, next) => {
|
|
964
|
+
const providerAuth = requireProviderAuth(req, res);
|
|
965
|
+
if (providerAuth === undefined)
|
|
966
|
+
return;
|
|
967
|
+
if (providerAuth.checks === undefined) {
|
|
968
|
+
next(new ProviderAuthOperationError("provider_auth_unavailable", "Provider checks are unavailable.", 503));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const checkId = boundedProviderAuthSessionId(req.params.checkId);
|
|
972
|
+
if (checkId === undefined) {
|
|
973
|
+
next(new TuiAdapterError("invalid_request", "A bounded provider auth check id is required."));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
void providerAuth.checks.cancel(checkId).then(() => {
|
|
977
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
978
|
+
res.status(204).end();
|
|
979
|
+
}).catch(next);
|
|
980
|
+
});
|
|
555
981
|
app.use((error, _req, res, next) => {
|
|
556
982
|
if (res.headersSent) {
|
|
557
983
|
next(error);
|
|
@@ -569,6 +995,13 @@ export async function startTuiAdapter(options) {
|
|
|
569
995
|
sendJsonError(res, error.status, error);
|
|
570
996
|
return;
|
|
571
997
|
}
|
|
998
|
+
if (error instanceof ProviderAuthOperationError) {
|
|
999
|
+
if (error.retryAfterSeconds !== undefined) {
|
|
1000
|
+
res.setHeader("Retry-After", String(error.retryAfterSeconds));
|
|
1001
|
+
}
|
|
1002
|
+
sendJsonError(res, error.status, error);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
572
1005
|
const isClientError = codeOf(error) === "invalid_request" ||
|
|
573
1006
|
(error instanceof SyntaxError && error.status === 400);
|
|
574
1007
|
sendJsonError(res, isClientError ? 400 : 500, error);
|
|
@@ -600,16 +1033,43 @@ export async function startTuiAdapter(options) {
|
|
|
600
1033
|
const url = `http://${hostForUrl(host)}:${boundPort}`;
|
|
601
1034
|
async function handleTurn(req, res) {
|
|
602
1035
|
const body = normalizeTurnBody(req.body, options.requestToolEnvironment);
|
|
1036
|
+
const requestId = randomUUID();
|
|
1037
|
+
const web = isRecord(body.metadata.web) ? body.metadata.web : undefined;
|
|
1038
|
+
const webTurnId = body.client === "web" && typeof web?.turnId === "string" && web.turnId.length > 0
|
|
1039
|
+
? web.turnId
|
|
1040
|
+
: undefined;
|
|
1041
|
+
const targetKey = webTurnId === undefined || options.responder.liveInputOwnership?.version !== 1
|
|
1042
|
+
? undefined
|
|
1043
|
+
: liveInputTargetKey(body.conversationId, webTurnId);
|
|
1044
|
+
if (targetKey !== undefined && liveInputTargets.has(targetKey)) {
|
|
1045
|
+
throw new TuiAdapterError("invalid_request", "Web turn is already active.");
|
|
1046
|
+
}
|
|
603
1047
|
const controller = new AbortController();
|
|
604
1048
|
activeTurns.add(controller);
|
|
605
1049
|
if (stopping)
|
|
606
1050
|
controller.abort(new Error("TUI adapter is stopping."));
|
|
607
|
-
|
|
1051
|
+
let target;
|
|
1052
|
+
if (targetKey !== undefined) {
|
|
1053
|
+
target = { state: "pending", waiters: new Set() };
|
|
1054
|
+
liveInputTargets.set(targetKey, target);
|
|
1055
|
+
}
|
|
608
1056
|
const request = {
|
|
609
1057
|
conversationId: body.conversationId,
|
|
610
1058
|
text: body.text,
|
|
611
1059
|
abortSignal: controller.signal,
|
|
612
1060
|
metadata: requestMetadata(body, requestId),
|
|
1061
|
+
...(target === undefined ? {} : {
|
|
1062
|
+
onLiveInputOwnership: (event) => {
|
|
1063
|
+
if (target?.state === "closed")
|
|
1064
|
+
return;
|
|
1065
|
+
if (event.status === "ready") {
|
|
1066
|
+
settleTarget(target, event.runId);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
settleTarget(target, undefined);
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
}),
|
|
613
1073
|
...(body.attachments === undefined || body.attachments.length === 0
|
|
614
1074
|
? {}
|
|
615
1075
|
: { attachments: body.attachments }),
|
|
@@ -646,6 +1106,11 @@ export async function startTuiAdapter(options) {
|
|
|
646
1106
|
}).catch(() => undefined);
|
|
647
1107
|
}
|
|
648
1108
|
finally {
|
|
1109
|
+
if (target !== undefined) {
|
|
1110
|
+
settleTarget(target, undefined);
|
|
1111
|
+
if (targetKey !== undefined && liveInputTargets.get(targetKey) === target)
|
|
1112
|
+
liveInputTargets.delete(targetKey);
|
|
1113
|
+
}
|
|
649
1114
|
activeTurns.delete(controller);
|
|
650
1115
|
res.end();
|
|
651
1116
|
}
|
|
@@ -660,9 +1125,13 @@ export async function startTuiAdapter(options) {
|
|
|
660
1125
|
stop() {
|
|
661
1126
|
stopPromise ??= (async () => {
|
|
662
1127
|
stopping = true;
|
|
1128
|
+
for (const target of liveInputTargets.values()) {
|
|
1129
|
+
settleTarget(target, undefined);
|
|
1130
|
+
}
|
|
1131
|
+
liveInputTargets.clear();
|
|
663
1132
|
for (const controller of activeTurns)
|
|
664
1133
|
controller.abort(new Error("TUI adapter stopped."));
|
|
665
|
-
await closeServerBounded(server);
|
|
1134
|
+
await Promise.all([closeServerBounded(server), options.providerAuth?.stop()]);
|
|
666
1135
|
activeTurns.clear();
|
|
667
1136
|
})();
|
|
668
1137
|
return stopPromise;
|
|
@@ -1069,6 +1538,36 @@ function sendMcpAppError(res, error) {
|
|
|
1069
1538
|
setPrivateMcpAppHeaders(res);
|
|
1070
1539
|
sendJsonError(res, status, error);
|
|
1071
1540
|
}
|
|
1541
|
+
function normalizeContextImportBody(rawConversationId, body) {
|
|
1542
|
+
const conversationId = normalizeOptionalString(typeof rawConversationId === "string" ? rawConversationId : undefined);
|
|
1543
|
+
if (conversationId === undefined
|
|
1544
|
+
|| Buffer.byteLength(conversationId, "utf8") > AGENT_CONTEXT_IMPORT_MAX_CONVERSATION_ID_BYTES
|
|
1545
|
+
|| conversationId.includes("\0")) {
|
|
1546
|
+
throw new TuiAdapterError("invalid_request", "A bounded conversationId is required.");
|
|
1547
|
+
}
|
|
1548
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
1549
|
+
throw new TuiAdapterError("invalid_request", "Request body must be a JSON object.");
|
|
1550
|
+
}
|
|
1551
|
+
const record = body;
|
|
1552
|
+
if (Object.keys(record).sort().join("\0") !== ["idempotencyKey", "text"].join("\0")) {
|
|
1553
|
+
throw new TuiAdapterError("invalid_request", "Request body must contain only text and idempotencyKey.");
|
|
1554
|
+
}
|
|
1555
|
+
if (typeof record.text !== "string"
|
|
1556
|
+
|| record.text.trim().length === 0
|
|
1557
|
+
|| Buffer.byteLength(record.text, "utf8") > AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES) {
|
|
1558
|
+
throw new TuiAdapterError("invalid_request", "text must be a string within the context import byte limit.");
|
|
1559
|
+
}
|
|
1560
|
+
if (typeof record.idempotencyKey !== "string"
|
|
1561
|
+
|| record.idempotencyKey.trim().length === 0
|
|
1562
|
+
|| record.idempotencyKey.includes("\0")
|
|
1563
|
+
|| Buffer.byteLength(record.idempotencyKey, "utf8") > AGENT_CONTEXT_IMPORT_MAX_IDEMPOTENCY_KEY_BYTES) {
|
|
1564
|
+
throw new TuiAdapterError("invalid_request", "idempotencyKey must be a bounded non-empty UTF-8 string.");
|
|
1565
|
+
}
|
|
1566
|
+
return {
|
|
1567
|
+
conversationId,
|
|
1568
|
+
request: { text: record.text, idempotencyKey: record.idempotencyKey },
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1072
1571
|
function normalizeVerbatimBody(rawConversationId, body) {
|
|
1073
1572
|
const conversationId = normalizeOptionalString(typeof rawConversationId === "string" ? rawConversationId : undefined);
|
|
1074
1573
|
if (conversationId === undefined) {
|
|
@@ -1371,6 +1870,171 @@ async function resolveInfo(info) {
|
|
|
1371
1870
|
}
|
|
1372
1871
|
return info;
|
|
1373
1872
|
}
|
|
1873
|
+
function normalizeModelCatalogRequest(req) {
|
|
1874
|
+
const rawProvider = typeof req.query.provider === "string" ? req.query.provider : undefined;
|
|
1875
|
+
const provider = normalizeOptionalString(rawProvider);
|
|
1876
|
+
if (provider !== undefined && Buffer.byteLength(provider, "utf8") > MAX_MODEL_CATALOG_PROVIDER_BYTES) {
|
|
1877
|
+
throw new TuiAdapterError("invalid_request", "provider is too large.");
|
|
1878
|
+
}
|
|
1879
|
+
const rawQuery = typeof req.query.q === "string" ? req.query.q : undefined;
|
|
1880
|
+
const query = normalizeOptionalString(rawQuery);
|
|
1881
|
+
if (query !== undefined && Buffer.byteLength(query, "utf8") > MAX_MODEL_CATALOG_QUERY_BYTES) {
|
|
1882
|
+
throw new TuiAdapterError("invalid_request", "q is too large.");
|
|
1883
|
+
}
|
|
1884
|
+
const rawCursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
|
1885
|
+
const cursor = normalizeOptionalString(rawCursor);
|
|
1886
|
+
if (cursor !== undefined && Buffer.byteLength(cursor, "utf8") > MAX_MODEL_CATALOG_CURSOR_BYTES) {
|
|
1887
|
+
throw new TuiAdapterError("invalid_request", "cursor is too large.");
|
|
1888
|
+
}
|
|
1889
|
+
if (provider === undefined && query === undefined) {
|
|
1890
|
+
throw new TuiAdapterError("invalid_request", "provider or q is required.");
|
|
1891
|
+
}
|
|
1892
|
+
// `TuiModelCatalogRequest` documents the two modes as mutually exclusive and
|
|
1893
|
+
// suppliers honour that by servicing `provider` and ignoring `query`. Sending
|
|
1894
|
+
// both must therefore be a client error, not a silently provider-scoped page
|
|
1895
|
+
// that looks like it answered the search.
|
|
1896
|
+
if (provider !== undefined && query !== undefined) {
|
|
1897
|
+
throw new TuiAdapterError("invalid_request", "provider and q are mutually exclusive.");
|
|
1898
|
+
}
|
|
1899
|
+
const rawLimit = typeof req.query.limit === "string"
|
|
1900
|
+
? Number(req.query.limit)
|
|
1901
|
+
: DEFAULT_MODEL_CATALOG_PAGE_SIZE;
|
|
1902
|
+
if (!Number.isSafeInteger(rawLimit) || rawLimit < 1 || rawLimit > MAX_MODEL_CATALOG_PAGE_SIZE) {
|
|
1903
|
+
throw new TuiAdapterError("invalid_request", `limit must be 1-${String(MAX_MODEL_CATALOG_PAGE_SIZE)}.`);
|
|
1904
|
+
}
|
|
1905
|
+
return {
|
|
1906
|
+
...(provider === undefined ? {} : { provider }),
|
|
1907
|
+
...(query === undefined ? {} : { query }),
|
|
1908
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
1909
|
+
limit: rawLimit,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* `/v1/info` fields the fence may shed, least load-bearing first. Every one of
|
|
1914
|
+
* them is already optional on the wire — the response literal omits each when
|
|
1915
|
+
* its source is absent — so shedding produces a body that is still valid at
|
|
1916
|
+
* schema 1, which matters because `TUI_WIRE_SCHEMA` is compared with `!==` and
|
|
1917
|
+
* cannot be bumped. `schema`, `pid` and `capabilities` are never sheddable:
|
|
1918
|
+
* they are the liveness and negotiation half of the response.
|
|
1919
|
+
*/
|
|
1920
|
+
const INFO_SHED_ORDER = [
|
|
1921
|
+
"modelOptions",
|
|
1922
|
+
"models",
|
|
1923
|
+
"providers",
|
|
1924
|
+
"skills",
|
|
1925
|
+
"label",
|
|
1926
|
+
"effort",
|
|
1927
|
+
"model",
|
|
1928
|
+
];
|
|
1929
|
+
/**
|
|
1930
|
+
* Send `/v1/info` under the byte contract its consumer enforces.
|
|
1931
|
+
*
|
|
1932
|
+
* Every contributor to this body carries its own producer-side budget (see the
|
|
1933
|
+
* budget table in `agent-app`'s `channel-drivers/tui.ts`, and
|
|
1934
|
+
* `MAX_SKILL_REGISTRY_BYTES` for skills) and those budgets sum to 960 KiB. This
|
|
1935
|
+
* is the last-resort fence behind them: without it the ONLY enforcement of the
|
|
1936
|
+
* 1 MiB cap lived in the consumer, so any producer-side miss took the agent
|
|
1937
|
+
* offline instead of degrading it.
|
|
1938
|
+
*
|
|
1939
|
+
* It measures the exact string it sends rather than estimating, and sends that
|
|
1940
|
+
* string rather than re-serializing, so what was measured is what goes on the
|
|
1941
|
+
* wire. Shedding is logged at error level: a body that reaches this fence is a
|
|
1942
|
+
* producer bug, and it must not disappear silently.
|
|
1943
|
+
*/
|
|
1944
|
+
function sendBoundedInfo(res, body, logger) {
|
|
1945
|
+
const candidate = { ...body };
|
|
1946
|
+
const dropped = [];
|
|
1947
|
+
let serialized = serializeInfoBody(candidate);
|
|
1948
|
+
while (serialized === undefined) {
|
|
1949
|
+
const field = largestSheddableInfoField(candidate);
|
|
1950
|
+
// Each pass removes one field from a finite set, so this terminates.
|
|
1951
|
+
if (field === undefined)
|
|
1952
|
+
break;
|
|
1953
|
+
delete candidate[field];
|
|
1954
|
+
dropped.push(field);
|
|
1955
|
+
serialized = serializeInfoBody(candidate);
|
|
1956
|
+
}
|
|
1957
|
+
if (serialized !== undefined) {
|
|
1958
|
+
if (dropped.length > 0) {
|
|
1959
|
+
logger?.error?.("TUI info body exceeded its wire budget; fields were dropped.", {
|
|
1960
|
+
droppedFields: dropped.join(","),
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
res.status(200).type("application/json").send(serialized);
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
// Nothing sheddable is left and the remainder still will not fit (or will not
|
|
1967
|
+
// serialize at all). Fall back to the fixed liveness floor: a schema-1 body
|
|
1968
|
+
// small by construction, which keeps the agent reachable and its capability
|
|
1969
|
+
// negotiation honest rather than answering 500 and reading as offline.
|
|
1970
|
+
logger?.error?.("TUI info body could not be bounded; served the minimal liveness body.", {
|
|
1971
|
+
droppedFields: dropped.join(","),
|
|
1972
|
+
});
|
|
1973
|
+
res.status(200).type("application/json").send(JSON.stringify({
|
|
1974
|
+
schema: TUI_WIRE_SCHEMA,
|
|
1975
|
+
pid: process.pid,
|
|
1976
|
+
capabilities: { attachments: true },
|
|
1977
|
+
}));
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* The optional field costing the most bytes right now.
|
|
1981
|
+
*
|
|
1982
|
+
* Shedding the biggest field first means the fence removes what is ACTUALLY
|
|
1983
|
+
* oversized instead of four innocent projections queued ahead of it: a 1.5 MiB
|
|
1984
|
+
* `skills` registry costs the console its skills, not its model picker as well.
|
|
1985
|
+
* Ties break towards the front of `INFO_SHED_ORDER` (least load-bearing first),
|
|
1986
|
+
* so the choice is deterministic. A field whose own value will not serialize
|
|
1987
|
+
* sorts first of all — it is the reason the body cannot be measured at all.
|
|
1988
|
+
*/
|
|
1989
|
+
function largestSheddableInfoField(body) {
|
|
1990
|
+
let largest;
|
|
1991
|
+
let largestBytes = -1;
|
|
1992
|
+
for (const field of INFO_SHED_ORDER) {
|
|
1993
|
+
if (!(field in body))
|
|
1994
|
+
continue;
|
|
1995
|
+
const bytes = infoFieldBytes(body[field]);
|
|
1996
|
+
if (bytes > largestBytes) {
|
|
1997
|
+
largest = field;
|
|
1998
|
+
largestBytes = bytes;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
return largest;
|
|
2002
|
+
}
|
|
2003
|
+
/** Serialized size of one field value; `Infinity` when it cannot be serialized at all. */
|
|
2004
|
+
function infoFieldBytes(value) {
|
|
2005
|
+
try {
|
|
2006
|
+
const serialized = JSON.stringify(value);
|
|
2007
|
+
return typeof serialized === "string"
|
|
2008
|
+
? Buffer.byteLength(serialized, "utf8")
|
|
2009
|
+
: Number.POSITIVE_INFINITY;
|
|
2010
|
+
}
|
|
2011
|
+
catch {
|
|
2012
|
+
return Number.POSITIVE_INFINITY;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
/** Serialize the candidate body, or `undefined` when it does not fit or does not serialize. */
|
|
2016
|
+
function serializeInfoBody(body) {
|
|
2017
|
+
let serialized;
|
|
2018
|
+
try {
|
|
2019
|
+
serialized = JSON.stringify(body);
|
|
2020
|
+
}
|
|
2021
|
+
catch {
|
|
2022
|
+
return undefined;
|
|
2023
|
+
}
|
|
2024
|
+
// `JSON.stringify` yields undefined for a non-serializable top-level value.
|
|
2025
|
+
if (typeof serialized !== "string")
|
|
2026
|
+
return undefined;
|
|
2027
|
+
return Buffer.byteLength(serialized, "utf8") > MAX_INFO_BODY_BYTES ? undefined : serialized;
|
|
2028
|
+
}
|
|
2029
|
+
function sendBoundedModelCatalog(res, page) {
|
|
2030
|
+
const body = JSON.stringify(page);
|
|
2031
|
+
if (Buffer.byteLength(body, "utf8") > MAX_MODEL_CATALOG_RESPONSE_BYTES) {
|
|
2032
|
+
// The supplier is expected to pre-bound pages; hitting this fence is a
|
|
2033
|
+
// producer bug, not a client mistake, so it reads as a 500.
|
|
2034
|
+
throw new TuiAdapterError("model_catalog_too_large", "Model catalog page exceeded its bounded wire contract.");
|
|
2035
|
+
}
|
|
2036
|
+
res.status(200).type("application/json").send(body);
|
|
2037
|
+
}
|
|
1374
2038
|
function cronJobId(value) {
|
|
1375
2039
|
const raw = typeof value === "string" ? value : undefined;
|
|
1376
2040
|
const jobId = normalizeOptionalString(raw);
|
|
@@ -1421,6 +2085,16 @@ function sendBoundedCronJson(res, status, value) {
|
|
|
1421
2085
|
}
|
|
1422
2086
|
res.status(status).type("application/json").send(serialized);
|
|
1423
2087
|
}
|
|
2088
|
+
/** A wake key identifies a flight; only the independent owner bearer authorizes it. */
|
|
2089
|
+
function authorizeMonitorWake(req, res, key, ownerBearer) {
|
|
2090
|
+
if (typeof key !== "string" || !key.trim().startsWith("monitor:"))
|
|
2091
|
+
return true;
|
|
2092
|
+
const presented = readAuthorizationBearer(req.header("x-mono-agent-monitor-wake-authorization"));
|
|
2093
|
+
if (ownerBearer !== undefined && presented !== undefined && bearerTokensEqual(presented, ownerBearer))
|
|
2094
|
+
return true;
|
|
2095
|
+
res.status(401).json({ error: { message: "Monitor wake requires owner authorization.", code: "invalid_api_key" } });
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
1424
2098
|
function authorize(req, res, apiKey) {
|
|
1425
2099
|
if (apiKey === undefined) {
|
|
1426
2100
|
return true;
|
|
@@ -1432,13 +2106,81 @@ function authorize(req, res, apiKey) {
|
|
|
1432
2106
|
res.status(401).json({ error: { message: "Invalid API key.", code: "invalid_api_key" } });
|
|
1433
2107
|
return false;
|
|
1434
2108
|
}
|
|
2109
|
+
function boundedProviderAuthSessionId(value) {
|
|
2110
|
+
return typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= 128
|
|
2111
|
+
? value
|
|
2112
|
+
: undefined;
|
|
2113
|
+
}
|
|
1435
2114
|
function sendJsonError(res, status, error) {
|
|
1436
|
-
res.status(status).json(
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
2115
|
+
res.status(status).type("application/json").send(boundedErrorBody(error));
|
|
2116
|
+
}
|
|
2117
|
+
function sendContextImportError(res, status, code, message, reason) {
|
|
2118
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
2119
|
+
res.status(status).json({ error: { code, message, reason } });
|
|
2120
|
+
}
|
|
2121
|
+
/** Appended to a message the fence had to cut, so a reader is never handed a
|
|
2122
|
+
* truncated diagnostic as if it were the whole one. */
|
|
2123
|
+
const TRUNCATED_MESSAGE_SUFFIX = "\u2026 [truncated]";
|
|
2124
|
+
/**
|
|
2125
|
+
* Serialize one error envelope under the SHARED `/v1/info` body cap.
|
|
2126
|
+
*
|
|
2127
|
+
* `sendBoundedInfo` bounds a success body by shedding fields; an error body has
|
|
2128
|
+
* no field to shed — it is one diagnostic message — so it is bounded by
|
|
2129
|
+
* clamping that message instead, which is the one place truncating is
|
|
2130
|
+
* obviously correct. Bounding success and not failure is not a wire contract:
|
|
2131
|
+
* before this, a rejecting `info` provider answered whatever its message
|
|
2132
|
+
* measured (a real probe: 1,052,696 bytes against a 1,048,576-byte cap),
|
|
2133
|
+
* because the route's `.catch` lands here and this responder had no bound.
|
|
2134
|
+
*
|
|
2135
|
+
* `code` survives clamping — the console and the TUI switch on it — and the
|
|
2136
|
+
* message keeps its head, so a clamped body still names what failed.
|
|
2137
|
+
*/
|
|
2138
|
+
function boundedErrorBody(error) {
|
|
2139
|
+
const message = errorToMessage(error);
|
|
2140
|
+
const rawCode = codeOf(error);
|
|
2141
|
+
// A `code` is an identifier, kept whole whenever it can be. A "code" that
|
|
2142
|
+
// alone overruns the cap is not an identifier, and dropping it is better than
|
|
2143
|
+
// letting it crowd out the message that explains the failure.
|
|
2144
|
+
const code = rawCode !== undefined
|
|
2145
|
+
&& Buffer.byteLength(errorEnvelope(TRUNCATED_MESSAGE_SUFFIX, rawCode), "utf8") <= MAX_INFO_BODY_BYTES
|
|
2146
|
+
? rawCode
|
|
2147
|
+
: undefined;
|
|
2148
|
+
const full = errorEnvelope(message, code);
|
|
2149
|
+
if (Buffer.byteLength(full, "utf8") <= MAX_INFO_BODY_BYTES)
|
|
2150
|
+
return full;
|
|
2151
|
+
return errorEnvelope(clampErrorMessage(message, code), code);
|
|
2152
|
+
}
|
|
2153
|
+
function errorEnvelope(message, code) {
|
|
2154
|
+
return JSON.stringify({ error: { message, ...(code === undefined ? {} : { code }) } });
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* A prefix of `message` whose envelope fits, plus the truncation marker.
|
|
2158
|
+
*
|
|
2159
|
+
* Measured against the SERIALIZED envelope, not the raw string, and shrunk in
|
|
2160
|
+
* PROPORTION to how far over the envelope is. Both halves matter: the overshoot
|
|
2161
|
+
* is counted in bytes while `kept` is counted in code units, and one unit can
|
|
2162
|
+
* escape to six bytes (`\u0007` -> `\\u0007`), so subtracting a byte overshoot
|
|
2163
|
+
* from a unit count deletes the entire message the moment escaping is heavy —
|
|
2164
|
+
* leaving a body that is bounded but says nothing about what failed.
|
|
2165
|
+
*/
|
|
2166
|
+
function clampErrorMessage(message, code) {
|
|
2167
|
+
let kept = message;
|
|
2168
|
+
for (;;) {
|
|
2169
|
+
const candidate = kept + TRUNCATED_MESSAGE_SUFFIX;
|
|
2170
|
+
const bytes = Buffer.byteLength(errorEnvelope(candidate, code), "utf8");
|
|
2171
|
+
// The marker-only envelope was proven to fit before this was called, so the
|
|
2172
|
+
// loop cannot spin at length 0.
|
|
2173
|
+
if (bytes <= MAX_INFO_BODY_BYTES || kept.length === 0)
|
|
2174
|
+
return candidate;
|
|
2175
|
+
// Never drop less than an eighth, so a proportion that barely moves (the
|
|
2176
|
+
// envelope's own fixed overhead) still converges in O(log n) passes.
|
|
2177
|
+
const proportional = Math.floor(kept.length * (MAX_INFO_BODY_BYTES / bytes));
|
|
2178
|
+
kept = kept.slice(0, Math.min(proportional, kept.length - Math.ceil(kept.length / 8)));
|
|
2179
|
+
// Never end on a lone high surrogate split out of a pair.
|
|
2180
|
+
const last = kept.charCodeAt(kept.length - 1);
|
|
2181
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
2182
|
+
kept = kept.slice(0, -1);
|
|
2183
|
+
}
|
|
1442
2184
|
}
|
|
1443
2185
|
function sendBoundedJobs(res, jobs) {
|
|
1444
2186
|
const body = serializeBoundedJobs(jobs);
|
|
@@ -1514,4 +2256,8 @@ function normalizeBasePath(basePath) {
|
|
|
1514
2256
|
}
|
|
1515
2257
|
return basePath.length === 1 ? "" : basePath.replace(/\/+$/u, "");
|
|
1516
2258
|
}
|
|
2259
|
+
function boundedMonitorId(value) {
|
|
2260
|
+
const monitorId = normalizeOptionalString(typeof value === "string" ? value : undefined);
|
|
2261
|
+
return monitorId === undefined || monitorId.length > 256 ? undefined : monitorId;
|
|
2262
|
+
}
|
|
1517
2263
|
//# sourceMappingURL=server.js.map
|