haechi 1.1.2 → 1.3.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.ko.md +46 -11
- package/README.md +46 -11
- package/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +165 -9
- package/docs/current/configuration.md +165 -9
- package/docs/current/operations-runbook.ko.md +155 -0
- package/docs/current/operations-runbook.md +241 -0
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +5 -3
- package/docs/current/risk-register-release-gate.md +13 -3
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +4 -2
- package/docs/current/threat-model.md +4 -2
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +20 -3
- package/package.json +7 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +57 -10
- package/packages/cli/runtime.mjs +402 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +975 -12
- package/packages/metrics/index.mjs +181 -0
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +525 -40
- package/packages/stream-filter/index.mjs +69 -7
|
@@ -10,10 +10,40 @@ const SSE_DONE = "[DONE]";
|
|
|
10
10
|
export async function inspectResponseStream({ source, sink, streaming, protector, format }) {
|
|
11
11
|
const wireFormat = format ?? streaming?.format ?? "ndjson";
|
|
12
12
|
const deltaPath = streaming?.deltaPath ?? null;
|
|
13
|
+
// Frame types that TERMINATE a delta sequence (declared per-adapter, e.g.
|
|
14
|
+
// Anthropic's content_block_stop/message_delta/message_stop). Before such a
|
|
15
|
+
// frame the held cross-frame buffer tail is flushed as a valid delta frame, so
|
|
16
|
+
// the residual lands in-order BEFORE the terminator — never after message_stop.
|
|
17
|
+
// Keepalives (ping) are deliberately NOT listed, so a match split across a ping
|
|
18
|
+
// is still caught by the sliding buffer.
|
|
19
|
+
const flushOnType = streaming?.flushOnType ?? null;
|
|
13
20
|
const decoder = new TextDecoder("utf-8");
|
|
14
21
|
const frames = createFrameSplitter(wireFormat);
|
|
15
22
|
|
|
16
23
|
let blocked = false;
|
|
24
|
+
// A structural template of the last frame that carried delta text, used to
|
|
25
|
+
// re-emit a held buffer tail as a VALID delta frame (preserving its wire
|
|
26
|
+
// wrapper — Anthropic's `event:` line — plus sibling fields like type/index).
|
|
27
|
+
let lastDeltaTemplate = null;
|
|
28
|
+
|
|
29
|
+
async function flushHeldTail() {
|
|
30
|
+
const flushed = await protector.flush();
|
|
31
|
+
if (flushed.blocked) {
|
|
32
|
+
blocked = true;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!flushed.text || !deltaPath) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (lastDeltaTemplate) {
|
|
39
|
+
const object = structuredClone(lastDeltaTemplate.object);
|
|
40
|
+
setByPath(object, deltaPath, flushed.text);
|
|
41
|
+
sink.write(serializeFrame(object, wireFormat, lastDeltaTemplate.original));
|
|
42
|
+
} else {
|
|
43
|
+
// No prior delta frame to model — fall back to a minimal synthesized frame.
|
|
44
|
+
sink.write(serializeFrame(buildPathObject(deltaPath, flushed.text), wireFormat, null));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
17
47
|
|
|
18
48
|
async function handleFrame(raw) {
|
|
19
49
|
const frame = { raw, body: raw.trim() };
|
|
@@ -26,6 +56,16 @@ export async function inspectResponseStream({ source, sink, streaming, protector
|
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
const json = parsed.json;
|
|
59
|
+
|
|
60
|
+
// A delta-terminating frame: flush the held tail (as a valid delta frame)
|
|
61
|
+
// before emitting it, so the residual is correctly ordered.
|
|
62
|
+
if (flushOnType && flushOnType.values.includes(getByPath(json, flushOnType.path))) {
|
|
63
|
+
await flushHeldTail();
|
|
64
|
+
if (blocked) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
29
69
|
let deltaText = null;
|
|
30
70
|
if (deltaPath) {
|
|
31
71
|
const found = getByPath(json, deltaPath);
|
|
@@ -50,6 +90,8 @@ export async function inspectResponseStream({ source, sink, streaming, protector
|
|
|
50
90
|
return;
|
|
51
91
|
}
|
|
52
92
|
setByPath(frameObject, deltaPath, pushed.text);
|
|
93
|
+
// Snapshot this frame's structure + wire wrapper as the flush template.
|
|
94
|
+
lastDeltaTemplate = { object: structuredClone(frameObject), original: frame };
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
sink.write(serializeFrame(frameObject, wireFormat, frame));
|
|
@@ -77,13 +119,8 @@ export async function inspectResponseStream({ source, sink, streaming, protector
|
|
|
77
119
|
}
|
|
78
120
|
|
|
79
121
|
if (!blocked) {
|
|
80
|
-
// Flush
|
|
81
|
-
|
|
82
|
-
if (flushed.blocked) {
|
|
83
|
-
blocked = true;
|
|
84
|
-
} else if (flushed.text && deltaPath) {
|
|
85
|
-
sink.write(serializeFrame(buildPathObject(deltaPath, flushed.text), wireFormat, null));
|
|
86
|
-
}
|
|
122
|
+
// Flush any remaining held tail (a stream that ended on a delta frame).
|
|
123
|
+
await flushHeldTail();
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
// The caller closes the sink AFTER recording the stream decision, so the
|
|
@@ -148,6 +185,31 @@ function parseFrame(frame, format) {
|
|
|
148
185
|
function serializeFrame(json, format, original) {
|
|
149
186
|
const body = JSON.stringify(json);
|
|
150
187
|
if (format === "sse") {
|
|
188
|
+
// Preserve the original SSE field lines (`event:`, `id:`, `retry:`, `:`
|
|
189
|
+
// comments) and substitute only the data payload. Event-typed streams
|
|
190
|
+
// (Anthropic Messages) dispatch on the `event:` line, so dropping it would
|
|
191
|
+
// make the stream unconsumable. OpenAI-style frames carry only a `data:`
|
|
192
|
+
// line, so the output is byte-identical to `data: ${body}\n\n`.
|
|
193
|
+
if (original && typeof original.raw === "string") {
|
|
194
|
+
const lines = original.raw.replace(/\n+$/, "").split("\n");
|
|
195
|
+
const out = [];
|
|
196
|
+
let dataWritten = false;
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
if (line.startsWith("data:")) {
|
|
199
|
+
// Collapse any (multi-line) data payload into the single new body.
|
|
200
|
+
if (!dataWritten) {
|
|
201
|
+
out.push(`data: ${body}`);
|
|
202
|
+
dataWritten = true;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
out.push(line);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!dataWritten) {
|
|
209
|
+
out.push(`data: ${body}`);
|
|
210
|
+
}
|
|
211
|
+
return `${out.join("\n")}\n\n`;
|
|
212
|
+
}
|
|
151
213
|
return `data: ${body}\n\n`;
|
|
152
214
|
}
|
|
153
215
|
// NDJSON: preserve the original trailing newline style when available.
|