@juspay/neurolink 12.25.5 → 12.26.1

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 +2 -2
  2. package/dist/browser/neurolink.min.js +382 -382
  3. package/dist/cli/commands/decide.d.ts +2 -1
  4. package/dist/cli/commands/decide.js +4 -2
  5. package/dist/cli/commands/setup.js +2 -1
  6. package/dist/cli/factories/commandFactory.js +1 -1
  7. package/dist/cli/utils/classifierRouterFlags.js +1 -1
  8. package/dist/constants/enums.d.ts +15 -0
  9. package/dist/constants/enums.js +16 -0
  10. package/dist/core/baseProvider.js +2 -2
  11. package/dist/factories/providerDescriptors.d.ts +16 -7
  12. package/dist/factories/providerDescriptors.js +97 -15
  13. package/dist/factories/providerRegistry.js +9 -1
  14. package/dist/mcp/externalServerManager.js +16 -2
  15. package/dist/mcp/mcpCircuitBreaker.d.ts +24 -3
  16. package/dist/mcp/mcpCircuitBreaker.js +57 -18
  17. package/dist/mcp/toolDiscoveryService.js +20 -4
  18. package/dist/models/manifestRegistry.js +2 -0
  19. package/dist/models/manifests/laya.d.ts +12 -0
  20. package/dist/models/manifests/laya.js +54 -0
  21. package/dist/neurolink.js +8 -3
  22. package/dist/providers/laya.d.ts +40 -0
  23. package/dist/providers/laya.js +170 -0
  24. package/dist/providers/systemOneDecision.d.ts +102 -0
  25. package/dist/providers/systemOneDecision.js +443 -0
  26. package/dist/providers/typesafe.d.ts +32 -47
  27. package/dist/providers/typesafe.js +64 -386
  28. package/dist/routing/classifierRouter.d.ts +2 -2
  29. package/dist/routing/classifierRouter.js +10 -8
  30. package/dist/types/classifierRouter.d.ts +13 -3
  31. package/dist/types/cli.d.ts +3 -2
  32. package/dist/types/decision.d.ts +29 -0
  33. package/dist/types/externalMcp.d.ts +7 -0
  34. package/dist/types/providers.d.ts +20 -1
  35. package/dist/utils/modelChoices.js +21 -1
  36. package/dist/utils/providerConfig.d.ts +5 -0
  37. package/dist/utils/providerConfig.js +17 -0
  38. package/docs-site/static/search-index.json +24 -10
  39. package/package.json +4 -2
@@ -8,7 +8,8 @@
8
8
  * for the full vocabulary.
9
9
  *
10
10
  * Credentials are env-only here, exactly as every other CLI command: the
11
- * decision provider (TypeSafe) reads `TYPESAFE_API_KEY` itself.
11
+ * decision provider reads its own settings (TYPESAFE_API_KEY, or LAYA_API_KEY
12
+ * and LAYA_BASE_URL) itself.
12
13
  */
13
14
  import type { CommandModule } from "yargs";
14
15
  import type { CliDecideArgs } from "../../types/index.js";
@@ -8,11 +8,13 @@
8
8
  * for the full vocabulary.
9
9
  *
10
10
  * Credentials are env-only here, exactly as every other CLI command: the
11
- * decision provider (TypeSafe) reads `TYPESAFE_API_KEY` itself.
11
+ * decision provider reads its own settings (TYPESAFE_API_KEY, or LAYA_API_KEY
12
+ * and LAYA_BASE_URL) itself.
12
13
  */
13
14
  import chalk from "chalk";
14
15
  import ora from "ora";
15
16
  import fs from "node:fs";
17
+ import { describeDecisionProviderKeys } from "../../factories/providerDescriptors.js";
16
18
  import { NeuroLink } from "../../neurolink.js";
17
19
  import { readDecisionChoice } from "../../utils/decisionAnswers.js";
18
20
  import { calculateCost, hasPricing } from "../../utils/pricing.js";
@@ -87,7 +89,7 @@ function describeDecideError(error) {
87
89
  }
88
90
  const message = error instanceof Error ? error.message : String(error);
89
91
  if (message.includes("No decision provider is configured")) {
90
- return "No decision provider is configured. Set TYPESAFE_API_KEY, or AI_GATEWAY_API_KEY for the Vercel AI Gateway route.";
92
+ return `No decision provider is configured. Set ${describeDecisionProviderKeys()}.`;
91
93
  }
92
94
  return message;
93
95
  }
@@ -20,7 +20,7 @@ import { handleGCPSetup } from "./setup-gcp.js";
20
20
  import { handleHuggingFaceSetup } from "./setup-huggingface.js";
21
21
  import { handleMistralSetup } from "./setup-mistral.js";
22
22
  import { PROVIDER_DESCRIPTORS_BY_NAME } from "../../factories/providerDescriptors.js";
23
- import { createCohereConfig, createDeepSeekConfig, createIdeogramConfig, createJinaConfig, createNvidiaNimConfig, createOpenAICompatibleConfig, createRecraftConfig, createReplicateConfig, createStabilityConfig, createVoyageConfig, createTypeSafeConfig, satisfiesFallbacks, } from "../../utils/providerConfig.js";
23
+ import { createCohereConfig, createDeepSeekConfig, createIdeogramConfig, createJinaConfig, createNvidiaNimConfig, createOpenAICompatibleConfig, createRecraftConfig, createReplicateConfig, createStabilityConfig, createVoyageConfig, createTypeSafeConfig, createLayaConfig, satisfiesFallbacks, } from "../../utils/providerConfig.js";
24
24
  import { getCatalogJsonEntries, buildCatalogConfigOptions, } from "../../providers/catalog/loader.js";
25
25
  // Provider information database
26
26
  const PROVIDERS = [
@@ -169,6 +169,7 @@ export const EXTRA_PROVIDER_CONFIGS = {
169
169
  ideogram: createIdeogramConfig(),
170
170
  recraft: createRecraftConfig(),
171
171
  typesafe: createTypeSafeConfig(),
172
+ laya: createLayaConfig(),
172
173
  ...Object.fromEntries(getCatalogJsonEntries()
173
174
  .filter((e) => e.id !== "mistral")
174
175
  .map((e) => [e.id, buildCatalogConfigOptions(e)])),
@@ -593,7 +593,7 @@ export class CLICommandFactory {
593
593
  },
594
594
  classifierStrategy: {
595
595
  type: "string",
596
- description: "Classifier strategy: 'auto' (default — 'jev' when TYPESAFE_API_KEY is set, else 'heuristic'), 'heuristic' (no LLM), 'llm' (a cheap model picks per prompt), or 'jev' (TypeSafe System One, ~400ms with calibrated confidence).",
596
+ description: "Classifier strategy: 'auto' (default — 'jev' when a decision provider is configured, such as TYPESAFE_API_KEY, or LAYA_API_KEY with LAYA_BASE_URL, else 'heuristic'), 'heuristic' (no LLM), 'llm' (a cheap model picks per prompt), or 'jev' (a System One decision model — TypeSafe Jev, or Laya — with calibrated confidence).",
597
597
  choices: ["auto", "heuristic", "llm", "jev"],
598
598
  alias: "classifier-strategy",
599
599
  },
@@ -37,7 +37,7 @@ export function buildClassifierRouterConfigFromCli(flags) {
37
37
  if (!flags.classifierRouter) {
38
38
  return undefined;
39
39
  }
40
- // Defaults to "auto", which resolves to "jev" when TYPESAFE_API_KEY is set
40
+ // Defaults to "auto", which resolves to "jev" when a decision provider's key is set
41
41
  // and "heuristic" otherwise — so a configured key upgrades CLI routing with
42
42
  // no flag change, and an unset one behaves exactly as before.
43
43
  const strategy = isStrategyKind(flags.classifierStrategy)
@@ -44,6 +44,8 @@ export declare enum AIProviderName {
44
44
  RECRAFT = "recraft",
45
45
  /** TypeSafe (Jev) — serves the `decide` inference type only. */
46
46
  TYPESAFE = "typesafe",
47
+ /** Laya (Convai, open weights) — serves the `decide` inference type only. */
48
+ LAYA = "laya",
47
49
  AUTO = "auto"
48
50
  }
49
51
  /**
@@ -1047,3 +1049,16 @@ export declare enum TypeSafeModels {
1047
1049
  JEV_LATEST = "jev-latest",
1048
1050
  JEV_PREVIEW = "jev-preview"
1049
1051
  }
1052
+ /**
1053
+ * Laya checkpoints. Hand-written: Laya is a Tier-3 provider, so it is not in
1054
+ * the provider catalog and codegen never touches this.
1055
+ *
1056
+ * `auto` sends no model, which lets Laya's own router pick a checkpoint by
1057
+ * the detected language of the state.
1058
+ */
1059
+ export declare enum LayaModels {
1060
+ TYPED_DECISIONS = "typed-decisions",
1061
+ ENGLISH = "english",
1062
+ MULTILINGUAL = "multilingual",
1063
+ AUTO = "auto"
1064
+ }
@@ -50,6 +50,8 @@ export var AIProviderName;
50
50
  AIProviderName["RECRAFT"] = "recraft";
51
51
  /** TypeSafe (Jev) — serves the `decide` inference type only. */
52
52
  AIProviderName["TYPESAFE"] = "typesafe";
53
+ /** Laya (Convai, open weights) — serves the `decide` inference type only. */
54
+ AIProviderName["LAYA"] = "laya";
53
55
  AIProviderName["AUTO"] = "auto";
54
56
  })(AIProviderName || (AIProviderName = {}));
55
57
  /**
@@ -1295,3 +1297,17 @@ export var TypeSafeModels;
1295
1297
  TypeSafeModels["JEV_LATEST"] = "jev-latest";
1296
1298
  TypeSafeModels["JEV_PREVIEW"] = "jev-preview";
1297
1299
  })(TypeSafeModels || (TypeSafeModels = {}));
1300
+ /**
1301
+ * Laya checkpoints. Hand-written: Laya is a Tier-3 provider, so it is not in
1302
+ * the provider catalog and codegen never touches this.
1303
+ *
1304
+ * `auto` sends no model, which lets Laya's own router pick a checkpoint by
1305
+ * the detected language of the state.
1306
+ */
1307
+ export var LayaModels;
1308
+ (function (LayaModels) {
1309
+ LayaModels["TYPED_DECISIONS"] = "typed-decisions";
1310
+ LayaModels["ENGLISH"] = "english";
1311
+ LayaModels["MULTILINGUAL"] = "multilingual";
1312
+ LayaModels["AUTO"] = "auto";
1313
+ })(LayaModels || (LayaModels = {}));
@@ -1,7 +1,7 @@
1
1
  import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
2
  import { directAgentTools } from "../agent/directTools.js";
3
3
  import { defaultProviderFor } from "../factories/mediaHandlerCatalog.js";
4
- import { PROVIDER_DESCRIPTORS_BY_NAME } from "../factories/providerDescriptors.js";
4
+ import { DECISION_PROVIDERS, PROVIDER_DESCRIPTORS_BY_NAME, } from "../factories/providerDescriptors.js";
5
5
  import { MiddlewareFactory } from "../middleware/factory.js";
6
6
  import { modelSupports } from "../models/modelRegistry.js";
7
7
  import { resolveRequestKind } from "./resolveRequestKind.js";
@@ -1854,7 +1854,7 @@ export class BaseProvider {
1854
1854
  async decide(_request) {
1855
1855
  logger.warn(`decide() called on ${this.providerName}, which does not serve the decide inference type`);
1856
1856
  throw new Error(`The decide inference type is not supported by the ${this.providerName} provider. ` +
1857
- `Supported providers: typesafe. A provider declares this capability with ` +
1857
+ `Supported providers: ${DECISION_PROVIDERS.map((d) => d.name).join(", ")}. A provider declares this capability with ` +
1858
1858
  `"decide" in its descriptor's inferenceKinds.`);
1859
1859
  }
1860
1860
  /**
@@ -1,5 +1,5 @@
1
1
  import { AIProviderName } from "../constants/enums.js";
2
- import type { ProviderDescriptor } from "../types/index.js";
2
+ import type { NeurolinkCredentials, 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
@@ -21,11 +21,20 @@ export declare const PROVIDER_ALIAS_INDEX: ReadonlyMap<string, AIProviderName>;
21
21
  */
22
22
  export declare const DECISION_PROVIDERS: readonly ProviderDescriptor[];
23
23
  /**
24
- * The decision provider to use when a caller names none: the first one whose
25
- * primary credential env var is actually set.
24
+ * The decision provider to use when a caller names none: the first
25
+ * DECISION_PROVIDERS entry that is fully configured, from the environment or
26
+ * from `credentials`.
26
27
  *
27
- * This is where "if somebody sets the key, we start using it" is implemented.
28
- * Returns undefined when none is configured, which every internal consumer
29
- * treats as "carry on exactly as before".
28
+ * This is where "if somebody configures it, we start using it" is
29
+ * implemented. Returns undefined when none is configured, which every
30
+ * internal consumer treats as "carry on exactly as before".
30
31
  */
31
- export declare function resolveDefaultDecisionProvider(): string | undefined;
32
+ export declare function resolveDefaultDecisionProvider(credentials?: NeurolinkCredentials): string | undefined;
33
+ /**
34
+ * The variables that would configure each decision provider, one clause per
35
+ * provider in precedence order, e.g. "TYPESAFE_API_KEY or AI_GATEWAY_API_KEY
36
+ * for typesafe, or LAYA_API_KEY and LAYA_BASE_URL for laya". Derived from
37
+ * DECISION_PROVIDERS so a new decision provider appears in every "nothing is
38
+ * configured" message without anyone editing one.
39
+ */
40
+ export declare function describeDecisionProviderKeys(): string;
@@ -1,5 +1,5 @@
1
1
  import { AIProviderName } from "../constants/enums.js";
2
- import { GoogleAIModels, OpenAIModels, AnthropicModels, VertexModels, OllamaModels, LiteLLMModels, NvidiaNimModels, OpenRouterModels, CohereModels, VoyageModels, TypeSafeModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, ReplicateModels, } from "../constants/enums.js";
2
+ import { GoogleAIModels, OpenAIModels, AnthropicModels, VertexModels, OllamaModels, LiteLLMModels, NvidiaNimModels, OpenRouterModels, CohereModels, VoyageModels, TypeSafeModels, LayaModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, ReplicateModels, } from "../constants/enums.js";
3
3
  import { API_KEY_FORMATS } from "../utils/providerConfig.js";
4
4
  import { getCatalogJsonEntries, catalogCredentialsKey, catalogEnvVar, } from "../providers/catalog/loader.js";
5
5
  import { DEFAULT_INFERENCE_KINDS } from "../types/index.js";
@@ -451,6 +451,56 @@ const HAND_DESCRIPTORS = [
451
451
  timeouts: { decideMs: 5000 },
452
452
  setupUrl: "https://console.typesafe.ai/keys",
453
453
  },
454
+ // Laya MUST stay after TypeSafe. resolveDefaultDecisionProvider() returns
455
+ // the first configured DECISION_PROVIDERS entry, in this order, so a host
456
+ // configured for both keeps Jev for every built-in consumer and reaches
457
+ // Laya only by naming it. Reordering these two silently changes which model
458
+ // routes, compacts and plans for every such host.
459
+ {
460
+ name: AIProviderName.LAYA,
461
+ aliases: [],
462
+ credentialsKey: "laya",
463
+ envVars: {
464
+ apiKey: "LAYA_API_KEY",
465
+ baseURL: "LAYA_BASE_URL",
466
+ // Laya has no built-in endpoint (it is self-hosted or behind a proxy),
467
+ // so a key is useless without a base URL. Both are required for it to
468
+ // count as configured, from the environment or credentials.laya.
469
+ extraRequired: ["LAYA_BASE_URL"],
470
+ model: "LAYA_MODEL",
471
+ },
472
+ defaultModel: LayaModels.TYPED_DECISIONS,
473
+ // Serves only `decide`, like TypeSafe — the one declaration that keeps it
474
+ // out of every generation code path.
475
+ inferenceKinds: ["decide"],
476
+ toolSupport: "none",
477
+ localRuntime: false,
478
+ healthCheck: "env-only",
479
+ // Deliberately NO autoSelectPriority / autoSelectPreference /
480
+ // defaultHealthSweepPriority, for the same reason as TypeSafe above.
481
+ timeouts: { decideMs: 5000 },
482
+ // Laya's encoders cut the state off silently past their window (measured
483
+ // live: a 60,000-character state answered 200 on 1,024 tokens). 768 is
484
+ // the 1,024-token window minus the 256-token budget Laya reserves for a
485
+ // question and its options; 320 is the English checkpoint's 512 minus
486
+ // 192. `auto` may route to English, and an unlisted name (Laya's server
487
+ // accepts aliases such as `en`) could be any checkpoint, so both get 320.
488
+ // 64 is the question cap in Laya's own server. Non-ASCII rates were
489
+ // measured live: Chinese is ~1.4 tokens per character on the English
490
+ // tokenizer (typed-decisions, english) and ~0.56 on multilingual.
491
+ decisionLimits: {
492
+ maxStateTokens: 320,
493
+ maxQuestions: 64,
494
+ nonAsciiTokensPerChar: 1.5,
495
+ models: {
496
+ "typed-decisions": { maxStateTokens: 768 },
497
+ multilingual: { maxStateTokens: 768, nonAsciiTokensPerChar: 0.6 },
498
+ english: { maxStateTokens: 320 },
499
+ auto: { maxStateTokens: 320 },
500
+ },
501
+ },
502
+ setupUrl: "https://github.com/NandhaKishorM/laya",
503
+ },
454
504
  ];
455
505
  /**
456
506
  * Builds a ProviderDescriptor for every JSON-catalog provider. Every field
@@ -554,20 +604,52 @@ export const PROVIDER_ALIAS_INDEX = new Map(PROVIDER_DESCRIPTORS.flatMap((d) =>
554
604
  */
555
605
  export const DECISION_PROVIDERS = PROVIDER_DESCRIPTORS.filter((d) => (d.inferenceKinds ?? DEFAULT_INFERENCE_KINDS).includes("decide"));
556
606
  /**
557
- * The decision provider to use when a caller names none: the first one whose
558
- * primary credential env var is actually set.
607
+ * Whether a decision provider is fully configured: its key and every
608
+ * extraRequired variable are set, each either in the environment or in
609
+ * `credentials` — the same config a caller passes to
610
+ * `new NeuroLink({ credentials })` or per call. Whitespace counts as unset.
611
+ */
612
+ function isDecisionProviderConfigured(descriptor, credentials) {
613
+ const isSet = (value) => typeof value === "string" && value.trim() !== "";
614
+ const inEnv = (name) => name !== undefined && isSet(process.env[name]);
615
+ const slice = descriptor.credentialsKey
616
+ ? credentials?.[descriptor.credentialsKey]
617
+ : undefined;
618
+ // `gatewayApiKey` is TypeSafe's config form of AI_GATEWAY_API_KEY.
619
+ const hasKey = [descriptor.envVars.apiKey, ...(descriptor.envVars.fallbacks ?? [])].some(inEnv) ||
620
+ isSet(slice?.apiKey) ||
621
+ isSet(slice?.gatewayApiKey);
622
+ // A required base URL can also come from credentials.<key>.baseURL.
623
+ const hasRequired = (descriptor.envVars.extraRequired ?? []).every((name) => inEnv(name) ||
624
+ (name === descriptor.envVars.baseURL && isSet(slice?.baseURL)));
625
+ return hasKey && hasRequired;
626
+ }
627
+ /**
628
+ * The decision provider to use when a caller names none: the first
629
+ * DECISION_PROVIDERS entry that is fully configured, from the environment or
630
+ * from `credentials`.
559
631
  *
560
- * This is where "if somebody sets the key, we start using it" is implemented.
561
- * Returns undefined when none is configured, which every internal consumer
562
- * treats as "carry on exactly as before".
632
+ * This is where "if somebody configures it, we start using it" is
633
+ * implemented. Returns undefined when none is configured, which every
634
+ * internal consumer treats as "carry on exactly as before".
563
635
  */
564
- export function resolveDefaultDecisionProvider() {
565
- for (const descriptor of DECISION_PROVIDERS) {
566
- const primary = descriptor.envVars.apiKey;
567
- const candidates = [primary, ...(descriptor.envVars.fallbacks ?? [])];
568
- if (candidates.some((v) => v && (process.env[v] ?? "").trim() !== "")) {
569
- return descriptor.name;
570
- }
571
- }
572
- return undefined;
636
+ export function resolveDefaultDecisionProvider(credentials) {
637
+ return DECISION_PROVIDERS.find((descriptor) => isDecisionProviderConfigured(descriptor, credentials))?.name;
638
+ }
639
+ /**
640
+ * The variables that would configure each decision provider, one clause per
641
+ * provider in precedence order, e.g. "TYPESAFE_API_KEY or AI_GATEWAY_API_KEY
642
+ * for typesafe, or LAYA_API_KEY and LAYA_BASE_URL for laya". Derived from
643
+ * DECISION_PROVIDERS so a new decision provider appears in every "nothing is
644
+ * configured" message without anyone editing one.
645
+ */
646
+ export function describeDecisionProviderKeys() {
647
+ return DECISION_PROVIDERS.map((descriptor) => {
648
+ const keys = [
649
+ descriptor.envVars.apiKey,
650
+ ...(descriptor.envVars.fallbacks ?? []),
651
+ ].filter((name) => typeof name === "string" && name !== "");
652
+ const required = descriptor.envVars.extraRequired ?? [];
653
+ return `${[keys.join(" or "), ...required].join(" and ")} for ${descriptor.name}`;
654
+ }).join(", or ");
573
655
  }
@@ -1,6 +1,6 @@
1
1
  import { ProviderFactory } from "./providerFactory.js";
2
2
  import { logger } from "../utils/logger.js";
3
- import { AIProviderName, GoogleAIModels, OpenAIModels, AnthropicModels, VertexModels, OllamaModels, LiteLLMModels, NvidiaNimModels, OpenRouterModels, CohereModels, VoyageModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, TypeSafeModels, ReplicateModels, } from "../constants/enums.js";
3
+ import { AIProviderName, GoogleAIModels, OpenAIModels, AnthropicModels, VertexModels, OllamaModels, LiteLLMModels, NvidiaNimModels, OpenRouterModels, CohereModels, VoyageModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, TypeSafeModels, LayaModels, ReplicateModels, } from "../constants/enums.js";
4
4
  import { PROVIDER_DESCRIPTORS_BY_NAME } from "./providerDescriptors.js";
5
5
  import { OPENAI_COMPAT_CATALOG } from "../providers/openaiCompatCatalog.js";
6
6
  import { providerChoicesFor } from "./mediaHandlerCatalog.js";
@@ -234,6 +234,14 @@ export class ProviderRegistry {
234
234
  const { TypeSafeProvider } = await import("../providers/typesafe.js");
235
235
  return new TypeSafeProvider(modelName, sdk, undefined, typesafeCreds);
236
236
  }, process.env.TYPESAFE_MODEL || TypeSafeModels.JEV_LATEST, ["jev", "typesafe-ai"], PROVIDER_DESCRIPTORS_BY_NAME.get(AIProviderName.TYPESAFE));
237
+ // Register Laya — the second `decide` provider, reached at whatever base
238
+ // URL its config names. Its descriptor declares inferenceKinds:
239
+ // ["decide"], so nothing in the generation fallback chain can reach it.
240
+ ProviderFactory.registerProvider(AIProviderName.LAYA, async (modelName, _providerName, sdk, _region, credentials) => {
241
+ const layaCreds = credentials;
242
+ const { LayaProvider } = await import("../providers/laya.js");
243
+ return new LayaProvider(modelName, sdk, undefined, layaCreds);
244
+ }, process.env.LAYA_MODEL || LayaModels.TYPED_DECISIONS, [], PROVIDER_DESCRIPTORS_BY_NAME.get(AIProviderName.LAYA));
237
245
  logger.debug("All AI providers registered successfully");
238
246
  // ===== MEDIA HANDLER REGISTRATION =====
239
247
  // Single registration path (Task 11): each ecosystem barrel (voice,
@@ -1702,11 +1702,25 @@ export class ExternalServerManager extends EventEmitter {
1702
1702
  instance.metrics.averageResponseTime =
1703
1703
  totalTime / instance.metrics.totalToolCalls;
1704
1704
  if (result.success) {
1705
- mcpLogger.debug(`[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
1705
+ // A resolved `{ isError: true }` still comes back with success:true so
1706
+ // the caller receives the MCP error payload as-is, but it is a failed
1707
+ // call for telemetry: label mcp_tool_calls_total accordingly instead
1708
+ // of counting every resolved error as a success.
1709
+ const resolvedError = result.isErrorResult === true;
1710
+ if (resolvedError) {
1711
+ // Keep this instance's totalErrors (and the getStatistics() /
1712
+ // getExternalMCPStatistics() it feeds) consistent with the breaker,
1713
+ // TelemetryService and ToolDiscoveryService's failedCalls, all of
1714
+ // which already count a resolved isError result as a failure.
1715
+ instance.metrics.totalErrors++;
1716
+ }
1717
+ mcpLogger.debug(resolvedError
1718
+ ? `[ExternalServerManager] Tool returned a resolved MCP error: ${toolName} on ${serverId}`
1719
+ : `[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
1706
1720
  duration,
1707
1721
  });
1708
1722
  try {
1709
- TelemetryService.getInstance()?.recordMCPToolCall(toolName, duration, true);
1723
+ TelemetryService.getInstance()?.recordMCPToolCall(toolName, duration, !resolvedError);
1710
1724
  }
1711
1725
  catch {
1712
1726
  /* telemetry should not break execution */
@@ -21,9 +21,30 @@ export declare class MCPCircuitBreaker extends EventEmitter {
21
21
  private cleanupTimer?;
22
22
  constructor(name: string, config?: Partial<CircuitBreakerConfig>);
23
23
  /**
24
- * Execute an operation with circuit breaker protection
25
- */
26
- execute<T>(operation: () => Promise<T>): Promise<T>;
24
+ * Execute an operation with circuit breaker protection.
25
+ *
26
+ * `operation` is handed a `recordResolvedFailure` callback so it can flag a
27
+ * *resolved* result as a logical failure without throwing. This matters for
28
+ * MCP tool calls: the client does not throw on a protocol error, it
29
+ * resolves with `{ isError: true, ... }`. Before this callback existed,
30
+ * such a call always fell through to the success path below — the breaker
31
+ * could never open for a tool that only ever "fails" by resolving an error
32
+ * result, and callers still need that resolved value returned as-is (not
33
+ * replaced by a thrown transport error). Call `recordResolvedFailure()`
34
+ * from inside `operation` when that resolved value represents a failure;
35
+ * the call is still counted as a normal, non-throwing success from the
36
+ * `Promise.race` below, so this callback is what tells `execute()`
37
+ * otherwise. Callers that never call it see no behavior change.
38
+ */
39
+ execute<T>(operation: (recordResolvedFailure: (reason?: string) => void) => Promise<T>): Promise<T>;
40
+ /**
41
+ * Shared bookkeeping for a failed outcome — a thrown error, or a resolved
42
+ * result the caller flagged via `recordResolvedFailure`. Records the call
43
+ * as a failure, emits `callFailure`, and runs the state transitions that
44
+ * used to live inline in the `catch` block: half-open failure immediately
45
+ * re-opens the circuit; closed checks the failure threshold.
46
+ */
47
+ private recordFailureOutcome;
27
48
  /**
28
49
  * Record a call in the history
29
50
  */
@@ -46,7 +46,20 @@ export class MCPCircuitBreaker extends EventEmitter {
46
46
  this.cleanupTimer = setInterval(() => this.cleanupCallHistory(), 60000);
47
47
  }
48
48
  /**
49
- * Execute an operation with circuit breaker protection
49
+ * Execute an operation with circuit breaker protection.
50
+ *
51
+ * `operation` is handed a `recordResolvedFailure` callback so it can flag a
52
+ * *resolved* result as a logical failure without throwing. This matters for
53
+ * MCP tool calls: the client does not throw on a protocol error, it
54
+ * resolves with `{ isError: true, ... }`. Before this callback existed,
55
+ * such a call always fell through to the success path below — the breaker
56
+ * could never open for a tool that only ever "fails" by resolving an error
57
+ * result, and callers still need that resolved value returned as-is (not
58
+ * replaced by a thrown transport error). Call `recordResolvedFailure()`
59
+ * from inside `operation` when that resolved value represents a failure;
60
+ * the call is still counted as a normal, non-throwing success from the
61
+ * `Promise.race` below, so this callback is what tells `execute()`
62
+ * otherwise. Callers that never call it see no behavior change.
50
63
  */
51
64
  async execute(operation) {
52
65
  const startTime = Date.now();
@@ -91,11 +104,29 @@ export class MCPCircuitBreaker extends EventEmitter {
91
104
  });
92
105
  }
93
106
  }
107
+ // resolvedFailureReason is set (via recordResolvedFailure, passed into
108
+ // operation() below) when operation() resolves normally but represents
109
+ // a logical failure — e.g. an MCP tool call that resolved
110
+ // { isError: true } instead of throwing. It is call-scoped (a local,
111
+ // not instance state), so concurrent execute() calls on the same
112
+ // breaker (the same tool invoked in parallel) can't cross-contaminate
113
+ // each other's outcome.
114
+ let resolvedFailureReason;
115
+ const recordResolvedFailure = (reason) => {
116
+ resolvedFailureReason = reason || "Operation resolved a failure result";
117
+ };
94
118
  // Execute operation with timeout
95
119
  const result = await Promise.race([
96
- operation(),
120
+ operation(recordResolvedFailure),
97
121
  this.timeoutPromise(this.config.operationTimeout),
98
122
  ]);
123
+ if (resolvedFailureReason !== undefined) {
124
+ // Same bookkeeping as a thrown error below, but the resolved value
125
+ // is still returned to the caller unchanged — no transport error is
126
+ // synthesized for a result the protocol itself resolved successfully.
127
+ this.recordFailureOutcome(resolvedFailureReason, Date.now() - startTime);
128
+ return result;
129
+ }
99
130
  // Record successful call
100
131
  this.recordCall(true, Date.now() - startTime);
101
132
  // Handle half-open success
@@ -111,26 +142,34 @@ export class MCPCircuitBreaker extends EventEmitter {
111
142
  catch (error) {
112
143
  // Record failed call
113
144
  const duration = Date.now() - startTime;
114
- this.recordCall(false, duration);
115
145
  const errorMessage = error instanceof Error ? error.message : String(error);
116
- // Emit failure event
117
- this.emit("callFailure", {
118
- error: errorMessage,
119
- duration,
120
- timestamp: new Date(),
121
- });
122
- // Handle state transitions on failure
123
- if (this.state === "half-open") {
124
- // Failure in half-open immediately opens circuit
125
- this.changeState("open", `Half-open test failed: ${errorMessage}`);
126
- }
127
- else if (this.state === "closed") {
128
- // Check if we should open the circuit
129
- this.checkFailureThreshold();
130
- }
146
+ this.recordFailureOutcome(errorMessage, duration);
131
147
  throw error;
132
148
  }
133
149
  }
150
+ /**
151
+ * Shared bookkeeping for a failed outcome — a thrown error, or a resolved
152
+ * result the caller flagged via `recordResolvedFailure`. Records the call
153
+ * as a failure, emits `callFailure`, and runs the state transitions that
154
+ * used to live inline in the `catch` block: half-open failure immediately
155
+ * re-opens the circuit; closed checks the failure threshold.
156
+ */
157
+ recordFailureOutcome(reason, duration) {
158
+ this.recordCall(false, duration);
159
+ this.emit("callFailure", {
160
+ error: reason,
161
+ duration,
162
+ timestamp: new Date(),
163
+ });
164
+ if (this.state === "half-open") {
165
+ // Failure in half-open immediately opens circuit
166
+ this.changeState("open", `Half-open test failed: ${reason}`);
167
+ }
168
+ else if (this.state === "closed") {
169
+ // Check if we should open the circuit
170
+ this.checkFailureThreshold();
171
+ }
172
+ }
134
173
  /**
135
174
  * Record a call in the history
136
175
  */
@@ -435,8 +435,13 @@ export class ToolDiscoveryService extends EventEmitter {
435
435
  resetTimeout: 30000,
436
436
  operationTimeout: effectiveTimeout,
437
437
  });
438
- // Execute tool with circuit breaker protection
439
- const result = await circuitBreaker.execute(async () => {
438
+ // Execute tool with circuit breaker protection. `recordResolvedFailure`
439
+ // lets the callback below tell the breaker that a *resolved* MCP
440
+ // isError result is a failure — the MCP client does not throw on a
441
+ // protocol error, so without this the breaker would only ever see a
442
+ // clean resolve and could never open for a tool that fails this way.
443
+ let isErrorResultDetected = false;
444
+ const result = await circuitBreaker.execute(async (recordResolvedFailure) => {
440
445
  return mcpTracer.startActiveSpan("neurolink.mcp.callTool", {
441
446
  kind: SpanKind.CLIENT,
442
447
  attributes: {
@@ -479,6 +484,11 @@ export class ToolDiscoveryService extends EventEmitter {
479
484
  code: SpanStatusCode.ERROR,
480
485
  message: errorText || `Tool ${toolName} returned isError`,
481
486
  });
487
+ // NLK: count a resolved isError result as a breaker failure
488
+ // and label completion telemetry as error below — without
489
+ // throwing, so the protocol result is still returned as-is.
490
+ isErrorResultDetected = true;
491
+ recordResolvedFailure(errorText || `Tool ${toolName} returned isError`);
482
492
  }
483
493
  else {
484
494
  callSpan.setStatus({ code: SpanStatusCode.OK });
@@ -528,8 +538,13 @@ export class ToolDiscoveryService extends EventEmitter {
528
538
  });
529
539
  });
530
540
  const duration = Date.now() - startTime;
531
- // Update tool statistics
532
- this.updateToolStats(toolKey, true, duration);
541
+ // Update tool statistics. A resolved isError result is a failed call
542
+ // for completion telemetry purposes even though the wrapper below
543
+ // still returns success:true / data:result unchanged — flipping
544
+ // `success` here would make externalServerManager.executeTool() throw
545
+ // instead of returning the resolved MCP error, which is exactly the
546
+ // "thrown transport error" this fix must not introduce.
547
+ this.updateToolStats(toolKey, !isErrorResultDetected, duration);
533
548
  // Validate output if requested
534
549
  if (options.validateOutput !== false) {
535
550
  this.validateToolOutput(result);
@@ -541,6 +556,7 @@ export class ToolDiscoveryService extends EventEmitter {
541
556
  return {
542
557
  success: true,
543
558
  data: result,
559
+ isErrorResult: isErrorResultDetected,
544
560
  duration,
545
561
  metadata: {
546
562
  toolName,
@@ -20,6 +20,7 @@ import { llamacppManifest } from "./manifests/llamacpp.js";
20
20
  import { xaiManifest } from "./manifests/xai.js";
21
21
  import { groqManifest } from "./manifests/groq.js";
22
22
  import { typesafeManifest } from "./manifests/typesafe.js";
23
+ import { layaManifest } from "./manifests/laya.js";
23
24
  import { cohereManifest } from "./manifests/cohere.js";
24
25
  import { togetherAiManifest } from "./manifests/together-ai.js";
25
26
  import { fireworksManifest } from "./manifests/fireworks.js";
@@ -118,6 +119,7 @@ export const MANIFEST_REGISTRY = {
118
119
  xai: xaiManifest,
119
120
  groq: groqManifest,
120
121
  typesafe: typesafeManifest,
122
+ laya: layaManifest,
121
123
  cerebras: catalogManifest("cerebras"),
122
124
  sambanova: catalogManifest("sambanova"),
123
125
  cohere: cohereManifest,
@@ -0,0 +1,12 @@
1
+ import type { ProviderModelManifest } from "../../types/index.js";
2
+ /**
3
+ * Laya — the `decide` inference type, not text generation.
4
+ *
5
+ * `contextWindow` is each checkpoint's encoder window, the whole budget for
6
+ * the state plus one question's instructions and options. `auto` carries the
7
+ * English figure because Laya's router may pick the English checkpoint.
8
+ *
9
+ * `maxOutputTokens` is required by the manifest type but has no honest value
10
+ * for a model that emits no text, so a small non-zero figure is used.
11
+ */
12
+ export declare const layaManifest: ProviderModelManifest;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Laya — the `decide` inference type, not text generation.
3
+ *
4
+ * `contextWindow` is each checkpoint's encoder window, the whole budget for
5
+ * the state plus one question's instructions and options. `auto` carries the
6
+ * English figure because Laya's router may pick the English checkpoint.
7
+ *
8
+ * `maxOutputTokens` is required by the manifest type but has no honest value
9
+ * for a model that emits no text, so a small non-zero figure is used.
10
+ */
11
+ export const layaManifest = {
12
+ defaultContextWindow: 1024,
13
+ models: {
14
+ _default: {
15
+ aliases: [],
16
+ contextWindow: 1024,
17
+ maxOutputTokens: 256,
18
+ vision: false,
19
+ functionCalling: false,
20
+ },
21
+ "typed-decisions": {
22
+ aliases: [],
23
+ displayName: "Laya (typed decisions)",
24
+ contextWindow: 1024,
25
+ maxOutputTokens: 256,
26
+ vision: false,
27
+ functionCalling: false,
28
+ },
29
+ multilingual: {
30
+ aliases: [],
31
+ displayName: "Laya (multilingual)",
32
+ contextWindow: 1024,
33
+ maxOutputTokens: 256,
34
+ vision: false,
35
+ functionCalling: false,
36
+ },
37
+ english: {
38
+ aliases: [],
39
+ displayName: "Laya (English)",
40
+ contextWindow: 512,
41
+ maxOutputTokens: 256,
42
+ vision: false,
43
+ functionCalling: false,
44
+ },
45
+ auto: {
46
+ aliases: [],
47
+ displayName: "Laya (routed by language)",
48
+ contextWindow: 512,
49
+ maxOutputTokens: 256,
50
+ vision: false,
51
+ functionCalling: false,
52
+ },
53
+ },
54
+ };