@juspay/neurolink 13.4.10 → 13.5.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 (145) hide show
  1. package/CHANGELOG.md +2 -3
  2. package/README.md +3 -2
  3. package/dist/adapters/video/videoAnalyzer.d.ts +1 -0
  4. package/dist/adapters/video/videoAnalyzer.js +4 -0
  5. package/dist/autoresearch/index.d.ts +1 -0
  6. package/dist/autoresearch/index.js +4 -0
  7. package/dist/autoresearch/worker.js +4 -0
  8. package/dist/browser/neurolink.min.js +466 -466
  9. package/dist/cli/commands/decide.js +25 -5
  10. package/dist/cli/commands/observability.js +11 -4
  11. package/dist/cli/commands/telemetry.js +36 -6
  12. package/dist/cli/utils/formatters.d.ts +17 -0
  13. package/dist/cli/utils/formatters.js +39 -0
  14. package/dist/constants/langfuse.d.ts +17 -1
  15. package/dist/constants/langfuse.js +23 -1
  16. package/dist/constants/telemetry.d.ts +6 -0
  17. package/dist/constants/telemetry.js +6 -0
  18. package/dist/core/baseProvider.js +2 -0
  19. package/dist/core/geminiLoopAdapter.js +19 -0
  20. package/dist/core/loopEngine.js +10 -7
  21. package/dist/core/modules/StreamHandler.js +5 -4
  22. package/dist/core/modules/TelemetryHandler.d.ts +5 -1
  23. package/dist/core/modules/TelemetryHandler.js +16 -0
  24. package/dist/core/modules/ToolsManager.js +3 -0
  25. package/dist/core/nativeGenerateLoop.js +1 -1
  26. package/dist/evaluation/index.d.ts +1 -0
  27. package/dist/evaluation/index.js +4 -0
  28. package/dist/evaluation/ragasEvaluator.js +1 -0
  29. package/dist/factories/providerDescriptors.d.ts +4 -3
  30. package/dist/factories/providerDescriptors.js +6 -4
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/mcp/externalServerManager.js +6 -2
  34. package/dist/mcp/toolRegistry.js +3 -0
  35. package/dist/neurolink.d.ts +20 -10
  36. package/dist/neurolink.js +134 -65
  37. package/dist/observability/FEATURE-STATUS.md +0 -4
  38. package/dist/observability/exporterRegistry.d.ts +6 -1
  39. package/dist/observability/exporterRegistry.js +18 -0
  40. package/dist/observability/exporterSpanProcessor.d.ts +20 -6
  41. package/dist/observability/exporterSpanProcessor.js +166 -34
  42. package/dist/observability/exporters/baseExporter.d.ts +22 -3
  43. package/dist/observability/exporters/baseExporter.js +87 -5
  44. package/dist/observability/exporters/langfuseExporter.d.ts +29 -3
  45. package/dist/observability/exporters/langfuseExporter.js +251 -41
  46. package/dist/observability/langfuseMasking.d.ts +1 -1
  47. package/dist/observability/langfuseMasking.js +1 -1
  48. package/dist/observability/otlpEncoding.d.ts +18 -5
  49. package/dist/observability/otlpEncoding.js +69 -12
  50. package/dist/observability/utils/spanSerializer.d.ts +3 -2
  51. package/dist/observability/utils/spanSerializer.js +5 -3
  52. package/dist/processors/base/BaseFileProcessor.d.ts +1 -0
  53. package/dist/processors/base/BaseFileProcessor.js +4 -0
  54. package/dist/processors/index.d.ts +1 -0
  55. package/dist/processors/index.js +4 -0
  56. package/dist/processors/registry/ProcessorRegistry.d.ts +1 -0
  57. package/dist/processors/registry/ProcessorRegistry.js +5 -0
  58. package/dist/providers/amazonBedrock/client.js +7 -5
  59. package/dist/providers/amazonSagemaker.js +32 -12
  60. package/dist/providers/anthropic/client.js +1 -0
  61. package/dist/providers/anthropic/loopAdapter.js +7 -3
  62. package/dist/providers/catalog/provider-catalog.schema.json +64 -3
  63. package/dist/providers/catalog/schema.js +81 -1
  64. package/dist/providers/catalog/xor.json +30 -10
  65. package/dist/providers/configuredDecision.d.ts +8 -1
  66. package/dist/providers/configuredDecision.js +22 -0
  67. package/dist/providers/decide/dialects/openaiDecisions.js +8 -0
  68. package/dist/providers/decide/dialects/systemOne.js +28 -2
  69. package/dist/providers/decide/guards.d.ts +6 -0
  70. package/dist/providers/decide/guards.js +22 -0
  71. package/dist/providers/googleAiStudio/client.js +2 -0
  72. package/dist/providers/googleVertex/client.d.ts +6 -5
  73. package/dist/providers/googleVertex/client.js +46 -19
  74. package/dist/providers/openaiChatCompletionsBase.js +4 -4
  75. package/dist/providers/systemOneDecision.d.ts +30 -7
  76. package/dist/providers/systemOneDecision.js +71 -14
  77. package/dist/proxy/codexUsage.js +59 -2
  78. package/dist/proxy/proxyTracer.d.ts +25 -1
  79. package/dist/proxy/proxyTracer.js +146 -22
  80. package/dist/proxy/proxyTranslationEngine.js +4 -2
  81. package/dist/proxy/sseInterceptor.js +6 -4
  82. package/dist/rag/chunkers/BaseChunker.js +1 -0
  83. package/dist/rag/index.d.ts +1 -0
  84. package/dist/rag/index.js +4 -0
  85. package/dist/rag/ragIntegration.js +1 -0
  86. package/dist/rag/reranker/reranker.js +2 -0
  87. package/dist/rag/retrieval/vectorQueryTool.js +1 -0
  88. package/dist/server/routes/codexProxyRoutes.js +3 -1
  89. package/dist/server/utils/validation.d.ts +3 -3
  90. package/dist/services/server/ai/observability/instrumentation.d.ts +8 -7
  91. package/dist/services/server/ai/observability/instrumentation.js +217 -47
  92. package/dist/services/server/ai/observability/langfuseSetup.d.ts +19 -1
  93. package/dist/services/server/ai/observability/langfuseSetup.js +62 -4
  94. package/dist/services/server/ai/observability/otlpSetup.d.ts +12 -2
  95. package/dist/services/server/ai/observability/otlpSetup.js +55 -4
  96. package/dist/telemetry/aiMetrics.d.ts +56 -0
  97. package/dist/telemetry/aiMetrics.js +229 -0
  98. package/dist/telemetry/attributes.d.ts +24 -1
  99. package/dist/telemetry/attributes.js +94 -9
  100. package/dist/telemetry/chatMessages.d.ts +6 -0
  101. package/dist/telemetry/chatMessages.js +37 -0
  102. package/dist/telemetry/index.d.ts +1 -1
  103. package/dist/telemetry/index.js +1 -1
  104. package/dist/telemetry/observation.d.ts +5 -5
  105. package/dist/telemetry/observation.js +128 -39
  106. package/dist/telemetry/spanSanitizer.d.ts +2 -2
  107. package/dist/telemetry/spanSanitizer.js +76 -8
  108. package/dist/telemetry/telemetryStarter.d.ts +16 -0
  109. package/dist/telemetry/telemetryStarter.js +28 -0
  110. package/dist/telemetry/traceContext.d.ts +3 -3
  111. package/dist/telemetry/traceContext.js +3 -3
  112. package/dist/telemetry/tracers.d.ts +13 -1
  113. package/dist/telemetry/tracers.js +49 -10
  114. package/dist/telemetry/withSpan.d.ts +19 -3
  115. package/dist/telemetry/withSpan.js +149 -20
  116. package/dist/types/cli.d.ts +2 -0
  117. package/dist/types/decision.d.ts +117 -10
  118. package/dist/types/exporter.d.ts +98 -3
  119. package/dist/types/livekit.d.ts +16 -1
  120. package/dist/types/loopEngine.d.ts +16 -1
  121. package/dist/types/observability.d.ts +38 -5
  122. package/dist/types/providerCatalog.d.ts +12 -1
  123. package/dist/types/providers.d.ts +2 -0
  124. package/dist/types/proxy.d.ts +2 -0
  125. package/dist/types/span.d.ts +26 -0
  126. package/dist/utils/decisionAnswers.d.ts +7 -1
  127. package/dist/utils/decisionAnswers.js +43 -2
  128. package/dist/utils/decisionLimits.d.ts +13 -1
  129. package/dist/utils/decisionLimits.js +52 -1
  130. package/dist/utils/decisionMedia.d.ts +4 -2
  131. package/dist/utils/decisionMedia.js +216 -14
  132. package/dist/utils/timeout.js +4 -2
  133. package/dist/voice/livekit/realtimeMcpTools.js +11 -2
  134. package/dist/voice/livekit/realtimeMemory.d.ts +20 -2
  135. package/dist/voice/livekit/realtimeMemory.js +139 -46
  136. package/dist/voice/livekit/realtimeVoiceAgent.js +20 -8
  137. package/dist/voice/livekit/turnRecorder.js +22 -9
  138. package/dist/workflow/core/ensembleExecutor.js +2 -0
  139. package/dist/workflow/core/judgeScorer.js +1 -0
  140. package/dist/workflow/core/responseConditioner.js +1 -0
  141. package/dist/workflow/core/workflowRunner.js +6 -1
  142. package/dist/workflow/index.d.ts +1 -0
  143. package/dist/workflow/index.js +4 -0
  144. package/docs-site/static/search-index.json +56 -48
  145. package/package.json +21 -2
@@ -2,3 +2,9 @@ export declare const isRecord: (v: unknown) => v is Record<string, unknown>;
2
2
  export declare const asNumber: (v: unknown) => number | undefined;
3
3
  export declare const asNumberMap: (v: unknown) => Record<string, number> | undefined;
4
4
  export declare const asStringMap: (v: unknown) => Record<string, string> | undefined;
5
+ /**
6
+ * A map whose values are shown as text: a string as is, anything else a JSON
7
+ * value can hold as its JSON text. A score legend echoes each level as the
8
+ * caller gave it, and a level may be a number or an object.
9
+ */
10
+ export declare const asTextMap: (v: unknown) => Record<string, string> | undefined;
@@ -27,3 +27,25 @@ export const asStringMap = (v) => {
27
27
  }
28
28
  return out;
29
29
  };
30
+ /**
31
+ * A map whose values are shown as text: a string as is, anything else a JSON
32
+ * value can hold as its JSON text. A score legend echoes each level as the
33
+ * caller gave it, and a level may be a number or an object.
34
+ */
35
+ export const asTextMap = (v) => {
36
+ if (!isRecord(v)) {
37
+ return undefined;
38
+ }
39
+ const out = {};
40
+ for (const [k, raw] of Object.entries(v)) {
41
+ if (typeof raw === "string") {
42
+ out[k] = raw;
43
+ continue;
44
+ }
45
+ if (raw === undefined || typeof raw === "function") {
46
+ return undefined;
47
+ }
48
+ out[k] = JSON.stringify(raw);
49
+ }
50
+ return out;
51
+ };
@@ -771,6 +771,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
771
771
  name: "provider.stream",
772
772
  tracer: tracers.provider,
773
773
  detached: true,
774
+ rootWhenOrphaned: true,
774
775
  attributes: {
775
776
  [ATTR.NL_MODEL]: modelName,
776
777
  [ATTR.NL_PROVIDER]: this.providerName,
@@ -1474,6 +1475,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
1474
1475
  name: "provider.generate",
1475
1476
  tracer: tracers.provider,
1476
1477
  detached: true,
1478
+ rootWhenOrphaned: true,
1477
1479
  attributes: {
1478
1480
  [ATTR.NL_MODEL]: modelName,
1479
1481
  [ATTR.NL_PROVIDER]: this.providerName,
@@ -520,11 +520,12 @@ export declare class GoogleVertexProvider extends BaseProvider {
520
520
  private preserveStreamResultAccessors;
521
521
  private withClaudeGeneration;
522
522
  /**
523
- * Emit `generation:end` so the Pipeline B observability listener creates
524
- * the corresponding `model.generation` span. Vertex bypasses the AI SDK
525
- * (and therefore the experimental_telemetry plumbing), so this hand-off
526
- * is the only way native Vertex calls show up in Langfuse / Pipeline B
527
- * exporters. Mirrors the Bedrock + Ollama pattern.
523
+ * Emit `generation:end` so event listeners and the in-process metrics
524
+ * aggregator record the generation. Vertex bypasses the AI SDK (and
525
+ * therefore the experimental_telemetry plumbing), so nothing else emits it;
526
+ * the in-process store is never exported, and the Langfuse generation
527
+ * observations are the spans opened where each model call is made.
528
+ * Mirrors the Bedrock + Ollama pattern.
528
529
  */
529
530
  private emitGenerationEnd;
530
531
  /**
@@ -43,7 +43,9 @@ import { createStreamChannel } from "../../core/streamChannel.js";
43
43
  import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
44
44
  import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
45
45
  import { resolveLiveTool } from "../../tools/toolDiscovery.js";
46
- import { ATTR, spanContentAttribute, spanModelRequestAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
46
+ import { ATTR, recordsSpanInputs, spanContentAttribute, spanModelRequestAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
47
+ import { recordsSpanContent } from "../../telemetry/attributes.js";
48
+ import { inTurnlessTree } from "../../telemetry/tracers.js";
47
49
  import { observe, observeGeneration, observeTrace, } from "../../telemetry/observation.js";
48
50
  import { withGeneration, withStepSpan } from "../../telemetry/withSpan.js";
49
51
  import { context as otelContext, SpanKind, SpanStatusCode, trace as otelTrace, } from "@opentelemetry/api";
@@ -1004,6 +1006,7 @@ export class GoogleVertexProvider extends BaseProvider {
1004
1006
  name: "provider.stream",
1005
1007
  tracer: tracers.provider,
1006
1008
  detached: true,
1009
+ rootWhenOrphaned: true,
1007
1010
  attributes: {
1008
1011
  [ATTR.NL_MODEL]: modelName,
1009
1012
  [ATTR.NL_PROVIDER]: this.providerName,
@@ -3472,7 +3475,7 @@ export class GoogleVertexProvider extends BaseProvider {
3472
3475
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
3473
3476
  // experimental_telemetry, so emit spans manually — one turn span, one
3474
3477
  // generation span per API call, one tool span per execution — all carrying
3475
- // langfuse.* attributes the LangfuseSpanProcessor maps to observations.
3478
+ // the langfuse.* observation attributes ExporterSpanProcessor exports.
3476
3479
  // Usage lives ONLY on the generation spans (Langfuse sums usage across
3477
3480
  // observations for trace totals, so repeating it on the turn double-counts).
3478
3481
  const offeredToolNames = (tools ?? []).map((anthropicTool) => anthropicTool.name);
@@ -5515,12 +5518,20 @@ export class GoogleVertexProvider extends BaseProvider {
5515
5518
  name: "provider.generate",
5516
5519
  tracer: tracers.provider,
5517
5520
  detached: true,
5521
+ rootWhenOrphaned: true,
5518
5522
  attributes: {
5519
5523
  [ATTR.NL_MODEL]: modelName,
5520
5524
  [ATTR.NL_PROVIDER]: this.providerName,
5521
5525
  },
5522
5526
  }, async (generateSpan) => {
5523
5527
  const generateStartTime = Date.now();
5528
+ // Neither this span nor executeGeneration is an observation: the
5529
+ // generation spans below carry the model call. Marked internal so no
5530
+ // exporter of ours sends them as duplicates, except for the root a
5531
+ // direct call opened for itself, which the loop hangs from.
5532
+ if (!inTurnlessTree(generateSpan)) {
5533
+ generateSpan.setAttribute(ATTR.NL_INTERNAL, true);
5534
+ }
5524
5535
  // One dispatch decision for the whole override: Vertex's generate()
5525
5536
  // bypasses BaseProvider.generate(), so it re-runs the same
5526
5537
  // resolveRequestKind() the core call sites use rather than keeping
@@ -5597,15 +5608,21 @@ export class GoogleVertexProvider extends BaseProvider {
5597
5608
  const inputPrompt = mergedOptions.input?.text ||
5598
5609
  mergedOptions.prompt ||
5599
5610
  "";
5600
- // Set generation input before the call so error paths still carry the
5601
- // request; output is set after the native call resolves.
5602
- const generationInputAttribute = spanModelRequestAttribute({
5603
- ...(mergedOptions.systemPrompt
5604
- ? { system: mergedOptions.systemPrompt }
5605
- : {}),
5606
- prompt: inputPrompt,
5607
- });
5608
- generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_INPUT, generationInputAttribute);
5611
+ // A host exporter on the same provider reads the input and output of
5612
+ // these two spans, so they follow the policy of a generation: masked,
5613
+ // and left out when inputs (or all content) are not recorded. The
5614
+ // input is set before the call so an error path still carries it.
5615
+ const generationInputAttribute = recordsSpanInputs()
5616
+ ? spanModelRequestAttribute({
5617
+ ...(mergedOptions.systemPrompt
5618
+ ? { system: mergedOptions.systemPrompt }
5619
+ : {}),
5620
+ prompt: inputPrompt,
5621
+ })
5622
+ : undefined;
5623
+ if (generationInputAttribute !== undefined) {
5624
+ generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_INPUT, generationInputAttribute);
5625
+ }
5609
5626
  try {
5610
5627
  let result;
5611
5628
  // Wrap the actual native generate call in `neurolink.executeGeneration`
@@ -5619,10 +5636,15 @@ export class GoogleVertexProvider extends BaseProvider {
5619
5636
  detached: true,
5620
5637
  attributes: {
5621
5638
  [ATTR.NL_MODEL]: modelName,
5639
+ [ATTR.NL_INTERNAL]: true,
5622
5640
  path: isAnthropicModel(modelName)
5623
5641
  ? "native.anthropic"
5624
5642
  : "native.google-genai",
5625
- [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: generationInputAttribute,
5643
+ ...(generationInputAttribute !== undefined
5644
+ ? {
5645
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: generationInputAttribute,
5646
+ }
5647
+ : {}),
5626
5648
  },
5627
5649
  }, async (executionSpan) => {
5628
5650
  let nativeResult;
@@ -5640,7 +5662,9 @@ export class GoogleVertexProvider extends BaseProvider {
5640
5662
  });
5641
5663
  nativeResult = await this.runNativeGenerateWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeGemini3GenerateWithSchemaFallback(effectiveOptions, modelName));
5642
5664
  }
5643
- executionSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(nativeResult?.content ?? ""));
5665
+ if (recordsSpanContent()) {
5666
+ executionSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(nativeResult?.content ?? ""));
5667
+ }
5644
5668
  return nativeResult;
5645
5669
  });
5646
5670
  // Pipe through TTS-of-AI-response when caller asks for it. The
@@ -5648,7 +5672,9 @@ export class GoogleVertexProvider extends BaseProvider {
5648
5672
  // enabled / useAiResponse is false, so the cost is zero on
5649
5673
  // non-TTS paths.
5650
5674
  result = await this.synthesizeAIResponseIfNeeded(result, options);
5651
- generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(result?.content ?? ""));
5675
+ if (recordsSpanContent()) {
5676
+ generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(result?.content ?? ""));
5677
+ }
5652
5678
  // Fire onFinish lifecycle callback for the native generate path.
5653
5679
  // Pipeline A providers get this for free via the AI SDK middleware
5654
5680
  // wrapper (LifecycleMiddleware); native @google/genai bypasses
@@ -6343,11 +6369,12 @@ export class GoogleVertexProvider extends BaseProvider {
6343
6369
  }));
6344
6370
  }
6345
6371
  /**
6346
- * Emit `generation:end` so the Pipeline B observability listener creates
6347
- * the corresponding `model.generation` span. Vertex bypasses the AI SDK
6348
- * (and therefore the experimental_telemetry plumbing), so this hand-off
6349
- * is the only way native Vertex calls show up in Langfuse / Pipeline B
6350
- * exporters. Mirrors the Bedrock + Ollama pattern.
6372
+ * Emit `generation:end` so event listeners and the in-process metrics
6373
+ * aggregator record the generation. Vertex bypasses the AI SDK (and
6374
+ * therefore the experimental_telemetry plumbing), so nothing else emits it;
6375
+ * the in-process store is never exported, and the Langfuse generation
6376
+ * observations are the spans opened where each model call is made.
6377
+ * Mirrors the Bedrock + Ollama pattern.
6351
6378
  */
6352
6379
  emitGenerationEnd(modelName, result, startTime, success, error, prompt) {
6353
6380
  const emitter = this.neurolink?.getEventEmitter();
@@ -1946,9 +1946,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1946
1946
  // a blanket "stop" for every stream that drained without throwing.
1947
1947
  streamMetadata.finishReason = toUnifiedFinishReason(rawFinishReason);
1948
1948
  // No-output path: stream completed normally but yielded zero text.
1949
- // Build an enriched sentinel + stamp the active OTel span so
1950
- // Pipeline B (ContextEnricher) surfaces a WARNING-level Langfuse
1951
- // observation instead of silently succeeding.
1949
+ // Build an enriched sentinel + stamp the active OTel span with the
1950
+ // WARNING observation level, which ExporterSpanProcessor exports,
1951
+ // so the observation does not read as a silent success.
1952
1952
  if (contentYielded === 0 && toolsUsed.length === 0) {
1953
1953
  logger.warn(`${providerName}: Stream produced no output — emitting enriched sentinel`);
1954
1954
  const fauxNoOutput = new NoOutputGeneratedError({
@@ -2078,7 +2078,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2078
2078
  ...(reasoningTokens > 0 ? { reasoningTokens } : {}),
2079
2079
  };
2080
2080
  };
2081
- const stepSpans = new StepSpans();
2081
+ const stepSpans = new StepSpans("stream");
2082
2082
  try {
2083
2083
  // May grow mid-turn: hydrated tools with wire-unsafe names need
2084
2084
  // reverse-mapping even when the initial name set required none.
@@ -22,6 +22,12 @@ export declare function describeValidationErrors(detail: readonly unknown[]): st
22
22
  * belongs in an error message or a log, whatever the wording, so the
23
23
  * configured key, anything shaped like a LiteLLM key, any long hex run and any
24
24
  * embedded `data:` URL are all removed.
25
+ *
26
+ * Its request-validation errors also quote the request back (pydantic's
27
+ * `input_value=…`, the start of the state and questions), and its routing
28
+ * errors append the gateway's fallback table, which names every model group it
29
+ * serves. The first is the caller's data and the second is the gateway's, so
30
+ * both are cut.
25
31
  */
26
32
  export declare function redactCredentials(message: string, apiKey: string): string;
27
33
  /**
@@ -85,12 +91,14 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
85
91
  */
86
92
  protected readRetryAfterMs(_headers: Headers): number | undefined;
87
93
  /**
88
- * The per-attempt timeout when the caller sets none. A provider whose latency
89
- * grows with the number of questions or the size of the state scales the
90
- * descriptor's allowance here. `stateTokens` is the local estimate, 0 when the
91
- * descriptor sets no local limits.
94
+ * The per-attempt timeout when the caller sets none: the descriptor's
95
+ * `decideMs`, plus `decideMsPerKStateTokens` for every full 1,000 estimated
96
+ * state tokens when it declares that, since a long state takes longer to read
97
+ * and a flat budget would cut off a request well inside the model's window. A
98
+ * provider whose latency grows another way overrides this. `stateTokens` is
99
+ * the local estimate, 0 when the descriptor sets no local limits.
92
100
  */
93
- protected defaultTimeoutMs(_questionCount: number, _stateTokens: number): number | undefined;
101
+ protected defaultTimeoutMs(_questionCount: number, stateTokens: number): number | undefined;
94
102
  /**
95
103
  * The part of a successful response that holds `answers`, `usage` and
96
104
  * `model`. The default is the response itself; a vendor that wraps every
@@ -101,6 +109,12 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
101
109
  /** Per-question confidence a transport reports outside the answer objects. */
102
110
  protected reportedConfidence(_payload: unknown): Record<string, number>;
103
111
  protected resolveResponseModel(payload: Record<string, unknown>, requestedModel: string): string;
112
+ /**
113
+ * The model this request goes to and is checked against: the call's own, else
114
+ * the instance's. A provider whose default cannot read some requests may pick
115
+ * another for them here.
116
+ */
117
+ protected resolveRequestModel(request: DecisionRequest): string;
104
118
  /** Extra fields for the credential-rejected warning. */
105
119
  protected diagnosticContext(): Record<string, unknown>;
106
120
  protected getProviderName(): AIProviderName;
@@ -126,8 +140,17 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
126
140
  */
127
141
  decide(request: DecisionRequest): Promise<DecisionResult>;
128
142
  /**
129
- * Turn the request's images and video into data URLs, or refuse it. Refused
130
- * as a non-retryable `invalid_request`: resending the same media cannot help.
143
+ * Refuse a question this provider does not read: a type it does not list in
144
+ * `decisionLimits.questionTypes` (absent means boolean, choice and score), or
145
+ * a choice or score past its per-question cap. Checked even when the limits
146
+ * are advisory, since a server that does not know a question type cannot
147
+ * answer it.
148
+ */
149
+ private assertSupportedQuestions;
150
+ /**
151
+ * Turn the request's images, video and audio into wire form, or refuse it.
152
+ * Refused as a non-retryable `invalid_request`: resending the same media
153
+ * cannot help.
131
154
  */
132
155
  private prepareMedia;
133
156
  /**
@@ -6,7 +6,7 @@ import { ProviderError } from "../types/index.js";
6
6
  import { prepareDecisionMedia } from "../utils/decisionMedia.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { redactUrlsInText } from "../utils/logSanitize.js";
9
- import { estimateDecisionStateTokens, resolveDecisionLimitsReading, } from "../utils/decisionLimits.js";
9
+ import { estimateDecisionStateTokens, resolveDecisionLimitsReading, unsupportedDecisionQuestion, } from "../utils/decisionLimits.js";
10
10
  import { systemOneDialect } from "./decide/dialects/systemOne.js";
11
11
  import { asNumber, isRecord } from "./decide/guards.js";
12
12
  /**
@@ -44,17 +44,31 @@ export function describeValidationErrors(detail) {
44
44
  * belongs in an error message or a log, whatever the wording, so the
45
45
  * configured key, anything shaped like a LiteLLM key, any long hex run and any
46
46
  * embedded `data:` URL are all removed.
47
+ *
48
+ * Its request-validation errors also quote the request back (pydantic's
49
+ * `input_value=…`, the start of the state and questions), and its routing
50
+ * errors append the gateway's fallback table, which names every model group it
51
+ * serves. The first is the caller's data and the second is the gateway's, so
52
+ * both are cut.
47
53
  */
48
54
  export function redactCredentials(message, apiKey) {
49
55
  const withoutKey = apiKey
50
56
  ? message.split(apiKey).join("[redacted]")
51
57
  : message;
52
- return withoutKey
58
+ return (withoutKey
53
59
  .replace(/\.?\s*Received API Key\s*=[\s\S]*$/, "")
60
+ .replace(/\.?\s*Received Model Group=[\s\S]*$/, "")
61
+ .replace(/\s*No fallback model group found[\s\S]*$/, "")
62
+ // Greedy: the quoted value may itself contain ", input_type=", so only the
63
+ // last one is a delimiter the server wrote.
64
+ .replace(/input_value=[\s\S]*(?=, input_type=)/g, "input_value=[redacted]")
65
+ // Cut short by the server, a quoted value has no input_type after it, and
66
+ // may run over several lines; everything after it is the value.
67
+ .replace(/input_value=(?!\[redacted\])[\s\S]*/g, "input_value=[redacted]")
54
68
  .replace(/\bsk-[A-Za-z0-9._-]+/g, "[redacted]")
55
69
  .replace(/\b[0-9a-f]{32,}\b/gi, "[redacted]")
56
70
  .replace(/data:[a-z]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi, "[media]")
57
- .trim();
71
+ .trim());
58
72
  }
59
73
  /**
60
74
  * Whether a failed `fetch` was one that refused a redirect. undici words every
@@ -151,13 +165,20 @@ export class SystemOneDecisionProvider extends BaseProvider {
151
165
  return undefined;
152
166
  }
153
167
  /**
154
- * The per-attempt timeout when the caller sets none. A provider whose latency
155
- * grows with the number of questions or the size of the state scales the
156
- * descriptor's allowance here. `stateTokens` is the local estimate, 0 when the
157
- * descriptor sets no local limits.
168
+ * The per-attempt timeout when the caller sets none: the descriptor's
169
+ * `decideMs`, plus `decideMsPerKStateTokens` for every full 1,000 estimated
170
+ * state tokens when it declares that, since a long state takes longer to read
171
+ * and a flat budget would cut off a request well inside the model's window. A
172
+ * provider whose latency grows another way overrides this. `stateTokens` is
173
+ * the local estimate, 0 when the descriptor sets no local limits.
158
174
  */
159
- defaultTimeoutMs(_questionCount, _stateTokens) {
160
- return this.getDescriptorDecideMs();
175
+ defaultTimeoutMs(_questionCount, stateTokens) {
176
+ const base = this.getDescriptorDecideMs();
177
+ const perK = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts
178
+ ?.decideMsPerKStateTokens;
179
+ return base === undefined || perK === undefined
180
+ ? base
181
+ : base + Math.floor(stateTokens / 1000) * perK;
161
182
  }
162
183
  /**
163
184
  * The part of a successful response that holds `answers`, `usage` and
@@ -175,6 +196,14 @@ export class SystemOneDecisionProvider extends BaseProvider {
175
196
  resolveResponseModel(payload, requestedModel) {
176
197
  return typeof payload.model === "string" ? payload.model : requestedModel;
177
198
  }
199
+ /**
200
+ * The model this request goes to and is checked against: the call's own, else
201
+ * the instance's. A provider whose default cannot read some requests may pick
202
+ * another for them here.
203
+ */
204
+ resolveRequestModel(request) {
205
+ return request.model ?? this.modelName ?? this.getDefaultModel();
206
+ }
178
207
  /** Extra fields for the credential-rejected warning. */
179
208
  diagnosticContext() {
180
209
  return {};
@@ -250,7 +279,8 @@ export class SystemOneDecisionProvider extends BaseProvider {
250
279
  retryable: false,
251
280
  });
252
281
  }
253
- const resolvedModel = request.model ?? this.modelName ?? this.getDefaultModel();
282
+ const resolvedModel = this.resolveRequestModel(request);
283
+ this.assertSupportedQuestions(request, resolvedModel);
254
284
  const stateTokens = this.assertWithinDecisionLimits(request, resolvedModel, questionEntries.length);
255
285
  const wireQuestions = Object.fromEntries(questionEntries.map(([id, question]) => [
256
286
  id,
@@ -393,12 +423,39 @@ export class SystemOneDecisionProvider extends BaseProvider {
393
423
  });
394
424
  }
395
425
  /**
396
- * Turn the request's images and video into data URLs, or refuse it. Refused
397
- * as a non-retryable `invalid_request`: resending the same media cannot help.
426
+ * Refuse a question this provider does not read: a type it does not list in
427
+ * `decisionLimits.questionTypes` (absent means boolean, choice and score), or
428
+ * a choice or score past its per-question cap. Checked even when the limits
429
+ * are advisory, since a server that does not know a question type cannot
430
+ * answer it.
431
+ */
432
+ assertSupportedQuestions(request, model) {
433
+ const descriptor = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName);
434
+ const limits = descriptor
435
+ ? resolveDecisionLimitsReading(descriptor, model)
436
+ : null;
437
+ for (const [id, question] of Object.entries(request.questions)) {
438
+ const problem = unsupportedDecisionQuestion(question, limits);
439
+ if (problem !== undefined) {
440
+ throw this.decisionError({
441
+ kind: "invalid_request",
442
+ message: `${this.vendorLabel()} cannot answer question "${id}": ${problem}.`,
443
+ retryable: false,
444
+ });
445
+ }
446
+ }
447
+ }
448
+ /**
449
+ * Turn the request's images, video and audio into wire form, or refuse it.
450
+ * Refused as a non-retryable `invalid_request`: resending the same media
451
+ * cannot help.
398
452
  */
399
453
  async prepareMedia(request) {
400
454
  const limits = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.decisionLimits;
401
- const prepared = await prepareDecisionMedia(request, limits?.media, this.vendorLabel(), listMediaDecisionProviders({ video: request.video !== undefined }));
455
+ const prepared = await prepareDecisionMedia(request, limits?.media, this.vendorLabel(), listMediaDecisionProviders({
456
+ video: request.video !== undefined,
457
+ audio: request.audio !== undefined,
458
+ }));
402
459
  if (prepared.status === "refused") {
403
460
  throw this.decisionError({
404
461
  kind: "invalid_request",
@@ -423,7 +480,7 @@ export class SystemOneDecisionProvider extends BaseProvider {
423
480
  if (bytes > limit) {
424
481
  throw this.decisionError({
425
482
  kind: "invalid_request",
426
- message: `The request is ${bytes} bytes; ${this.vendorLabel()} accepts at most ${limit}. Send fewer or smaller images${media?.video ? ", or a shorter video" : ""}.`,
483
+ message: `The request is ${bytes} bytes; ${this.vendorLabel()} accepts at most ${limit}. Send fewer or smaller images${media?.video ? ", or a shorter video" : ""}${media?.audio ? ", or a shorter audio clip" : ""}.`,
427
484
  retryable: false,
428
485
  });
429
486
  }
@@ -42,6 +42,14 @@
42
42
  import { appendFileSync } from "node:fs";
43
43
  import { extractSSEEvents } from "./sseInterceptor.js";
44
44
  import { sanitizeForLog } from "../utils/logSanitize.js";
45
+ /**
46
+ * Characters of `response.output_item.done` payloads kept to stand in for a
47
+ * completed response that carries no output. Bounds what the tap holds for a
48
+ * stream whose items it cannot otherwise discard.
49
+ */
50
+ const OUTPUT_ITEMS_LIMIT_CHARS = 1024 * 1024;
51
+ /** Type of the item that stands where output items past the cap were left out. */
52
+ const OMITTED_ITEMS_TYPE = "neurolink_output_omitted";
45
53
  const nonNegativeInt = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
46
54
  ? Math.floor(value)
47
55
  : 0;
@@ -208,6 +216,42 @@ function createCaptureSink() {
208
216
  export function createCodexUsageTap() {
209
217
  const evidence = { completed: false, terminalBytes: 0 };
210
218
  let totalBytes = 0;
219
+ // Items announced by response.output_item.done. The terminal response may
220
+ // arrive with an empty or absent output, leaving these as the only record
221
+ // of what the model said.
222
+ const streamedItems = [];
223
+ let streamedItemChars = 0;
224
+ let omittedItems = 0;
225
+ // An item past the cap leaves a marker in its place, so the record reads as
226
+ // incomplete rather than as a shorter reply; adjacent omissions share one.
227
+ const omitItem = () => {
228
+ omittedItems += 1;
229
+ const last = streamedItems[streamedItems.length - 1];
230
+ if (last?.type === OMITTED_ITEMS_TYPE) {
231
+ streamedItems[streamedItems.length - 1] = {
232
+ ...last,
233
+ omitted_items: Number(last.omitted_items) + 1,
234
+ };
235
+ return;
236
+ }
237
+ const marker = {
238
+ type: OMITTED_ITEMS_TYPE,
239
+ omitted_items: 1,
240
+ note: `…[truncated: output items past ${OUTPUT_ITEMS_LIMIT_CHARS} characters are not recorded]`,
241
+ };
242
+ streamedItems.push(marker);
243
+ streamedItemChars += JSON.stringify(marker).length;
244
+ };
245
+ const withStreamedOutput = (response) => {
246
+ if ((Array.isArray(response.output) && response.output.length > 0) ||
247
+ streamedItems.length === 0) {
248
+ return response;
249
+ }
250
+ if (omittedItems > 0) {
251
+ evidence.outputItemsOmitted = true;
252
+ }
253
+ return { ...response, output: [...streamedItems] };
254
+ };
211
255
  const inspectEvidence = (events) => {
212
256
  for (const frame of events) {
213
257
  if (frame.data.trim() === "[DONE]") {
@@ -234,6 +278,19 @@ export function createCodexUsageTap() {
234
278
  evidence.firstUsefulOutputEvent = String(type);
235
279
  }
236
280
  }
281
+ if (type === "response.output_item.done" &&
282
+ event.item &&
283
+ typeof event.item === "object" &&
284
+ !Array.isArray(event.item)) {
285
+ if (streamedItemChars + frame.data.length <=
286
+ OUTPUT_ITEMS_LIMIT_CHARS) {
287
+ streamedItems.push(event.item);
288
+ streamedItemChars += frame.data.length;
289
+ }
290
+ else {
291
+ omitItem();
292
+ }
293
+ }
237
294
  // Some clients/providers emit complete output items without deltas.
238
295
  // Reasoning/control events are not useful client output. Recognize
239
296
  // actual tool calls and text only, without inventing an earlier time.
@@ -277,8 +334,8 @@ export function createCodexUsageTap() {
277
334
  type.startsWith("response.") &&
278
335
  completedResponse &&
279
336
  typeof completedResponse === "object" &&
280
- "output" in completedResponse) {
281
- evidence.response = completedResponse;
337
+ ("output" in completedResponse || streamedItems.length > 0)) {
338
+ evidence.response = withStreamedOutput(completedResponse);
282
339
  }
283
340
  if (type === "response.completed") {
284
341
  evidence.completed = true;
@@ -12,7 +12,7 @@
12
12
  * - calculateCost() from pricing.ts for cost tracking
13
13
  */
14
14
  import { type Span } from "@opentelemetry/api";
15
- import type { AccountSelectionContext, ProxyRequestContext, ProxyRequestOrigin, ResponseInfoContext, UpstreamAttemptContext, UsageContext, ProxyUsageAttributionOptions, RuntimeRequestMetadata, ProxyLogTraceContext } from "../types/index.js";
15
+ import type { AccountSelectionContext, JsonValue, ProxyRequestContext, ProxyRequestOrigin, ResponseInfoContext, UpstreamAttemptContext, UsageContext, ProxyUsageAttributionOptions, RuntimeRequestMetadata, ProxyLogTraceContext } from "../types/index.js";
16
16
  declare class ProxyTracer {
17
17
  private readonly rootSpan;
18
18
  private readonly traceContext;
@@ -43,6 +43,12 @@ declare class ProxyTracer {
43
43
  private readonly requestedModel;
44
44
  private input?;
45
45
  private output?;
46
+ /** The parsed response outranks the raw upstream body that arrives after it. */
47
+ private outputIsParsed;
48
+ /** A model call made through `runModelCall` records the generation. */
49
+ private modelCallDelegated;
50
+ /** That call's records carry no content; fixed when the call starts. */
51
+ private modelContentWithheld;
46
52
  private responseModel?;
47
53
  private finishReason?;
48
54
  private firstOutputAt?;
@@ -65,6 +71,20 @@ declare class ProxyTracer {
65
71
  startUpstreamAttempt(ctx: UpstreamAttemptContext): Span;
66
72
  /** Span covering the SSE stream relay phase. */
67
73
  startStream(): Span;
74
+ /**
75
+ * Run a model call inside this request's trace context, so the spans it
76
+ * starts nest under the proxy root and carry the request's user and session.
77
+ * That call's generation holds usage and cost, so the root records none of
78
+ * its own. Its prompt and answer are request and response bodies, so its
79
+ * records carry them only with `NEUROLINK_PROXY_TRACE_BODY_LOGGING=true`.
80
+ */
81
+ runModelCall<T>(fn: () => T): T;
82
+ /**
83
+ * The delegated call's stream, read and closed under the same content rule:
84
+ * the call records its answer while the stream is consumed, which happens
85
+ * after `runModelCall` has returned.
86
+ */
87
+ modelStream<T>(iterator: AsyncIterator<T>): AsyncIterator<T>;
68
88
  /** Record account selection outcome on the root span. */
69
89
  setAccountSelection(ctx: AccountSelectionContext): void;
70
90
  /** Attribute fallback metrics to the account that actually served them. */
@@ -99,6 +119,8 @@ declare class ProxyTracer {
99
119
  attemptCount: number;
100
120
  reason: string;
101
121
  }): void;
122
+ /** Record the client request body as the input of the root and its generation. */
123
+ setRequestInput(body: JsonValue): void;
102
124
  /** Log the incoming client request body (redacted). */
103
125
  logRequestBody(body: string): void;
104
126
  /** Log the incoming client request headers (redacted). */
@@ -127,6 +149,8 @@ declare class ProxyTracer {
127
149
  getUsage(): UsageContext | undefined;
128
150
  /** End the root span with final HTTP status and duration, and emit OTEL metrics. */
129
151
  end(responseStatus: number, durationMs: number): void;
152
+ /** Bodies are only worth keeping while something is recording the span. */
153
+ private recordsBodies;
130
154
  private levelFor;
131
155
  private observeOutcome;
132
156
  /**