@juspay/neurolink 12.27.0 → 12.27.2

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.
@@ -757,7 +757,96 @@ class ProxyTracer {
757
757
  return this.bridge.injectContext({}, trace.setSpan(context.active(), this.rootSpan));
758
758
  }
759
759
  }
760
+ /**
761
+ * Consecutive failures a configured fallback leg may accumulate before the
762
+ * proxy says so out loud.
763
+ *
764
+ * A leg that is attempted and never succeeds is invisible today: the counters
765
+ * record every attempt and every failure, but nothing reads them back, so the
766
+ * condition surfaces only if an operator happens to query telemetry. Meanwhile
767
+ * every request falls through to the next leg, which can bill on a different
768
+ * account at a different rate, so the first symptom is a cost change rather
769
+ * than an error. Twenty is low enough to catch a dead leg within seconds of
770
+ * sustained traffic and high enough that ordinary account rotation or a brief
771
+ * cooldown never trips it.
772
+ */
773
+ const DEAD_FALLBACK_LEG_THRESHOLD = 20;
774
+ /**
775
+ * Upper bound on tracked legs.
776
+ *
777
+ * Not every key comes from the configured chain. The auto-provider path records
778
+ * `auto-provider/<requested model>`, and the requested model is whatever the
779
+ * caller asked for, so a client that fails requests under varying model names
780
+ * mints a new key each time. Without a bound these collections grow for the
781
+ * lifetime of the process. The cap is far above any real fallback chain, so a
782
+ * genuine leg is never evicted in practice.
783
+ */
784
+ const MAX_TRACKED_FALLBACK_LEGS = 256;
785
+ /** Consecutive failures per `provider/model`, reset by any success. */
786
+ const fallbackFailureStreaks = new Map();
787
+ /** Legs already reported, so a dead leg is stated once and not once per turn. */
788
+ const reportedDeadFallbackLegs = new Set();
789
+ /**
790
+ * Record a streak, evicting the least recently touched leg past the cap.
791
+ *
792
+ * `Map` and `Set` iterate in insertion order and re-setting an existing key does
793
+ * not move it, so a key is deleted before being re-added. That makes recency the
794
+ * eviction order and keeps a leg that is actively failing from being dropped in
795
+ * favour of a stale one.
796
+ */
797
+ function touchFallbackLegStreak(legKey, streak) {
798
+ fallbackFailureStreaks.delete(legKey);
799
+ fallbackFailureStreaks.set(legKey, streak);
800
+ while (fallbackFailureStreaks.size > MAX_TRACKED_FALLBACK_LEGS) {
801
+ const oldest = fallbackFailureStreaks.keys().next();
802
+ if (oldest.done) {
803
+ break;
804
+ }
805
+ fallbackFailureStreaks.delete(oldest.value);
806
+ reportedDeadFallbackLegs.delete(oldest.value);
807
+ }
808
+ }
809
+ /**
810
+ * Report a fallback leg that has stopped serving, and its recovery.
811
+ *
812
+ * Deliberately keyed per leg rather than globally: a second leg going dark must
813
+ * still be reported while the first is known dead.
814
+ */
815
+ function trackFallbackLegHealth(legKey, status, errorMessage) {
816
+ if (status === "success") {
817
+ fallbackFailureStreaks.delete(legKey);
818
+ if (reportedDeadFallbackLegs.delete(legKey)) {
819
+ logger.always(`[proxy] fallback ${legKey} is serving again after being reported dead`);
820
+ }
821
+ return;
822
+ }
823
+ const streak = (fallbackFailureStreaks.get(legKey) ?? 0) + 1;
824
+ touchFallbackLegStreak(legKey, streak);
825
+ if (streak < DEAD_FALLBACK_LEG_THRESHOLD) {
826
+ return;
827
+ }
828
+ if (reportedDeadFallbackLegs.has(legKey)) {
829
+ return;
830
+ }
831
+ // Marked reported only once the report is out. Marking first meant a throwing
832
+ // logger — swallowed by the caller's catch — silenced the leg permanently,
833
+ // which is the one outcome this whole mechanism exists to prevent.
834
+ logger.always(`[proxy] fallback ${legKey} has failed ${streak} consecutive attempts with ` +
835
+ `no success — this leg is not serving. Traffic is falling through to the ` +
836
+ `next leg in the chain, which may bill differently. Check the account's ` +
837
+ `quota and credentials` +
838
+ (errorMessage ? `; last error: ${errorMessage.slice(0, 200)}` : ""));
839
+ reportedDeadFallbackLegs.add(legKey);
840
+ }
760
841
  export function recordFallbackAttempt(attrs) {
842
+ // Leg health is tracked outside the metrics try/catch: a telemetry failure
843
+ // must not be the reason an operator never hears that a leg is dead.
844
+ try {
845
+ trackFallbackLegHealth(`${attrs.provider}/${attrs.model}`, attrs.status, attrs.errorMessage);
846
+ }
847
+ catch {
848
+ // Never let health reporting break a request.
849
+ }
761
850
  try {
762
851
  const m = getProxyMetrics();
763
852
  const labels = { provider: attrs.provider, model: attrs.model };
@@ -776,6 +865,18 @@ export function recordFallbackAttempt(attrs) {
776
865
  // metrics are best-effort
777
866
  }
778
867
  }
868
+ /** Reset leg-health state between tests. */
869
+ export const __fallbackLegHealthTestHooks = {
870
+ threshold: DEAD_FALLBACK_LEG_THRESHOLD,
871
+ maxTrackedLegs: MAX_TRACKED_FALLBACK_LEGS,
872
+ trackedCount: () => fallbackFailureStreaks.size,
873
+ reset: () => {
874
+ fallbackFailureStreaks.clear();
875
+ reportedDeadFallbackLegs.clear();
876
+ },
877
+ streakOf: (legKey) => fallbackFailureStreaks.get(legKey) ?? 0,
878
+ isReported: (legKey) => reportedDeadFallbackLegs.has(legKey),
879
+ };
779
880
  export { ProxyTracer };
780
881
  /** Standard SERVER span covers every proxy door, including parsing and admission failures. */
781
882
  export function startProxyHttpTrace(metadata, headers) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.27.0",
3
+ "version": "12.27.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -88,6 +88,7 @@
88
88
  "test:mcp:sdk": "pnpm exec tsx test/continuous-test-suite-mcp-sdk.ts",
89
89
  "test:mcp:cli": "pnpm exec tsx test/continuous-test-suite-mcp-cli.ts",
90
90
  "test:mcp:stdio-lifecycle": "pnpm exec tsx test/continuous-test-suite-mcp-stdio-lifecycle.ts",
91
+ "test:process-exit": "pnpm exec tsx test/continuous-test-suite-process-exit.ts",
91
92
  "test:mcp-direct-name-repair": "pnpm exec tsx test/continuous-test-suite-mcp-direct-name-repair.ts",
92
93
  "test:mcp:min-tools-readiness": "pnpm exec tsx test/continuous-test-suite-mcp-min-tools-readiness.ts",
93
94
  "test:mcp:full": "pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:mcp:stdio-lifecycle && pnpm run test:mcp:min-tools-readiness && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:mcp:http",