relmio 0.6.0 → 0.8.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.
@@ -0,0 +1,501 @@
1
+ import {
2
+ constants,
3
+ generateKeyPair as generateKeyPairCallback,
4
+ privateDecrypt,
5
+ randomUUID,
6
+ } from "node:crypto";
7
+ import { promisify } from "node:util";
8
+
9
+ const generateKeyPair = promisify(generateKeyPairCallback);
10
+
11
+ const MAX_SESSIONS = 8;
12
+ const KEY_TTL_MS = 5 * 60 * 1_000;
13
+ const REQUEST_TIMEOUT_MS = 120_000;
14
+ const MAX_ENDPOINT_LENGTH = 64;
15
+ const MAX_CIPHERTEXT_LENGTH = 4_096;
16
+ const MAX_INPUT_LENGTH = 8_192;
17
+ const MAX_CONVERSATION_ID_LENGTH = 160;
18
+ const MAX_RESPONSE_BYTES = 512 * 1_024;
19
+ const MAX_OUTPUT_LENGTH = 12 * 1_024;
20
+
21
+ function requestError(message, statusCode = 400) {
22
+ return Object.assign(new Error(message), { statusCode });
23
+ }
24
+
25
+ function expiredKeyError() {
26
+ return requestError(
27
+ "This test credential has expired or was forgotten. Secure it again.",
28
+ 409,
29
+ );
30
+ }
31
+
32
+ function adapterError(statusCode = 502) {
33
+ return requestError("The local adapter test could not be completed.", statusCode);
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ return value !== null && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ function isBoundedText(value, maximumLength) {
41
+ return (
42
+ typeof value === "string" &&
43
+ value.length > 0 &&
44
+ value.length <= maximumLength &&
45
+ !/[\u0000-\u001f\u007f]/u.test(value)
46
+ );
47
+ }
48
+
49
+ export function parseLocalAdapterBaseUrl(value) {
50
+ if (typeof value !== "string" || value.length > MAX_ENDPOINT_LENGTH) {
51
+ throw requestError("Enter a valid local adapter address.");
52
+ }
53
+
54
+ const match = /^http:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/u.exec(value);
55
+ if (!match) {
56
+ throw requestError("Enter a valid local adapter address.");
57
+ }
58
+
59
+ const port = Number(match[1]);
60
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
61
+ throw requestError("Enter a valid local adapter address.");
62
+ }
63
+
64
+ return `http://127.0.0.1:${port}`;
65
+ }
66
+
67
+ function validateMessageRequest(request) {
68
+ if (!isPlainObject(request)) {
69
+ throw requestError("Enter a valid local adapter test request.");
70
+ }
71
+ const endpointBaseUrl = parseLocalAdapterBaseUrl(request.endpointBaseUrl);
72
+ if (!isBoundedText(request.keyId, 128)) {
73
+ throw expiredKeyError();
74
+ }
75
+ if (
76
+ typeof request.encryptedCredential !== "string" ||
77
+ request.encryptedCredential.length < 32 ||
78
+ request.encryptedCredential.length > MAX_CIPHERTEXT_LENGTH ||
79
+ !/^[A-Za-z0-9+/]+={0,2}$/u.test(request.encryptedCredential)
80
+ ) {
81
+ throw requestError("Secure the client credential again before testing.");
82
+ }
83
+ if (!isBoundedText(request.input, MAX_INPUT_LENGTH)) {
84
+ throw requestError("Enter a shorter chat message.");
85
+ }
86
+ if (
87
+ request.conversationId !== undefined &&
88
+ !isBoundedText(request.conversationId, MAX_CONVERSATION_ID_LENGTH)
89
+ ) {
90
+ throw requestError("Start a new conversation and try again.");
91
+ }
92
+
93
+ return {
94
+ endpointBaseUrl,
95
+ keyId: request.keyId,
96
+ encryptedCredential: request.encryptedCredential,
97
+ input: request.input,
98
+ ...(request.conversationId !== undefined
99
+ ? { conversationId: request.conversationId }
100
+ : {}),
101
+ };
102
+ }
103
+
104
+ function decodeCiphertext(value) {
105
+ const decoded = Buffer.from(value, "base64");
106
+ if (
107
+ decoded.length === 0 ||
108
+ decoded.toString("base64") !== value
109
+ ) {
110
+ decoded.fill(0);
111
+ throw requestError("Secure the client credential again before testing.");
112
+ }
113
+ return decoded;
114
+ }
115
+
116
+ async function readBoundedResponse(response) {
117
+ const contentLength = response.headers?.get?.("content-length");
118
+ if (
119
+ contentLength !== null &&
120
+ contentLength !== undefined &&
121
+ (!/^\d+$/u.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)
122
+ ) {
123
+ throw adapterError();
124
+ }
125
+
126
+ const reader = response.body?.getReader?.();
127
+ if (!reader) {
128
+ throw adapterError();
129
+ }
130
+
131
+ const chunks = [];
132
+ let bytes = 0;
133
+ try {
134
+ for (;;) {
135
+ const { done, value } = await reader.read();
136
+ if (done) {
137
+ break;
138
+ }
139
+ const chunk = Buffer.from(value);
140
+ bytes += chunk.length;
141
+ if (bytes > MAX_RESPONSE_BYTES) {
142
+ chunk.fill(0);
143
+ throw adapterError();
144
+ }
145
+ chunks.push(chunk);
146
+ }
147
+ return Buffer.concat(chunks).toString("utf8");
148
+ } finally {
149
+ for (const chunk of chunks) {
150
+ chunk.fill(0);
151
+ }
152
+ reader.releaseLock?.();
153
+ }
154
+ }
155
+
156
+ function parseAdapterResponse(text) {
157
+ let value;
158
+ try {
159
+ value = JSON.parse(text);
160
+ } catch {
161
+ throw adapterError();
162
+ }
163
+ if (
164
+ !isPlainObject(value) ||
165
+ !isBoundedText(value.conversationId, MAX_CONVERSATION_ID_LENGTH) ||
166
+ !isBoundedText(value.output, MAX_OUTPUT_LENGTH)
167
+ ) {
168
+ throw adapterError();
169
+ }
170
+ return {
171
+ conversationId: value.conversationId,
172
+ output: value.output,
173
+ };
174
+ }
175
+
176
+ function parseEventBlock(block) {
177
+ const dataLines = [];
178
+ let event;
179
+ for (const line of block.split(/\r?\n/u)) {
180
+ if (line.startsWith("event:")) {
181
+ event = line.slice(6).trim();
182
+ } else if (line.startsWith("data:")) {
183
+ dataLines.push(line.slice(5).trimStart());
184
+ }
185
+ }
186
+ if (!event || dataLines.length === 0) {
187
+ throw adapterError();
188
+ }
189
+ let data;
190
+ try {
191
+ data = JSON.parse(dataLines.join("\n"));
192
+ } catch {
193
+ throw adapterError();
194
+ }
195
+ if (!isPlainObject(data)) {
196
+ throw adapterError();
197
+ }
198
+ return { event, data };
199
+ }
200
+
201
+ async function consumeAdapterStream(response, onEvent) {
202
+ if (
203
+ !/^text\/event-stream(?:\s*;|$)/iu.test(
204
+ response.headers?.get?.("content-type") ?? "",
205
+ ) ||
206
+ response.headers?.get?.("x-relmio-stream") !== "v1"
207
+ ) {
208
+ throw adapterError();
209
+ }
210
+ const reader = response.body?.getReader?.();
211
+ if (!reader) {
212
+ throw adapterError();
213
+ }
214
+
215
+ const decoder = new TextDecoder();
216
+ let buffer = "";
217
+ let bytes = 0;
218
+ let conversationId;
219
+ let failed = false;
220
+ let output = "";
221
+ let terminal = false;
222
+ const processBlock = (block) => {
223
+ if (!block.trim() || block.trimStart().startsWith(":")) {
224
+ return;
225
+ }
226
+ const { event, data } = parseEventBlock(block);
227
+ if (event === "start") {
228
+ return;
229
+ }
230
+ if (event === "progress") {
231
+ onEvent("progress", { phase: "working" });
232
+ return;
233
+ }
234
+ if (event === "delta") {
235
+ if (
236
+ typeof data.text !== "string" ||
237
+ data.text.length === 0 ||
238
+ data.text.includes("\0") ||
239
+ output.length + data.text.length > MAX_OUTPUT_LENGTH
240
+ ) {
241
+ throw adapterError();
242
+ }
243
+ output += data.text;
244
+ onEvent("delta", { text: data.text });
245
+ return;
246
+ }
247
+ if (event === "error") {
248
+ failed = true;
249
+ return;
250
+ }
251
+ if (event === "terminal") {
252
+ if (terminal || !["completed", "failed"].includes(data.outcome)) {
253
+ throw adapterError();
254
+ }
255
+ terminal = true;
256
+ failed ||= data.outcome !== "completed";
257
+ if (!failed) {
258
+ if (!isBoundedText(data.conversationId, MAX_CONVERSATION_ID_LENGTH)) {
259
+ throw adapterError();
260
+ }
261
+ conversationId = data.conversationId;
262
+ }
263
+ return;
264
+ }
265
+ throw adapterError();
266
+ };
267
+
268
+ try {
269
+ for (;;) {
270
+ const { done, value } = await reader.read();
271
+ if (done) break;
272
+ bytes += value.byteLength;
273
+ if (bytes > MAX_RESPONSE_BYTES) {
274
+ throw adapterError();
275
+ }
276
+ buffer += decoder.decode(value, { stream: true });
277
+ const blocks = buffer.split(/\r?\n\r?\n/u);
278
+ buffer = blocks.pop() ?? "";
279
+ for (const block of blocks) processBlock(block);
280
+ }
281
+ buffer += decoder.decode();
282
+ if (buffer.trim()) processBlock(buffer);
283
+ } catch (error) {
284
+ try {
285
+ await reader.cancel();
286
+ } catch {
287
+ // The adapter stream may already be closed after a malformed terminal.
288
+ }
289
+ throw error;
290
+ } finally {
291
+ reader.releaseLock?.();
292
+ }
293
+
294
+ if (failed || !terminal || !conversationId || output.length === 0) {
295
+ throw adapterError();
296
+ }
297
+ return { conversationId, output };
298
+ }
299
+
300
+ function isTimeout(error) {
301
+ return error?.name === "AbortError" || error?.name === "TimeoutError";
302
+ }
303
+
304
+ export function createLocalChatTestService({
305
+ fetchImpl = fetch,
306
+ now = () => Date.now(),
307
+ keyTtlMs = KEY_TTL_MS,
308
+ maxSessions = MAX_SESSIONS,
309
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
310
+ } = {}) {
311
+ const sessions = new Map();
312
+ let pendingKeyIssuances = 0;
313
+
314
+ function expireSession(keyId, session) {
315
+ if (sessions.get(keyId) !== session) {
316
+ return;
317
+ }
318
+ clearTimeout(session.expiryTimer);
319
+ session.abortController?.abort();
320
+ session.privateKey = undefined;
321
+ sessions.delete(keyId);
322
+ }
323
+
324
+ function discardExpiredSessions() {
325
+ const currentTime = now();
326
+ for (const [keyId, session] of sessions) {
327
+ if (session.expiresAt <= currentTime) {
328
+ expireSession(keyId, session);
329
+ }
330
+ }
331
+ }
332
+
333
+ function resetAllSessions() {
334
+ for (const [keyId, session] of sessions) {
335
+ expireSession(keyId, session);
336
+ }
337
+ }
338
+
339
+ function getLiveSession(keyId) {
340
+ discardExpiredSessions();
341
+ const session = sessions.get(keyId);
342
+ if (!session || session.expiresAt <= now()) {
343
+ if (session) {
344
+ expireSession(keyId, session);
345
+ }
346
+ throw expiredKeyError();
347
+ }
348
+ return session;
349
+ }
350
+
351
+ return {
352
+ async issueKey() {
353
+ discardExpiredSessions();
354
+ if (sessions.size + pendingKeyIssuances >= maxSessions) {
355
+ throw requestError(
356
+ "Too many open tester sessions. Forget one or wait for it to expire.",
357
+ 429,
358
+ );
359
+ }
360
+ pendingKeyIssuances += 1;
361
+ try {
362
+ const { publicKey, privateKey } = await generateKeyPair("rsa", {
363
+ modulusLength: 2_048,
364
+ publicExponent: 0x10001,
365
+ });
366
+ const keyId = randomUUID();
367
+ const expiresAt = now() + keyTtlMs;
368
+ const session = {
369
+ abortController: null,
370
+ expiresAt,
371
+ expiryTimer: null,
372
+ inFlight: false,
373
+ privateKey,
374
+ };
375
+ sessions.set(keyId, session);
376
+ session.expiryTimer = setTimeout(
377
+ () => expireSession(keyId, session),
378
+ keyTtlMs,
379
+ );
380
+ session.expiryTimer.unref?.();
381
+ return {
382
+ keyId,
383
+ publicKeyJwk: publicKey.export({ format: "jwk" }),
384
+ algorithm: "RSA-OAEP-256",
385
+ expiresAt: new Date(expiresAt).toISOString(),
386
+ };
387
+ } finally {
388
+ pendingKeyIssuances -= 1;
389
+ }
390
+ },
391
+
392
+ async message(untrustedRequest, options = {}) {
393
+ const request = validateMessageRequest(untrustedRequest);
394
+ const onEvent =
395
+ typeof options.onEvent === "function" ? options.onEvent : null;
396
+ const externalSignal =
397
+ options.signal instanceof AbortSignal ? options.signal : null;
398
+ const session = getLiveSession(request.keyId);
399
+ if (session.inFlight) {
400
+ throw requestError("Wait for the current test message to finish.", 409);
401
+ }
402
+ session.inFlight = true;
403
+ let encryptedCredential;
404
+ let decryptedCredential;
405
+ let authorization;
406
+ let abortFromCaller;
407
+ let timeout;
408
+ try {
409
+ encryptedCredential = decodeCiphertext(request.encryptedCredential);
410
+ try {
411
+ decryptedCredential = privateDecrypt(
412
+ {
413
+ key: session.privateKey,
414
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
415
+ oaepHash: "sha256",
416
+ },
417
+ encryptedCredential,
418
+ );
419
+ } catch {
420
+ throw requestError("Secure the client credential again before testing.");
421
+ }
422
+ if (
423
+ decryptedCredential.length === 0 ||
424
+ decryptedCredential.length > 512 ||
425
+ /[\r\n\u0000]/u.test(decryptedCredential.toString("utf8"))
426
+ ) {
427
+ throw requestError("Secure the client credential again before testing.");
428
+ }
429
+ authorization = `Bearer ${decryptedCredential.toString("utf8")}`;
430
+
431
+ let response;
432
+ try {
433
+ session.abortController = new AbortController();
434
+ abortFromCaller = () => session.abortController?.abort();
435
+ if (externalSignal?.aborted) {
436
+ abortFromCaller();
437
+ } else {
438
+ externalSignal?.addEventListener("abort", abortFromCaller, {
439
+ once: true,
440
+ });
441
+ }
442
+ timeout = setTimeout(
443
+ () => session.abortController?.abort(),
444
+ requestTimeoutMs,
445
+ );
446
+ response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
447
+ method: "POST",
448
+ headers: {
449
+ Accept: onEvent ? "text/event-stream" : "application/json",
450
+ Authorization: authorization,
451
+ "Content-Type": "application/json",
452
+ },
453
+ body: JSON.stringify({
454
+ input: request.input,
455
+ ...(request.conversationId !== undefined
456
+ ? { conversationId: request.conversationId }
457
+ : {}),
458
+ }),
459
+ redirect: "error",
460
+ signal: session.abortController.signal,
461
+ });
462
+ } catch (error) {
463
+ throw adapterError(isTimeout(error) ? 504 : 502);
464
+ }
465
+ if (!response?.ok || response.status < 200 || response.status >= 300) {
466
+ throw adapterError();
467
+ }
468
+ return onEvent
469
+ ? await consumeAdapterStream(response, onEvent)
470
+ : parseAdapterResponse(await readBoundedResponse(response));
471
+ } finally {
472
+ clearTimeout(timeout);
473
+ externalSignal?.removeEventListener("abort", abortFromCaller);
474
+ authorization = undefined;
475
+ encryptedCredential?.fill(0);
476
+ decryptedCredential?.fill(0);
477
+ session.abortController = null;
478
+ session.inFlight = false;
479
+ }
480
+ },
481
+
482
+ async reset(request) {
483
+ if (!isPlainObject(request) || !isBoundedText(request.keyId, 128)) {
484
+ throw requestError("Choose a valid tester session to forget.");
485
+ }
486
+ const session = sessions.get(request.keyId);
487
+ if (session) {
488
+ expireSession(request.keyId, session);
489
+ }
490
+ return { forgotten: true };
491
+ },
492
+
493
+ resetAll() {
494
+ resetAllSessions();
495
+ },
496
+
497
+ dispose() {
498
+ resetAllSessions();
499
+ },
500
+ };
501
+ }
package/src/ui/local.css CHANGED
@@ -6,6 +6,29 @@
6
6
  scroll-behavior: smooth;
7
7
  }
8
8
 
9
+ .local-wizard .local-eyebrow {
10
+ margin: 0 0 0.4rem;
11
+ color: var(--accent-deep);
12
+ font: 800 0.6875rem/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ letter-spacing: 0.12em;
14
+ text-transform: uppercase;
15
+ }
16
+
17
+ .local-wizard .rail {
18
+ padding: 1rem;
19
+ border: 1px solid var(--border);
20
+ border-radius: var(--radius);
21
+ background: color-mix(in srgb, var(--surface) 78%, transparent);
22
+ }
23
+
24
+ .local-wizard .steps li {
25
+ min-height: 2.5rem;
26
+ }
27
+
28
+ .local-wizard .steps li[aria-current="step"] em {
29
+ color: var(--text);
30
+ }
31
+
9
32
  .local-wizard .status-row {
10
33
  margin-bottom: 1rem;
11
34
  }
@@ -365,6 +388,165 @@
365
388
  font-weight: 700;
366
389
  }
367
390
 
391
+ .chat-tester {
392
+ display: grid;
393
+ gap: 1rem;
394
+ margin-top: 1.25rem;
395
+ padding: clamp(1rem, 2vw, 1.5rem);
396
+ border: 1px solid var(--accent-border);
397
+ border-radius: var(--radius);
398
+ background:
399
+ linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
400
+ linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
401
+ var(--accent-soft);
402
+ background-size: 28px 28px;
403
+ }
404
+
405
+ .chat-tester-heading {
406
+ display: flex;
407
+ align-items: flex-start;
408
+ justify-content: space-between;
409
+ gap: 1rem;
410
+ }
411
+
412
+ .chat-tester-heading h3 {
413
+ margin: 0.2rem 0 0;
414
+ }
415
+
416
+ .chat-tester-heading p:not(.section-label) {
417
+ margin: 0.45rem 0 0;
418
+ color: var(--text-muted);
419
+ }
420
+
421
+ .chat-tester-heading .section-label {
422
+ margin: 0;
423
+ }
424
+
425
+ .chat-tester-route {
426
+ display: grid;
427
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
428
+ align-items: center;
429
+ gap: 0.625rem;
430
+ padding: 0.75rem;
431
+ border: 1px solid var(--accent-border);
432
+ border-radius: var(--radius-sm);
433
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
434
+ }
435
+
436
+ .chat-tester-route > span {
437
+ display: grid;
438
+ gap: 0.2rem;
439
+ min-width: 0;
440
+ }
441
+
442
+ .chat-tester-route strong,
443
+ .chat-tester-route small {
444
+ overflow-wrap: anywhere;
445
+ }
446
+
447
+ .chat-tester-route strong {
448
+ font-size: 0.8125rem;
449
+ }
450
+
451
+ .chat-tester-route small {
452
+ color: var(--text-muted);
453
+ font: 0.6875rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
454
+ }
455
+
456
+ .chat-tester-route i {
457
+ color: var(--accent-deep);
458
+ font-style: normal;
459
+ }
460
+
461
+ .chat-tester-form fieldset {
462
+ display: grid;
463
+ grid-template-columns: repeat(2, minmax(0, 1fr));
464
+ gap: 0.875rem;
465
+ margin: 0;
466
+ padding: 0;
467
+ border: 0;
468
+ }
469
+
470
+ .chat-tester-form legend {
471
+ grid-column: 1 / -1;
472
+ width: 100%;
473
+ margin-bottom: 0.5rem;
474
+ font-weight: 800;
475
+ }
476
+
477
+ .chat-tester-form .button,
478
+ .chat-tester-message-form .button {
479
+ margin-top: 0.875rem;
480
+ }
481
+
482
+ .chat-tester-status,
483
+ .chat-tester-error {
484
+ margin: 0;
485
+ padding: 0.75rem 0.875rem;
486
+ border-radius: var(--radius-sm);
487
+ font-size: 0.875rem;
488
+ overflow-wrap: anywhere;
489
+ }
490
+
491
+ .chat-tester-status {
492
+ border: 1px solid var(--accent-border);
493
+ background: var(--surface);
494
+ color: var(--text-muted);
495
+ }
496
+
497
+ .chat-tester-error {
498
+ border: 1px solid var(--warning-border);
499
+ background: var(--warning-soft);
500
+ color: var(--warning);
501
+ }
502
+
503
+ .chat-tester-transcript {
504
+ display: grid;
505
+ gap: 0.75rem;
506
+ max-height: 28rem;
507
+ margin: 0;
508
+ padding: 0;
509
+ overflow: auto;
510
+ list-style: none;
511
+ }
512
+
513
+ .chat-tester-turn {
514
+ max-width: min(100%, 46rem);
515
+ padding: 0.75rem 0.875rem;
516
+ border: 1px solid var(--border-strong);
517
+ border-radius: var(--radius-sm);
518
+ background: var(--surface);
519
+ }
520
+
521
+ .chat-tester-turn-assistant {
522
+ border-color: var(--accent-border);
523
+ background: var(--accent-soft);
524
+ }
525
+
526
+ .chat-tester-turn-incomplete {
527
+ border-color: var(--warning-border);
528
+ border-style: dashed;
529
+ background: var(--warning-soft);
530
+ }
531
+
532
+ .chat-tester-turn-incomplete strong {
533
+ color: var(--warning);
534
+ }
535
+
536
+ .chat-tester-turn strong {
537
+ display: block;
538
+ margin-bottom: 0.25rem;
539
+ font-size: 0.75rem;
540
+ letter-spacing: 0.02em;
541
+ text-transform: uppercase;
542
+ }
543
+
544
+ .chat-tester-turn p {
545
+ margin: 0;
546
+ overflow-wrap: anywhere;
547
+ white-space: pre-wrap;
548
+ }
549
+
368
550
  .local-wizard a.button {
369
551
  text-decoration: none;
370
552
  }
@@ -379,10 +561,23 @@
379
561
  @media (max-width: 48rem) {
380
562
  .target-picker,
381
563
  .local-fields,
382
- .local-review-grid {
564
+ .local-review-grid,
565
+ .chat-tester-form fieldset {
383
566
  grid-template-columns: minmax(0, 1fr);
384
567
  }
385
568
 
569
+ .chat-tester-heading {
570
+ flex-direction: column;
571
+ }
572
+
573
+ .chat-tester-route {
574
+ grid-template-columns: minmax(0, 1fr);
575
+ }
576
+
577
+ .chat-tester-route i {
578
+ transform: rotate(90deg);
579
+ }
580
+
386
581
  .review-details div {
387
582
  grid-template-columns: minmax(0, 1fr);
388
583
  gap: 0.2rem;