@juspay/neurolink 13.2.0 → 13.4.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.
@@ -599,7 +599,6 @@ export declare abstract class BaseProvider implements AIProvider {
599
599
  /**
600
600
  * Get AI SDK model with middleware applied
601
601
  * This method wraps the base model with any configured middleware
602
- * TODO(#1953): Implement global level middlewares that can be used
603
602
  */
604
603
  protected getAISDKModelWithMiddleware(options?: TextGenerationOptions | StreamOptions): Promise<LanguageModel>;
605
604
  /**
@@ -117,7 +117,11 @@ export class BaseProvider {
117
117
  modelName;
118
118
  providerName;
119
119
  defaultTimeout = 30000; // 30 seconds
120
- middlewareOptions; // TODO(#1953): Implement global level middlewares that can be used
120
+ // Only set by a subclass passing the 4th constructor argument; no provider
121
+ // does. NeuroLink resolves its instance, global and environment middleware
122
+ // defaults into `options.middleware` before a call reaches here (see
123
+ // middleware/defaults.ts), so a per-call value already carries them.
124
+ middlewareOptions;
121
125
  // Tools are conditionally included based on centralized configuration
122
126
  directTools = shouldDisableBuiltinTools()
123
127
  ? {}
@@ -2509,7 +2513,6 @@ export class BaseProvider {
2509
2513
  /**
2510
2514
  * Get AI SDK model with middleware applied
2511
2515
  * This method wraps the base model with any configured middleware
2512
- * TODO(#1953): Implement global level middlewares that can be used
2513
2516
  */
2514
2517
  async getAISDKModelWithMiddleware(options = {}) {
2515
2518
  return this.applyMiddlewareToModel(await this.getAISDKModel(), options);
package/dist/index.d.ts CHANGED
@@ -89,6 +89,7 @@ export { shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, set
89
89
  export { clearAnalyticsMetrics, createAnalyticsMiddleware, getAnalyticsMetrics, } from "./middleware/builtin/analytics.js";
90
90
  export { createLifecycleMiddleware } from "./middleware/builtin/lifecycle.js";
91
91
  export { MiddlewareFactory } from "./middleware/factory.js";
92
+ export { clearGlobalMiddleware, configureGlobalMiddleware, } from "./middleware/defaults.js";
92
93
  export { tool, jsonSchema, stepCountIs } from "./utils/tool.js";
93
94
  export { ExporterRegistry } from "./observability/exporterRegistry.js";
94
95
  export { NoOpExporter } from "./observability/exporters/baseExporter.js";
package/dist/index.js CHANGED
@@ -178,6 +178,7 @@ export { shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, set
178
178
  export { clearAnalyticsMetrics, createAnalyticsMiddleware, getAnalyticsMetrics, } from "./middleware/builtin/analytics.js";
179
179
  export { createLifecycleMiddleware } from "./middleware/builtin/lifecycle.js";
180
180
  export { MiddlewareFactory } from "./middleware/factory.js";
181
+ export { clearGlobalMiddleware, configureGlobalMiddleware, } from "./middleware/defaults.js";
181
182
  // Tool + schema helpers. Previously a consumer reached for `tool()` and
182
183
  // `jsonSchema()` from the ai package to build tools for generate({tools}).
183
184
  // That package is no longer a dependency, so the equivalents ship here.
@@ -0,0 +1,68 @@
1
+ import type { MiddlewareFactoryOptions } from "../types/index.js";
2
+ /**
3
+ * Middleware defaults.
4
+ *
5
+ * A generate()/stream() call resolves its middleware from up to four layers,
6
+ * lowest precedence first:
7
+ *
8
+ * 1. NEUROLINK_MIDDLEWARE_PRESET (process environment)
9
+ * 2. configureGlobalMiddleware() (module-level, set at runtime)
10
+ * 3. new NeuroLink({ middleware }) (per instance)
11
+ * 4. generate({ middleware }) (per call)
12
+ *
13
+ * A layer that is absent changes nothing, so a call made with no default set
14
+ * keeps exactly the middleware it passed.
15
+ */
16
+ export declare const MIDDLEWARE_PRESET_ENV_VAR = "NEUROLINK_MIDDLEWARE_PRESET";
17
+ /**
18
+ * Layer `override` over `base`.
19
+ *
20
+ * - `preset`, `middleware`, `enabledMiddleware` and `disabledMiddleware`: the
21
+ * override's value replaces the base's whole; arrays are never concatenated.
22
+ * - `middlewareConfig`: merged per middleware id. Within an id, `enabled` and
23
+ * `conditions` come from the override when it sets them, and `config` is
24
+ * merged key by key, so a key the override sets replaces the base's value
25
+ * for that key as a whole (nested objects and arrays are not combined).
26
+ * - `global`: merged key by key.
27
+ *
28
+ * The factory applies `disabledMiddleware` after `enabledMiddleware`, so an id
29
+ * the override switches on must be taken out of a disabled list it inherits
30
+ * (and the reverse), or a lower layer's list would out-vote a call that asked
31
+ * for the opposite.
32
+ */
33
+ export declare function mergeMiddlewareOptions(base: MiddlewareFactoryOptions, override: MiddlewareFactoryOptions): MiddlewareFactoryOptions;
34
+ /**
35
+ * Resolve the options a call runs with. `defaults` is ordered lowest
36
+ * precedence first; the per-call options sit on top.
37
+ *
38
+ * With no default present this returns `call` itself, so callers that never
39
+ * opted in see no change at all.
40
+ */
41
+ export declare function resolveMiddlewareOptions(defaults: ReadonlyArray<MiddlewareFactoryOptions | undefined>, call: MiddlewareFactoryOptions | undefined): MiddlewareFactoryOptions | undefined;
42
+ /**
43
+ * Validate a default layer and copy it, so later edits to the caller's object
44
+ * cannot change a default that was already set. Plain data (a word list, a
45
+ * replacement string) is copied in full; custom middleware, callbacks and
46
+ * model handles keep their identity. Throws a TypeError for a value that is
47
+ * not an object and for a preset name that is not built in.
48
+ */
49
+ export declare function snapshotMiddlewareOptions(options: MiddlewareFactoryOptions, source: string): MiddlewareFactoryOptions;
50
+ /**
51
+ * Process-wide middleware defaults. Applies to every NeuroLink instance, below
52
+ * the instance's own `middleware` option and above NEUROLINK_MIDDLEWARE_PRESET.
53
+ * Replaces any earlier call rather than adding to it, and is read on each
54
+ * generate()/stream(), so instances that already exist pick it up. Throws a
55
+ * TypeError when `options` is not an object or names a preset that is not
56
+ * built in.
57
+ */
58
+ export declare function configureGlobalMiddleware(options: MiddlewareFactoryOptions): void;
59
+ /** Remove the defaults set by {@link configureGlobalMiddleware}. */
60
+ export declare function clearGlobalMiddleware(): void;
61
+ /** The current global defaults, or undefined when none are set. */
62
+ export declare function getGlobalMiddleware(): MiddlewareFactoryOptions | undefined;
63
+ /**
64
+ * The preset named by NEUROLINK_MIDDLEWARE_PRESET, as the lowest default layer.
65
+ * An unset or blank variable is no layer; an unrecognised name is reported
66
+ * on stderr once per distinct value and ignored.
67
+ */
68
+ export declare function readEnvMiddlewarePreset(env?: NodeJS.ProcessEnv): MiddlewareFactoryOptions | undefined;
@@ -0,0 +1,225 @@
1
+ import { logger } from "../utils/logger.js";
2
+ import { BUILT_IN_MIDDLEWARE_PRESET_NAMES } from "./presets.js";
3
+ /**
4
+ * Middleware defaults.
5
+ *
6
+ * A generate()/stream() call resolves its middleware from up to four layers,
7
+ * lowest precedence first:
8
+ *
9
+ * 1. NEUROLINK_MIDDLEWARE_PRESET (process environment)
10
+ * 2. configureGlobalMiddleware() (module-level, set at runtime)
11
+ * 3. new NeuroLink({ middleware }) (per instance)
12
+ * 4. generate({ middleware }) (per call)
13
+ *
14
+ * A layer that is absent changes nothing, so a call made with no default set
15
+ * keeps exactly the middleware it passed.
16
+ */
17
+ export const MIDDLEWARE_PRESET_ENV_VAR = "NEUROLINK_MIDDLEWARE_PRESET";
18
+ let globalMiddleware;
19
+ const warnedPresets = new Set();
20
+ const isPlainObject = (value) => typeof value === "object" &&
21
+ value !== null &&
22
+ !Array.isArray(value) &&
23
+ !(value instanceof Date) &&
24
+ !(value instanceof RegExp);
25
+ /** Own entries whose value is not `undefined`, so an unset key never wins. */
26
+ const definedEntries = (value) => Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined));
27
+ const isPureData = (value) => {
28
+ if (value === null || typeof value !== "object") {
29
+ return typeof value !== "function" && typeof value !== "symbol";
30
+ }
31
+ if (Array.isArray(value)) {
32
+ return value.every(isPureData);
33
+ }
34
+ const proto = Object.getPrototypeOf(value);
35
+ return ((proto === Object.prototype || proto === null) &&
36
+ Object.values(value).every(isPureData));
37
+ };
38
+ /**
39
+ * Copy a value only when it is plain data (JSON-like, no functions or class
40
+ * instances anywhere inside). A model handle, a callback or a custom
41
+ * middleware keeps its identity, because copying one would split its state
42
+ * from the object the caller still holds.
43
+ */
44
+ const copyIfPureData = (value) => isPureData(value) ? structuredClone(value) : value;
45
+ const isEmptyLayer = (layer) => Object.keys(definedEntries(layer)).length === 0;
46
+ const mergeConfigEntry = (base, override) => {
47
+ const merged = {
48
+ ...definedEntries(base),
49
+ ...definedEntries(override),
50
+ };
51
+ if (base?.config !== undefined || override?.config !== undefined) {
52
+ merged.config = {
53
+ ...definedEntries(base?.config),
54
+ ...definedEntries(override?.config),
55
+ };
56
+ }
57
+ return merged;
58
+ };
59
+ /** Middleware ids this layer switches on or off, by any of the three routes. */
60
+ const idsSwitched = (layer, state) => {
61
+ const ids = new Set((state ? layer.enabledMiddleware : layer.disabledMiddleware) ?? []);
62
+ for (const [id, entry] of Object.entries(layer.middlewareConfig ?? {})) {
63
+ if (entry?.enabled === state) {
64
+ ids.add(id);
65
+ }
66
+ }
67
+ return ids;
68
+ };
69
+ /**
70
+ * Layer `override` over `base`.
71
+ *
72
+ * - `preset`, `middleware`, `enabledMiddleware` and `disabledMiddleware`: the
73
+ * override's value replaces the base's whole; arrays are never concatenated.
74
+ * - `middlewareConfig`: merged per middleware id. Within an id, `enabled` and
75
+ * `conditions` come from the override when it sets them, and `config` is
76
+ * merged key by key, so a key the override sets replaces the base's value
77
+ * for that key as a whole (nested objects and arrays are not combined).
78
+ * - `global`: merged key by key.
79
+ *
80
+ * The factory applies `disabledMiddleware` after `enabledMiddleware`, so an id
81
+ * the override switches on must be taken out of a disabled list it inherits
82
+ * (and the reverse), or a lower layer's list would out-vote a call that asked
83
+ * for the opposite.
84
+ */
85
+ export function mergeMiddlewareOptions(base, override) {
86
+ const merged = {};
87
+ const preset = override.preset ?? base.preset;
88
+ if (preset !== undefined) {
89
+ merged.preset = preset;
90
+ }
91
+ const custom = override.middleware ?? base.middleware;
92
+ if (custom !== undefined) {
93
+ merged.middleware = [...custom];
94
+ }
95
+ const switchedOn = idsSwitched(override, true);
96
+ const switchedOff = idsSwitched(override, false);
97
+ const enabled = override.enabledMiddleware ??
98
+ base.enabledMiddleware?.filter((id) => !switchedOff.has(id));
99
+ if (enabled !== undefined) {
100
+ merged.enabledMiddleware = [...enabled];
101
+ }
102
+ const disabled = override.disabledMiddleware ??
103
+ base.disabledMiddleware?.filter((id) => !switchedOn.has(id));
104
+ if (disabled !== undefined) {
105
+ merged.disabledMiddleware = [...disabled];
106
+ }
107
+ if (base.middlewareConfig || override.middlewareConfig) {
108
+ const ids = new Set([
109
+ ...Object.keys(base.middlewareConfig ?? {}),
110
+ ...Object.keys(override.middlewareConfig ?? {}),
111
+ ]);
112
+ merged.middlewareConfig = Object.fromEntries([...ids].map((id) => [
113
+ id,
114
+ mergeConfigEntry(base.middlewareConfig?.[id], override.middlewareConfig?.[id]),
115
+ ]));
116
+ }
117
+ if (base.global || override.global) {
118
+ merged.global = {
119
+ ...definedEntries(base.global),
120
+ ...definedEntries(override.global),
121
+ };
122
+ }
123
+ return merged;
124
+ }
125
+ /**
126
+ * Resolve the options a call runs with. `defaults` is ordered lowest
127
+ * precedence first; the per-call options sit on top.
128
+ *
129
+ * With no default present this returns `call` itself, so callers that never
130
+ * opted in see no change at all.
131
+ */
132
+ export function resolveMiddlewareOptions(defaults, call) {
133
+ const layers = defaults.filter((layer) => layer !== undefined && !isEmptyLayer(layer));
134
+ if (layers.length === 0) {
135
+ return call;
136
+ }
137
+ const inherited = layers.reduce((acc, layer) => mergeMiddlewareOptions(acc, layer), {});
138
+ return isPlainObject(call)
139
+ ? mergeMiddlewareOptions(inherited, call)
140
+ : inherited;
141
+ }
142
+ /**
143
+ * Validate a default layer and copy it, so later edits to the caller's object
144
+ * cannot change a default that was already set. Plain data (a word list, a
145
+ * replacement string) is copied in full; custom middleware, callbacks and
146
+ * model handles keep their identity. Throws a TypeError for a value that is
147
+ * not an object and for a preset name that is not built in.
148
+ */
149
+ export function snapshotMiddlewareOptions(options, source) {
150
+ if (!isPlainObject(options)) {
151
+ throw new TypeError(`${source}: middleware must be an object of MiddlewareFactoryOptions`);
152
+ }
153
+ if (options.preset !== undefined &&
154
+ !BUILT_IN_MIDDLEWARE_PRESET_NAMES.includes(options.preset)) {
155
+ // An unknown name left in the layer would replace a valid lower-layer
156
+ // preset and leave guardrails off with no sign of it, so a typo fails here.
157
+ throw new TypeError(`${source}: unknown middleware preset ${JSON.stringify(options.preset)}. Known presets: ${BUILT_IN_MIDDLEWARE_PRESET_NAMES.join(", ")}.`);
158
+ }
159
+ const merged = mergeMiddlewareOptions({}, options);
160
+ return {
161
+ ...merged,
162
+ ...(merged.middlewareConfig
163
+ ? {
164
+ middlewareConfig: Object.fromEntries(Object.entries(merged.middlewareConfig).map(([id, entry]) => [
165
+ id,
166
+ {
167
+ ...entry,
168
+ ...(entry.config
169
+ ? {
170
+ config: Object.fromEntries(Object.entries(entry.config).map(([key, value]) => [
171
+ key,
172
+ copyIfPureData(value),
173
+ ])),
174
+ }
175
+ : {}),
176
+ ...(entry.conditions
177
+ ? { conditions: copyIfPureData(entry.conditions) }
178
+ : {}),
179
+ },
180
+ ])),
181
+ }
182
+ : {}),
183
+ };
184
+ }
185
+ /**
186
+ * Process-wide middleware defaults. Applies to every NeuroLink instance, below
187
+ * the instance's own `middleware` option and above NEUROLINK_MIDDLEWARE_PRESET.
188
+ * Replaces any earlier call rather than adding to it, and is read on each
189
+ * generate()/stream(), so instances that already exist pick it up. Throws a
190
+ * TypeError when `options` is not an object or names a preset that is not
191
+ * built in.
192
+ */
193
+ export function configureGlobalMiddleware(options) {
194
+ globalMiddleware = snapshotMiddlewareOptions(options, "configureGlobalMiddleware");
195
+ }
196
+ /** Remove the defaults set by {@link configureGlobalMiddleware}. */
197
+ export function clearGlobalMiddleware() {
198
+ globalMiddleware = undefined;
199
+ }
200
+ /** The current global defaults, or undefined when none are set. */
201
+ export function getGlobalMiddleware() {
202
+ return globalMiddleware;
203
+ }
204
+ /**
205
+ * The preset named by NEUROLINK_MIDDLEWARE_PRESET, as the lowest default layer.
206
+ * An unset or blank variable is no layer; an unrecognised name is reported
207
+ * on stderr once per distinct value and ignored.
208
+ */
209
+ export function readEnvMiddlewarePreset(env = process.env) {
210
+ const raw = env[MIDDLEWARE_PRESET_ENV_VAR]?.trim();
211
+ if (!raw) {
212
+ return undefined;
213
+ }
214
+ if (BUILT_IN_MIDDLEWARE_PRESET_NAMES.includes(raw)) {
215
+ return { preset: raw };
216
+ }
217
+ if (!warnedPresets.has(raw)) {
218
+ warnedPresets.add(raw);
219
+ // Written to stderr rather than through logger.warn, which drops warnings
220
+ // unless debugging: a mistyped preset would otherwise leave guardrails off
221
+ // with no sign of it.
222
+ logger.alwaysStderr(`[Middleware] Ignoring ${MIDDLEWARE_PRESET_ENV_VAR}=${JSON.stringify(raw)}: not a known preset. Known presets: ${BUILT_IN_MIDDLEWARE_PRESET_NAMES.join(", ")}.`);
223
+ }
224
+ return undefined;
225
+ }
@@ -1,4 +1,5 @@
1
1
  import { MiddlewareRegistry } from "./registry.js";
2
+ import { createBuiltInMiddlewarePresets } from "./presets.js";
2
3
  import { createAnalyticsMiddleware } from "./builtin/analytics.js";
3
4
  import { createGuardrailsMiddleware } from "./builtin/guardrails.js";
4
5
  import { createAutoEvaluationMiddleware } from "./builtin/autoEvaluation.js";
@@ -30,21 +31,9 @@ export class MiddlewareFactory {
30
31
  lifecycle: createLifecycleMiddleware,
31
32
  };
32
33
  // Register built-in presets
33
- this.registerPreset({
34
- name: "default",
35
- description: "Default preset with analytics enabled.",
36
- config: { analytics: { enabled: true } },
37
- });
38
- this.registerPreset({
39
- name: "all",
40
- description: "Enables all available middleware.",
41
- config: { analytics: { enabled: true }, guardrails: { enabled: true } },
42
- });
43
- this.registerPreset({
44
- name: "security",
45
- description: "Focuses on security with guardrails.",
46
- config: { guardrails: { enabled: true } },
47
- });
34
+ for (const preset of createBuiltInMiddlewarePresets()) {
35
+ this.registerPreset(preset);
36
+ }
48
37
  // Register custom middleware if provided
49
38
  logger.debug("Initializing MiddlewareFactory", { options });
50
39
  if (options.middleware) {
@@ -0,0 +1,12 @@
1
+ import type { MiddlewarePreset } from "../types/index.js";
2
+ /**
3
+ * The presets every `MiddlewareFactory` registers. Built fresh on each call so
4
+ * a factory that edits its own `presets` map cannot change another factory's.
5
+ *
6
+ * `all` is deliberately not "everything": autoEvaluation and lifecycle are
7
+ * opt-in per call and stay out of it. `security` only switches guardrails on;
8
+ * it ships no word list and no model filter, so it redacts nothing until the
9
+ * caller supplies `badWords` or `modelFilter`.
10
+ */
11
+ export declare function createBuiltInMiddlewarePresets(): MiddlewarePreset[];
12
+ export declare const BUILT_IN_MIDDLEWARE_PRESET_NAMES: readonly string[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The presets every `MiddlewareFactory` registers. Built fresh on each call so
3
+ * a factory that edits its own `presets` map cannot change another factory's.
4
+ *
5
+ * `all` is deliberately not "everything": autoEvaluation and lifecycle are
6
+ * opt-in per call and stay out of it. `security` only switches guardrails on;
7
+ * it ships no word list and no model filter, so it redacts nothing until the
8
+ * caller supplies `badWords` or `modelFilter`.
9
+ */
10
+ export function createBuiltInMiddlewarePresets() {
11
+ return [
12
+ {
13
+ name: "default",
14
+ description: "Default preset with analytics enabled.",
15
+ config: { analytics: { enabled: true } },
16
+ },
17
+ {
18
+ name: "all",
19
+ description: "Enables analytics and guardrails.",
20
+ config: { analytics: { enabled: true }, guardrails: { enabled: true } },
21
+ },
22
+ {
23
+ name: "security",
24
+ description: "Enables guardrails. Filters nothing until badWords or modelFilter is configured.",
25
+ config: { guardrails: { enabled: true } },
26
+ },
27
+ ];
28
+ }
29
+ export const BUILT_IN_MIDDLEWARE_PRESET_NAMES = createBuiltInMiddlewarePresets().map((preset) => preset.name);
@@ -159,6 +159,8 @@ export declare class NeuroLink {
159
159
  /** Session-scoped pins of tools discovered via search_tools (tools.discovery mode). */
160
160
  private discoveryPins;
161
161
  private enableOrchestration;
162
+ private readonly envMiddleware?;
163
+ private readonly constructorMiddleware?;
162
164
  private authProvider?;
163
165
  private pendingAuthConfig?;
164
166
  private authInitPromise?;
@@ -234,6 +236,13 @@ export declare class NeuroLink {
234
236
  private enforceSessionBudget;
235
237
  private assertInputText;
236
238
  private applyAuthenticatedRequestContext;
239
+ /**
240
+ * Fold the instance, global and environment middleware defaults beneath the
241
+ * call's own `middleware`. Runs before the lifecycle helpers so they layer
242
+ * onFinish/onError on top of the resolved config rather than replacing it.
243
+ * With no default set the options are left untouched.
244
+ */
245
+ private applyMiddlewareDefaults;
237
246
  private applyGenerateLifecycleMiddleware;
238
247
  private applyStreamLifecycleMiddleware;
239
248
  private initializeMemoryConfig;
package/dist/neurolink.js CHANGED
@@ -94,6 +94,7 @@ import { hasLifecycleErrorFired, markLifecycleErrorFired, markLifecycleStreamCal
94
94
  import { interleaveTTSStream } from "./utils/ttsStream.js";
95
95
  import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
96
96
  import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
97
+ import { getGlobalMiddleware, readEnvMiddlewarePreset, resolveMiddlewareOptions, snapshotMiddlewareOptions, } from "./middleware/defaults.js";
97
98
  import { preserveLiveStreamAccessors } from "./utils/streamResultAccessors.js";
98
99
  import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "./utils/json/coerce.js";
99
100
  // Factory processing imports
@@ -655,6 +656,12 @@ export class NeuroLink {
655
656
  discoveryPins = new Map();
656
657
  // Add orchestration property
657
658
  enableOrchestration;
659
+ // Language-model middleware defaults, lowest precedence first: the layer
660
+ // read from NEUROLINK_MIDDLEWARE_PRESET, then the constructor's `middleware`
661
+ // option. The module-level global registry sits between them and is read on
662
+ // each call, so it can change after construction. See middleware/defaults.ts.
663
+ envMiddleware;
664
+ constructorMiddleware;
658
665
  // Authentication provider for secure access control
659
666
  authProvider;
660
667
  pendingAuthConfig;
@@ -900,6 +907,18 @@ export class NeuroLink {
900
907
  ...tokenDerivedFields,
901
908
  };
902
909
  }
910
+ /**
911
+ * Fold the instance, global and environment middleware defaults beneath the
912
+ * call's own `middleware`. Runs before the lifecycle helpers so they layer
913
+ * onFinish/onError on top of the resolved config rather than replacing it.
914
+ * With no default set the options are left untouched.
915
+ */
916
+ applyMiddlewareDefaults(options) {
917
+ const resolved = resolveMiddlewareOptions([this.envMiddleware, getGlobalMiddleware(), this.constructorMiddleware], options.middleware);
918
+ if (resolved !== options.middleware) {
919
+ options.middleware = resolved;
920
+ }
921
+ }
903
922
  applyGenerateLifecycleMiddleware(options) {
904
923
  if (!options.onFinish && !options.onError) {
905
924
  return;
@@ -1207,6 +1226,13 @@ export class NeuroLink {
1207
1226
  return metricsTraceContextStorage.getStore() ?? null;
1208
1227
  }
1209
1228
  constructor(config) {
1229
+ // Validated first: an invalid default must throw before the constructor
1230
+ // starts telemetry or any other process-wide state.
1231
+ this.envMiddleware = readEnvMiddlewarePreset();
1232
+ this.constructorMiddleware =
1233
+ config?.middleware === undefined
1234
+ ? undefined
1235
+ : snapshotMiddlewareOptions(config.middleware, "NeuroLink constructor");
1210
1236
  if (config?.toolRegistry) {
1211
1237
  // A supplied registry (a host's, shared with a worker) keeps its owner's
1212
1238
  // roots; per-call executeTool() binds this instance's policy anyway.
@@ -4157,6 +4183,7 @@ Current user's request: ${currentInput}`;
4157
4183
  options.input.text = piiResult.text;
4158
4184
  }
4159
4185
  this.enforceSessionBudget(options.maxBudgetUsd);
4186
+ this.applyMiddlewareDefaults(options);
4160
4187
  this.applyGenerateLifecycleMiddleware(options);
4161
4188
  await this.applyAuthenticatedRequestContext(options);
4162
4189
  return { options, originalPrompt };
@@ -5470,6 +5497,9 @@ Current user's request: ${currentInput}`;
5470
5497
  // NL-004: Resolve model aliases/deprecations before processing
5471
5498
  isolated.model = resolveModel(isolated.model, this.modelAliasConfig);
5472
5499
  bindFileToolRoots(isolated, this.resolveFileToolRoots(isolated.toolRoots));
5500
+ // generateText() reaches the same providers as generate() without passing
5501
+ // through its prelude, so the middleware defaults are folded in here too.
5502
+ this.applyMiddlewareDefaults(isolated);
5473
5503
  // Use internal generation method directly
5474
5504
  return await this.generateTextInternal(isolated);
5475
5505
  }
@@ -8291,6 +8321,7 @@ Current user's request: ${currentInput}`;
8291
8321
  this.enforceSessionBudget(options.maxBudgetUsd);
8292
8322
  await this.applyAuthenticatedRequestContext(options);
8293
8323
  this.emitStreamStartEvents(options, startTime);
8324
+ this.applyMiddlewareDefaults(options);
8294
8325
  this.applyStreamLifecycleMiddleware(options);
8295
8326
  }
8296
8327
  async maybeHandleWorkflowStreamRequest(params) {
@@ -17,6 +17,7 @@ import type { ContextRelevanceOptions } from "./context.js";
17
17
  import type { DecisionHooks } from "./decision.js";
18
18
  import type { SkillsConfig } from "./skills.js";
19
19
  import type { KnowledgeGroundingConfig } from "./knowledge.js";
20
+ import type { MiddlewareFactoryOptions } from "./middleware.js";
20
21
  /**
21
22
  * Main NeuroLink configuration type
22
23
  */
@@ -184,6 +185,27 @@ export type NeurolinkConstructorConfig = {
184
185
  * {@link KnowledgeGroundingConfig}.
185
186
  */
186
187
  knowledgeGrounding?: KnowledgeGroundingConfig;
188
+ /**
189
+ * Language-model middleware applied to every generate()/stream() on this
190
+ * instance (guardrails, analytics, custom middleware). Spares each call from
191
+ * repeating the same `middleware` option.
192
+ *
193
+ * Precedence, lowest to highest: `NEUROLINK_MIDDLEWARE_PRESET`,
194
+ * `configureGlobalMiddleware()`, this option, then the call's own
195
+ * `middleware`. `middlewareConfig` merges per middleware id, and a key set
196
+ * by a higher layer replaces the lower layer's value; arrays are replaced,
197
+ * never concatenated. A call can switch off an inherited middleware with
198
+ * `disabledMiddleware` or `middlewareConfig.<id>.enabled: false`.
199
+ *
200
+ * Throws a TypeError at construction when this is not an object or its
201
+ * `preset` is not a built-in preset (`default`, `all` or `security`).
202
+ *
203
+ * Unset by default, in which case calls behave exactly as before. Note that
204
+ * `preset: "security"` only turns guardrails on: nothing is redacted until
205
+ * `middlewareConfig.guardrails.config` supplies `badWords` or `modelFilter`.
206
+ * See {@link MiddlewareFactoryOptions}.
207
+ */
208
+ middleware?: MiddlewareFactoryOptions;
187
209
  };
188
210
  /**
189
211
  * Configuration for MCP enhancement modules wired into generate()/stream() paths.
@@ -421,30 +421,52 @@ export type LiveKitServerCredentials = {
421
421
  apiKey: string;
422
422
  apiSecret: string;
423
423
  };
424
+ /**
425
+ * The realtime speech-to-speech model a call runs on: Gemini Live on Vertex
426
+ * (the default) or xAI Grok Voice through LiveKit's xAI plugin.
427
+ */
428
+ export type RealtimeVoiceBackend = "gemini" | "xai";
429
+ /**
430
+ * A LiveKit realtime model as far as callers of the model factory can rely on.
431
+ * It is declared structurally so the published declarations name no optional
432
+ * LiveKit plugin; at runtime it is a `RealtimeModel` subclass of the backend's
433
+ * plugin.
434
+ */
435
+ export type RealtimeLlm = {
436
+ readonly model: string;
437
+ label: () => string;
438
+ close: () => Promise<void>;
439
+ };
424
440
  /**
425
441
  * Realtime voice configuration resolved from the environment.
426
442
  *
427
- * In speech-to-speech mode one realtime model (Gemini Live on Vertex) does STT,
428
- * reasoning, TTS, and turn detection — so there is no separate STT/TTS/VAD/EOU
429
- * config. `resolveRealtimeVoiceConfig` fills every field from `process.env`
430
- * (with defaults); `RealtimeVoiceAgentConfig` lets a caller override any of them.
443
+ * In speech-to-speech mode one realtime model (Gemini Live on Vertex, or xAI
444
+ * Grok Voice) does STT, reasoning, TTS, and turn detection — so there is no
445
+ * separate STT/TTS/VAD/EOU config. `resolveRealtimeVoiceConfig` fills every
446
+ * field from `process.env` (with defaults); `RealtimeVoiceAgentConfig` lets a
447
+ * caller override any of them. The API key of the xai backend is deliberately
448
+ * not part of the config: the xAI plugin reads `XAI_API_KEY` itself.
431
449
  */
432
450
  export type RealtimeVoiceConfig = {
433
- /** Vertex project id (from VERTEX_PROJECT / GOOGLE_AUTH_* / GOOGLE_CLOUD_PROJECT_ID). */
451
+ /** Which realtime model family handles the call (VOICE_REALTIME_BACKEND; default "gemini"). */
452
+ backend: RealtimeVoiceBackend;
453
+ /** Vertex project id (from VERTEX_PROJECT / GOOGLE_AUTH_* / GOOGLE_CLOUD_PROJECT_ID). Gemini only. */
434
454
  project: string | undefined;
435
- /** Vertex location; native-audio Live is served on `global`, not regionally. */
455
+ /** Vertex location; native-audio Live is served on `global`, not regionally. Gemini only. */
436
456
  location: string;
437
- /** Realtime model id (e.g. "gemini-live-2.5-flash"). */
457
+ /** Realtime model id (e.g. "gemini-live-2.5-flash" for gemini, "grok-voice-3.0" for xai). */
438
458
  model: string;
439
- /** Optional Gemini voice name; omit for the plugin default. */
459
+ /** Optional voice name for either backend; omit for the plugin default. */
440
460
  voice: string | undefined;
441
- /** Response modality: "AUDIO" (native S2S) or "TEXT" (half-cascade). */
461
+ /** xai only: WebSocket base URL of the realtime endpoint (XAI_REALTIME_BASE_URL); unset keeps the plugin's own endpoint. */
462
+ xaiBaseURL: string | undefined;
463
+ /** Response modality: "AUDIO" (native S2S) or "TEXT" (half-cascade). Gemini only. */
442
464
  responseModality: string;
443
465
  /** System prompt / instructions for the agent. */
444
466
  systemPrompt: string;
445
467
  /** Opening line the agent speaks on connect ("" disables). */
446
468
  greeting: string;
447
- /** Whether to bridge Lighthouse MCP tools as Gemini function tools. */
469
+ /** Whether to bridge Lighthouse MCP tools as function tools of the realtime model (either backend). */
448
470
  toolsEnabled: boolean;
449
471
  /** Full URL of the MCP server the tools are bridged from. */
450
472
  mcpUrl: string;
@@ -474,10 +496,14 @@ export type RealtimeVoiceLogContext = {
474
496
  * everything via env.
475
497
  */
476
498
  export type RealtimeVoiceAgentConfig = {
499
+ /** Realtime backend; omitted means the environment's VOICE_REALTIME_BACKEND, and without it "gemini". */
500
+ backend?: RealtimeVoiceBackend;
477
501
  project?: string;
478
502
  location?: string;
479
503
  model?: string;
480
504
  voice?: string;
505
+ /** xai only: WebSocket base URL of the realtime endpoint. */
506
+ xaiBaseURL?: string;
481
507
  responseModality?: string;
482
508
  systemPrompt?: string;
483
509
  greeting?: string;
@@ -736,7 +762,7 @@ export type BuildRealtimeMcpToolsParams = {
736
762
  * Hard cap per MCP tool call, in milliseconds (default 30000).
737
763
  *
738
764
  * Without one, a stalled MCP server holds the realtime turn open forever:
739
- * Gemini waits on the function result, so the user gets silence rather than
765
+ * The realtime model waits on the function result, so the user gets silence rather than
740
766
  * an error. Bounding the call turns that into a normal tool failure the
741
767
  * model can talk about.
742
768
  */