@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +457 -457
  3. package/dist/cli/commands/proxyAnalyze.js +7 -0
  4. package/dist/core/modules/GenerationHandler.js +36 -14
  5. package/dist/lib/core/modules/GenerationHandler.js +36 -14
  6. package/dist/lib/neurolink.d.ts +15 -0
  7. package/dist/lib/neurolink.js +77 -44
  8. package/dist/lib/providers/anthropic/client.js +17 -1
  9. package/dist/lib/proxy/accountQuotaRefreshCoordinator.d.ts +19 -0
  10. package/dist/lib/proxy/accountQuotaRefreshCoordinator.js +105 -0
  11. package/dist/lib/proxy/accountUsage.d.ts +5 -5
  12. package/dist/lib/proxy/accountUsage.js +5 -5
  13. package/dist/lib/proxy/providerTransportCoordinator.d.ts +17 -0
  14. package/dist/lib/proxy/providerTransportCoordinator.js +156 -0
  15. package/dist/lib/proxy/proxyAnalysis.js +59 -0
  16. package/dist/lib/proxy/routingEvidence.d.ts +1 -1
  17. package/dist/lib/proxy/routingEvidence.js +3 -0
  18. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +12 -4
  19. package/dist/lib/server/routes/claudeProxyRoutes.js +319 -69
  20. package/dist/lib/types/proxy.d.ts +79 -0
  21. package/dist/lib/types/utilities.d.ts +29 -0
  22. package/dist/lib/utils/json/coerce.d.ts +21 -1
  23. package/dist/lib/utils/json/coerce.js +148 -11
  24. package/dist/neurolink.d.ts +15 -0
  25. package/dist/neurolink.js +77 -44
  26. package/dist/providers/anthropic/client.js +17 -1
  27. package/dist/proxy/accountQuotaRefreshCoordinator.d.ts +19 -0
  28. package/dist/proxy/accountQuotaRefreshCoordinator.js +104 -0
  29. package/dist/proxy/accountUsage.d.ts +5 -5
  30. package/dist/proxy/accountUsage.js +5 -5
  31. package/dist/proxy/providerTransportCoordinator.d.ts +17 -0
  32. package/dist/proxy/providerTransportCoordinator.js +155 -0
  33. package/dist/proxy/proxyAnalysis.js +59 -0
  34. package/dist/proxy/routingEvidence.d.ts +1 -1
  35. package/dist/proxy/routingEvidence.js +3 -0
  36. package/dist/server/routes/claudeProxyRoutes.d.ts +12 -4
  37. package/dist/server/routes/claudeProxyRoutes.js +319 -69
  38. package/dist/types/proxy.d.ts +79 -0
  39. package/dist/types/utilities.d.ts +29 -0
  40. package/dist/utils/json/coerce.d.ts +21 -1
  41. package/dist/utils/json/coerce.js +148 -11
  42. package/package.json +2 -1
@@ -28,6 +28,9 @@ function printAnalysis(report) {
28
28
  }
29
29
  if (report.coverage.attempts) {
30
30
  logger.always(` Attempts: ${report.attempts.total}, ${report.attempts.errors} errors${report.attempts.errors > 0 ? ` ${JSON.stringify(report.attempts.errorTypes)}` : ""}`);
31
+ if (Object.keys(report.attempts.transportScopes).length > 0) {
32
+ logger.always(` Transport scopes: ${JSON.stringify(report.attempts.transportScopes)}`);
33
+ }
31
34
  }
32
35
  logger.always(report.coverage.lifecycle
33
36
  ? ` Lifecycle: ${report.lifecycle.accepted} accepted, ${report.lifecycle.terminal} terminal, ${report.lifecycle.unsettled} unsettled`
@@ -49,6 +52,10 @@ function printAnalysis(report) {
49
52
  if (report.coverage.routingDecisions) {
50
53
  logger.always(` Decisions: ${report.routing.totalRecords} (${report.routing.records.length} retained), modes: ${JSON.stringify(report.routing.modes)}`);
51
54
  logger.always(` Selection reasons: ${JSON.stringify(report.routing.selectionReasons)}`);
55
+ const productionQuotaProbes = report.routing.selectionReasons.quota_probe ?? 0;
56
+ if (productionQuotaProbes > 0) {
57
+ logger.always(chalk.yellow(` WARNING: ${productionQuotaProbes} production request(s) were selected for quota discovery`));
58
+ }
52
59
  logger.always(` Initial accounts: ${JSON.stringify(report.routing.initialAccounts)}`);
53
60
  logger.always(` Final account changed after retry: ${report.routing.finalAccountChanges}, outside candidate set: ${report.routing.finalOutsideCandidateSet}`);
54
61
  }
@@ -25,7 +25,7 @@ import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheR
25
25
  import { DEFAULT_MAX_STEPS, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../constants.js";
26
26
  import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
27
27
  import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
28
- import { coerceJsonToSchema } from "../../utils/json/coerce.js";
28
+ import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "../../utils/json/coerce.js";
29
29
  import { convertZodToJsonSchema } from "../../utils/schemaConversion.js";
30
30
  import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
31
31
  import { Output, stepCountIs } from "../../utils/tool.js";
@@ -800,26 +800,35 @@ export class GenerationHandler {
800
800
  }
801
801
  return coerced.content;
802
802
  }
803
- try {
804
- const scalar = JSON.parse(strippedText);
805
- if (scalar === "") {
803
+ const scalar = recoverScalarRoot(strippedText, options.schema);
804
+ switch (scalar.kind) {
805
+ case "empty":
806
806
  // A JSON-encoded empty string is an EMPTY completion, not a
807
807
  // recovered scalar — normalize to a true empty ('' content, no
808
808
  // structuredData) so callers' empty-response handling fires
809
809
  // instead of a literal '""' reaching the user.
810
810
  logger.warn("[GenerationHandler] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: this.providerName, model: this.modelName });
811
811
  return "";
812
- }
813
- if (scalar !== null && scalar !== undefined) {
814
- structuredData = scalar;
812
+ case "accepted":
813
+ // A JSON scalar root is only real structured data when the caller's
814
+ // schema actually accepts it. Under an OBJECT schema a recovered
815
+ // string/number is the raw completion in disguise (the shape a
816
+ // truncated response degrades to) — publishing it would hand the
817
+ // caller a `structuredData` that violates the schema they passed.
818
+ structuredData = scalar.value;
819
+ return strippedText;
820
+ case "rejected":
821
+ logger.warn("[GenerationHandler] recovered a JSON scalar the requested schema rejects; leaving structuredData unset", {
822
+ provider: this.providerName,
823
+ model: this.modelName,
824
+ scalarType: typeof scalar.value,
825
+ });
826
+ return strippedText;
827
+ case "nullish":
828
+ case "not-json":
829
+ logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
815
830
  return strippedText;
816
- }
817
- }
818
- catch {
819
- // not JSON at all — fall through to raw text + WARN
820
831
  }
821
- logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
822
- return strippedText;
823
832
  };
824
833
  if (useStructuredOutput) {
825
834
  try {
@@ -834,7 +843,20 @@ export class GenerationHandler {
834
843
  // (a string identical to the step text) and coerce it instead.
835
844
  const rawTextEcho = typeof experimentalOutput === "string" &&
836
845
  experimentalOutput === (generateResult.text ?? "");
837
- if (experimentalOutput !== undefined && !rawTextEcho) {
846
+ // The equality check above only catches an EXACT echo. On a multi-step
847
+ // or truncated turn the echo can differ from `text` (a different step's
848
+ // text, a fence, trailing whitespace), and a raw string would then be
849
+ // published as `structuredData` under an object schema — the "returned
850
+ // a string instead of the schema object" failure. A string is trusted
851
+ // as structured output ONLY when the caller's schema accepts it
852
+ // (string-root schemas keep working); otherwise it is coerced like any
853
+ // other raw model text.
854
+ const untrustedStringOutput = typeof experimentalOutput === "string" &&
855
+ !!options.schema &&
856
+ !schemaAccepts(options.schema, experimentalOutput);
857
+ if (experimentalOutput !== undefined &&
858
+ !rawTextEcho &&
859
+ !untrustedStringOutput) {
838
860
  // AI-SDK already parsed + schema-validated the object. Expose it
839
861
  // directly and serialise canonically — no hand-parsing needed.
840
862
  structuredData = experimentalOutput;
@@ -25,7 +25,7 @@ import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheR
25
25
  import { DEFAULT_MAX_STEPS, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../constants.js";
26
26
  import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
27
27
  import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
28
- import { coerceJsonToSchema } from "../../utils/json/coerce.js";
28
+ import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "../../utils/json/coerce.js";
29
29
  import { convertZodToJsonSchema } from "../../utils/schemaConversion.js";
30
30
  import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
31
31
  import { Output, stepCountIs } from "../../utils/tool.js";
@@ -800,26 +800,35 @@ export class GenerationHandler {
800
800
  }
801
801
  return coerced.content;
802
802
  }
803
- try {
804
- const scalar = JSON.parse(strippedText);
805
- if (scalar === "") {
803
+ const scalar = recoverScalarRoot(strippedText, options.schema);
804
+ switch (scalar.kind) {
805
+ case "empty":
806
806
  // A JSON-encoded empty string is an EMPTY completion, not a
807
807
  // recovered scalar — normalize to a true empty ('' content, no
808
808
  // structuredData) so callers' empty-response handling fires
809
809
  // instead of a literal '""' reaching the user.
810
810
  logger.warn("[GenerationHandler] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: this.providerName, model: this.modelName });
811
811
  return "";
812
- }
813
- if (scalar !== null && scalar !== undefined) {
814
- structuredData = scalar;
812
+ case "accepted":
813
+ // A JSON scalar root is only real structured data when the caller's
814
+ // schema actually accepts it. Under an OBJECT schema a recovered
815
+ // string/number is the raw completion in disguise (the shape a
816
+ // truncated response degrades to) — publishing it would hand the
817
+ // caller a `structuredData` that violates the schema they passed.
818
+ structuredData = scalar.value;
819
+ return strippedText;
820
+ case "rejected":
821
+ logger.warn("[GenerationHandler] recovered a JSON scalar the requested schema rejects; leaving structuredData unset", {
822
+ provider: this.providerName,
823
+ model: this.modelName,
824
+ scalarType: typeof scalar.value,
825
+ });
826
+ return strippedText;
827
+ case "nullish":
828
+ case "not-json":
829
+ logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
815
830
  return strippedText;
816
- }
817
- }
818
- catch {
819
- // not JSON at all — fall through to raw text + WARN
820
831
  }
821
- logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
822
- return strippedText;
823
832
  };
824
833
  if (useStructuredOutput) {
825
834
  try {
@@ -834,7 +843,20 @@ export class GenerationHandler {
834
843
  // (a string identical to the step text) and coerce it instead.
835
844
  const rawTextEcho = typeof experimentalOutput === "string" &&
836
845
  experimentalOutput === (generateResult.text ?? "");
837
- if (experimentalOutput !== undefined && !rawTextEcho) {
846
+ // The equality check above only catches an EXACT echo. On a multi-step
847
+ // or truncated turn the echo can differ from `text` (a different step's
848
+ // text, a fence, trailing whitespace), and a raw string would then be
849
+ // published as `structuredData` under an object schema — the "returned
850
+ // a string instead of the schema object" failure. A string is trusted
851
+ // as structured output ONLY when the caller's schema accepts it
852
+ // (string-root schemas keep working); otherwise it is coerced like any
853
+ // other raw model text.
854
+ const untrustedStringOutput = typeof experimentalOutput === "string" &&
855
+ !!options.schema &&
856
+ !schemaAccepts(options.schema, experimentalOutput);
857
+ if (experimentalOutput !== undefined &&
858
+ !rawTextEcho &&
859
+ !untrustedStringOutput) {
838
860
  // AI-SDK already parsed + schema-validated the object. Expose it
839
861
  // directly and serialise canonically — no hand-parsing needed.
840
862
  structuredData = experimentalOutput;
@@ -775,6 +775,21 @@ export declare class NeuroLink {
775
775
  private applyClassifierRouting;
776
776
  private prepareGenerateAugmentations;
777
777
  private buildGenerateTextOptions;
778
+ /**
779
+ * Provider-agnostic JSON recovery for schema requests. Structured-output
780
+ * enforcement makes valid JSON the overwhelming case; for every other
781
+ * provider path — including generate() overrides (Vertex, Anthropic,
782
+ * Bedrock, Google AI Studio) — object/array roots are recovered here via
783
+ * balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
784
+ * with the parsed value exposed as `structuredData`. If nothing JSON-shaped
785
+ * is recoverable (pure prose), the raw text is returned, `structuredData`
786
+ * stays undefined, and a WARN makes the case observable.
787
+ *
788
+ * Mutates `textResult` in place, and must run BEFORE the end-of-generation
789
+ * emits so event consumers see the same content/structuredData the caller
790
+ * receives.
791
+ */
792
+ private recoverStructuredData;
778
793
  private finalizeGenerateRequestResult;
779
794
  private emitGenerateErrorEvent;
780
795
  /**
@@ -74,7 +74,7 @@ import { CircuitBreaker, ERROR_CODES, ErrorFactory, isAbortError, isRetriableErr
74
74
  import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "./utils/lifecycleCallbacks.js";
75
75
  import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
76
76
  import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
77
- import { coerceJsonToSchema } from "./utils/json/coerce.js";
77
+ import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "./utils/json/coerce.js";
78
78
  // Factory processing imports
79
79
  import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
80
80
  import { logger, mcpLogger } from "./utils/logger.js";
@@ -4058,52 +4058,85 @@ Current user's request: ${currentInput}`;
4058
4058
  }
4059
4059
  return textOptions;
4060
4060
  }
4061
- finalizeGenerateRequestResult(params) {
4062
- const { generateSpan, options, textOptions, textResult, factoryResult, originalPrompt, startTime, } = params;
4063
- // Provider-agnostic JSON coercion for schema requests. Structured-output
4064
- // enforcement makes valid JSON the overwhelming case; for every other
4065
- // provider pathincluding generate() overrides (Vertex, Anthropic,
4066
- // Bedrock, Google AI Studio) object/array roots are recovered here via
4067
- // balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
4068
- // with the parsed value exposed as `structuredData`. If nothing
4069
- // JSON-shaped is recoverable (pure prose), the raw text is returned,
4070
- // `structuredData` stays undefined, and a WARN makes the case observable.
4071
- // Runs BEFORE the end-of-generation emits below so event consumers see
4072
- // the same coerced content/structuredData the caller receives.
4073
- if (textOptions.schema &&
4074
- textResult.structuredData === undefined &&
4075
- typeof textResult.content === "string") {
4076
- const coerced = coerceJsonToSchema(textResult.content, textOptions.schema);
4077
- if (coerced) {
4078
- textResult.content = coerced.content;
4079
- textResult.structuredData = coerced.structuredData;
4080
- if (coerced.repaired) {
4081
- textResult.jsonRepaired = true;
4082
- }
4083
- if (coerced.truncated) {
4084
- textResult.jsonTruncated = true;
4085
- }
4061
+ /**
4062
+ * Provider-agnostic JSON recovery for schema requests. Structured-output
4063
+ * enforcement makes valid JSON the overwhelming case; for every other
4064
+ * provider path including generate() overrides (Vertex, Anthropic,
4065
+ * Bedrock, Google AI Studio) object/array roots are recovered here via
4066
+ * balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
4067
+ * with the parsed value exposed as `structuredData`. If nothing JSON-shaped
4068
+ * is recoverable (pure prose), the raw text is returned, `structuredData`
4069
+ * stays undefined, and a WARN makes the case observable.
4070
+ *
4071
+ * Mutates `textResult` in place, and must run BEFORE the end-of-generation
4072
+ * emits so event consumers see the same content/structuredData the caller
4073
+ * receives.
4074
+ */
4075
+ recoverStructuredData(textResult, schema) {
4076
+ // A provider path that produced its own `structuredData` normally owns it.
4077
+ // The one exception is a STRING the caller's schema rejects: that is the
4078
+ // raw completion leaking through as structured output (the shape a
4079
+ // truncated response degrades to), so re-run recovery over the text rather
4080
+ // than handing back a value the declared schema forbids.
4081
+ const structuredIsRejectedString = typeof textResult.structuredData === "string" &&
4082
+ !schemaAccepts(schema, textResult.structuredData);
4083
+ if (!schema ||
4084
+ (textResult.structuredData !== undefined &&
4085
+ !structuredIsRejectedString) ||
4086
+ typeof textResult.content !== "string") {
4087
+ return;
4088
+ }
4089
+ if (structuredIsRejectedString) {
4090
+ textResult.structuredData = undefined;
4091
+ }
4092
+ const coerced = coerceJsonToSchema(textResult.content, schema);
4093
+ if (coerced) {
4094
+ textResult.content = coerced.content;
4095
+ textResult.structuredData = coerced.structuredData;
4096
+ if (coerced.repaired) {
4097
+ textResult.jsonRepaired = true;
4086
4098
  }
4087
- else {
4088
- try {
4089
- const scalar = JSON.parse(textResult.content);
4090
- if (scalar === "") {
4091
- // A JSON-encoded empty string is an EMPTY completion, not a
4092
- // recovered scalar — normalize to a true empty so callers'
4093
- // empty-response handling fires instead of a literal '""'
4094
- // reaching the user. `structuredData` stays undefined.
4095
- textResult.content = "";
4096
- logger.warn("[NeuroLink] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: textResult.provider, model: textResult.model });
4097
- }
4098
- else if (scalar !== null && scalar !== undefined) {
4099
- textResult.structuredData = scalar;
4100
- }
4101
- }
4102
- catch {
4103
- logger.warn("[NeuroLink] schema requested but no JSON could be recovered from model output; returning raw text", { provider: textResult.provider, model: textResult.model });
4104
- }
4099
+ if (coerced.truncated) {
4100
+ textResult.jsonTruncated = true;
4105
4101
  }
4102
+ return;
4106
4103
  }
4104
+ const scalar = recoverScalarRoot(textResult.content, schema);
4105
+ switch (scalar.kind) {
4106
+ case "empty":
4107
+ // A JSON-encoded empty string is an EMPTY completion, not a recovered
4108
+ // scalar — normalize to a true empty so callers' empty-response
4109
+ // handling fires instead of a literal '""' reaching the user.
4110
+ // `structuredData` stays undefined.
4111
+ textResult.content = "";
4112
+ logger.warn("[NeuroLink] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: textResult.provider, model: textResult.model });
4113
+ break;
4114
+ case "accepted":
4115
+ // Only publish a scalar root the caller's schema actually accepts.
4116
+ // Under an OBJECT schema a recovered string is the raw completion in
4117
+ // disguise — the shape a truncated response degrades to — and exposing
4118
+ // it hands the caller a `structuredData` that violates the schema they
4119
+ // passed.
4120
+ textResult.structuredData = scalar.value;
4121
+ break;
4122
+ case "rejected":
4123
+ logger.warn("[NeuroLink] recovered a JSON scalar the requested schema rejects; leaving structuredData unset", {
4124
+ provider: textResult.provider,
4125
+ model: textResult.model,
4126
+ scalarType: typeof scalar.value,
4127
+ });
4128
+ break;
4129
+ case "nullish":
4130
+ // JSON null/undefined — no structured value to publish.
4131
+ break;
4132
+ case "not-json":
4133
+ logger.warn("[NeuroLink] schema requested but no JSON could be recovered from model output; returning raw text", { provider: textResult.provider, model: textResult.model });
4134
+ break;
4135
+ }
4136
+ }
4137
+ finalizeGenerateRequestResult(params) {
4138
+ const { generateSpan, options, textOptions, textResult, factoryResult, originalPrompt, startTime, } = params;
4139
+ this.recoverStructuredData(textResult, textOptions.schema);
4107
4140
  // Surface truncation when a schema was requested: either the provider
4108
4141
  // reported finishReason="length" or the recovered JSON came from an
4109
4142
  // unclosed span. Either way `structuredData` may be incomplete — warn at
@@ -1206,6 +1206,9 @@ export class AnthropicProvider extends BaseProvider {
1206
1206
  }
1207
1207
  const content = [];
1208
1208
  let finalResultText;
1209
+ // Text emitted in forced-json mode, kept only as a fallback (see below).
1210
+ const jsonModeText = [];
1211
+ let jsonToolAnswered = false;
1209
1212
  for (const block of response.content) {
1210
1213
  if (block.type === "thinking") {
1211
1214
  content.push({ type: "reasoning", text: block.thinking });
@@ -1213,13 +1216,17 @@ export class AnthropicProvider extends BaseProvider {
1213
1216
  else if (block.type === "text") {
1214
1217
  // In forced-json mode the payload arrives via the tool input, not
1215
1218
  // text — pass text through only in normal mode.
1216
- if (!jsonTool) {
1219
+ if (jsonTool) {
1220
+ jsonModeText.push(block.text);
1221
+ }
1222
+ else {
1217
1223
  content.push({ type: "text", text: block.text });
1218
1224
  }
1219
1225
  }
1220
1226
  else if (block.type === "tool_use") {
1221
1227
  if (jsonTool && block.name === jsonTool) {
1222
1228
  // Unwrap the synthetic tool call back into text JSON.
1229
+ jsonToolAnswered = true;
1223
1230
  content.push({
1224
1231
  type: "text",
1225
1232
  text: stringifyToolInput(block.input),
@@ -1241,6 +1248,15 @@ export class AnthropicProvider extends BaseProvider {
1241
1248
  }
1242
1249
  }
1243
1250
  }
1251
+ // Forced-json mode normally drops text blocks because the payload rides
1252
+ // in the synthetic tool's input. But when the response is cut short
1253
+ // (stop_reason "max_tokens") the tool call can be missing entirely, and
1254
+ // dropping the text would leave an EMPTY completion with nothing for
1255
+ // coerceJsonToSchema to recover. Fall back to the text so a partial
1256
+ // object can still be salvaged and flagged truncated.
1257
+ if (jsonTool && !jsonToolAnswered && jsonModeText.length > 0) {
1258
+ content.push({ type: "text", text: jsonModeText.join("") });
1259
+ }
1244
1260
  // final_result is terminal — parity with the native Claude-on-Vertex
1245
1261
  // and Gemini loops, which break out of the tool loop the moment it
1246
1262
  // arrives. Reasoning blocks are kept; any prose preamble and any tool
@@ -0,0 +1,19 @@
1
+ import type { AccountUsageFetchResult, ProxyPassthroughAccount, ProxyQuotaRefreshRunResult, ProxyQuotaRefreshMetrics, ProxyQuotaRefreshRuntimeState } from "../types/index.js";
2
+ export declare class AccountQuotaRefreshCoordinator {
3
+ private readonly inFlight;
4
+ private readonly states;
5
+ private metrics;
6
+ getState(accountKey: string): ProxyQuotaRefreshRuntimeState;
7
+ /**
8
+ * Run one refresh per account. `trigger` must identify the quota window or
9
+ * handoff condition so repeated requests deduplicate without hiding a later
10
+ * reset window.
11
+ */
12
+ run(account: ProxyPassthroughAccount, trigger: string, fetcher: (candidate: ProxyPassthroughAccount) => Promise<AccountUsageFetchResult>, options?: {
13
+ force?: boolean;
14
+ now?: number;
15
+ }): Promise<ProxyQuotaRefreshRunResult>;
16
+ clear(): void;
17
+ getMetrics(): ProxyQuotaRefreshMetrics;
18
+ private getOrCreateState;
19
+ }
@@ -0,0 +1,105 @@
1
+ const FAILURE_BACKOFF_MS = [30_000, 2 * 60_000, 10 * 60_000];
2
+ const MAX_FAILURE_BACKOFF_MS = 30 * 60_000;
3
+ export class AccountQuotaRefreshCoordinator {
4
+ inFlight = new Map();
5
+ states = new Map();
6
+ metrics = {
7
+ attempted: 0,
8
+ succeeded: 0,
9
+ failed: 0,
10
+ coalesced: 0,
11
+ backoffSuppressed: 0,
12
+ triggerDeduplicated: 0,
13
+ };
14
+ getState(accountKey) {
15
+ const state = this.states.get(accountKey);
16
+ return state
17
+ ? { ...state, inFlight: this.inFlight.has(accountKey) }
18
+ : { inFlight: false, consecutiveFailures: 0, coalesced: 0 };
19
+ }
20
+ /**
21
+ * Run one refresh per account. `trigger` must identify the quota window or
22
+ * handoff condition so repeated requests deduplicate without hiding a later
23
+ * reset window.
24
+ */
25
+ run(account, trigger, fetcher, options = {}) {
26
+ const existing = this.inFlight.get(account.key);
27
+ if (existing) {
28
+ const state = this.getOrCreateState(account.key);
29
+ state.coalesced += 1;
30
+ this.metrics.coalesced += 1;
31
+ return existing;
32
+ }
33
+ const now = options.now ?? Date.now();
34
+ const state = this.getOrCreateState(account.key);
35
+ if (!options.force && state.lastCompletedTrigger === trigger) {
36
+ this.metrics.triggerDeduplicated += 1;
37
+ return Promise.resolve({ kind: "not_due" });
38
+ }
39
+ if (!options.force && now < (state.nextEligibleAt ?? 0)) {
40
+ this.metrics.backoffSuppressed += 1;
41
+ return Promise.resolve({
42
+ kind: "backoff",
43
+ nextEligibleAt: state.nextEligibleAt ?? now,
44
+ });
45
+ }
46
+ state.lastAttemptAt = now;
47
+ this.metrics.attempted += 1;
48
+ const task = fetcher(account)
49
+ .catch((error) => ({
50
+ ok: false,
51
+ reason: "network",
52
+ error: error instanceof Error ? error.message : String(error),
53
+ }))
54
+ .then((result) => {
55
+ const completedAt = options.now ?? Date.now();
56
+ if (result.ok === false) {
57
+ this.metrics.failed += 1;
58
+ state.consecutiveFailures += 1;
59
+ state.lastFailureReason = result.reason;
60
+ const backoffBase = FAILURE_BACKOFF_MS[Math.max(0, state.consecutiveFailures - 1)] ??
61
+ MAX_FAILURE_BACKOFF_MS;
62
+ const jitter = 0.9 + Math.random() * 0.2;
63
+ state.nextEligibleAt = completedAt + Math.round(backoffBase * jitter);
64
+ }
65
+ else {
66
+ this.metrics.succeeded += 1;
67
+ state.lastSuccessAt = completedAt;
68
+ state.nextEligibleAt = undefined;
69
+ state.consecutiveFailures = 0;
70
+ state.lastFailureReason = undefined;
71
+ state.lastCompletedTrigger = trigger;
72
+ }
73
+ return { kind: "completed", result, startedAt: now };
74
+ })
75
+ .finally(() => {
76
+ this.inFlight.delete(account.key);
77
+ });
78
+ this.inFlight.set(account.key, task);
79
+ return task;
80
+ }
81
+ clear() {
82
+ this.inFlight.clear();
83
+ this.states.clear();
84
+ this.metrics = {
85
+ attempted: 0,
86
+ succeeded: 0,
87
+ failed: 0,
88
+ coalesced: 0,
89
+ backoffSuppressed: 0,
90
+ triggerDeduplicated: 0,
91
+ };
92
+ }
93
+ getMetrics() {
94
+ return { ...this.metrics };
95
+ }
96
+ getOrCreateState(accountKey) {
97
+ let state = this.states.get(accountKey);
98
+ if (!state) {
99
+ state = { inFlight: false, consecutiveFailures: 0, coalesced: 0 };
100
+ this.states.set(accountKey, state);
101
+ }
102
+ return state;
103
+ }
104
+ }
105
+ //# sourceMappingURL=accountQuotaRefreshCoordinator.js.map
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * On-Demand Account Usage Fetch
3
3
  *
4
- * Manual refetch path for account limits: queries Anthropic's OAuth usage
5
- * endpoint (the same call Claude Code's /usage makes) to get FRESH session /
6
- * weekly / model-scoped windows for an account without consuming any tokens
7
- * and without starting a 5h session window.
4
+ * Lightweight account-limit refresh: queries Anthropic's OAuth usage endpoint
5
+ * (the same call Claude Code's /usage makes) to get fresh session / weekly /
6
+ * model-scoped windows without consuming tokens or starting a 5h session.
8
7
  *
9
8
  * This complements — never replaces — the passive header capture in
10
9
  * accountQuota.ts: `usageToQuota` normalizes the endpoint payload into the
11
10
  * same `AccountQuota` shape so refreshed data flows through the existing
12
- * save/merge/cooldown chain.
11
+ * save/merge/cooldown chain. Manual refresh and adaptive proxy prewarming use
12
+ * the same transport through the route-owned single-flight coordinator.
13
13
  *
14
14
  * Only OAuth (Bearer) accounts have subscription windows; api_key accounts
15
15
  * are skipped and keep their header-derived absolute limits.
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * On-Demand Account Usage Fetch
3
3
  *
4
- * Manual refetch path for account limits: queries Anthropic's OAuth usage
5
- * endpoint (the same call Claude Code's /usage makes) to get FRESH session /
6
- * weekly / model-scoped windows for an account without consuming any tokens
7
- * and without starting a 5h session window.
4
+ * Lightweight account-limit refresh: queries Anthropic's OAuth usage endpoint
5
+ * (the same call Claude Code's /usage makes) to get fresh session / weekly /
6
+ * model-scoped windows without consuming tokens or starting a 5h session.
8
7
  *
9
8
  * This complements — never replaces — the passive header capture in
10
9
  * accountQuota.ts: `usageToQuota` normalizes the endpoint payload into the
11
10
  * same `AccountQuota` shape so refreshed data flows through the existing
12
- * save/merge/cooldown chain.
11
+ * save/merge/cooldown chain. Manual refresh and adaptive proxy prewarming use
12
+ * the same transport through the route-owned single-flight coordinator.
13
13
  *
14
14
  * Only OAuth (Bearer) accounts have subscription windows; api_key accounts
15
15
  * are skipped and keep their header-derived absolute limits.
@@ -0,0 +1,17 @@
1
+ import type { ProxyProviderTransportPermit } from "../types/index.js";
2
+ export declare class ProviderTransportCoordinator {
3
+ private generation;
4
+ private degraded;
5
+ private backoffUntil;
6
+ private lastErrorCode;
7
+ private lastTransportScope;
8
+ private probe;
9
+ acquire(signal?: AbortSignal): Promise<ProxyProviderTransportPermit>;
10
+ reportSuccess(permit: ProxyProviderTransportPermit): void;
11
+ reportTransportFailure(errorCode: string | undefined, transportScope: "shared_provider_transport" | "connection_transport", permit: ProxyProviderTransportPermit): void;
12
+ reportProbeAbandoned(permit: ProxyProviderTransportPermit): void;
13
+ clear(): void;
14
+ private wait;
15
+ private waitForProbe;
16
+ private waitForProbeOrAbort;
17
+ }