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.
@@ -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 { buildPolicy, createPolicyEngine } from "../policy/index.mjs";
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 policy = buildPolicy(normalized.privacy.profile
137
- ? applyPrivacyProfile(policySource, normalized.privacy.profile)
138
- : policySource);
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 ?? createPolicyEngine(policy);
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 })");
@@ -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, context = {}) {
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 policyEngine.decide({ detection, context, mode: effectiveMode }));
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
- return { protectJson };
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
- // Reserved for 0.6 auth: hard null so unvalidated identity objects cannot
273
- // reach the audit log before the PII-safe hashing contract exists.
274
- identity: null,
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
- const adapterId = target.adapter ?? adapterFromTargetType(target.type);
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