chatpanel-gateway 0.1.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/src/stream.js ADDED
@@ -0,0 +1,83 @@
1
+ // Protocol-agnostic stream restorer.
2
+ //
3
+ // Both OpenAI and Anthropic stream Server-Sent Events whose payloads embed the
4
+ // model's text (and tool-call argument JSON) as plain strings. Our placeholders
5
+ // are literal, well-formed substrings ([[TYPE_n]]) wherever they appear — inside
6
+ // "delta.content", inside a streamed "arguments" JSON string, anywhere. So we can
7
+ // restore them with a single pass over the raw outgoing bytes, regardless of
8
+ // protocol, holding back a tail when a token might be split across chunks.
9
+ //
10
+ // Note on pseudonyms: a dictionary `alias` is substituted at REDACTION time (the
11
+ // upstream already sees the alias, never a placeholder), so it flows back to the
12
+ // agent unchanged — its defined "permanent substitution" behavior. Only reversible
13
+ // [[TYPE_n]] tokens are restored here.
14
+
15
+ import { restoreText } from 'chatpanel-pii';
16
+
17
+ // Returns a TransformStream-free chunk transformer: feed it decoded string chunks,
18
+ // it returns the prefix that's safe to forward now and buffers a possibly-partial
19
+ // trailing token. Call flush() at end-of-stream.
20
+ export function makeTokenRestorer(vault) {
21
+ let buf = '';
22
+ return {
23
+ push(chunk) {
24
+ if (!vault) return chunk || '';
25
+ buf += chunk || '';
26
+ // If an unterminated "[[" sits in the tail, a token may still be forming —
27
+ // hold from there. "[[" can't legitimately appear except as a token open.
28
+ const open = buf.lastIndexOf('[[');
29
+ let safe;
30
+ if (open !== -1 && !buf.slice(open).includes(']]')) {
31
+ safe = buf.slice(0, open);
32
+ buf = buf.slice(open);
33
+ } else {
34
+ safe = buf;
35
+ buf = '';
36
+ }
37
+ return restoreText(safe, vault);
38
+ },
39
+ flush() {
40
+ const out = vault ? restoreText(buf, vault) : buf;
41
+ buf = '';
42
+ return out;
43
+ },
44
+ };
45
+ }
46
+
47
+ // Deep-restore a parsed value (non-streaming responses): tool-call argument
48
+ // objects hold placeholders inside their string fields. Walks strings/arrays/
49
+ // objects, restoring reversible tokens. (Streaming uses makeTokenRestorer; this
50
+ // is only for the buffered/non-stream path.)
51
+ export function restoreDeep(value, vault) {
52
+ if (!vault) return value;
53
+ if (typeof value === 'string') return restoreText(value, vault);
54
+ if (Array.isArray(value)) return value.map((v) => restoreDeep(v, vault));
55
+ if (value && typeof value === 'object') {
56
+ const out = {};
57
+ for (const k of Object.keys(value)) out[k] = restoreDeep(value[k], vault);
58
+ return out;
59
+ }
60
+ return value;
61
+ }
62
+
63
+ // Pipe a fetch Response body (web ReadableStream) through the restorer into a
64
+ // Node response. Works on raw bytes decoded as UTF-8 — fine because placeholders
65
+ // are ASCII, so even if a multibyte char is split the token bytes are intact.
66
+ export async function pipeRestoredStream(upstreamBody, nodeRes, vault) {
67
+ const restorer = makeTokenRestorer(vault);
68
+ const decoder = new TextDecoder();
69
+ const reader = upstreamBody.getReader();
70
+ try {
71
+ for (;;) {
72
+ const { done, value } = await reader.read();
73
+ if (done) break;
74
+ const text = decoder.decode(value, { stream: true });
75
+ const out = restorer.push(text);
76
+ if (out) nodeRes.write(out);
77
+ }
78
+ const tail = restorer.push(decoder.decode()) + restorer.flush();
79
+ if (tail) nodeRes.write(tail);
80
+ } finally {
81
+ nodeRes.end();
82
+ }
83
+ }