@juspay/neurolink 10.12.7 → 10.12.9
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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +457 -457
- package/dist/cli/commands/proxyAnalyze.js +7 -0
- package/dist/core/modules/GenerationHandler.js +36 -14
- package/dist/lib/core/modules/GenerationHandler.js +36 -14
- package/dist/lib/neurolink.d.ts +15 -0
- package/dist/lib/neurolink.js +77 -44
- package/dist/lib/providers/anthropic/client.js +17 -1
- package/dist/lib/proxy/accountQuotaRefreshCoordinator.d.ts +19 -0
- package/dist/lib/proxy/accountQuotaRefreshCoordinator.js +105 -0
- package/dist/lib/proxy/accountUsage.d.ts +5 -5
- package/dist/lib/proxy/accountUsage.js +5 -5
- package/dist/lib/proxy/providerTransportCoordinator.d.ts +17 -0
- package/dist/lib/proxy/providerTransportCoordinator.js +156 -0
- package/dist/lib/proxy/proxyAnalysis.js +59 -0
- package/dist/lib/proxy/routingEvidence.d.ts +1 -1
- package/dist/lib/proxy/routingEvidence.js +3 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +12 -4
- package/dist/lib/server/routes/claudeProxyRoutes.js +319 -69
- package/dist/lib/types/proxy.d.ts +79 -0
- package/dist/lib/types/utilities.d.ts +29 -0
- package/dist/lib/utils/json/coerce.d.ts +21 -1
- package/dist/lib/utils/json/coerce.js +148 -11
- package/dist/neurolink.d.ts +15 -0
- package/dist/neurolink.js +77 -44
- package/dist/providers/anthropic/client.js +17 -1
- package/dist/proxy/accountQuotaRefreshCoordinator.d.ts +19 -0
- package/dist/proxy/accountQuotaRefreshCoordinator.js +104 -0
- package/dist/proxy/accountUsage.d.ts +5 -5
- package/dist/proxy/accountUsage.js +5 -5
- package/dist/proxy/providerTransportCoordinator.d.ts +17 -0
- package/dist/proxy/providerTransportCoordinator.js +155 -0
- package/dist/proxy/proxyAnalysis.js +59 -0
- package/dist/proxy/routingEvidence.d.ts +1 -1
- package/dist/proxy/routingEvidence.js +3 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +12 -4
- package/dist/server/routes/claudeProxyRoutes.js +319 -69
- package/dist/types/proxy.d.ts +79 -0
- package/dist/types/utilities.d.ts +29 -0
- package/dist/utils/json/coerce.d.ts +21 -1
- package/dist/utils/json/coerce.js +148 -11
- package/package.json +2 -1
package/dist/types/proxy.d.ts
CHANGED
|
@@ -430,6 +430,21 @@ export type ProxyAccountRoutingStrategy = (typeof PROXY_ACCOUNT_ROUTING_STRATEGI
|
|
|
430
430
|
export type ProxyAccountRoutingMode = (typeof PROXY_ACCOUNT_ROUTING_MODES)[number];
|
|
431
431
|
export type ProxyAccountRoutingReason = (typeof PROXY_ACCOUNT_ROUTING_REASONS)[number];
|
|
432
432
|
export type ProxyAccountType = (typeof PROXY_ACCOUNT_TYPES)[number];
|
|
433
|
+
export type ProxyQuotaFreshness = "unknown" | "fresh" | "stale_known" | "refresh_due";
|
|
434
|
+
export type ProxyQuotaRefreshReason = "startup_unknown" | "handoff_prewarm" | "ambiguous_snapshot" | "manual";
|
|
435
|
+
export type ProxyQuotaSaturationKind = "none" | "soft" | "hard";
|
|
436
|
+
export type ProxyTransportScope = "shared_provider_transport" | "connection_transport" | "account_specific";
|
|
437
|
+
export type ProxyNetworkTransportScope = Exclude<ProxyTransportScope, "account_specific">;
|
|
438
|
+
export type ProxyProviderTransportProbeOutcome = "recovered" | "failed" | "abandoned";
|
|
439
|
+
export type ProxyProviderTransportPermit = {
|
|
440
|
+
allowed: true;
|
|
441
|
+
probe: boolean;
|
|
442
|
+
generation: number;
|
|
443
|
+
} | {
|
|
444
|
+
allowed: false;
|
|
445
|
+
errorCode: string | null;
|
|
446
|
+
transportScope: ProxyNetworkTransportScope;
|
|
447
|
+
};
|
|
433
448
|
export type ProxyAccountRoutingCandidate = {
|
|
434
449
|
account: string;
|
|
435
450
|
accountType: ProxyAccountType;
|
|
@@ -440,6 +455,15 @@ export type ProxyAccountRoutingCandidate = {
|
|
|
440
455
|
saturated: boolean;
|
|
441
456
|
quotaObserved: boolean;
|
|
442
457
|
quotaStale: boolean;
|
|
458
|
+
quotaFreshness?: ProxyQuotaFreshness;
|
|
459
|
+
refreshNeeded?: boolean;
|
|
460
|
+
refreshReason?: ProxyQuotaRefreshReason | null;
|
|
461
|
+
refreshInFlight?: boolean;
|
|
462
|
+
lastRefreshAttemptAt?: number | null;
|
|
463
|
+
lastRefreshSuccessAt?: number | null;
|
|
464
|
+
nextRefreshEligibleAt?: number | null;
|
|
465
|
+
saturationKind?: ProxyQuotaSaturationKind;
|
|
466
|
+
softLimitOverrideReason?: "overage" | "weekly_expiry" | null;
|
|
443
467
|
quotaLastUpdated: number | null;
|
|
444
468
|
quotaAgeMs: number | null;
|
|
445
469
|
coolingActive: boolean;
|
|
@@ -478,7 +502,17 @@ export type ProxyAccountSortMetrics = {
|
|
|
478
502
|
usable: boolean;
|
|
479
503
|
saturated: boolean;
|
|
480
504
|
hasQuota: boolean;
|
|
505
|
+
quotaEvidenceRank: number;
|
|
481
506
|
quotaStale: boolean;
|
|
507
|
+
quotaFreshness: ProxyQuotaFreshness;
|
|
508
|
+
refreshNeeded: boolean;
|
|
509
|
+
refreshReason: ProxyQuotaRefreshReason | null;
|
|
510
|
+
refreshInFlight: boolean;
|
|
511
|
+
lastRefreshAttemptAt: number | null;
|
|
512
|
+
lastRefreshSuccessAt: number | null;
|
|
513
|
+
nextRefreshEligibleAt: number | null;
|
|
514
|
+
saturationKind: ProxyQuotaSaturationKind;
|
|
515
|
+
softLimitOverrideReason: "overage" | "weekly_expiry" | null;
|
|
482
516
|
quotaLastUpdated: number | null;
|
|
483
517
|
quotaAgeMs: number | null;
|
|
484
518
|
coolingActive: boolean;
|
|
@@ -514,6 +548,8 @@ export type RequestLogEntry = {
|
|
|
514
548
|
errorMessage?: string;
|
|
515
549
|
/** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
|
|
516
550
|
errorCode?: string;
|
|
551
|
+
/** Whether changing credentials can affect this transport failure. */
|
|
552
|
+
transportScope?: ProxyTransportScope;
|
|
517
553
|
inputTokens?: number;
|
|
518
554
|
outputTokens?: number;
|
|
519
555
|
cacheCreationTokens?: number;
|
|
@@ -545,6 +581,8 @@ export type RequestAttemptLogEntry = {
|
|
|
545
581
|
errorMessage?: string;
|
|
546
582
|
/** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
|
|
547
583
|
errorCode?: string;
|
|
584
|
+
/** Whether changing credentials can affect this transport failure. */
|
|
585
|
+
transportScope?: ProxyTransportScope;
|
|
548
586
|
/** Whether this failed attempt may be retried without changing the request. */
|
|
549
587
|
retryable?: boolean;
|
|
550
588
|
/** Distinguishes short-lived admission throttles from exhausted quota windows. */
|
|
@@ -579,11 +617,15 @@ export type ClaudeFinalRequestLogger = (status: number, accountLabel: string, ac
|
|
|
579
617
|
outputTokens?: number;
|
|
580
618
|
cacheCreationTokens?: number;
|
|
581
619
|
cacheReadTokens?: number;
|
|
620
|
+
errorCode?: string;
|
|
621
|
+
transportScope?: ProxyTransportScope;
|
|
582
622
|
}) => void;
|
|
583
623
|
export type ClaudeLoggedErrorBuilder = (status: number, message: string, errorType?: string, extra?: {
|
|
584
624
|
account?: string;
|
|
585
625
|
accountType?: string;
|
|
586
626
|
attempt?: number;
|
|
627
|
+
errorCode?: string;
|
|
628
|
+
transportScope?: ProxyTransportScope;
|
|
587
629
|
}) => ClaudeErrorResponse;
|
|
588
630
|
export type ClaudeRequestRuntimeContext = {
|
|
589
631
|
tracer?: ProxyTracer;
|
|
@@ -603,6 +645,7 @@ export type AnthropicAttemptLogger = (status: number, errorType?: string, errorM
|
|
|
603
645
|
retryable?: boolean;
|
|
604
646
|
/** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
|
|
605
647
|
errorCode?: string;
|
|
648
|
+
transportScope?: ProxyTransportScope;
|
|
606
649
|
rateLimitKind?: "transient" | "quota";
|
|
607
650
|
cooldownReason?: "transient" | "session" | "weekly" | "unified";
|
|
608
651
|
/** Override for nested retries whose attempt starts after this logger. */
|
|
@@ -624,6 +667,8 @@ export type AnthropicLoopState = {
|
|
|
624
667
|
authCooldownMessage: string | null;
|
|
625
668
|
fallbackFailureMessage?: string;
|
|
626
669
|
attemptNumber: number;
|
|
670
|
+
lastTransportErrorCode?: string;
|
|
671
|
+
lastTransportScope?: ProxyNetworkTransportScope;
|
|
627
672
|
};
|
|
628
673
|
export type AnthropicUpstreamBody = {
|
|
629
674
|
bodyStr: string;
|
|
@@ -765,6 +810,8 @@ export type TransientRateLimitRetryBudget = {
|
|
|
765
810
|
export type AnthropicUpstreamFetchResult = {
|
|
766
811
|
continueLoop: boolean;
|
|
767
812
|
retrySameAccount?: boolean;
|
|
813
|
+
transportScope?: ProxyNetworkTransportScope;
|
|
814
|
+
errorCode?: string;
|
|
768
815
|
/** When set, the caller should wait this many ms before retrying (from upstream retry-after). */
|
|
769
816
|
retryAfterMs?: number;
|
|
770
817
|
/** Set on a genuine 429: how long / why to cool this account before rotating. */
|
|
@@ -1033,6 +1080,35 @@ export type AccountUsageFetchResult = {
|
|
|
1033
1080
|
error: string;
|
|
1034
1081
|
status?: number;
|
|
1035
1082
|
};
|
|
1083
|
+
export type ProxyQuotaRefreshRuntimeState = {
|
|
1084
|
+
inFlight: boolean;
|
|
1085
|
+
lastAttemptAt?: number;
|
|
1086
|
+
lastSuccessAt?: number;
|
|
1087
|
+
nextEligibleAt?: number;
|
|
1088
|
+
consecutiveFailures: number;
|
|
1089
|
+
lastFailureReason?: string;
|
|
1090
|
+
lastCompletedTrigger?: string;
|
|
1091
|
+
coalesced: number;
|
|
1092
|
+
};
|
|
1093
|
+
export type ProxyQuotaRefreshMetrics = {
|
|
1094
|
+
attempted: number;
|
|
1095
|
+
succeeded: number;
|
|
1096
|
+
failed: number;
|
|
1097
|
+
coalesced: number;
|
|
1098
|
+
backoffSuppressed: number;
|
|
1099
|
+
triggerDeduplicated: number;
|
|
1100
|
+
};
|
|
1101
|
+
export type ProxyQuotaRefreshRunResult = {
|
|
1102
|
+
kind: "completed";
|
|
1103
|
+
result: AccountUsageFetchResult;
|
|
1104
|
+
/** Lower bound for the freshness of the returned usage snapshot. */
|
|
1105
|
+
startedAt: number;
|
|
1106
|
+
} | {
|
|
1107
|
+
kind: "backoff";
|
|
1108
|
+
nextEligibleAt: number;
|
|
1109
|
+
} | {
|
|
1110
|
+
kind: "not_due";
|
|
1111
|
+
};
|
|
1036
1112
|
/** Per-account result inside a GET /limits response. */
|
|
1037
1113
|
export type ProxyLimitsAccountResult = {
|
|
1038
1114
|
/** Account label (quota-store key). */
|
|
@@ -1053,6 +1129,8 @@ export type ProxyLimitsRefreshResponse = {
|
|
|
1053
1129
|
/** True when served from stored state without contacting Anthropic. */
|
|
1054
1130
|
snapshot: boolean;
|
|
1055
1131
|
results: ProxyLimitsAccountResult[];
|
|
1132
|
+
/** Process-local refresh activity; contains no credentials or response body. */
|
|
1133
|
+
refreshMetrics?: ProxyQuotaRefreshMetrics;
|
|
1056
1134
|
};
|
|
1057
1135
|
/**
|
|
1058
1136
|
* Provenance of the quota numbers attached to a single proxy response.
|
|
@@ -1518,6 +1596,7 @@ export type ProxyAnalysisReport = {
|
|
|
1518
1596
|
errors: number;
|
|
1519
1597
|
errorTypes: Record<string, number>;
|
|
1520
1598
|
errorCodes: Record<string, number>;
|
|
1599
|
+
transportScopes: Record<string, number>;
|
|
1521
1600
|
};
|
|
1522
1601
|
rateLimits: {
|
|
1523
1602
|
attemptRateLimits: number;
|
|
@@ -279,3 +279,32 @@ export type JsonCoercionResult = {
|
|
|
279
279
|
*/
|
|
280
280
|
truncated: boolean;
|
|
281
281
|
};
|
|
282
|
+
/**
|
|
283
|
+
* Decision returned by `recoverScalarRoot`. Each caller applies it to its own
|
|
284
|
+
* result shape and logger prefix, preserving its existing warning behaviour:
|
|
285
|
+
*
|
|
286
|
+
* - `empty` — the text is a JSON-encoded empty string (an EMPTY
|
|
287
|
+
* completion, not a recovered scalar). Callers normalize to a
|
|
288
|
+
* true empty.
|
|
289
|
+
* - `accepted` — a scalar root the caller's schema accepts; safe to publish
|
|
290
|
+
* as `structuredData`.
|
|
291
|
+
* - `rejected` — a scalar root the caller's schema rejects (e.g. a raw
|
|
292
|
+
* string under an object schema — the shape a truncated
|
|
293
|
+
* response degrades to). Do NOT publish it.
|
|
294
|
+
* - `nullish` — the text is the JSON literals `null`/`undefined`; there is
|
|
295
|
+
* no structured value to publish.
|
|
296
|
+
* - `not-json` — the text is not JSON at all.
|
|
297
|
+
*/
|
|
298
|
+
export type ScalarRecoveryDecision = {
|
|
299
|
+
kind: "empty";
|
|
300
|
+
} | {
|
|
301
|
+
kind: "accepted";
|
|
302
|
+
value: unknown;
|
|
303
|
+
} | {
|
|
304
|
+
kind: "rejected";
|
|
305
|
+
value: unknown;
|
|
306
|
+
} | {
|
|
307
|
+
kind: "nullish";
|
|
308
|
+
} | {
|
|
309
|
+
kind: "not-json";
|
|
310
|
+
};
|
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
import type { JsonCoercionResult, ValidationSchema } from "../../types/index.js";
|
|
1
|
+
import type { JsonCoercionResult, ScalarRecoveryDecision, ValidationSchema } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Does `value` satisfy `schema`? A schema we cannot validate with (absent, or
|
|
4
|
+
* without a Zod-style `safeParse`) accepts everything — callers use this as a
|
|
5
|
+
* gate, not as proof, so an unknown schema must never block a value.
|
|
6
|
+
*
|
|
7
|
+
* Exists so the consumers of coerceJsonToSchema can refuse to publish a
|
|
8
|
+
* recovered value as `structuredData` when the caller's schema rejects it —
|
|
9
|
+
* e.g. a raw *string* under an object schema, which is the shape a truncated
|
|
10
|
+
* response degrades to.
|
|
11
|
+
*/
|
|
12
|
+
export declare function schemaAccepts(schema: ValidationSchema | undefined, value: unknown): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Recover a JSON *scalar* root (string/number/boolean) from model text. The
|
|
15
|
+
* object/array case is handled by `coerceJsonToSchema`; this covers the
|
|
16
|
+
* residual scalar root after that path returns null. Encapsulates the JSON
|
|
17
|
+
* parsing, the empty-string normalization, and the `schemaAccepts` gate so the
|
|
18
|
+
* same policy cannot drift between consumers (`neurolink.recoverStructuredData`
|
|
19
|
+
* and `GenerationHandler.coerceTextMode`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function recoverScalarRoot(text: string, schema: ValidationSchema | undefined): ScalarRecoveryDecision;
|
|
2
22
|
/**
|
|
3
23
|
* Try to produce canonical JSON from `text`. Returns null when no JSON object
|
|
4
24
|
* could be recovered (caller should then keep the raw text).
|
|
@@ -23,6 +23,48 @@ import { nextBalancedJsonSpan } from "./extract.js";
|
|
|
23
23
|
function hasSafeParse(schema) {
|
|
24
24
|
return typeof schema.safeParse === "function";
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Does `value` satisfy `schema`? A schema we cannot validate with (absent, or
|
|
28
|
+
* without a Zod-style `safeParse`) accepts everything — callers use this as a
|
|
29
|
+
* gate, not as proof, so an unknown schema must never block a value.
|
|
30
|
+
*
|
|
31
|
+
* Exists so the consumers of coerceJsonToSchema can refuse to publish a
|
|
32
|
+
* recovered value as `structuredData` when the caller's schema rejects it —
|
|
33
|
+
* e.g. a raw *string* under an object schema, which is the shape a truncated
|
|
34
|
+
* response degrades to.
|
|
35
|
+
*/
|
|
36
|
+
export function schemaAccepts(schema, value) {
|
|
37
|
+
if (!schema || !hasSafeParse(schema)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return schema.safeParse(value).success;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Recover a JSON *scalar* root (string/number/boolean) from model text. The
|
|
44
|
+
* object/array case is handled by `coerceJsonToSchema`; this covers the
|
|
45
|
+
* residual scalar root after that path returns null. Encapsulates the JSON
|
|
46
|
+
* parsing, the empty-string normalization, and the `schemaAccepts` gate so the
|
|
47
|
+
* same policy cannot drift between consumers (`neurolink.recoverStructuredData`
|
|
48
|
+
* and `GenerationHandler.coerceTextMode`).
|
|
49
|
+
*/
|
|
50
|
+
export function recoverScalarRoot(text, schema) {
|
|
51
|
+
try {
|
|
52
|
+
const scalar = JSON.parse(text);
|
|
53
|
+
if (scalar === "") {
|
|
54
|
+
return { kind: "empty" };
|
|
55
|
+
}
|
|
56
|
+
if (scalar === null || scalar === undefined) {
|
|
57
|
+
return { kind: "nullish" };
|
|
58
|
+
}
|
|
59
|
+
if (schemaAccepts(schema, scalar)) {
|
|
60
|
+
return { kind: "accepted", value: scalar };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "rejected", value: scalar };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { kind: "not-json" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
26
68
|
/**
|
|
27
69
|
* Parse `candidate` as JSON, repairing common escaping mistakes on failure.
|
|
28
70
|
* Returns the parsed value plus whether jsonrepair had to alter the text.
|
|
@@ -51,6 +93,34 @@ function parseOrRepair(candidate) {
|
|
|
51
93
|
}
|
|
52
94
|
/** Bounds the recursive nested-string unwrap against pathological inputs. */
|
|
53
95
|
const MAX_NESTED_UNWRAP_DEPTH = 6;
|
|
96
|
+
/** Bounds the structural back-off when salvaging a truncated root object. */
|
|
97
|
+
const MAX_SALVAGE_TRIMS = 8;
|
|
98
|
+
/**
|
|
99
|
+
* Last-resort recovery for output cut off mid-JSON: walk the unclosed root span
|
|
100
|
+
* backwards to successively earlier structural boundaries (`,` / `}` / `]`) and
|
|
101
|
+
* re-attempt parse+repair at each one. Truncation can land somewhere jsonrepair
|
|
102
|
+
* cannot close on its own (inside an escape, a half-written key, a dangling
|
|
103
|
+
* separator); dropping back to the last completed field gives it a repairable
|
|
104
|
+
* prefix. Returns a PARTIAL object — the fields the model did finish — so a
|
|
105
|
+
* truncated response degrades to an incomplete object rather than to raw text.
|
|
106
|
+
*/
|
|
107
|
+
function salvageTruncatedRoot(span) {
|
|
108
|
+
let candidate = span;
|
|
109
|
+
for (let i = 0; i < MAX_SALVAGE_TRIMS && candidate.length > 1; i++) {
|
|
110
|
+
const cut = Math.max(candidate.lastIndexOf(","), candidate.lastIndexOf("}"), candidate.lastIndexOf("]"));
|
|
111
|
+
if (cut <= 0) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
candidate = candidate.slice(0, cut);
|
|
115
|
+
const outcome = parseOrRepair(candidate);
|
|
116
|
+
if (outcome !== undefined &&
|
|
117
|
+
outcome.value !== null &&
|
|
118
|
+
typeof outcome.value === "object") {
|
|
119
|
+
return outcome.value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
54
124
|
/**
|
|
55
125
|
* Recursively replace any string-valued field whose content is itself a JSON
|
|
56
126
|
* object/array with the parsed value. Models sometimes double-encode a NESTED
|
|
@@ -135,30 +205,54 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
135
205
|
// 3. first "{" or "[" to end of text (TRUNCATED output —
|
|
136
206
|
// finishReason=length — where the closing bracket was cut off;
|
|
137
207
|
// jsonrepair closes it)
|
|
138
|
-
// `truncated` marks
|
|
139
|
-
//
|
|
140
|
-
//
|
|
208
|
+
// `truncated` marks a candidate that could only come from output cut short:
|
|
209
|
+
// the first-open-to-end span, and — when the ROOT bracket never closes —
|
|
210
|
+
// every other candidate too, since all of them are then partial views of an
|
|
211
|
+
// unfinished document.
|
|
212
|
+
//
|
|
213
|
+
// `rootAligned` marks candidates that start at the document's first opening
|
|
214
|
+
// bracket, i.e. at the real root. Candidates are ordered root-aligned FIRST.
|
|
215
|
+
// On truncated output the root brace never balances, so the balanced scan
|
|
216
|
+
// walks on and matches brackets that live INSIDE a string value (`[step 1]`
|
|
217
|
+
// in a shell script, say) — a syntactically fine but semantically bogus
|
|
218
|
+
// array. Preferring the root keeps the partial real object ahead of that.
|
|
141
219
|
const candidates = [];
|
|
220
|
+
const openIndexes = [text.indexOf("{"), text.indexOf("[")].filter((i) => i >= 0);
|
|
221
|
+
const firstOpen = openIndexes.length > 0 ? Math.min(...openIndexes) : -1;
|
|
222
|
+
let rootClosed = false;
|
|
142
223
|
let searchFrom = 0;
|
|
143
224
|
for (;;) {
|
|
144
225
|
const found = nextBalancedJsonSpan(text, searchFrom);
|
|
145
226
|
if (!found) {
|
|
146
227
|
break;
|
|
147
228
|
}
|
|
148
|
-
|
|
229
|
+
const start = found.end - found.span.length;
|
|
230
|
+
if (start === firstOpen) {
|
|
231
|
+
rootClosed = true;
|
|
232
|
+
}
|
|
233
|
+
candidates.push({
|
|
234
|
+
text: found.span,
|
|
235
|
+
truncated: false,
|
|
236
|
+
rootAligned: start === firstOpen,
|
|
237
|
+
});
|
|
149
238
|
searchFrom = found.end;
|
|
150
239
|
}
|
|
151
|
-
|
|
152
|
-
const
|
|
240
|
+
// No balanced span starts at the root bracket → the document is unclosed.
|
|
241
|
+
const rootUnclosed = firstOpen >= 0 && !rootClosed;
|
|
153
242
|
const lastClose = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
|
|
154
243
|
if (firstOpen >= 0 && lastClose > firstOpen) {
|
|
155
244
|
candidates.push({
|
|
156
245
|
text: text.slice(firstOpen, lastClose + 1),
|
|
157
|
-
truncated:
|
|
246
|
+
truncated: rootUnclosed,
|
|
247
|
+
rootAligned: true,
|
|
158
248
|
});
|
|
159
249
|
}
|
|
160
250
|
if (firstOpen >= 0) {
|
|
161
|
-
candidates.push({
|
|
251
|
+
candidates.push({
|
|
252
|
+
text: text.slice(firstOpen),
|
|
253
|
+
truncated: true,
|
|
254
|
+
rootAligned: true,
|
|
255
|
+
});
|
|
162
256
|
}
|
|
163
257
|
// JSON-string-literal wrapper: some providers double-encode and return the
|
|
164
258
|
// object as a JSON *string* (e.g. `"{\"k\":1}"`). Unwrap one layer and add
|
|
@@ -174,7 +268,11 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
174
268
|
if (!innerSpan) {
|
|
175
269
|
break;
|
|
176
270
|
}
|
|
177
|
-
candidates.push({
|
|
271
|
+
candidates.push({
|
|
272
|
+
text: innerSpan.span,
|
|
273
|
+
truncated: false,
|
|
274
|
+
rootAligned: false,
|
|
275
|
+
});
|
|
178
276
|
innerFrom = innerSpan.end;
|
|
179
277
|
}
|
|
180
278
|
}
|
|
@@ -183,11 +281,20 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
183
281
|
// not a string literal — ignore
|
|
184
282
|
}
|
|
185
283
|
}
|
|
284
|
+
// Stable partition: root-aligned candidates first. Only the ORDER changes —
|
|
285
|
+
// no candidate is dropped — so the multi-object "prefer the most complete
|
|
286
|
+
// schema-valid candidate" selection below is unaffected (it is order
|
|
287
|
+
// independent), while the unschema'd first-parseable-wins path and the
|
|
288
|
+
// `firstValid` fallback now favour the document's real root.
|
|
289
|
+
const ordered = [
|
|
290
|
+
...candidates.filter((c) => c.rootAligned),
|
|
291
|
+
...candidates.filter((c) => !c.rootAligned),
|
|
292
|
+
];
|
|
186
293
|
let firstValid;
|
|
187
294
|
const schemaValid = [];
|
|
188
295
|
const hasSchema = !!(schema && hasSafeParse(schema));
|
|
189
296
|
const seen = new Set();
|
|
190
|
-
for (const candidate of
|
|
297
|
+
for (const candidate of ordered) {
|
|
191
298
|
if (seen.has(candidate.text)) {
|
|
192
299
|
continue;
|
|
193
300
|
}
|
|
@@ -202,6 +309,7 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
202
309
|
value: outcome.value,
|
|
203
310
|
repaired: outcome.repaired,
|
|
204
311
|
truncated: candidate.truncated,
|
|
312
|
+
rootAligned: candidate.rootAligned,
|
|
205
313
|
};
|
|
206
314
|
if (firstValid === undefined) {
|
|
207
315
|
firstValid = record;
|
|
@@ -229,6 +337,7 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
229
337
|
value: unwrapped.value,
|
|
230
338
|
repaired: true,
|
|
231
339
|
truncated: candidate.truncated,
|
|
340
|
+
rootAligned: candidate.rootAligned,
|
|
232
341
|
});
|
|
233
342
|
}
|
|
234
343
|
else if (
|
|
@@ -247,6 +356,7 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
247
356
|
value: outcome.value[0],
|
|
248
357
|
repaired: true,
|
|
249
358
|
truncated: candidate.truncated,
|
|
359
|
+
rootAligned: candidate.rootAligned,
|
|
250
360
|
});
|
|
251
361
|
}
|
|
252
362
|
}
|
|
@@ -261,7 +371,34 @@ export function coerceJsonToSchema(text, schema) {
|
|
|
261
371
|
? cur
|
|
262
372
|
: best)
|
|
263
373
|
: undefined;
|
|
264
|
-
|
|
374
|
+
let chosen = schemaMatch ?? firstValid;
|
|
375
|
+
// Salvage the root when the document is unclosed AND nothing trustworthy came
|
|
376
|
+
// out of it: either nothing parsed at all, or the only parseable candidate is
|
|
377
|
+
// a bracket pair scraped from INSIDE a string value (not root-aligned, not
|
|
378
|
+
// schema-valid) — the `["step 1"]`-from-a-shell-script case. Backing off to
|
|
379
|
+
// the last completed field yields a PARTIAL OBJECT flagged `truncated`,
|
|
380
|
+
// instead of a bogus value or raw text degrading to a string.
|
|
381
|
+
if (rootUnclosed &&
|
|
382
|
+
schemaMatch === undefined &&
|
|
383
|
+
(chosen === undefined || !chosen.rootAligned)) {
|
|
384
|
+
const salvaged = salvageTruncatedRoot(text.slice(firstOpen));
|
|
385
|
+
if (salvaged !== undefined) {
|
|
386
|
+
logger.debug("[coerceJsonToSchema] salvaged a partial truncated object", {
|
|
387
|
+
textLength: text.length,
|
|
388
|
+
});
|
|
389
|
+
chosen = {
|
|
390
|
+
value: salvaged,
|
|
391
|
+
repaired: true,
|
|
392
|
+
truncated: true,
|
|
393
|
+
rootAligned: true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
else if (chosen !== undefined) {
|
|
397
|
+
// Nothing salvageable, but we still know the output was truncated —
|
|
398
|
+
// never report a candidate scraped from a truncated document as complete.
|
|
399
|
+
chosen = { ...chosen, truncated: true };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
265
402
|
if (chosen === undefined) {
|
|
266
403
|
return null;
|
|
267
404
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "10.12.
|
|
3
|
+
"version": "10.12.9",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|
|
@@ -135,6 +135,7 @@
|
|
|
135
135
|
"test:file-detector-magic-bytes": "npx tsx test/continuous-test-suite-file-detector-magic-bytes.ts",
|
|
136
136
|
"test:json": "npx tsx test/continuous-test-suite-json.ts",
|
|
137
137
|
"test:json-e2e": "npx tsx test/continuous-test-suite-json-e2e.ts",
|
|
138
|
+
"test:coerce-truncation": "npx tsx test/continuous-test-suite-coerce-truncation.ts",
|
|
138
139
|
"test:workflow": "npx tsx test/continuous-test-suite-workflow.ts",
|
|
139
140
|
"test:hitl": "npx tsx test/continuous-test-suite-hitl.ts",
|
|
140
141
|
"test:analytics": "npx tsx test/continuous-test-suite-analytics.ts",
|