@juspay/neurolink 9.79.2 → 9.80.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +351 -347
  3. package/dist/cli/factories/commandFactory.js +61 -0
  4. package/dist/cli/utils/classifierRouterFlags.d.ts +19 -0
  5. package/dist/cli/utils/classifierRouterFlags.js +111 -0
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/index.d.ts +1 -1
  9. package/dist/lib/index.js +1 -1
  10. package/dist/lib/neurolink.d.ts +14 -0
  11. package/dist/lib/neurolink.js +131 -0
  12. package/dist/lib/providers/googleNativeGemini3.d.ts +26 -0
  13. package/dist/lib/providers/googleNativeGemini3.js +48 -0
  14. package/dist/lib/providers/googleVertex.d.ts +16 -0
  15. package/dist/lib/providers/googleVertex.js +200 -24
  16. package/dist/lib/routing/classifierRouter.d.ts +52 -0
  17. package/dist/lib/routing/classifierRouter.js +269 -0
  18. package/dist/lib/routing/classifierStrategies.d.ts +23 -0
  19. package/dist/lib/routing/classifierStrategies.js +156 -0
  20. package/dist/lib/routing/index.d.ts +2 -0
  21. package/dist/lib/routing/index.js +2 -0
  22. package/dist/lib/session/globalSessionState.d.ts +10 -1
  23. package/dist/lib/session/globalSessionState.js +18 -0
  24. package/dist/lib/types/classifierRouter.d.ts +193 -0
  25. package/dist/lib/types/classifierRouter.js +17 -0
  26. package/dist/lib/types/cli.d.ts +27 -3
  27. package/dist/lib/types/config.d.ts +10 -0
  28. package/dist/lib/types/index.d.ts +1 -0
  29. package/dist/lib/types/index.js +2 -0
  30. package/dist/neurolink.d.ts +14 -0
  31. package/dist/neurolink.js +131 -0
  32. package/dist/providers/googleNativeGemini3.d.ts +26 -0
  33. package/dist/providers/googleNativeGemini3.js +48 -0
  34. package/dist/providers/googleVertex.d.ts +16 -0
  35. package/dist/providers/googleVertex.js +200 -24
  36. package/dist/routing/classifierRouter.d.ts +52 -0
  37. package/dist/routing/classifierRouter.js +268 -0
  38. package/dist/routing/classifierStrategies.d.ts +23 -0
  39. package/dist/routing/classifierStrategies.js +155 -0
  40. package/dist/routing/index.d.ts +2 -0
  41. package/dist/routing/index.js +2 -0
  42. package/dist/session/globalSessionState.d.ts +10 -1
  43. package/dist/session/globalSessionState.js +18 -0
  44. package/dist/types/classifierRouter.d.ts +193 -0
  45. package/dist/types/classifierRouter.js +16 -0
  46. package/dist/types/cli.d.ts +27 -3
  47. package/dist/types/config.d.ts +10 -0
  48. package/dist/types/index.d.ts +1 -0
  49. package/dist/types/index.js +2 -0
  50. package/package.json +2 -1
@@ -11,6 +11,7 @@ import { normalizeEvaluationData } from "../../lib/utils/evaluationUtils.js";
11
11
  import { logger } from "../../lib/utils/logger.js";
12
12
  import { createThinkingConfigFromRecord } from "../../lib/utils/thinkingConfig.js";
13
13
  import { buildToolRoutingConfigFromCli } from "../utils/toolRoutingFlags.js";
14
+ import { buildClassifierRouterConfigFromCli } from "../utils/classifierRouterFlags.js";
14
15
  import { configManager } from "../commands/config.js";
15
16
  import { MCPCommandFactory } from "../commands/mcp.js";
16
17
  import { ModelsCommandFactory } from "../commands/models.js";
@@ -577,6 +578,43 @@ export class CLICommandFactory {
577
578
  description: "Path to a JSON file OR inline JSON array of {id, description} server descriptors for the routable catalog.",
578
579
  alias: "tool-routing-servers",
579
580
  },
581
+ // Classifier-router options
582
+ classifierRouter: {
583
+ type: "boolean",
584
+ description: "Enable the classifier router: classify each request and pick a model (and tools) from --classifier-pool.",
585
+ alias: "classifier-router",
586
+ },
587
+ classifierStrategy: {
588
+ type: "string",
589
+ description: "Classifier strategy: 'heuristic' (default, no LLM) or 'llm' (a cheap model picks per prompt).",
590
+ choices: ["heuristic", "llm"],
591
+ alias: "classifier-strategy",
592
+ },
593
+ classifierModelProvider: {
594
+ type: "string",
595
+ description: "Provider for the LLM classifier model (strategy=llm).",
596
+ alias: "classifier-model-provider",
597
+ },
598
+ classifierModelName: {
599
+ type: "string",
600
+ description: "Model name for the LLM classifier model (strategy=llm).",
601
+ alias: "classifier-model-name",
602
+ },
603
+ classifierModelRegion: {
604
+ type: "string",
605
+ description: "Region for the LLM classifier model (strategy=llm).",
606
+ alias: "classifier-model-region",
607
+ },
608
+ classifierPool: {
609
+ type: "string",
610
+ description: "Path to a JSON file OR inline JSON array of pool members: { provider, model?, region?, description?, tiers?, cost?, quality?, id? }.",
611
+ alias: "classifier-pool",
612
+ },
613
+ classifierTimeout: {
614
+ type: "number",
615
+ description: "LLM classifier hard timeout in milliseconds.",
616
+ alias: "classifier-timeout",
617
+ },
580
618
  region: {
581
619
  type: "string",
582
620
  description: "Vertex AI region (e.g., us-central1, europe-west1, asia-northeast1)",
@@ -868,6 +906,14 @@ export class CLICommandFactory {
868
906
  toolRoutingRouterRegion: argv.toolRoutingRouterRegion,
869
907
  toolRoutingAlwaysInclude: argv.toolRoutingAlwaysInclude,
870
908
  toolRoutingServers: argv.toolRoutingServers,
909
+ // Classifier-router flags — constructor-level config (see note above).
910
+ classifierRouter: argv.classifierRouter,
911
+ classifierStrategy: argv.classifierStrategy,
912
+ classifierModelProvider: argv.classifierModelProvider,
913
+ classifierModelName: argv.classifierModelName,
914
+ classifierModelRegion: argv.classifierModelRegion,
915
+ classifierPool: argv.classifierPool,
916
+ classifierTimeout: argv.classifierTimeout,
871
917
  };
872
918
  }
873
919
  /**
@@ -2269,6 +2315,11 @@ export class CLICommandFactory {
2269
2315
  if (toolRoutingConfig) {
2270
2316
  globalSession.setToolRoutingConfig(toolRoutingConfig);
2271
2317
  }
2318
+ // Inject classifier-router config into the SDK instance before constructing it.
2319
+ const classifierRouterConfig = buildClassifierRouterConfigFromCli(options);
2320
+ if (classifierRouterConfig) {
2321
+ globalSession.setClassifierRouterConfig(classifierRouterConfig);
2322
+ }
2272
2323
  // Initialize SDK and session
2273
2324
  const sdk = globalSession.getOrCreateNeuroLink();
2274
2325
  const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
@@ -2504,6 +2555,11 @@ export class CLICommandFactory {
2504
2555
  if (toolRoutingConfig) {
2505
2556
  globalSession.setToolRoutingConfig(toolRoutingConfig);
2506
2557
  }
2558
+ // Inject classifier-router config into the SDK instance before constructing it.
2559
+ const classifierRouterConfig = buildClassifierRouterConfigFromCli(options);
2560
+ if (classifierRouterConfig) {
2561
+ globalSession.setClassifierRouterConfig(classifierRouterConfig);
2562
+ }
2507
2563
  const sdk = globalSession.getOrCreateNeuroLink();
2508
2564
  const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
2509
2565
  const enhancedOptions = { ...options, ...sessionVariables };
@@ -2985,6 +3041,11 @@ export class CLICommandFactory {
2985
3041
  if (toolRoutingConfig) {
2986
3042
  globalSession.setToolRoutingConfig(toolRoutingConfig);
2987
3043
  }
3044
+ // Inject classifier-router config into the SDK instance before constructing it.
3045
+ const classifierRouterConfig = buildClassifierRouterConfigFromCli(options);
3046
+ if (classifierRouterConfig) {
3047
+ globalSession.setClassifierRouterConfig(classifierRouterConfig);
3048
+ }
2988
3049
  const sdk = globalSession.getOrCreateNeuroLink();
2989
3050
  const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
2990
3051
  const enhancedOptions = { ...options, ...sessionVariables };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * CLI helper: parse --classifier-* flags into a ClassifierRouterConfig.
3
+ *
4
+ * Side-effect-free so it can be unit-tested without a running NeuroLink
5
+ * instance. Mirrors the `--tool-routing-*` flag parser.
6
+ */
7
+ import type { CliClassifierRouterFlags, ClassifierRouterConfig } from "../../lib/types/index.js";
8
+ /**
9
+ * Build a {@link ClassifierRouterConfig} from the parsed CLI flags.
10
+ *
11
+ * Returns `undefined` when `--classifier-router` is absent or false, so callers
12
+ * can skip the setter entirely with a simple truthiness check.
13
+ *
14
+ * `--classifier-pool` is parsed permissively:
15
+ * - If the value is an existing file path, the file is read and JSON-parsed.
16
+ * - Otherwise the value is treated as an inline JSON string.
17
+ * - On any parse error a warning is logged and the pool is left empty.
18
+ */
19
+ export declare function buildClassifierRouterConfigFromCli(flags: CliClassifierRouterFlags & Record<string, unknown>): ClassifierRouterConfig | undefined;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * CLI helper: parse --classifier-* flags into a ClassifierRouterConfig.
3
+ *
4
+ * Side-effect-free so it can be unit-tested without a running NeuroLink
5
+ * instance. Mirrors the `--tool-routing-*` flag parser.
6
+ */
7
+ import fs from "node:fs";
8
+ import { logger } from "../../lib/utils/logger.js";
9
+ // Trust model: --classifier-pool is a local, user-supplied CLI flag. The
10
+ // invoking user already holds the process's OS permissions, so absolute and
11
+ // home-dir config paths are fully supported — there is no cross-trust boundary
12
+ // to enforce (a local CLI reading a path its own operator passed is not a
13
+ // path-traversal vector). The caps below are a robustness guard against
14
+ // accidentally pointing at a huge file (e.g. a log or binary), not a security
15
+ // boundary.
16
+ const MAX_POOL_INPUT_BYTES = 1_000_000; // 1 MB
17
+ const MAX_POOL_ENTRIES = 1_000;
18
+ /**
19
+ * Build a {@link ClassifierRouterConfig} from the parsed CLI flags.
20
+ *
21
+ * Returns `undefined` when `--classifier-router` is absent or false, so callers
22
+ * can skip the setter entirely with a simple truthiness check.
23
+ *
24
+ * `--classifier-pool` is parsed permissively:
25
+ * - If the value is an existing file path, the file is read and JSON-parsed.
26
+ * - Otherwise the value is treated as an inline JSON string.
27
+ * - On any parse error a warning is logged and the pool is left empty.
28
+ */
29
+ export function buildClassifierRouterConfigFromCli(flags) {
30
+ if (!flags.classifierRouter) {
31
+ return undefined;
32
+ }
33
+ const strategy = flags.classifierStrategy === "llm" ? "llm" : "heuristic";
34
+ const pool = typeof flags.classifierPool === "string" &&
35
+ flags.classifierPool.trim() !== ""
36
+ ? parsePoolFlag(flags.classifierPool)
37
+ : [];
38
+ if (pool.length === 0) {
39
+ logger.warn("[classifier-router] --classifier-router is enabled but --classifier-pool is empty/missing; model routing is a no-op until a pool is provided.");
40
+ }
41
+ const config = {
42
+ enabled: true,
43
+ classifier: strategy,
44
+ pool,
45
+ };
46
+ if (flags.classifierTimeout !== undefined) {
47
+ const t = flags.classifierTimeout;
48
+ if (Number.isFinite(t) && t > 0) {
49
+ config.timeoutMs = t;
50
+ }
51
+ else {
52
+ logger.warn(`[classifier-router] --classifier-timeout value ${String(t)} is not a positive finite number; ignoring (SDK default applies).`);
53
+ }
54
+ }
55
+ const hasProvider = typeof flags.classifierModelProvider === "string";
56
+ const hasModel = typeof flags.classifierModelName === "string";
57
+ const hasRegion = typeof flags.classifierModelRegion === "string";
58
+ if (hasProvider || hasModel || hasRegion) {
59
+ config.classifierModel = {
60
+ ...(hasProvider && {
61
+ provider: flags.classifierModelProvider,
62
+ }),
63
+ ...(hasModel && { model: flags.classifierModelName }),
64
+ ...(hasRegion && { region: flags.classifierModelRegion }),
65
+ };
66
+ }
67
+ return config;
68
+ }
69
+ /**
70
+ * Parse the `--classifier-pool` value (file path first, then inline JSON).
71
+ * Logs a warning and returns `[]` on any error (fail open).
72
+ */
73
+ function parsePoolFlag(value) {
74
+ try {
75
+ const resolved = fs.existsSync(value) ? value : null;
76
+ if (resolved !== null) {
77
+ const { size } = fs.statSync(resolved);
78
+ if (size > MAX_POOL_INPUT_BYTES) {
79
+ logger.warn(`[classifier-router] --classifier-pool file exceeds ${MAX_POOL_INPUT_BYTES} bytes (got ${size}); ignoring.`);
80
+ return [];
81
+ }
82
+ }
83
+ else if (Buffer.byteLength(value, "utf8") > MAX_POOL_INPUT_BYTES) {
84
+ logger.warn(`[classifier-router] --classifier-pool inline JSON exceeds ${MAX_POOL_INPUT_BYTES} bytes; ignoring.`);
85
+ return [];
86
+ }
87
+ const jsonText = resolved ? fs.readFileSync(resolved, "utf8") : value;
88
+ const parsed = JSON.parse(jsonText);
89
+ if (!Array.isArray(parsed)) {
90
+ logger.warn("[classifier-router] --classifier-pool must be a JSON array; ignoring.");
91
+ return [];
92
+ }
93
+ // Validate loosely — every member must at least name a provider.
94
+ const valid = parsed.filter((entry) => typeof entry === "object" &&
95
+ entry !== null &&
96
+ typeof entry.provider === "string");
97
+ if (valid.length !== parsed.length) {
98
+ logger.warn(`[classifier-router] ${parsed.length - valid.length} pool member(s) skipped — each must have a string "provider" field.`);
99
+ }
100
+ if (valid.length > MAX_POOL_ENTRIES) {
101
+ logger.warn(`[classifier-router] --classifier-pool has ${valid.length} entries; truncating to ${MAX_POOL_ENTRIES}.`);
102
+ return valid.slice(0, MAX_POOL_ENTRIES);
103
+ }
104
+ return valid;
105
+ }
106
+ catch (err) {
107
+ logger.warn(`[classifier-router] Failed to parse --classifier-pool: ${err.message}. Using empty pool (fail open).`);
108
+ return [];
109
+ }
110
+ }
111
+ //# sourceMappingURL=classifierRouterFlags.js.map
package/dist/index.d.ts CHANGED
@@ -367,7 +367,7 @@ export { createMultiJudgeWorkflow, MULTI_JUDGE_3_WORKFLOW, MULTI_JUDGE_5_WORKFLO
367
367
  * });
368
368
  * ```
369
369
  */
370
- export { classifyProviderError, ModelPool, createDefaultRequestRouter, } from "./routing/index.js";
370
+ export { classifyProviderError, ModelPool, createDefaultRequestRouter, ClassifierRouter, classifyHeuristic, } from "./routing/index.js";
371
371
  /**
372
372
  * Server Adapters for exposing NeuroLink as HTTP APIs
373
373
  *
package/dist/index.js CHANGED
@@ -574,7 +574,7 @@ export { createMultiJudgeWorkflow, MULTI_JUDGE_3_WORKFLOW, MULTI_JUDGE_5_WORKFLO
574
574
  * });
575
575
  * ```
576
576
  */
577
- export { classifyProviderError, ModelPool, createDefaultRequestRouter, } from "./routing/index.js";
577
+ export { classifyProviderError, ModelPool, createDefaultRequestRouter, ClassifierRouter, classifyHeuristic, } from "./routing/index.js";
578
578
  // ============================================================================
579
579
  // SERVER ADAPTERS - HTTP API Framework Integration
580
580
  // ============================================================================
@@ -367,7 +367,7 @@ export { createMultiJudgeWorkflow, MULTI_JUDGE_3_WORKFLOW, MULTI_JUDGE_5_WORKFLO
367
367
  * });
368
368
  * ```
369
369
  */
370
- export { classifyProviderError, ModelPool, createDefaultRequestRouter, } from "./routing/index.js";
370
+ export { classifyProviderError, ModelPool, createDefaultRequestRouter, ClassifierRouter, classifyHeuristic, } from "./routing/index.js";
371
371
  /**
372
372
  * Server Adapters for exposing NeuroLink as HTTP APIs
373
373
  *
package/dist/lib/index.js CHANGED
@@ -574,7 +574,7 @@ export { createMultiJudgeWorkflow, MULTI_JUDGE_3_WORKFLOW, MULTI_JUDGE_5_WORKFLO
574
574
  * });
575
575
  * ```
576
576
  */
577
- export { classifyProviderError, ModelPool, createDefaultRequestRouter, } from "./routing/index.js";
577
+ export { classifyProviderError, ModelPool, createDefaultRequestRouter, ClassifierRouter, classifyHeuristic, } from "./routing/index.js";
578
578
  // ============================================================================
579
579
  // SERVER ADAPTERS - HTTP API Framework Integration
580
580
  // ============================================================================
@@ -112,6 +112,7 @@ export declare class NeuroLink {
112
112
  private readonly fallbackConfig;
113
113
  private readonly modelPool;
114
114
  private readonly requestRouter;
115
+ private readonly classifierRouter;
115
116
  /**
116
117
  * Merge instance-level credentials with per-call credentials.
117
118
  *
@@ -634,6 +635,19 @@ export declare class NeuroLink {
634
635
  * @param thinkingLevel — optional thinking level string from the call options.
635
636
  */
636
637
  private applyRequestRouter;
638
+ /**
639
+ * Applies the host-configured `classifierRouter` to `options` in place.
640
+ *
641
+ * Classifies the request by difficulty and selects a provider/model from the
642
+ * configured base pool (cheaper/faster for easy tasks, more capable for hard
643
+ * ones), and optionally narrows the tool set via `toolFilter`/`excludeTools`.
644
+ *
645
+ * Skipped when: no router is configured; the inbound call is the classifier's
646
+ * own generate() (marked `__classifierRouted`); the caller pinned BOTH
647
+ * provider and model; or a ModelPool is configured (the pool owns selection).
648
+ * Fails open — any error leaves options unchanged.
649
+ */
650
+ private applyClassifierRouting;
637
651
  private prepareGenerateAugmentations;
638
652
  private buildGenerateTextOptions;
639
653
  private finalizeGenerateRequestResult;
@@ -87,6 +87,7 @@ import { isNonNullObject } from "./utils/typeUtils.js";
87
87
  import { getWorkflow } from "./workflow/core/workflowRegistry.js";
88
88
  import { runWorkflow } from "./workflow/core/workflowRunner.js";
89
89
  import { ModelPool, classifyProviderError } from "./routing/index.js";
90
+ import { ClassifierRouter } from "./routing/classifierRouter.js";
90
91
  import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, isNonRetryableProviderError as sharedIsNonRetryableProviderError, } from "./utils/providerErrorClassification.js";
91
92
  /**
92
93
  * NL-002: Classify MCP error messages into categories for AI disambiguation.
@@ -388,6 +389,9 @@ export class NeuroLink {
388
389
  // RequestRouter: pluggable pre-call provider/model selector.
389
390
  // Stored directly from config.requestRouter; null when not configured.
390
391
  requestRouter;
392
+ // ClassifierRouter: opt-in "classify → pick model + tools" pre-call router.
393
+ // Built from config.classifierRouter; null when not configured.
394
+ classifierRouter;
391
395
  /**
392
396
  * Merge instance-level credentials with per-call credentials.
393
397
  *
@@ -796,6 +800,17 @@ export class NeuroLink {
796
800
  this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
797
801
  // RequestRouter: store the host-supplied function; null when not configured.
798
802
  this.requestRouter = config?.requestRouter ?? null;
803
+ // ClassifierRouter: opt-in. The LLM strategy reuses this instance's
804
+ // generate() (marked so it never recursively re-routes). Fails open.
805
+ this.classifierRouter = config?.classifierRouter?.enabled
806
+ ? new ClassifierRouter(config.classifierRouter, {
807
+ generate: (genOptions) => this.generate(genOptions),
808
+ logger: {
809
+ debug: (message, meta) => logger.debug(message, meta),
810
+ warn: (message, meta) => logger.warn(message, meta),
811
+ },
812
+ })
813
+ : null;
799
814
  logger.setEventEmitter(this.emitter);
800
815
  // Read tool cache duration from environment variables, with a default
801
816
  const cacheDurationEnv = process.env.NEUROLINK_TOOL_CACHE_DURATION;
@@ -3105,6 +3120,13 @@ Current user's request: ${currentInput}`;
3105
3120
  }
3106
3121
  async runStandardGenerateRequest(options, originalPrompt, generateSpan) {
3107
3122
  const startTime = Date.now();
3123
+ // Pre-call classifier router: opt-in. Classifies the request and selects a
3124
+ // provider/model from the configured base pool (and optionally narrows the
3125
+ // tool set) BEFORE orchestration/requestRouter, so it takes precedence.
3126
+ // Fails open.
3127
+ await this.applyClassifierRouting(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
3128
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
3129
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
3108
3130
  await this.maybeApplyGenerateOrchestration(options);
3109
3131
  // Pre-call request router: opt-in, only when the caller did not set both
3110
3132
  // provider+model. Fails open (any router error leaves options unchanged).
@@ -3241,6 +3263,12 @@ Current user's request: ${currentInput}`;
3241
3263
  if (!this.requestRouter) {
3242
3264
  return;
3243
3265
  }
3266
+ // Stand down if the classifier router already selected a provider/model on
3267
+ // this turn — one explicit precedence order: classifierRouter wins.
3268
+ if (options.context
3269
+ ?.__classifierRouted) {
3270
+ return;
3271
+ }
3244
3272
  // Skip if the caller explicitly set both provider AND model — they know
3245
3273
  // what they want; the router should not override an intentional choice.
3246
3274
  if (options.provider && options.model) {
@@ -3307,6 +3335,103 @@ Current user's request: ${currentInput}`;
3307
3335
  });
3308
3336
  }
3309
3337
  }
3338
+ /**
3339
+ * Applies the host-configured `classifierRouter` to `options` in place.
3340
+ *
3341
+ * Classifies the request by difficulty and selects a provider/model from the
3342
+ * configured base pool (cheaper/faster for easy tasks, more capable for hard
3343
+ * ones), and optionally narrows the tool set via `toolFilter`/`excludeTools`.
3344
+ *
3345
+ * Skipped when: no router is configured; the inbound call is the classifier's
3346
+ * own generate() (marked `__classifierRouted`); the caller pinned BOTH
3347
+ * provider and model; or a ModelPool is configured (the pool owns selection).
3348
+ * Fails open — any error leaves options unchanged.
3349
+ */
3350
+ async applyClassifierRouting(options, promptText, hasTools, requiresVision, thinkingLevel) {
3351
+ if (!this.classifierRouter) {
3352
+ return;
3353
+ }
3354
+ const opt = options;
3355
+ // Prevent recursion: the LLM classifier itself calls generate() with this
3356
+ // marker set. Also respect any explicit prior routing decision.
3357
+ if (opt.context?.__classifierRouted) {
3358
+ return;
3359
+ }
3360
+ // Honor an intentional caller choice of BOTH provider and model.
3361
+ if (options.provider && options.model) {
3362
+ return;
3363
+ }
3364
+ // A ModelPool overrides provider/model per member unconditionally; let it
3365
+ // own selection (mirrors applyRequestRouter precedence).
3366
+ if (this.modelPool) {
3367
+ logger.debug("[NeuroLink] applyClassifierRouting: skipped — modelPool takes precedence");
3368
+ return;
3369
+ }
3370
+ const sessionId = typeof opt.context?.sessionId === "string"
3371
+ ? opt.context.sessionId
3372
+ : undefined;
3373
+ try {
3374
+ const decision = await this.classifierRouter.route({
3375
+ prompt: promptText,
3376
+ estimatedInputTokens: Math.ceil((promptText?.length ?? 0) / 4),
3377
+ hasTools,
3378
+ requiresVision,
3379
+ thinkingLevel,
3380
+ sessionId,
3381
+ });
3382
+ if (!decision) {
3383
+ return;
3384
+ }
3385
+ // Apply provider/model/region as a coherent set (same discipline as
3386
+ // applyRequestRouter): only fill fields the caller did not pin.
3387
+ if (decision.provider && !options.provider) {
3388
+ options.provider = decision.provider;
3389
+ if (decision.model && !options.model) {
3390
+ options.model = decision.model;
3391
+ }
3392
+ if (decision.region && !options.region) {
3393
+ options.region = decision.region;
3394
+ }
3395
+ }
3396
+ else if (!decision.provider && decision.model && !options.model) {
3397
+ options.model = decision.model;
3398
+ }
3399
+ // Narrow tools unless the caller disabled tools entirely.
3400
+ if (!opt.disableTools) {
3401
+ if (decision.excludeTools && decision.excludeTools.length > 0) {
3402
+ opt.excludeTools = Array.from(new Set([...(opt.excludeTools ?? []), ...decision.excludeTools]));
3403
+ }
3404
+ if (decision.toolFilter && decision.toolFilter.length > 0) {
3405
+ const allow = decision.toolFilter;
3406
+ opt.toolFilter =
3407
+ opt.toolFilter && opt.toolFilter.length > 0
3408
+ ? opt.toolFilter.filter((t) => allow.includes(t))
3409
+ : [...allow];
3410
+ }
3411
+ }
3412
+ // Mark routed so orchestration/requestRouter stand down this turn.
3413
+ if (decision.provider || decision.model) {
3414
+ const ctx = opt.context ?? {};
3415
+ ctx.__classifierRouted = true;
3416
+ opt.context = ctx;
3417
+ }
3418
+ if (decision.reason) {
3419
+ logger.debug("[NeuroLink] classifierRouter decision", {
3420
+ reason: decision.reason,
3421
+ difficulty: decision.difficulty,
3422
+ provider: options.provider,
3423
+ model: options.model,
3424
+ });
3425
+ }
3426
+ }
3427
+ catch (classifierErr) {
3428
+ logger.warn("[NeuroLink] classifierRouter threw — proceeding unrouted", {
3429
+ error: classifierErr instanceof Error
3430
+ ? classifierErr.message
3431
+ : String(classifierErr),
3432
+ });
3433
+ }
3434
+ }
3310
3435
  async prepareGenerateAugmentations(options) {
3311
3436
  if (options.rag?.files?.length) {
3312
3437
  try {
@@ -5829,6 +5954,12 @@ Current user's request: ${currentInput}`;
5829
5954
  // classify correctly. After the workflow short-circuit, so workflow
5830
5955
  // streams skip it.
5831
5956
  await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.applyToolRoutingExclusions(options, originalPrompt)));
5957
+ // Pre-call classifier router for stream: opt-in, fails open. Runs before
5958
+ // the request router so it takes precedence.
5959
+ await this.applyClassifierRouting(options, originalPrompt, !options.disableTools &&
5960
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
5961
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
5962
+ .thinking?.thinkingLevel);
5832
5963
  // Pre-call request router for stream: opt-in, fails open.
5833
5964
  await this.applyRequestRouter(options, originalPrompt, !options.disableTools &&
5834
5965
  (!!(options.tools && Object.keys(options.tools).length > 0) ||
@@ -96,6 +96,32 @@ export declare function buildNativeConfig(options: {
96
96
  * Compute a safe, clamped maxSteps value.
97
97
  */
98
98
  export declare function computeMaxSteps(rawMaxSteps?: number): number;
99
+ /**
100
+ * Map a `@google/genai` `Candidate.finishReason` enum value onto NeuroLink's
101
+ * unified finish reason, mirroring anthropic.ts `mapAnthropicStopReason`.
102
+ *
103
+ * Enum values per `@google/genai` `FinishReason`: STOP, MAX_TOKENS, SAFETY,
104
+ * RECITATION, LANGUAGE, OTHER, BLOCKLIST, PROHIBITED_CONTENT, SPII,
105
+ * MALFORMED_FUNCTION_CALL, IMAGE_SAFETY, UNEXPECTED_TOOL_CALL,
106
+ * FINISH_REASON_UNSPECIFIED. Unknown / unset / non-terminal values default to
107
+ * "stop" (a clean completion is the safe assumption).
108
+ *
109
+ * Returns a plain string (not a `{ unified, raw }` object): the Vertex result
110
+ * builders and the consuming layer (neurolink.ts `finishReason || "unknown"`
111
+ * and `finishReason === "length"`) compare against plain strings.
112
+ */
113
+ export declare function mapGeminiFinishReason(raw: string | null | undefined): "stop" | "length" | "tool-calls" | "content-filter";
114
+ /**
115
+ * Append a step's text to a running cross-step accumulator, ignoring empty
116
+ * steps and inserting a single newline between non-empty contributions.
117
+ *
118
+ * The native Gemini loops overwrite per-step text into `lastStepText`, so when
119
+ * the loop is force-terminated by the step cap the intermediate tool-step prose
120
+ * is lost and a canned placeholder becomes the answer. Accumulating here mirrors
121
+ * the Vertex-Claude loop's `aggregatedTurnText += block.text` so the gathered
122
+ * text can be surfaced at the maxSteps-exhaustion exit instead of the placeholder.
123
+ */
124
+ export declare function appendStepText(accumulated: string, stepText: string): string;
99
125
  /**
100
126
  * Process stream chunks to extract raw response parts, function calls, and usage metadata.
101
127
  *
@@ -450,6 +450,54 @@ export function computeMaxSteps(rawMaxSteps) {
450
450
  ? Math.min(Math.floor(value), GEMINI3_NATIVE_MAX_STEPS)
451
451
  : Math.min(DEFAULT_MAX_STEPS, GEMINI3_NATIVE_MAX_STEPS);
452
452
  }
453
+ /**
454
+ * Map a `@google/genai` `Candidate.finishReason` enum value onto NeuroLink's
455
+ * unified finish reason, mirroring anthropic.ts `mapAnthropicStopReason`.
456
+ *
457
+ * Enum values per `@google/genai` `FinishReason`: STOP, MAX_TOKENS, SAFETY,
458
+ * RECITATION, LANGUAGE, OTHER, BLOCKLIST, PROHIBITED_CONTENT, SPII,
459
+ * MALFORMED_FUNCTION_CALL, IMAGE_SAFETY, UNEXPECTED_TOOL_CALL,
460
+ * FINISH_REASON_UNSPECIFIED. Unknown / unset / non-terminal values default to
461
+ * "stop" (a clean completion is the safe assumption).
462
+ *
463
+ * Returns a plain string (not a `{ unified, raw }` object): the Vertex result
464
+ * builders and the consuming layer (neurolink.ts `finishReason || "unknown"`
465
+ * and `finishReason === "length"`) compare against plain strings.
466
+ */
467
+ export function mapGeminiFinishReason(raw) {
468
+ switch (raw) {
469
+ case "MAX_TOKENS":
470
+ return "length";
471
+ case "MALFORMED_FUNCTION_CALL":
472
+ case "UNEXPECTED_TOOL_CALL":
473
+ return "tool-calls";
474
+ case "SAFETY":
475
+ case "RECITATION":
476
+ case "BLOCKLIST":
477
+ case "PROHIBITED_CONTENT":
478
+ case "SPII":
479
+ case "IMAGE_SAFETY":
480
+ return "content-filter";
481
+ default:
482
+ return "stop";
483
+ }
484
+ }
485
+ /**
486
+ * Append a step's text to a running cross-step accumulator, ignoring empty
487
+ * steps and inserting a single newline between non-empty contributions.
488
+ *
489
+ * The native Gemini loops overwrite per-step text into `lastStepText`, so when
490
+ * the loop is force-terminated by the step cap the intermediate tool-step prose
491
+ * is lost and a canned placeholder becomes the answer. Accumulating here mirrors
492
+ * the Vertex-Claude loop's `aggregatedTurnText += block.text` so the gathered
493
+ * text can be surfaced at the maxSteps-exhaustion exit instead of the placeholder.
494
+ */
495
+ export function appendStepText(accumulated, stepText) {
496
+ if (!stepText) {
497
+ return accumulated;
498
+ }
499
+ return accumulated ? `${accumulated}\n${stepText}` : stepText;
500
+ }
453
501
  /**
454
502
  * Process stream chunks to extract raw response parts, function calls, and usage metadata.
455
503
  *
@@ -160,6 +160,22 @@ export declare class GoogleVertexProvider extends BaseProvider {
160
160
  * This bypasses @ai-sdk/google-vertex to properly handle thought_signature
161
161
  */
162
162
  private executeNativeGemini3Generate;
163
+ /**
164
+ * One-shot, tools-disabled model call used when a native Gemini agentic loop
165
+ * is force-terminated by the step cap with no text produced. Lets the model
166
+ * synthesize a final answer from the function results already in `contents`
167
+ * instead of returning a canned placeholder (Bug 1, part b).
168
+ *
169
+ * Tools are disabled by OMITTING `config.tools` — the codebase's established
170
+ * mechanism. `@google/genai`'s `FunctionCallingConfigMode.NONE` is documented
171
+ * as equivalent to passing no function declarations, and `functionCallingConfig`
172
+ * is not used anywhere in this codebase. When the structured-output
173
+ * (`final_result`) pattern was active, a trailing instruction countermands the
174
+ * earlier "you MUST call final_result" directive so the model answers in plain
175
+ * text. Never throws — returns empty text so the caller falls back to the
176
+ * placeholder, guaranteeing no new failure path.
177
+ */
178
+ private synthesizeFinalAnswerWithoutTools;
163
179
  /**
164
180
  * Create native AnthropicVertex client for Claude models
165
181
  */