@juspay/neurolink 12.22.0 → 12.23.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 (39) hide show
  1. package/CHANGELOG.md +6 -3
  2. package/dist/adapters/providerImageAdapter.js +16 -11
  3. package/dist/browser/neurolink.min.js +379 -379
  4. package/dist/cli/factories/commandFactory.js +26 -3
  5. package/dist/constants/enums.d.ts +10 -0
  6. package/dist/constants/enums.js +11 -0
  7. package/dist/core/constants.js +1 -1
  8. package/dist/factories/providerDescriptors.d.ts +1 -1
  9. package/dist/factories/providerDescriptors.js +15 -13
  10. package/dist/image-gen/ImageGenService.js +16 -14
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/mcp/externalServerManager.d.ts +9 -0
  14. package/dist/mcp/externalServerManager.js +88 -16
  15. package/dist/neurolink.js +18 -11
  16. package/dist/providers/amazonBedrock/client.js +30 -1
  17. package/dist/providers/catalog/friendli.json +130 -0
  18. package/dist/providers/catalog/index.generated.d.ts +1 -1
  19. package/dist/providers/catalog/index.generated.js +3 -0
  20. package/dist/providers/catalog/mistral.json +1 -2
  21. package/dist/providers/catalog/schema.js +34 -0
  22. package/dist/providers/googleAiStudio/client.js +18 -2
  23. package/dist/providers/googleVertex/client.js +13 -4
  24. package/dist/providers/openaiChatCompletionsClient.d.ts +4 -0
  25. package/dist/providers/openaiChatCompletionsClient.js +20 -5
  26. package/dist/providers/recraft.js +8 -1
  27. package/dist/types/cli.d.ts +2 -0
  28. package/dist/types/externalMcp.d.ts +20 -2
  29. package/dist/types/file.d.ts +13 -0
  30. package/dist/types/generate.d.ts +6 -1
  31. package/dist/types/mcp.d.ts +17 -0
  32. package/dist/types/providerCatalog.generated.d.ts +2 -2
  33. package/dist/types/providers.d.ts +4 -0
  34. package/dist/utils/imageDetection.d.ts +10 -0
  35. package/dist/utils/imageDetection.js +38 -12
  36. package/dist/utils/pdfProcessor.d.ts +21 -3
  37. package/dist/utils/pdfProcessor.js +124 -48
  38. package/docs-site/static/search-index.json +1 -1
  39. package/package.json +3 -4
@@ -10,6 +10,7 @@ import { ContextFactory, } from "../../types/index.js";
10
10
  import { checkRedisAvailability } from "../../utils/conversationMemory.js";
11
11
  import { normalizeEvaluationData } from "../../utils/evaluationUtils.js";
12
12
  import { logger } from "../../utils/logger.js";
13
+ import { sniffImageMimeType } from "../../utils/imageDetection.js";
13
14
  import { createThinkingConfigFromRecord } from "../../utils/thinkingConfig.js";
14
15
  import { buildToolRoutingConfigFromCli } from "../utils/toolRoutingFlags.js";
15
16
  import { buildClassifierRouterConfigFromCli } from "../utils/classifierRouterFlags.js";
@@ -233,7 +234,7 @@ export class CLICommandFactory {
233
234
  },
234
235
  imageOutput: {
235
236
  type: "string",
236
- description: "Custom path for generated image (default: generated-images/image-<timestamp>.png)",
237
+ description: "Custom path for generated image (default: generated-images/image-<timestamp>.<ext>, where the extension follows the format the provider actually returned)",
237
238
  alias: "image-output",
238
239
  },
239
240
  // Behavior control options
@@ -1060,7 +1061,8 @@ export class CLICommandFactory {
1060
1061
  else {
1061
1062
  const imageDir = "generated-images";
1062
1063
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1063
- imagePath = path.join(imageDir, `image-${timestamp}.png`);
1064
+ const ext = imageExtensionFor(generateResult.imageOutput.mimeType, generateResult.imageOutput.base64);
1065
+ imagePath = path.join(imageDir, `image-${timestamp}.${ext}`);
1064
1066
  // Create directory if it doesn't exist
1065
1067
  if (!fs.existsSync(imageDir)) {
1066
1068
  fs.mkdirSync(imageDir, { recursive: true });
@@ -3055,7 +3057,8 @@ export class CLICommandFactory {
3055
3057
  else {
3056
3058
  const imageDir = "generated-images";
3057
3059
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
3058
- imagePath = path.join(imageDir, `image-${timestamp}.png`);
3060
+ const ext = imageExtensionFor(undefined, streamResult.imageBase64);
3061
+ imagePath = path.join(imageDir, `image-${timestamp}.${ext}`);
3059
3062
  if (!fs.existsSync(imageDir)) {
3060
3063
  fs.mkdirSync(imageDir, { recursive: true });
3061
3064
  }
@@ -4545,4 +4548,24 @@ export class CLICommandFactory {
4545
4548
  }
4546
4549
  /** Re-export of CLICommandFactory's static option definitions for direct import by tests/tooling. */
4547
4550
  export const commonOptions = CLICommandFactory.commonOptions;
4551
+ /**
4552
+ * File extension for generated image bytes. The provider now labels what it
4553
+ * returned, so a WebP no longer gets written to a `.png` filename — which is
4554
+ * the same wrong-label problem one layer up from the provider.
4555
+ */
4556
+ function imageExtensionFor(mimeType, base64) {
4557
+ const known = {
4558
+ "image/png": "png",
4559
+ "image/jpeg": "jpg",
4560
+ "image/webp": "webp",
4561
+ "image/gif": "gif",
4562
+ };
4563
+ if (mimeType && known[mimeType]) {
4564
+ return known[mimeType];
4565
+ }
4566
+ // No label from the provider: sniff the leading bytes rather than assume.
4567
+ const head = Buffer.from(base64.slice(0, 64), "base64");
4568
+ const sniffed = sniffImageMimeType(head);
4569
+ return sniffed ? known[sniffed] : "png";
4570
+ }
4548
4571
  //# sourceMappingURL=commandFactory.js.map
@@ -23,6 +23,7 @@ export declare enum AIProviderName {
23
23
  CEREBRAS = "cerebras",
24
24
  CLOUDFLARE = "cloudflare",
25
25
  FIREWORKS = "fireworks",
26
+ FRIENDLI = "friendli",
26
27
  GMICLOUD = "gmicloud",
27
28
  GROQ = "groq",
28
29
  INCEPTION_LABS = "inception-labs",
@@ -876,6 +877,15 @@ export declare enum FireworksModels {
876
877
  LLAMA_V3P2_11B_VISION_INSTRUCT = "accounts/fireworks/models/llama-v3p2-11b-vision-instruct",
877
878
  PHI_3_VISION_128K_INSTRUCT = "accounts/fireworks/models/phi-3-vision-128k-instruct"
878
879
  }
880
+ export declare enum FriendliModels {
881
+ ZAI_ORG_GLM_5_3 = "zai-org/GLM-5.3",
882
+ ZAI_ORG_GLM_5_3_FLASH = "zai-org/GLM-5.3-Flash",
883
+ ZAI_ORG_GLM_5_2 = "zai-org/GLM-5.2",
884
+ ZAI_ORG_GLM_5_1 = "zai-org/GLM-5.1",
885
+ GOOGLE_GEMMA_4_31B_IT = "google/gemma-4-31B-it",
886
+ DEEPSEEK_AI_DEEPSEEK_V3_2 = "deepseek-ai/DeepSeek-V3.2",
887
+ MINIMAXAI_MINIMAX_M2_5 = "MiniMaxAI/MiniMax-M2.5"
888
+ }
879
889
  export declare enum GmicloudModels {
880
890
  MINIMAXAI_MINIMAX_M3 = "MiniMaxAI/MiniMax-M3"
881
891
  }
@@ -28,6 +28,7 @@ export var AIProviderName;
28
28
  AIProviderName["CEREBRAS"] = "cerebras";
29
29
  AIProviderName["CLOUDFLARE"] = "cloudflare";
30
30
  AIProviderName["FIREWORKS"] = "fireworks";
31
+ AIProviderName["FRIENDLI"] = "friendli";
31
32
  AIProviderName["GMICLOUD"] = "gmicloud";
32
33
  AIProviderName["GROQ"] = "groq";
33
34
  AIProviderName["INCEPTION_LABS"] = "inception-labs";
@@ -1135,6 +1136,16 @@ export var FireworksModels;
1135
1136
  FireworksModels["LLAMA_V3P2_11B_VISION_INSTRUCT"] = "accounts/fireworks/models/llama-v3p2-11b-vision-instruct";
1136
1137
  FireworksModels["PHI_3_VISION_128K_INSTRUCT"] = "accounts/fireworks/models/phi-3-vision-128k-instruct";
1137
1138
  })(FireworksModels || (FireworksModels = {}));
1139
+ export var FriendliModels;
1140
+ (function (FriendliModels) {
1141
+ FriendliModels["ZAI_ORG_GLM_5_3"] = "zai-org/GLM-5.3";
1142
+ FriendliModels["ZAI_ORG_GLM_5_3_FLASH"] = "zai-org/GLM-5.3-Flash";
1143
+ FriendliModels["ZAI_ORG_GLM_5_2"] = "zai-org/GLM-5.2";
1144
+ FriendliModels["ZAI_ORG_GLM_5_1"] = "zai-org/GLM-5.1";
1145
+ FriendliModels["GOOGLE_GEMMA_4_31B_IT"] = "google/gemma-4-31B-it";
1146
+ FriendliModels["DEEPSEEK_AI_DEEPSEEK_V3_2"] = "deepseek-ai/DeepSeek-V3.2";
1147
+ FriendliModels["MINIMAXAI_MINIMAX_M2_5"] = "MiniMaxAI/MiniMax-M2.5";
1148
+ })(FriendliModels || (FriendliModels = {}));
1138
1149
  export var GmicloudModels;
1139
1150
  (function (GmicloudModels) {
1140
1151
  GmicloudModels["MINIMAXAI_MINIMAX_M3"] = "MiniMaxAI/MiniMax-M3";
@@ -311,7 +311,7 @@ export const PDF_LIMITS = {
311
311
  DEFAULT_MAX_CANVAS_PIXELS: 16_777_216,
312
312
  // Floor for the downscaled render scale (#260 follow-up). A crafted/malformed
313
313
  // MediaBox can drive the estimated pixel count toward Infinity, which would
314
- // otherwise collapse `effectiveScale` to 0 and hand `pdf-to-img` a degenerate
314
+ // otherwise collapse `effectiveScale` to 0 and hand the renderer a degenerate
315
315
  // viewport. Never let the downscale branch go below this.
316
316
  MIN_EFFECTIVE_SCALE: 0.1,
317
317
  };
@@ -3,7 +3,7 @@ import type { ProviderDescriptor } from "../types/index.js";
3
3
  /**
4
4
  * Single source of truth for provider identity, credentials, defaults, and
5
5
  * runtime behavior classification — the hand-maintained providers plus the
6
- * 8 JSON-catalog providers derived above.
6
+ * 15 JSON-catalog providers derived above.
7
7
  */
8
8
  export declare const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[];
9
9
  /** O(1) canonical-name → descriptor lookup. */
@@ -5,10 +5,10 @@ import { getCatalogJsonEntries, catalogCredentialsKey, catalogEnvVar, } from "..
5
5
  import { DEFAULT_INFERENCE_KINDS } from "../types/index.js";
6
6
  /**
7
7
  * Hand-maintained provider identity, credentials, defaults, and runtime
8
- * behavior classification for every provider EXCEPT the 8 JSON-catalog
8
+ * behavior classification for every provider EXCEPT the 15 JSON-catalog
9
9
  * providers derived by buildCatalogDescriptors() below (see
10
- * PROVIDER_DESCRIPTORS's own doc for why Mistral, a 9th catalog provider,
11
- * stays here too). Pure data — no provider-class imports, no dynamic
10
+ * PROVIDER_DESCRIPTORS's own doc for why Mistral, the remaining catalog
11
+ * provider, stays here too). Pure data — no provider-class imports, no dynamic
12
12
  * import(), no side effects beyond building the two derived lookup maps
13
13
  * below. Order follows the AIProviderName enum declaration order
14
14
  * (enums.ts:8-40) so this file stays easy to diff against it.
@@ -496,13 +496,14 @@ const HAND_DESCRIPTORS = [
496
496
  },
497
497
  ];
498
498
  /**
499
- * Builds ProviderDescriptor entries for 8 of the 9 JSON-catalog providers —
500
- * cerebras, cloudflare, fireworks, groq, perplexity, sambanova, together-ai,
501
- * xai. Every field is derived from the catalog JSON
499
+ * Builds ProviderDescriptor entries for 15 of the 16 JSON-catalog providers —
500
+ * api-route, baseten, cerebras, cloudflare, fireworks, gmicloud, groq,
501
+ * inception-labs, io-intelligence, mancer, perplexity, sambanova,
502
+ * together-ai, upstage, xai. Every field is derived from the catalog JSON
502
503
  * (src/lib/providers/catalog/<id>.json), never hand-typed.
503
504
  *
504
- * Mistral is the 9th catalog provider but is deliberately excluded here and
505
- * stays in HAND_DESCRIPTORS above: its JSON setup.url
505
+ * Mistral is the remaining catalog provider but is deliberately excluded
506
+ * here and stays in HAND_DESCRIPTORS above: its JSON setup.url
506
507
  * ("https://console.mistral.ai/") diverges from the long-shipped descriptor
507
508
  * setupUrl ("https://console.mistral.ai/api-keys" — a real value conflict,
508
509
  * not missing enrichment), its envVars has no `model` key the JSON would
@@ -539,11 +540,12 @@ function buildCatalogDescriptor(entry) {
539
540
  credentialsKey: catalogCredentialsKey(entry),
540
541
  envVars,
541
542
  defaultModel: entry.models.default,
542
- // All 9 catalog providers currently have capabilities.tools: true, so
543
- // this is runtime-identical today either way. "none" (not an invented
544
- // literal — it's the union's own no-tool-support member, the same one
543
+ // 15 of the 16 catalog providers have capabilities.tools: true; Mancer
544
+ // ships tools: false, so this ternary's false branch is live, not
545
+ // hypothetical. "none" (not an invented literal — it's the union's own
546
+ // no-tool-support member, the same one
545
547
  // REPLICATE/VOYAGE/JINA/STABILITY/IDEOGRAM/RECRAFT use above) is the
546
- // correct false-branch if a future catalog provider ships tools: false.
548
+ // correct false-branch here.
547
549
  toolSupport: entry.capabilities.tools ? "native" : "none",
548
550
  localRuntime: false,
549
551
  healthCheck: "env-only",
@@ -561,7 +563,7 @@ function buildCatalogDescriptors() {
561
563
  /**
562
564
  * Single source of truth for provider identity, credentials, defaults, and
563
565
  * runtime behavior classification — the hand-maintained providers plus the
564
- * 8 JSON-catalog providers derived above.
566
+ * 15 JSON-catalog providers derived above.
565
567
  */
566
568
  export const PROVIDER_DESCRIPTORS = [
567
569
  ...HAND_DESCRIPTORS,
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { withTimeout } from "../utils/errorHandling.js";
29
29
  import { DEFAULT_IMAGE_GEN_CONFIG } from "../types/index.js";
30
+ import { detectImageMimeType, sniffImageMimeType, } from "../utils/imageDetection.js";
30
31
  /**
31
32
  * NeuroLink instance type (avoiding circular dependencies)
32
33
  */
@@ -233,9 +234,14 @@ export class ImageGenService {
233
234
  const img = imageOutput;
234
235
  if (typeof img.base64 === "string") {
235
236
  const base64 = img.base64;
236
- const mimeType = img.mimeType ?? "image/png";
237
+ const imageBuffer = Buffer.from(base64, "base64");
238
+ // Providers that do not label the format get it sniffed from the
239
+ // bytes rather than assumed to be PNG (Recraft, for one, returns WebP).
240
+ const mimeType = typeof img.mimeType === "string"
241
+ ? img.mimeType
242
+ : detectImageMimeType(imageBuffer);
237
243
  return {
238
- imageBuffer: Buffer.from(base64, "base64"),
244
+ imageBuffer,
239
245
  base64,
240
246
  mimeType,
241
247
  };
@@ -247,7 +253,7 @@ export class ImageGenService {
247
253
  return {
248
254
  imageBuffer: imageOutput,
249
255
  base64,
250
- mimeType: "image/png",
256
+ mimeType: detectImageMimeType(imageOutput),
251
257
  };
252
258
  }
253
259
  // Check for data URI format in content
@@ -269,20 +275,16 @@ export class ImageGenService {
269
275
  // Try to detect if it's base64 encoded image data
270
276
  try {
271
277
  const buffer = Buffer.from(content, "base64");
272
- // Check for PNG magic bytes
273
- if (buffer[0] === 0x89 && buffer[1] === 0x50) {
274
- return {
275
- imageBuffer: buffer,
276
- base64: content,
277
- mimeType: "image/png",
278
- };
279
- }
280
- // Check for JPEG magic bytes
281
- if (buffer[0] === 0xff && buffer[1] === 0xd8) {
278
+ // Only treat the content as an image when it carries a known
279
+ // signature. `detectImageMimeType` answers `image/png` for bytes it
280
+ // does not recognise, so asking it here would turn arbitrary text into
281
+ // an image result; `sniffImageMimeType` says "no" instead.
282
+ const sniffed = sniffImageMimeType(buffer);
283
+ if (sniffed !== null) {
282
284
  return {
283
285
  imageBuffer: buffer,
284
286
  base64: content,
285
- mimeType: "image/jpeg",
287
+ mimeType: sniffed,
286
288
  };
287
289
  }
288
290
  }
package/dist/index.d.ts CHANGED
@@ -73,6 +73,7 @@ export { BeatovenMusic, BeatovenMusicHandler, ElevenLabsMusic, ElevenLabsMusicHa
73
73
  export { DIDAvatar, DIDAvatarHandler, HeyGenAvatar, HeyGenAvatarHandler, registerDefaultAvatarHandlers, ReplicateAvatar, ReplicateAvatarHandler, } from "./avatar/index.js";
74
74
  export { isVertexVideoConfigured, KlingVideoHandler, registerDefaultVideoHandlers, ReplicateVideoHandler, RunwayVideoHandler, VertexVideoHandler, } from "./adapters/video/index.js";
75
75
  export { ImageGenService } from "./image-gen/ImageGenService.js";
76
+ export { detectImageMimeType, sniffImageMimeType, } from "./utils/imageDetection.js";
76
77
  export { HITLManager } from "./hitl/hitlManager.js";
77
78
  export { ProviderRegistry } from "./factories/providerRegistry.js";
78
79
  import { NeuroLink } from "./neurolink.js";
package/dist/index.js CHANGED
@@ -147,6 +147,7 @@ export { DIDAvatar, DIDAvatarHandler, HeyGenAvatar, HeyGenAvatarHandler, registe
147
147
  export { isVertexVideoConfigured, KlingVideoHandler, registerDefaultVideoHandlers, ReplicateVideoHandler, RunwayVideoHandler, VertexVideoHandler, } from "./adapters/video/index.js";
148
148
  // Image generation + HITL — surfaced from their dedicated barrels
149
149
  export { ImageGenService } from "./image-gen/ImageGenService.js";
150
+ export { detectImageMimeType, sniffImageMimeType, } from "./utils/imageDetection.js";
150
151
  export { HITLManager } from "./hitl/hitlManager.js";
151
152
  // Provider registry (for tests, advanced consumers, and tools that need to
152
153
  // invoke registerAllProviders() outside of constructing a NeuroLink instance)
@@ -92,6 +92,15 @@ export declare class ExternalServerManager extends EventEmitter {
92
92
  * Start an external MCP server
93
93
  */
94
94
  private startServer;
95
+ /**
96
+ * Close a live client/transport pair, clearing the lifecycle hooks first
97
+ * so the `onclose` they would otherwise trigger cannot schedule a restart
98
+ * of a connection being torn down deliberately. Shared by `stopServer` and
99
+ * the minTools readiness gate in `startServer`, which rejects a server
100
+ * before it is ever registered as connected. Leaves status untouched —
101
+ * callers set whatever status fits their case.
102
+ */
103
+ private closeInstanceConnection;
95
104
  /**
96
105
  * Stop an external MCP server
97
106
  */
@@ -61,6 +61,29 @@ function isConnectionLostError(error) {
61
61
  const message = error instanceof Error ? error.message : String(error);
62
62
  return CONNECTION_LOST_PATTERN.test(message);
63
63
  }
64
+ /**
65
+ * Thrown by `startServer` when a server's discovered tool count falls below
66
+ * its configured `minTools` floor. Deliberately thrown *inside* the same
67
+ * try/catch that already handles every other start failure, so the existing
68
+ * machinery (status -> "failed", `lastError`, span exception recording,
69
+ * rethrow to `addServer`'s own catch) applies unchanged; `addServer`
70
+ * recognizes this specific error via `instanceof` to attach the
71
+ * `readiness: "insufficient_tools"` + `toolsDiscovered` metadata that a
72
+ * generic start failure does not carry.
73
+ */
74
+ class InsufficientToolsError extends Error {
75
+ serverId;
76
+ toolsDiscovered;
77
+ minTools;
78
+ readiness = "insufficient_tools";
79
+ constructor(serverId, toolsDiscovered, minTools) {
80
+ super(`Server '${serverId}' discovered ${toolsDiscovered} tool(s), below the configured minTools=${minTools}`);
81
+ this.serverId = serverId;
82
+ this.toolsDiscovered = toolsDiscovered;
83
+ this.minTools = minTools;
84
+ this.name = "InsufficientToolsError";
85
+ }
86
+ }
64
87
  /**
65
88
  * Consecutive idle health checks a server may fail to answer before it is
66
89
  * treated as hung and restarted. Two, not one: a single missed ping right
@@ -639,6 +662,7 @@ export class ExternalServerManager extends EventEmitter {
639
662
  retries: config.retries,
640
663
  healthCheckInterval: config.healthCheckInterval,
641
664
  autoRestart: config.autoRestart,
665
+ minTools: config.minTools,
642
666
  cwd: config.cwd,
643
667
  url: config.url,
644
668
  metadata: {
@@ -680,6 +704,7 @@ export class ExternalServerManager extends EventEmitter {
680
704
  retries: serverInfo.retries,
681
705
  healthCheckInterval: serverInfo.healthCheckInterval,
682
706
  autoRestart: serverInfo.autoRestart,
707
+ minTools: serverInfo.minTools,
683
708
  cwd: serverInfo.cwd,
684
709
  url: serverInfo.url,
685
710
  blockedTools: serverInfo.blockedTools,
@@ -768,6 +793,7 @@ export class ExternalServerManager extends EventEmitter {
768
793
  metadata: {
769
794
  timestamp: Date.now(),
770
795
  operation: "addServer",
796
+ readiness: "ready",
771
797
  toolsDiscovered: finalInstance.tools.length,
772
798
  },
773
799
  };
@@ -778,6 +804,23 @@ export class ExternalServerManager extends EventEmitter {
778
804
  mcpLogger.debug(`[ExternalServerManager] Failed to add server ${serverId}:`, error);
779
805
  // Clean up if instance was created
780
806
  this.servers.delete(serverId);
807
+ // The minTools readiness gate carries structured metadata a generic
808
+ // start failure does not: how many tools were actually discovered, so
809
+ // a caller can distinguish "too few tools" from "could not connect".
810
+ if (error instanceof InsufficientToolsError) {
811
+ return {
812
+ success: false,
813
+ error: error.message,
814
+ serverId,
815
+ duration: Date.now() - startTime,
816
+ metadata: {
817
+ timestamp: Date.now(),
818
+ operation: "addServer",
819
+ readiness: error.readiness,
820
+ toolsDiscovered: error.toolsDiscovered,
821
+ },
822
+ };
823
+ }
781
824
  return {
782
825
  success: false,
783
826
  error: error instanceof Error ? error.message : String(error),
@@ -885,6 +928,22 @@ export class ExternalServerManager extends EventEmitter {
885
928
  this.updateServerStatus(serverId, "connected");
886
929
  // Discover tools from the server
887
930
  await this.discoverServerTools(serverId);
931
+ // Readiness gate: a server whose discovered tool count falls below
932
+ // its configured minTools (default 0 — no minimum, so resource/prompt
933
+ // -only servers stay valid) is torn back down instead of being marked
934
+ // connected/healthy. Thrown here — before tools are registered with
935
+ // the main registry and before health monitoring starts — so a
936
+ // rejected server is never marked ready even transiently, and the
937
+ // throw is caught by this method's own catch below, which sets status
938
+ // "failed" and rethrows to addServer's catch the same way any other
939
+ // start failure does.
940
+ const minTools = config.minTools ?? 0;
941
+ if (instance.toolsMap.size < minTools) {
942
+ const toolsDiscovered = instance.toolsMap.size;
943
+ mcpLogger.warn(`[ExternalServerManager] Server '${serverId}' discovered ${toolsDiscovered} tool(s), below the configured minTools=${minTools}; tearing the connection back down`);
944
+ await this.closeInstanceConnection(serverId, instance);
945
+ throw new InsufficientToolsError(serverId, toolsDiscovered, minTools);
946
+ }
888
947
  // Register tools with main registry if integration is enabled
889
948
  if (this.enableMainRegistryIntegration) {
890
949
  await this.registerServerToolsWithMainRegistry(serverId);
@@ -920,6 +979,31 @@ export class ExternalServerManager extends EventEmitter {
920
979
  span.end();
921
980
  }
922
981
  }
982
+ /**
983
+ * Close a live client/transport pair, clearing the lifecycle hooks first
984
+ * so the `onclose` they would otherwise trigger cannot schedule a restart
985
+ * of a connection being torn down deliberately. Shared by `stopServer` and
986
+ * the minTools readiness gate in `startServer`, which rejects a server
987
+ * before it is ever registered as connected. Leaves status untouched —
988
+ * callers set whatever status fits their case.
989
+ */
990
+ async closeInstanceConnection(serverId, instance) {
991
+ if (!instance.client || !instance.transportInstance) {
992
+ return;
993
+ }
994
+ instance.client.onclose = undefined;
995
+ instance.client.onerror = undefined;
996
+ try {
997
+ await MCPClientFactory.closeClient(instance.client, instance.transportInstance);
998
+ }
999
+ catch (error) {
1000
+ mcpLogger.debug(`[ExternalServerManager] Error closing client for ${serverId}:`, error);
1001
+ }
1002
+ instance.client = null;
1003
+ instance.transportInstance = null;
1004
+ instance.process = null;
1005
+ instance.pid = undefined;
1006
+ }
923
1007
  /**
924
1008
  * Stop an external MCP server
925
1009
  */
@@ -951,22 +1035,10 @@ export class ExternalServerManager extends EventEmitter {
951
1035
  // Clear server tools from discovery service
952
1036
  this.toolDiscovery.clearServerTools(serverId);
953
1037
  // Close MCP client using factory cleanup. This close is ours, not a
954
- // crash: drop the lifecycle hooks first so the onclose it triggers
955
- // cannot schedule a restart of a server we are deliberately stopping.
956
- if (instance.client && instance.transportInstance) {
957
- instance.client.onclose = undefined;
958
- instance.client.onerror = undefined;
959
- try {
960
- await MCPClientFactory.closeClient(instance.client, instance.transportInstance);
961
- }
962
- catch (error) {
963
- mcpLogger.debug(`[ExternalServerManager] Error closing client for ${serverId}:`, error);
964
- }
965
- instance.client = null;
966
- instance.transportInstance = null;
967
- instance.process = null;
968
- instance.pid = undefined;
969
- }
1038
+ // crash: closeInstanceConnection drops the lifecycle hooks first so
1039
+ // the onclose it triggers cannot schedule a restart of a server we
1040
+ // are deliberately stopping.
1041
+ await this.closeInstanceConnection(serverId, instance);
970
1042
  this.updateServerStatus(serverId, "stopped");
971
1043
  span.setStatus({ code: SpanStatusCode.OK });
972
1044
  mcpLogger.info(`[ExternalServerManager] Server stopped: ${serverId}`);
package/dist/neurolink.js CHANGED
@@ -7696,6 +7696,24 @@ Current user's request: ${currentInput}`;
7696
7696
  factoryResult,
7697
7697
  sessionId: enhancedOptions.context?.sessionId,
7698
7698
  });
7699
+ // Curator P2-4 dedup (concurrency-safe): native provider stream paths
7700
+ // emit `generation:end` themselves. We attach a per-stream mutable
7701
+ // flag to `enhancedOptions._streamDedupContext`; the provider flips it
7702
+ // before its own emit and the finally block below reads the same
7703
+ // closed-over reference, so the orchestration does not emit a second
7704
+ // event for the same stream. Concurrent streams have different option
7705
+ // objects and therefore different contexts, so they cannot interfere.
7706
+ //
7707
+ // #1741: this MUST be attached before `createMCPStream`. That call
7708
+ // reaches `provider.stream({ ...options })`, which copies the options
7709
+ // — so a context attached afterwards never reaches the provider, and
7710
+ // every native stream path silently emitted twice. The flag looked
7711
+ // present on `enhancedOptions` and was simply absent from the object
7712
+ // the provider actually held.
7713
+ const dedupContext = {
7714
+ providerEmitted: false,
7715
+ };
7716
+ enhancedOptions._streamDedupContext = dedupContext;
7699
7717
  // Kept whole (not just destructured) so its toolsUsed/toolExecutions
7700
7718
  // descriptors can be re-applied onto the final StreamResult near the
7701
7719
  // bottom of this method, without ever reading — and thereby freezing —
@@ -7733,17 +7751,6 @@ Current user's request: ${currentInput}`;
7733
7751
  const streamStartTime = Date.now();
7734
7752
  const sessionId = enhancedOptions.context
7735
7753
  ?.sessionId;
7736
- // Curator P2-4 dedup (concurrency-safe): native provider stream paths
7737
- // (Gemini 3 on Vertex / Google AI Studio) emit `generation:end`
7738
- // themselves. We attach a per-stream mutable flag directly to
7739
- // `enhancedOptions._streamDedupContext` — native providers receive
7740
- // these options and flip the flag before their emit; this finally
7741
- // block reads the same closed-over reference. Concurrent streams
7742
- // have different option objects so the contexts don't interfere.
7743
- const dedupContext = {
7744
- providerEmitted: false,
7745
- };
7746
- enhancedOptions._streamDedupContext = dedupContext;
7747
7754
  const processedStream = (async function* () {
7748
7755
  let streamError;
7749
7756
  // Curator P2-4: hoist `resolvedUsage` so the finally block can emit a
@@ -261,6 +261,15 @@ export class AmazonBedrockProvider extends BaseProvider {
261
261
  success: false,
262
262
  error: error instanceof Error ? error.message : String(error),
263
263
  });
264
+ // #1741: the SDK emits its own `generation:end` unless the native
265
+ // path marks that it already did. The failure branch has no result
266
+ // to carry the mark, so it goes on the error — the same shape the
267
+ // AI Studio path uses. Without it one failed Bedrock generate is
268
+ // counted twice in analytics and as two Pipeline B spans.
269
+ if (error && typeof error === "object") {
270
+ error._generationEndEmitted =
271
+ true;
272
+ }
264
273
  }
265
274
  throw error;
266
275
  }
@@ -288,7 +297,7 @@ export class AmazonBedrockProvider extends BaseProvider {
288
297
  // `baseProvider` fell back to `result.toolsUsed || []` and every turn
289
298
  // reported zero tools while running them correctly — leaving analytics,
290
299
  // cost attribution and audit blind on this path.
291
- return {
300
+ const generateResult = {
292
301
  content: text, // CLI expects 'content' not 'text'
293
302
  usage,
294
303
  model: this.modelName || this.getDefaultModel(),
@@ -320,6 +329,16 @@ export class AmazonBedrockProvider extends BaseProvider {
320
329
  toolExecutions: resolveToolExecutionRecords(options, transformToolExecutions(toolExecutions)),
321
330
  enhancedWithTools: toolExecutions.some((execution) => execution.error === undefined),
322
331
  };
332
+ // #1741: the SDK's `finalizeGenerateRequestResult` emits its own
333
+ // `generation:end` unless the result carries this mark, so every
334
+ // SUCCESSFUL Bedrock generate was counted twice — an extra Langfuse
335
+ // `model.generation` observation and an extra analytics record per call,
336
+ // which inflates Bedrock's cost against every other provider. Vertex and
337
+ // AI Studio have always set it; Bedrock emitted without marking. Set
338
+ // after construction, like those two, because the field is deliberately
339
+ // not part of the public `EnhancedGenerateResult` surface.
340
+ generateResult._generationEndEmitted = true;
341
+ return generateResult;
323
342
  }
324
343
  async conversationLoop(options) {
325
344
  // The step cap is now the same `maxSteps || DEFAULT_MAX_STEPS` the
@@ -1072,6 +1091,16 @@ export class AmazonBedrockProvider extends BaseProvider {
1072
1091
  // never injected and generation:end is emitted by hand for
1073
1092
  // Pipeline B (Langfuse).
1074
1093
  if (streamEmitter) {
1094
+ // #1741: tell the orchestration's finally block this stream's
1095
+ // `generation:end` has already been emitted here, exactly as the
1096
+ // other native stream paths do. The context object is attached to
1097
+ // the caller's options by `NeuroLink.stream()`; BaseProvider
1098
+ // spreads the options but carries the same context reference, so
1099
+ // mutating it is what the orchestration reads back.
1100
+ const dedupContext = options._streamDedupContext;
1101
+ if (dedupContext) {
1102
+ dedupContext.providerEmitted = true;
1103
+ }
1075
1104
  streamEmitter.emit("generation:end", {
1076
1105
  provider: self.providerName,
1077
1106
  responseTime: Date.now() - startTime,