@juspay/neurolink 12.17.1 → 12.17.3

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 (73) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/dist/browser/neurolink.min.js +430 -421
  3. package/dist/cli/commands/proxy.js +455 -259
  4. package/dist/constants/contextWindows.d.ts +2 -0
  5. package/dist/constants/contextWindows.js +4 -0
  6. package/dist/neurolink.js +8 -3
  7. package/dist/providers/openaiChatCompletionsBase.js +60 -51
  8. package/dist/proxy/anthropicBudgetResponse.d.ts +3 -0
  9. package/dist/proxy/anthropicBudgetResponse.js +153 -0
  10. package/dist/proxy/bodyCaptureProcessing.d.ts +1 -0
  11. package/dist/proxy/bodyCaptureProcessing.js +23 -4
  12. package/dist/proxy/bodyCaptureWorker.js +20 -2
  13. package/dist/proxy/codexFallback.d.ts +10 -0
  14. package/dist/proxy/codexFallback.js +108 -24
  15. package/dist/proxy/codexUsage.js +2 -0
  16. package/dist/proxy/globalInstaller.d.ts +32 -1
  17. package/dist/proxy/globalInstaller.js +386 -3
  18. package/dist/proxy/otelLogSink.d.ts +3 -3
  19. package/dist/proxy/otelLogSink.js +137 -144
  20. package/dist/proxy/otlpLogTransport.d.ts +3 -0
  21. package/dist/proxy/otlpLogTransport.js +334 -0
  22. package/dist/proxy/proxyActivity.d.ts +22 -2
  23. package/dist/proxy/proxyActivity.js +114 -9
  24. package/dist/proxy/proxyAnalysis.js +82 -52
  25. package/dist/proxy/proxyClientCancellation.d.ts +7 -0
  26. package/dist/proxy/proxyClientCancellation.js +23 -0
  27. package/dist/proxy/proxyContextPreflight.d.ts +25 -0
  28. package/dist/proxy/proxyContextPreflight.js +337 -0
  29. package/dist/proxy/proxyFailureDetails.d.ts +19 -0
  30. package/dist/proxy/proxyFailureDetails.js +96 -0
  31. package/dist/proxy/proxyLifecycle.js +7 -0
  32. package/dist/proxy/proxyRouteBodyCapture.d.ts +13 -0
  33. package/dist/proxy/proxyRouteBodyCapture.js +112 -0
  34. package/dist/proxy/proxyTokenBudget.d.ts +40 -0
  35. package/dist/proxy/proxyTokenBudget.js +523 -0
  36. package/dist/proxy/proxyTracer.d.ts +3 -2
  37. package/dist/proxy/proxyTracer.js +46 -20
  38. package/dist/proxy/proxyTranslationEngine.d.ts +8 -10
  39. package/dist/proxy/proxyTranslationEngine.js +736 -376
  40. package/dist/proxy/requestLogger.d.ts +1 -0
  41. package/dist/proxy/requestLogger.js +107 -5
  42. package/dist/proxy/restartControl.js +9 -4
  43. package/dist/proxy/rollingWorkerProcess.js +65 -0
  44. package/dist/proxy/rollingWorkerSupervisor.js +7 -0
  45. package/dist/proxy/updateCoordinator.d.ts +14 -1
  46. package/dist/proxy/updateCoordinator.js +57 -2
  47. package/dist/server/routes/claudeProxyRoutes.d.ts +4 -5
  48. package/dist/server/routes/claudeProxyRoutes.js +809 -577
  49. package/dist/server/routes/codexProxyRoutes.js +191 -9
  50. package/dist/server/routes/openaiProxyRoutes.d.ts +1 -1
  51. package/dist/server/routes/openaiProxyRoutes.js +261 -69
  52. package/dist/types/cli.d.ts +3 -0
  53. package/dist/types/codex.d.ts +5 -1
  54. package/dist/types/index.d.ts +2 -0
  55. package/dist/types/index.js +2 -0
  56. package/dist/types/proxy.d.ts +148 -3
  57. package/dist/types/proxy.js +0 -15
  58. package/dist/types/proxyBudget.d.ts +74 -0
  59. package/dist/types/proxyBudget.js +1 -0
  60. package/dist/types/proxyContext.d.ts +35 -0
  61. package/dist/types/proxyContext.js +1 -0
  62. package/dist/utils/fileDetector.js +28 -7
  63. package/dist/utils/messageBuilder.js +51 -26
  64. package/dist/utils/pdfProcessor.js +26 -4
  65. package/docs/assets/dashboards/README.md +37 -0
  66. package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +197 -89
  67. package/docs-site/static/search-index.json +1 -1
  68. package/package.json +7 -2
  69. package/scripts/observability/NATIVE-DURABLE.md +63 -0
  70. package/scripts/observability/otel-collector.proxy-native-durable.yaml +93 -0
  71. package/scripts/observability/proxy-telemetry-backend.mjs +32 -5
  72. package/scripts/observability/proxy-telemetry-check.mjs +396 -61
  73. package/scripts/observability/query-proxy-history.mjs +34 -5
@@ -52,6 +52,8 @@ export declare function registerRuntimeOutputCeiling(provider: string, model: st
52
52
  * undefined when the serving infrastructure has not advertised one.
53
53
  */
54
54
  export declare function getRuntimeOutputCeiling(provider: string, model?: string): number | undefined;
55
+ /** Remove a model ceiling that discovery no longer advertises as valid. */
56
+ export declare function clearRuntimeOutputCeiling(provider: string, model: string): void;
55
57
  /** Test hook: clear runtime-discovered output ceilings (state is module-global). */
56
58
  export declare function clearRuntimeOutputCeilings(): void;
57
59
  /**
@@ -425,6 +425,10 @@ export function getRuntimeOutputCeiling(provider, model) {
425
425
  }
426
426
  return RUNTIME_OUTPUT_CEILINGS.get(`${provider}:${model}`);
427
427
  }
428
+ /** Remove a model ceiling that discovery no longer advertises as valid. */
429
+ export function clearRuntimeOutputCeiling(provider, model) {
430
+ RUNTIME_OUTPUT_CEILINGS.delete(`${provider}:${model}`);
431
+ }
428
432
  /** Test hook: clear runtime-discovered output ceilings (state is module-global). */
429
433
  export function clearRuntimeOutputCeilings() {
430
434
  RUNTIME_OUTPUT_CEILINGS.clear();
package/dist/neurolink.js CHANGED
@@ -5899,7 +5899,10 @@ Current user's request: ${currentInput}`;
5899
5899
  toolsUsed: result.toolsUsed || [],
5900
5900
  toolCalls: result.toolCalls ?? [],
5901
5901
  toolExecutions: transformedToolExecutions,
5902
- enhancedWithTools: Boolean(hasToolExecutions),
5902
+ // A tool RAN (toolsUsed), not merely dispatched (toolExecutions can
5903
+ // include failed/never-found calls) — see hasToolExecutions above,
5904
+ // which still gates the null-result check and must not change.
5905
+ enhancedWithTools: !!(result.toolsUsed && result.toolsUsed.length > 0),
5903
5906
  availableTools: transformToolsForMCP(transformToolsToExpectedFormat(availableTools)),
5904
5907
  audio: result.audio,
5905
5908
  ttsMetadata: result.ttsMetadata,
@@ -6060,7 +6063,8 @@ Current user's request: ${currentInput}`;
6060
6063
  executionTime: te.durationMs,
6061
6064
  success: !te.isError,
6062
6065
  })),
6063
- enhancedWithTools: !!poolResult.toolExecutions?.length,
6066
+ // A tool RAN (toolsUsed), not merely dispatched.
6067
+ enhancedWithTools: !!poolResult.toolsUsed?.length,
6064
6068
  analytics: poolResult.analytics,
6065
6069
  evaluation: poolResult.evaluation,
6066
6070
  audio: poolResult.audio,
@@ -6432,7 +6436,8 @@ Current user's request: ${currentInput}`;
6432
6436
  executionTime: te.durationMs,
6433
6437
  success: !te.isError,
6434
6438
  })),
6435
- enhancedWithTools: !!result.toolExecutions?.length,
6439
+ // A tool RAN (toolsUsed), not merely dispatched.
6440
+ enhancedWithTools: !!result.toolsUsed?.length,
6436
6441
  analytics: result.analytics,
6437
6442
  evaluation: result.evaluation,
6438
6443
  audio: result.audio,
@@ -899,57 +899,66 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
899
899
  // Omit the tools for a pass that only has to reformat an answer the
900
900
  // model has already given. Dropping them is what makes `response_format`
901
901
  // legal again on a vendor that refuses the two together.
902
- withTools = true) => runNativeGenerateLoop({
903
- doGenerate,
904
- ...createNativeGenerateGuard({
905
- provider: this.providerName,
906
- availableInputTokens: getAvailableInputTokens(this.providerName, modelId, options.maxTokens ?? undefined),
907
- // Runs on every step of the generate loop, so a cyclic or
908
- // oversized tool schema must not throw the turn away. Shares the
909
- // estimator's own serializer: its fallback over-reports, which
910
- // makes the guard fire rather than silently size the overhead at
911
- // nothing.
912
- getFixedOverheadTokens: () => estimateTokens(serializeForEstimate({
913
- tools: v3Tools,
914
- responseFormat: format,
915
- }), this.providerName),
916
- }),
917
- conversation: conv,
918
- ...(withTools && v3Tools ? { tools: v3Tools } : {}),
919
- toolsRecord,
920
- ...(withTools && hasTools && options.toolChoice
921
- ? { toolChoice: resolveToolChoice(options, toolsRecord, true) }
922
- : {}),
923
- // The per-call `timeout` keeps its per-MODEL-CALL meaning once
924
- // `turnTimeoutMs` owns the whole-turn deadline, and it reaches the
925
- // model layer only through this channel. Without it each step fell
926
- // back to the provider default, so a caller asking for a short
927
- // per-call timeout got the default on every request.
928
- ...(typeof options.timeout === "number"
929
- ? {
930
- providerOptions: {
931
- neurolink: { timeoutMs: options.timeout },
932
- },
933
- }
934
- : {}),
935
- ...(format ? { responseFormat: format } : {}),
936
- maxSteps: options.maxSteps || DEFAULT_MAX_STEPS,
937
- ...(options.maxTokens ? { maxOutputTokens: options.maxTokens } : {}),
938
- ...(options.temperature !== undefined
939
- ? { temperature: options.temperature }
940
- : {}),
941
- ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
942
- ...(options.toolTimeoutMs !== undefined
943
- ? { toolTimeoutMs: options.toolTimeoutMs }
944
- : {}),
945
- // withProviderRetry + handleProviderError are what the ai loop
946
- // supplied around each call: without them a 429 surfaces as a raw
947
- // upstream string instead of a RateLimitError, and a throttle is
948
- // never retried.
949
- runStep: (call) => withProviderRetry(call, trace.getActiveSpan() ?? undefined, `${this.providerName} generate`).catch((err) => {
950
- throw this.handleProviderError(err);
951
- }),
952
- }, toolExecutionSummaries);
902
+ withTools = true) => {
903
+ return runNativeGenerateLoop({
904
+ doGenerate,
905
+ ...createNativeGenerateGuard({
906
+ provider: this.providerName,
907
+ availableInputTokens: getAvailableInputTokens(this.providerName, modelId, options.maxTokens ?? undefined),
908
+ // Runs on every step of the generate loop, so a cyclic or
909
+ // oversized tool schema must not throw the turn away. Shares the
910
+ // estimator's own serializer: its fallback over-reports, which
911
+ // makes the guard fire rather than silently size the overhead at
912
+ // nothing.
913
+ // Charge the guard only for what this pass actually sends. A
914
+ // reformat pass runs with `withTools === false` and omits `tools`
915
+ // from the request (see the spread below), so counting them here
916
+ // over-reports the fixed overhead. That inflation is not cosmetic:
917
+ // it feeds `planLoopGuardReclaim`, so a large tool schema can push
918
+ // the estimate past the reclaim threshold and drop older tool
919
+ // exchanges out of a reformat request that would have fit.
920
+ getFixedOverheadTokens: () => estimateTokens(serializeForEstimate({
921
+ ...(withTools && v3Tools ? { tools: v3Tools } : {}),
922
+ responseFormat: format,
923
+ }), this.providerName),
924
+ }),
925
+ conversation: conv,
926
+ ...(withTools && v3Tools ? { tools: v3Tools } : {}),
927
+ toolsRecord,
928
+ ...(withTools && hasTools && options.toolChoice
929
+ ? { toolChoice: resolveToolChoice(options, toolsRecord, true) }
930
+ : {}),
931
+ // The per-call `timeout` keeps its per-MODEL-CALL meaning once
932
+ // `turnTimeoutMs` owns the whole-turn deadline, and it reaches the
933
+ // model layer only through this channel. Without it each step fell
934
+ // back to the provider default, so a caller asking for a short
935
+ // per-call timeout got the default on every request.
936
+ ...(typeof options.timeout === "number"
937
+ ? {
938
+ providerOptions: {
939
+ neurolink: { timeoutMs: options.timeout },
940
+ },
941
+ }
942
+ : {}),
943
+ ...(format ? { responseFormat: format } : {}),
944
+ maxSteps: options.maxSteps || DEFAULT_MAX_STEPS,
945
+ ...(options.maxTokens ? { maxOutputTokens: options.maxTokens } : {}),
946
+ ...(options.temperature !== undefined
947
+ ? { temperature: options.temperature }
948
+ : {}),
949
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
950
+ ...(options.toolTimeoutMs !== undefined
951
+ ? { toolTimeoutMs: options.toolTimeoutMs }
952
+ : {}),
953
+ // withProviderRetry + handleProviderError are what the ai loop
954
+ // supplied around each call: without them a 429 surfaces as a raw
955
+ // upstream string instead of a RateLimitError, and a throttle is
956
+ // never retried.
957
+ runStep: (call) => withProviderRetry(call, trace.getActiveSpan() ?? undefined, `${this.providerName} generate`).catch((err) => {
958
+ throw this.handleProviderError(err);
959
+ }),
960
+ }, toolExecutionSummaries);
961
+ };
953
962
  // Structured output rides `response_format` first. When a vendor rejects
954
963
  // that outright — a tools/JSON-mode conflict, or a schema its constrained
955
964
  // decoder will not accept — the recovery is to ask for the same object in
@@ -0,0 +1,3 @@
1
+ import type { ProxyTokenBudgetLease } from "../types/index.js";
2
+ /** Observe consumed bytes only: no tee, eager draining, or unbounded SSE frame. */
3
+ export declare function observeAnthropicBudgetResponse(response: Response, lease: ProxyTokenBudgetLease, onSettlement: () => void, signal?: AbortSignal): Response;
@@ -0,0 +1,153 @@
1
+ import { settleProxyTokenBudget } from "./proxyTokenBudget.js";
2
+ /** Observe consumed bytes only: no tee, eager draining, or unbounded SSE frame. */
3
+ export function observeAnthropicBudgetResponse(response, lease, onSettlement, signal) {
4
+ if (lease.snapshot.scope === "disabled") {
5
+ return response;
6
+ }
7
+ let finishing;
8
+ let terminalSeen = false;
9
+ let finalOutputUsageSeen = false;
10
+ let logicalFailureSeen = false;
11
+ let input;
12
+ let output;
13
+ let cachedRead = 0;
14
+ let cachedWrite = 0;
15
+ let pending = "";
16
+ let oversized = false;
17
+ const isSse = response.headers
18
+ .get("content-type")
19
+ ?.includes("text/event-stream");
20
+ const decoder = new TextDecoder();
21
+ const readUsage = (text) => {
22
+ try {
23
+ const event = JSON.parse(text);
24
+ if (event?.type === "message_stop") {
25
+ terminalSeen = true;
26
+ }
27
+ if (event?.type === "error") {
28
+ logicalFailureSeen = true;
29
+ }
30
+ const usage = event?.usage ?? event?.message?.usage;
31
+ if (!usage || typeof usage !== "object") {
32
+ return;
33
+ }
34
+ const valid = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
35
+ if (valid(usage.input_tokens)) {
36
+ input = Math.max(input ?? 0, usage.input_tokens);
37
+ }
38
+ if (valid(usage.output_tokens)) {
39
+ if (event?.type === "message_delta") {
40
+ finalOutputUsageSeen = true;
41
+ }
42
+ output = Math.max(output ?? 0, usage.output_tokens);
43
+ }
44
+ if (valid(usage.cache_read_input_tokens)) {
45
+ cachedRead = Math.max(cachedRead, usage.cache_read_input_tokens);
46
+ }
47
+ if (valid(usage.cache_creation_input_tokens)) {
48
+ cachedWrite = Math.max(cachedWrite, usage.cache_creation_input_tokens);
49
+ }
50
+ }
51
+ catch {
52
+ /* Incomplete/oversized frames keep the conservative reservation. */
53
+ }
54
+ };
55
+ const observe = (text) => {
56
+ if (!isSse) {
57
+ if (!oversized && pending.length + text.length <= 1024 * 1024) {
58
+ pending += text;
59
+ }
60
+ else {
61
+ oversized = true;
62
+ pending = "";
63
+ }
64
+ return;
65
+ }
66
+ // Split large chunks without retaining a large frame between pulls.
67
+ for (const part of text.split(/(?<=\n)/u)) {
68
+ if (!oversized && pending.length + part.length <= 64 * 1024) {
69
+ pending += part;
70
+ }
71
+ else {
72
+ oversized = true;
73
+ pending = "";
74
+ }
75
+ if (part.endsWith("\n")) {
76
+ if (!oversized && pending.startsWith("data:")) {
77
+ readUsage(pending.slice(5).trim());
78
+ }
79
+ pending = "";
80
+ oversized = false;
81
+ }
82
+ }
83
+ };
84
+ const finish = (normal) => {
85
+ if (finishing) {
86
+ return finishing;
87
+ }
88
+ finishing = (async () => {
89
+ signal?.removeEventListener("abort", onAbort);
90
+ if (normal) {
91
+ observe(decoder.decode());
92
+ if (!oversized && pending) {
93
+ readUsage(isSse && pending.startsWith("data:")
94
+ ? pending.slice(5).trim()
95
+ : pending);
96
+ }
97
+ }
98
+ const actual = normal &&
99
+ !logicalFailureSeen &&
100
+ (!isSse || (terminalSeen && finalOutputUsageSeen)) &&
101
+ input !== undefined &&
102
+ output !== undefined
103
+ ? input + output + cachedRead + cachedWrite
104
+ : undefined;
105
+ try {
106
+ await settleProxyTokenBudget(lease, actual);
107
+ }
108
+ finally {
109
+ onSettlement();
110
+ }
111
+ })();
112
+ return finishing;
113
+ };
114
+ const onAbort = () => {
115
+ void finish(false).catch(() => undefined);
116
+ };
117
+ if (signal?.aborted) {
118
+ onAbort();
119
+ }
120
+ else {
121
+ signal?.addEventListener("abort", onAbort, { once: true });
122
+ }
123
+ if (!response.body) {
124
+ void finish(false).catch(() => undefined);
125
+ return response;
126
+ }
127
+ const reader = response.body.getReader();
128
+ return new Response(new ReadableStream({
129
+ async pull(controller) {
130
+ try {
131
+ const next = await reader.read();
132
+ if (next.done) {
133
+ await finish(true);
134
+ controller.close();
135
+ return;
136
+ }
137
+ observe(decoder.decode(next.value, { stream: true }));
138
+ controller.enqueue(next.value);
139
+ }
140
+ catch (error) {
141
+ await finish(false).catch(() => undefined);
142
+ controller.error(error);
143
+ }
144
+ },
145
+ async cancel(reason) {
146
+ await Promise.allSettled([reader.cancel(reason), finish(false)]);
147
+ },
148
+ }, { highWaterMark: 0 }), {
149
+ status: response.status,
150
+ statusText: response.statusText,
151
+ headers: response.headers,
152
+ });
153
+ }
@@ -10,6 +10,7 @@ export declare function prepareProxyBodyForLogging(body: unknown): {
10
10
  bytes?: number;
11
11
  truncated: boolean;
12
12
  originalBytes?: number;
13
+ unparseableFrames: number;
13
14
  };
14
15
  /**
15
16
  * Expose the same header-redaction policy to replay and metadata
@@ -54,7 +54,7 @@ function redactNestedValue(key, value) {
54
54
  : value;
55
55
  }
56
56
  /** Redact every value under a sensitive key, including objects and arrays. */
57
- function redactBody(body) {
57
+ function redactBody(body, redaction) {
58
58
  if (body === undefined || body === null) {
59
59
  return undefined;
60
60
  }
@@ -83,6 +83,7 @@ function redactBody(body) {
83
83
  redacted = JSON.stringify(JSON.parse(data), redactNestedValue);
84
84
  }
85
85
  catch {
86
+ redaction.unparseableFrames++;
86
87
  redacted = "[UNPARSEABLE DATA REDACTED]";
87
88
  }
88
89
  return [
@@ -95,6 +96,7 @@ function redactBody(body) {
95
96
  // Invalid or truncated structured JSON must not expose an unterminated
96
97
  // credential value that a quoted-string regex cannot safely redact.
97
98
  if (/^\s*[[{]/.test(body)) {
99
+ redaction.unparseableFrames++;
98
100
  return "[UNPARSEABLE DATA REDACTED]";
99
101
  }
100
102
  // Plain-text bodies retain the existing credential-field redaction.
@@ -175,13 +177,15 @@ export function splitUtf8StringByBytes(input, maxBytes) {
175
177
  * ceiling.
176
178
  */
177
179
  function prepareRedactedBody(body, maxBytes = MAX_CAPTURED_BODY_BYTES) {
178
- const redacted = redactBody(body);
180
+ const redaction = { unparseableFrames: 0 };
181
+ const redacted = redactBody(body, redaction);
179
182
  if (redacted === undefined) {
180
- return { truncated: false };
183
+ return { truncated: false, ...redaction };
181
184
  }
182
185
  return {
183
186
  ...truncateUtf8String(redacted, maxBytes),
184
187
  originalBytes: utf8ByteLength(redacted),
188
+ ...redaction,
185
189
  };
186
190
  }
187
191
  /**
@@ -251,11 +255,25 @@ export async function processProxyBodyCapture(entry, logDir) {
251
255
  const headers = redactHeaders(entry.headers);
252
256
  const limit = logDir === null ? MAX_OTEL_CAPTURED_BODY_BYTES : MAX_CAPTURED_BODY_BYTES;
253
257
  const prepared = prepareRedactedBody(entry.body, limit);
254
- prepared.truncated ||= entry.sourceTruncated === true;
258
+ // Structured objects no longer carry their wire byte representation. Do not
259
+ // serialize a second unredacted copy merely to manufacture a byte count.
260
+ const inputRetainedBytes = typeof entry.body === "string" ? utf8ByteLength(entry.body) : undefined;
261
+ const captureMetadata = {
262
+ sourceTruncated: entry.sourceTruncated === true,
263
+ processingTruncated: prepared.truncated,
264
+ inputRetainedBytes,
265
+ inputEncoding: typeof entry.body === "string"
266
+ ? "utf8_text"
267
+ : "structured_object",
268
+ redactionLossy: prepared.unparseableFrames > 0,
269
+ unparseableRedactedFrames: prepared.unparseableFrames,
270
+ };
271
+ prepared.truncated ||= captureMetadata.sourceTruncated;
255
272
  if (logDir === null) {
256
273
  return {
257
274
  headers,
258
275
  stored: {
276
+ ...captureMetadata,
259
277
  redactedBody: prepared.value,
260
278
  redactedBodyBytes: prepared.bytes,
261
279
  bodyTruncated: prepared.truncated,
@@ -281,6 +299,7 @@ export async function processProxyBodyCapture(entry, logDir) {
281
299
  headers,
282
300
  stored: {
283
301
  ...stored,
302
+ ...captureMetadata,
284
303
  bodyCaptureLimitBytes: limit,
285
304
  originalRedactedBodyBytes: prepared.originalBytes,
286
305
  },
@@ -37,6 +37,7 @@ const snapshot = {
37
37
  rejectionReasons: {},
38
38
  };
39
39
  const pending = new Map();
40
+ const publishingSince = new Map();
40
41
  const otelAdmissionWaiters = [];
41
42
  let admissionRetryTimer;
42
43
  /** Atomically reserve the active clone/publication lease. */
@@ -111,7 +112,7 @@ function waitForOtelAdmission(bytes) {
111
112
  drainOtelAdmissionWaiters();
112
113
  }, OTEL_ADMISSION_WAIT_MS);
113
114
  timer.unref?.();
114
- otelAdmissionWaiters.push({ bytes, resolve, timer });
115
+ otelAdmissionWaiters.push({ bytes, queuedAt: Date.now(), resolve, timer });
115
116
  });
116
117
  }
117
118
  function releaseCapture(bytes) {
@@ -276,6 +277,7 @@ export async function captureProxyBody(entry, logDir, consume) {
276
277
  return consume({
277
278
  error,
278
279
  stored: { bodyWriteFailed: true },
280
+ admissionWaitMs: Math.max(0, Date.now() - queuedAt),
279
281
  admission: {
280
282
  limitingResource: bytes > maxEntryBytes
281
283
  ? "entry"
@@ -293,6 +295,7 @@ export async function captureProxyBody(entry, logDir, consume) {
293
295
  },
294
296
  });
295
297
  }
298
+ const admittedAt = Date.now();
296
299
  let current;
297
300
  try {
298
301
  current = getWorker();
@@ -312,14 +315,19 @@ export async function captureProxyBody(entry, logDir, consume) {
312
315
  timer.unref();
313
316
  pending.set(id, {
314
317
  timer,
318
+ startedAt: Date.now(),
315
319
  resolve: (result) => {
316
320
  // Keep the byte/count lease through index writes and OTLP publication,
317
321
  // so completed worker results cannot form an unbounded parent backlog.
322
+ publishingSince.set(id, Date.now());
323
+ result.admissionWaitMs = Math.max(0, admittedAt - queuedAt);
324
+ result.workerQueueWaitMs = Math.max(0, (result.queueWaitMs ?? 0) - result.admissionWaitMs);
318
325
  void consume(result)
319
326
  .catch(() => {
320
327
  result.error ??= "body_capture_publication_failed";
321
328
  })
322
329
  .finally(() => {
330
+ publishingSince.delete(id);
323
331
  releaseCapture(bytes);
324
332
  if (result.error || result.stored.bodyWriteFailed) {
325
333
  snapshot.failed += 1;
@@ -351,7 +359,17 @@ export async function captureProxyBody(entry, logDir, consume) {
351
359
  * retained publication work.
352
360
  */
353
361
  export function getBodyCaptureWorkerSnapshot() {
354
- return { ...snapshot, rejectionReasons: { ...snapshot.rejectionReasons } };
362
+ const now = Date.now();
363
+ const oldestAge = (times) => times.length ? Math.max(0, now - Math.min(...times)) : 0;
364
+ return {
365
+ ...snapshot,
366
+ rejectionReasons: { ...snapshot.rejectionReasons },
367
+ processing: pending.size,
368
+ publishing: publishingSince.size,
369
+ oldestAdmissionWaitMs: oldestAge(otelAdmissionWaiters.map((waiter) => waiter.queuedAt)),
370
+ oldestProcessingMs: oldestAge([...pending.values()].map((task) => task.startedAt)),
371
+ oldestPublicationMs: oldestAge([...publishingSince.values()]),
372
+ };
355
373
  }
356
374
  /** Isolated tests point at a separately executed built worker. */
357
375
  export const __bodyCaptureWorkerTestHooks = {
@@ -10,8 +10,18 @@ import type { ClaudeRequest, CodexFallbackResult, CodexFallbackStream, CodexReas
10
10
  export declare class CodexFallbackResponseError extends Error {
11
11
  readonly status: number;
12
12
  readonly responseBody: string;
13
+ readonly code?: string;
14
+ readonly retryable?: boolean;
13
15
  constructor(status: number, responseBody: string);
14
16
  }
17
+ /** A provider terminal event, preserved through the format adapter. */
18
+ export declare class CodexFallbackStreamError extends Error {
19
+ readonly status: number;
20
+ readonly code: string;
21
+ readonly retryable?: boolean;
22
+ readonly usage?: CodexFallbackResult["usage"];
23
+ constructor(payload: Record<string, unknown>, eventType: string, usage?: CodexFallbackResult["usage"]);
24
+ }
15
25
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
16
26
  export declare function convertClaudeRequestToCodex(body: ClaudeRequest, model: string, reasoningEffort?: CodexReasoningEffort): CodexResponsesRequest;
17
27
  /**