haechi 0.4.0 → 0.6.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 +264 -0
- package/README.md +50 -4
- package/docs/README.md +4 -6
- package/docs/current/api-stability.ko.md +4 -1
- package/docs/current/api-stability.md +4 -1
- package/docs/current/configuration.ko.md +233 -0
- package/docs/current/configuration.md +233 -0
- package/docs/current/release-0.5-implementation-scope.ko.md +69 -0
- package/docs/current/release-0.5-implementation-scope.md +69 -0
- package/docs/current/release-0.6-implementation-scope.ko.md +151 -0
- package/docs/current/release-0.6-implementation-scope.md +151 -0
- package/docs/current/release-process.ko.md +2 -2
- package/docs/current/release-process.md +2 -2
- package/docs/current/risk-register-release-gate.ko.md +3 -3
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/threat-model.ko.md +8 -4
- package/docs/current/threat-model.md +8 -4
- package/haechi.config.example.json +13 -1
- package/package.json +4 -2
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +253 -27
- package/packages/cli/runtime.mjs +113 -7
- package/packages/core/index.mjs +126 -6
- package/packages/policy/index.mjs +82 -0
- package/packages/protocol-adapters/index.mjs +33 -14
- package/packages/proxy/index.mjs +237 -4
- package/packages/stream-filter/index.mjs +194 -0
package/packages/cli/runtime.mjs
CHANGED
|
@@ -2,13 +2,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { createHaechi } from "../core/index.mjs";
|
|
4
4
|
import { createDefaultFilterEngine } from "../filter/index.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { createPolicyProfiles } from "../policy/index.mjs";
|
|
6
6
|
import { createLocalCryptoProvider, initLocalKeyFile } from "../crypto/index.mjs";
|
|
7
7
|
import { createJsonlAuditSink } from "../audit/index.mjs";
|
|
8
8
|
import { createLocalTokenVault } from "../token-vault/index.mjs";
|
|
9
9
|
import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
|
|
10
10
|
import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
|
|
11
11
|
import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
|
|
12
|
+
import { createBearerAuthProvider } from "../auth/index.mjs";
|
|
12
13
|
import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
|
|
13
14
|
|
|
14
15
|
export const DEFAULT_CONFIG_PATH = "haechi.config.json";
|
|
@@ -34,7 +35,9 @@ export function defaultConfig() {
|
|
|
34
35
|
maxBytes: 1048576
|
|
35
36
|
},
|
|
36
37
|
streaming: {
|
|
37
|
-
requestMode: "block"
|
|
38
|
+
requestMode: "block",
|
|
39
|
+
responseMode: "enforce",
|
|
40
|
+
maxMatchBytes: 256
|
|
38
41
|
},
|
|
39
42
|
limits: {
|
|
40
43
|
maxRequestBytes: 1048576,
|
|
@@ -71,6 +74,11 @@ export function defaultConfig() {
|
|
|
71
74
|
privacy: {
|
|
72
75
|
profile: null
|
|
73
76
|
},
|
|
77
|
+
auth: {
|
|
78
|
+
provider: "none",
|
|
79
|
+
store: ".haechi/auth.json",
|
|
80
|
+
allowedLabelKeys: ["team", "env", "tier", "role"]
|
|
81
|
+
},
|
|
74
82
|
mcp: {
|
|
75
83
|
allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
|
|
76
84
|
protectParams: true,
|
|
@@ -133,19 +141,25 @@ export function createRuntime(config, providers = {}) {
|
|
|
133
141
|
...normalized.policy,
|
|
134
142
|
mode: normalized.policy.mode ?? normalized.mode
|
|
135
143
|
};
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
const policyProfiles = createPolicyProfiles(policySource, {
|
|
145
|
+
transform: (source) => normalized.privacy.profile
|
|
146
|
+
? applyPrivacyProfile(source, normalized.privacy.profile)
|
|
147
|
+
: source
|
|
148
|
+
});
|
|
139
149
|
|
|
140
150
|
const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
|
|
141
151
|
assertProvider("filterEngine", filterEngine, ["detect"]);
|
|
142
|
-
const policyEngine = providers.policyEngine ??
|
|
152
|
+
const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
|
|
143
153
|
assertProvider("policyEngine", policyEngine, ["decide"]);
|
|
144
154
|
|
|
155
|
+
const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
|
|
156
|
+
|
|
145
157
|
return {
|
|
146
158
|
config: normalized,
|
|
147
159
|
tokenVault,
|
|
148
160
|
auditSink,
|
|
161
|
+
authProvider,
|
|
162
|
+
policyProfiles,
|
|
149
163
|
protocolAdapter: createProtocolAdapter(normalized.target),
|
|
150
164
|
haechi: createHaechi({
|
|
151
165
|
mode: normalized.mode,
|
|
@@ -210,6 +224,11 @@ export function normalizeConfig(config) {
|
|
|
210
224
|
...defaultConfig().privacy,
|
|
211
225
|
...(config.privacy ?? {})
|
|
212
226
|
},
|
|
227
|
+
auth: {
|
|
228
|
+
...defaultConfig().auth,
|
|
229
|
+
...(config.auth ?? {}),
|
|
230
|
+
allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
|
|
231
|
+
},
|
|
213
232
|
mcp: {
|
|
214
233
|
...defaultConfig().mcp,
|
|
215
234
|
...(config.mcp ?? {}),
|
|
@@ -271,15 +290,32 @@ export function normalizeConfig(config) {
|
|
|
271
290
|
if (typeof merged.responseProtection.maxBytes !== "number" || merged.responseProtection.maxBytes < 1) {
|
|
272
291
|
throw new Error("responseProtection.maxBytes must be a positive number");
|
|
273
292
|
}
|
|
274
|
-
if (!["block", "pass-through"].includes(merged.streaming.requestMode)) {
|
|
293
|
+
if (!["block", "pass-through", "inspect"].includes(merged.streaming.requestMode)) {
|
|
275
294
|
throw new Error(`Invalid streaming.requestMode: ${merged.streaming.requestMode}`);
|
|
276
295
|
}
|
|
296
|
+
if (!["dry-run", "report-only", "enforce"].includes(merged.streaming.responseMode)) {
|
|
297
|
+
throw new Error(`Invalid streaming.responseMode: ${merged.streaming.responseMode}`);
|
|
298
|
+
}
|
|
299
|
+
if (typeof merged.streaming.maxMatchBytes !== "number" || merged.streaming.maxMatchBytes < 1) {
|
|
300
|
+
throw new Error("streaming.maxMatchBytes must be a positive number");
|
|
301
|
+
}
|
|
277
302
|
if (typeof merged.limits.maxRequestBytes !== "number" || merged.limits.maxRequestBytes < 1) {
|
|
278
303
|
throw new Error("limits.maxRequestBytes must be a positive number");
|
|
279
304
|
}
|
|
280
305
|
if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
|
|
281
306
|
throw new Error("limits.upstreamTimeoutMs must be a positive number");
|
|
282
307
|
}
|
|
308
|
+
validatePolicyExtras(merged.policy);
|
|
309
|
+
if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
|
|
310
|
+
throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
|
|
311
|
+
}
|
|
312
|
+
if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
|
|
313
|
+
throw new Error("auth.store must be a non-empty string");
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(merged.auth.allowedLabelKeys)
|
|
316
|
+
|| !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
|
|
317
|
+
throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
|
|
318
|
+
}
|
|
283
319
|
createProtocolAdapter(merged.target);
|
|
284
320
|
return merged;
|
|
285
321
|
}
|
|
@@ -288,6 +324,76 @@ export function isValidPort(port) {
|
|
|
288
324
|
return Number.isInteger(port) && port >= 0 && port <= 65535;
|
|
289
325
|
}
|
|
290
326
|
|
|
327
|
+
function validatePolicyExtras(policy) {
|
|
328
|
+
if (policy.modelAllowlist !== undefined) {
|
|
329
|
+
assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
|
|
330
|
+
}
|
|
331
|
+
if (policy.rate !== undefined) {
|
|
332
|
+
assertRate(policy.rate, "policy.rate");
|
|
333
|
+
}
|
|
334
|
+
if (policy.profiles !== undefined) {
|
|
335
|
+
if (typeof policy.profiles !== "object" || policy.profiles === null || Array.isArray(policy.profiles)) {
|
|
336
|
+
throw new Error("policy.profiles must be an object of named profiles");
|
|
337
|
+
}
|
|
338
|
+
for (const [name, profile] of Object.entries(policy.profiles)) {
|
|
339
|
+
if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
|
|
340
|
+
throw new Error(`policy.profiles.${name} must be an object`);
|
|
341
|
+
}
|
|
342
|
+
if (profile.modelAllowlist !== undefined) {
|
|
343
|
+
assertModelAllowlist(profile.modelAllowlist, `policy.profiles.${name}.modelAllowlist`);
|
|
344
|
+
}
|
|
345
|
+
if (profile.rate !== undefined) {
|
|
346
|
+
assertRate(profile.rate, `policy.profiles.${name}.rate`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (policy.profileBinding !== undefined) {
|
|
351
|
+
const binding = policy.profileBinding;
|
|
352
|
+
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
|
|
353
|
+
throw new Error("policy.profileBinding must be an object");
|
|
354
|
+
}
|
|
355
|
+
if (typeof binding.default !== "string" || !binding.default.trim()) {
|
|
356
|
+
throw new Error("policy.profileBinding.default must be a profile name");
|
|
357
|
+
}
|
|
358
|
+
for (const field of ["byScope", "byLabel"]) {
|
|
359
|
+
if (binding[field] !== undefined
|
|
360
|
+
&& (typeof binding[field] !== "object" || binding[field] === null || Array.isArray(binding[field]))) {
|
|
361
|
+
throw new Error(`policy.profileBinding.${field} must be an object`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function assertModelAllowlist(value, label) {
|
|
368
|
+
if (!Array.isArray(value) || !value.every((model) => typeof model === "string" && model.trim())) {
|
|
369
|
+
throw new Error(`${label} must be an array of non-empty strings`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function assertRate(value, label) {
|
|
374
|
+
if (typeof value !== "object" || value === null
|
|
375
|
+
|| typeof value.requestsPerMinute !== "number" || value.requestsPerMinute < 1) {
|
|
376
|
+
throw new Error(`${label}.requestsPerMinute must be a positive number`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resolveAuthProvider(config, providers, cryptoProvider) {
|
|
381
|
+
if (config.auth.provider === "external") {
|
|
382
|
+
if (typeof providers.authProvider?.authenticate !== "function") {
|
|
383
|
+
throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
|
|
384
|
+
}
|
|
385
|
+
return providers.authProvider;
|
|
386
|
+
}
|
|
387
|
+
if (providers.authProvider) {
|
|
388
|
+
// An injected provider overrides the built-in selection.
|
|
389
|
+
return providers.authProvider;
|
|
390
|
+
}
|
|
391
|
+
if (config.auth.provider === "bearer") {
|
|
392
|
+
return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
291
397
|
function createConfiguredCryptoProvider(config) {
|
|
292
398
|
if (config.keys.provider === "external") {
|
|
293
399
|
throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
|
package/packages/core/index.mjs
CHANGED
|
@@ -7,14 +7,19 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
7
7
|
throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
async function protectJson(payload,
|
|
10
|
+
async function protectJson(payload, rawContext = {}) {
|
|
11
|
+
// A per-request policy engine (a named profile selected from identity)
|
|
12
|
+
// overrides the default. It is a control object, NOT data: strip it before
|
|
13
|
+
// anything downstream (tokenize AAD, audit) sees the context.
|
|
14
|
+
const { policyEngine: contextEngine, ...context } = rawContext;
|
|
11
15
|
const effectiveMode = context.mode ?? mode;
|
|
16
|
+
const engine = contextEngine ?? policyEngine;
|
|
12
17
|
const entries = collectStringEntries(payload);
|
|
13
18
|
const detections = await filterEngine.detect({ entries, context });
|
|
14
19
|
const decisions = [];
|
|
15
20
|
|
|
16
21
|
for (const detection of detections) {
|
|
17
|
-
decisions.push(await
|
|
22
|
+
decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
@@ -51,7 +56,120 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
// Stateful protector for an incremental text stream (SSE/NDJSON deltas).
|
|
60
|
+
// Holds a bounded raw tail so a detection split across chunk boundaries is
|
|
61
|
+
// caught before the leading part is emitted. maxMatchBytes bounds the
|
|
62
|
+
// guarantee: a single match longer than it may still split across frames.
|
|
63
|
+
function createStreamProtector(rawContext = {}) {
|
|
64
|
+
// Strip the control-object policy engine from the data context (see
|
|
65
|
+
// protectJson) so it cannot leak into tokenize AAD or audit.
|
|
66
|
+
const { policyEngine: contextEngine, ...context } = rawContext;
|
|
67
|
+
const effectiveMode = context.mode ?? mode;
|
|
68
|
+
const engine = contextEngine ?? policyEngine;
|
|
69
|
+
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
70
|
+
const maxMatchBytes = context.maxMatchBytes ?? 256;
|
|
71
|
+
const byType = {};
|
|
72
|
+
const byAction = {};
|
|
73
|
+
let detectionCount = 0;
|
|
74
|
+
let pending = "";
|
|
75
|
+
|
|
76
|
+
function tally(detections, decisions) {
|
|
77
|
+
detections.forEach((detection, index) => {
|
|
78
|
+
byType[detection.type] = (byType[detection.type] ?? 0) + 1;
|
|
79
|
+
const action = decisions[index]?.action ?? "unknown";
|
|
80
|
+
byAction[action] = (byAction[action] ?? 0) + 1;
|
|
81
|
+
detectionCount += 1;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function decideAll(detections) {
|
|
86
|
+
const decisions = [];
|
|
87
|
+
for (const detection of detections) {
|
|
88
|
+
decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
|
|
89
|
+
}
|
|
90
|
+
return decisions;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Transform a complete, committed text segment.
|
|
94
|
+
async function transformSegment(text) {
|
|
95
|
+
const detections = await filterEngine.detect({
|
|
96
|
+
entries: collectStringEntries(text),
|
|
97
|
+
context
|
|
98
|
+
});
|
|
99
|
+
const decisions = await decideAll(detections);
|
|
100
|
+
tally(detections, decisions);
|
|
101
|
+
const blocked = enforced && decisions.some((decision) => decision.action === "block");
|
|
102
|
+
if (blocked) {
|
|
103
|
+
return { text: "", blocked: true };
|
|
104
|
+
}
|
|
105
|
+
if (!enforced || detections.length === 0) {
|
|
106
|
+
return { text, blocked: false };
|
|
107
|
+
}
|
|
108
|
+
const items = detections.map((detection, index) => ({ detection, decision: decisions[index] }));
|
|
109
|
+
const transformed = await transformString(text, items, { context, cryptoProvider, tokenVault, issuedTokens: null });
|
|
110
|
+
return { text: transformed, blocked: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
// Protect string leaves of a parsed frame OTHER than the incremental
|
|
115
|
+
// delta text (e.g. tool-call arguments). Returns the mutated object.
|
|
116
|
+
async protectFrameExtras(value) {
|
|
117
|
+
const detections = await filterEngine.detect({
|
|
118
|
+
entries: collectStringEntries(value),
|
|
119
|
+
context
|
|
120
|
+
});
|
|
121
|
+
if (detections.length === 0) {
|
|
122
|
+
return { value, blocked: false };
|
|
123
|
+
}
|
|
124
|
+
const decisions = await decideAll(detections);
|
|
125
|
+
tally(detections, decisions);
|
|
126
|
+
const blocked = enforced && decisions.some((decision) => decision.action === "block");
|
|
127
|
+
if (blocked) {
|
|
128
|
+
return { value: null, blocked: true };
|
|
129
|
+
}
|
|
130
|
+
if (!enforced) {
|
|
131
|
+
return { value, blocked: false };
|
|
132
|
+
}
|
|
133
|
+
const transformed = await transformPayload(value, detections, decisions, {
|
|
134
|
+
context, cryptoProvider, tokenVault, enforced
|
|
135
|
+
});
|
|
136
|
+
return { value: transformed, blocked: false };
|
|
137
|
+
},
|
|
138
|
+
// Append incremental text; return the portion safe to emit now.
|
|
139
|
+
async push(text) {
|
|
140
|
+
pending += text;
|
|
141
|
+
const detections = await filterEngine.detect({
|
|
142
|
+
entries: collectStringEntries(pending),
|
|
143
|
+
context
|
|
144
|
+
});
|
|
145
|
+
let commit = Math.max(0, pending.length - maxMatchBytes);
|
|
146
|
+
const straddlers = detections.filter((detection) => detection.end > commit);
|
|
147
|
+
if (straddlers.length > 0) {
|
|
148
|
+
commit = Math.min(commit, ...straddlers.map((detection) => detection.start));
|
|
149
|
+
}
|
|
150
|
+
if (commit <= 0) {
|
|
151
|
+
return { text: "", blocked: false };
|
|
152
|
+
}
|
|
153
|
+
const head = pending.slice(0, commit);
|
|
154
|
+
pending = pending.slice(commit);
|
|
155
|
+
return transformSegment(head);
|
|
156
|
+
},
|
|
157
|
+
// Drain the held tail at end of stream (no more cross-frame risk).
|
|
158
|
+
async flush() {
|
|
159
|
+
const tail = pending;
|
|
160
|
+
pending = "";
|
|
161
|
+
if (!tail) {
|
|
162
|
+
return { text: "", blocked: false };
|
|
163
|
+
}
|
|
164
|
+
return transformSegment(tail);
|
|
165
|
+
},
|
|
166
|
+
summary() {
|
|
167
|
+
return { detectionCount, byType, byAction };
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { protectJson, createStreamProtector };
|
|
55
173
|
}
|
|
56
174
|
|
|
57
175
|
export function collectStringEntries(value, path = []) {
|
|
@@ -269,9 +387,11 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
|
|
|
269
387
|
timestamp: new Date().toISOString(),
|
|
270
388
|
protocol: context.protocol ?? "custom",
|
|
271
389
|
operation: context.operation ?? "protect",
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
390
|
+
// PII-safe identity built by the auth layer (subject/issuer are keyed
|
|
391
|
+
// HMACs); null when no auth is configured. `profile` is the resolved
|
|
392
|
+
// policy profile name (or null).
|
|
393
|
+
identity: context.identity ?? null,
|
|
394
|
+
profile: context.profile ?? null,
|
|
275
395
|
mode,
|
|
276
396
|
enforced,
|
|
277
397
|
blocked,
|
|
@@ -130,6 +130,88 @@ export function createPolicyEngine(policy) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Compiles the base policy plus every named profile into ready policy engines
|
|
134
|
+
// and a resolver that maps an identity to one. A profile inherits the base
|
|
135
|
+
// policy's presets/actions and overrides on top (so a profile need only state
|
|
136
|
+
// what differs). `transform` (e.g. applyPrivacyProfile) is applied to each
|
|
137
|
+
// compiled policy source before buildPolicy.
|
|
138
|
+
export function createPolicyProfiles(policyConfig = {}, { transform } = {}) {
|
|
139
|
+
const { profiles = {}, profileBinding = null, ...baseSource } = policyConfig;
|
|
140
|
+
const apply = (source) => (transform ? transform(source) : source);
|
|
141
|
+
|
|
142
|
+
const baseEngine = createPolicyEngine(buildPolicy(apply(baseSource)));
|
|
143
|
+
const profileNames = Object.keys(profiles);
|
|
144
|
+
const engines = new Map();
|
|
145
|
+
|
|
146
|
+
for (const name of profileNames) {
|
|
147
|
+
const override = profiles[name] ?? {};
|
|
148
|
+
const merged = {
|
|
149
|
+
...baseSource,
|
|
150
|
+
...override,
|
|
151
|
+
// Profile presets replace the base presets when given; actions merge over
|
|
152
|
+
// the base via buildPolicy's strengthen-only rules.
|
|
153
|
+
actions: { ...(baseSource.actions ?? {}), ...(override.actions ?? {}) },
|
|
154
|
+
modelAllowlist: override.modelAllowlist ?? baseSource.modelAllowlist,
|
|
155
|
+
rate: override.rate ?? baseSource.rate
|
|
156
|
+
};
|
|
157
|
+
engines.set(name, {
|
|
158
|
+
policyEngine: createPolicyEngine(buildPolicy(apply(merged))),
|
|
159
|
+
modelAllowlist: merged.modelAllowlist ?? null,
|
|
160
|
+
rate: merged.rate ?? null
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (profileBinding) {
|
|
165
|
+
if (!profileBinding.default || !engines.has(profileBinding.default)) {
|
|
166
|
+
throw new Error("policy.profileBinding.default must name a declared profile");
|
|
167
|
+
}
|
|
168
|
+
for (const map of [profileBinding.byScope ?? {}, profileBinding.byLabel ?? {}]) {
|
|
169
|
+
for (const [key, target] of Object.entries(map)) {
|
|
170
|
+
if (!engines.has(target)) {
|
|
171
|
+
throw new Error(`policy.profileBinding maps ${key} to unknown profile: ${target}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (profileNames.length > 0) {
|
|
176
|
+
throw new Error("policy.profiles requires policy.profileBinding with a default");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const base = {
|
|
180
|
+
policyEngine: baseEngine,
|
|
181
|
+
modelAllowlist: baseSource.modelAllowlist ?? null,
|
|
182
|
+
rate: baseSource.rate ?? null
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
base,
|
|
187
|
+
hasProfiles: profileNames.length > 0,
|
|
188
|
+
// Resolve identity → { profile, policyEngine, modelAllowlist, rate }.
|
|
189
|
+
// Order: scope match → label match → default. Without profiles or identity,
|
|
190
|
+
// the base policy applies.
|
|
191
|
+
resolve(identity) {
|
|
192
|
+
if (!profileBinding) {
|
|
193
|
+
return { profile: null, ...base };
|
|
194
|
+
}
|
|
195
|
+
if (identity) {
|
|
196
|
+
for (const scope of identity.scopes ?? []) {
|
|
197
|
+
const name = profileBinding.byScope?.[scope];
|
|
198
|
+
if (name) {
|
|
199
|
+
return { profile: name, ...engines.get(name) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const [key, value] of Object.entries(identity.labels ?? {})) {
|
|
203
|
+
const name = profileBinding.byLabel?.[`${key}=${value}`];
|
|
204
|
+
if (name) {
|
|
205
|
+
return { profile: name, ...engines.get(name) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const fallback = profileBinding.default;
|
|
210
|
+
return { profile: fallback, ...engines.get(fallback) };
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
133
215
|
export function validatePolicy(policy) {
|
|
134
216
|
if (!policy || typeof policy !== "object") {
|
|
135
217
|
throw new Error("Policy must be an object");
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
+
// Streaming descriptors: `format` is the wire framing, `deltaPath` is the
|
|
2
|
+
// primary incremental-text channel (index 0 of choices for OpenAI-style).
|
|
3
|
+
// A null deltaPath means "no known channel" — frames still get within-frame
|
|
4
|
+
// protection but no cross-frame buffering.
|
|
5
|
+
const SSE_CHAT = { format: "sse", deltaPath: ["choices", 0, "delta", "content"] };
|
|
6
|
+
const SSE_COMPLETION = { format: "sse", deltaPath: ["choices", 0, "text"] };
|
|
7
|
+
const SSE_RESPONSES = { format: "sse", deltaPath: null };
|
|
8
|
+
const SSE_LLAMA_LEGACY = { format: "sse", deltaPath: ["content"] };
|
|
9
|
+
const NDJSON_OLLAMA_CHAT = { format: "ndjson", deltaPath: ["message", "content"] };
|
|
10
|
+
const NDJSON_OLLAMA_GENERATE = { format: "ndjson", deltaPath: ["response"] };
|
|
11
|
+
|
|
1
12
|
const ADAPTERS = {
|
|
2
13
|
"openai-compatible": {
|
|
3
14
|
id: "openai-compatible",
|
|
4
15
|
protocol: "llm-http",
|
|
5
16
|
routes: [
|
|
6
|
-
route("/v1/chat/completions", "chat-completions"),
|
|
7
|
-
route("/v1/completions", "completions"),
|
|
8
|
-
route("/v1/responses", "responses"),
|
|
17
|
+
route("/v1/chat/completions", "chat-completions", { streaming: SSE_CHAT }),
|
|
18
|
+
route("/v1/completions", "completions", { streaming: SSE_COMPLETION }),
|
|
19
|
+
route("/v1/responses", "responses", { streaming: SSE_RESPONSES }),
|
|
9
20
|
route("/v1/embeddings", "embeddings")
|
|
10
21
|
]
|
|
11
22
|
},
|
|
@@ -13,9 +24,9 @@ const ADAPTERS = {
|
|
|
13
24
|
id: "vllm-openai",
|
|
14
25
|
protocol: "vllm-openai",
|
|
15
26
|
routes: [
|
|
16
|
-
route("/v1/chat/completions", "chat-completions"),
|
|
17
|
-
route("/v1/completions", "completions"),
|
|
18
|
-
route("/v1/responses", "responses"),
|
|
27
|
+
route("/v1/chat/completions", "chat-completions", { streaming: SSE_CHAT }),
|
|
28
|
+
route("/v1/completions", "completions", { streaming: SSE_COMPLETION }),
|
|
29
|
+
route("/v1/responses", "responses", { streaming: SSE_RESPONSES }),
|
|
19
30
|
route("/v1/embeddings", "embeddings")
|
|
20
31
|
]
|
|
21
32
|
},
|
|
@@ -23,10 +34,10 @@ const ADAPTERS = {
|
|
|
23
34
|
id: "llama-cpp",
|
|
24
35
|
protocol: "llama-cpp",
|
|
25
36
|
routes: [
|
|
26
|
-
route("/v1/chat/completions", "chat-completions"),
|
|
27
|
-
route("/v1/completions", "completions"),
|
|
37
|
+
route("/v1/chat/completions", "chat-completions", { streaming: SSE_CHAT }),
|
|
38
|
+
route("/v1/completions", "completions", { streaming: SSE_COMPLETION }),
|
|
28
39
|
route("/v1/embeddings", "embeddings"),
|
|
29
|
-
route("/completion", "legacy-completion")
|
|
40
|
+
route("/completion", "legacy-completion", { streaming: SSE_LLAMA_LEGACY })
|
|
30
41
|
]
|
|
31
42
|
},
|
|
32
43
|
"ollama": {
|
|
@@ -34,8 +45,8 @@ const ADAPTERS = {
|
|
|
34
45
|
protocol: "ollama",
|
|
35
46
|
routes: [
|
|
36
47
|
// Ollama streams /api/chat and /api/generate unless the request sets stream:false.
|
|
37
|
-
route("/api/chat", "chat", { streamingDefault: true }),
|
|
38
|
-
route("/api/generate", "generate", { streamingDefault: true }),
|
|
48
|
+
route("/api/chat", "chat", { streamingDefault: true, streaming: NDJSON_OLLAMA_CHAT }),
|
|
49
|
+
route("/api/generate", "generate", { streamingDefault: true, streaming: NDJSON_OLLAMA_GENERATE }),
|
|
39
50
|
route("/api/embed", "embed"),
|
|
40
51
|
route("/api/embeddings", "embeddings")
|
|
41
52
|
]
|
|
@@ -47,7 +58,13 @@ const TARGET_TYPE_ALIASES = {
|
|
|
47
58
|
};
|
|
48
59
|
|
|
49
60
|
export function createProtocolAdapter(target = {}) {
|
|
50
|
-
|
|
61
|
+
// A specific target.type (vllm-openai, ollama, llama-cpp) names its own
|
|
62
|
+
// adapter and wins over a generic/default target.adapter — otherwise the
|
|
63
|
+
// default config's adapter ("openai-compatible") would shadow the type after
|
|
64
|
+
// a deep merge and silently route an Ollama target to OpenAI paths.
|
|
65
|
+
const adapterId = ADAPTERS[target.type]
|
|
66
|
+
? target.type
|
|
67
|
+
: (target.adapter ?? adapterFromTargetType(target.type));
|
|
51
68
|
const adapter = ADAPTERS[adapterId];
|
|
52
69
|
if (!adapter) {
|
|
53
70
|
throw new Error(`Unknown protocol adapter: ${adapterId}`);
|
|
@@ -71,7 +88,8 @@ export function createProtocolAdapter(target = {}) {
|
|
|
71
88
|
operation,
|
|
72
89
|
protectRequest: matched?.protectRequest ?? true,
|
|
73
90
|
protectResponse: matched?.protectResponse ?? true,
|
|
74
|
-
streamingByDefault: matched?.streamingDefault ?? false
|
|
91
|
+
streamingByDefault: matched?.streamingDefault ?? false,
|
|
92
|
+
streaming: matched?.streaming ?? null
|
|
75
93
|
};
|
|
76
94
|
}
|
|
77
95
|
};
|
|
@@ -98,7 +116,8 @@ function route(path, operation, options = {}) {
|
|
|
98
116
|
operation,
|
|
99
117
|
protectRequest: options.protectRequest ?? true,
|
|
100
118
|
protectResponse: options.protectResponse ?? true,
|
|
101
|
-
streamingDefault: options.streamingDefault ?? false
|
|
119
|
+
streamingDefault: options.streamingDefault ?? false,
|
|
120
|
+
streaming: options.streaming ?? null
|
|
102
121
|
};
|
|
103
122
|
}
|
|
104
123
|
|