@librechat/agents 3.3.11 → 3.3.13

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 (205) hide show
  1. package/dist/cjs/graphs/Graph.cjs +246 -25
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +1 -0
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuseSpanRegistry.cjs +6 -3
  6. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  8. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  9. package/dist/cjs/llm/invoke.cjs +146 -36
  10. package/dist/cjs/llm/invoke.cjs.map +1 -1
  11. package/dist/cjs/llm/openai/index.cjs +2 -2
  12. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  13. package/dist/cjs/llm/streamLimits.cjs +723 -0
  14. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  15. package/dist/cjs/main.cjs +8 -0
  16. package/dist/cjs/run.cjs +6 -2
  17. package/dist/cjs/run.cjs.map +1 -1
  18. package/dist/cjs/session/AgentSession.cjs +4 -1
  19. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  20. package/dist/cjs/stream.cjs +103 -12
  21. package/dist/cjs/stream.cjs.map +1 -1
  22. package/dist/cjs/summarization/node.cjs +157 -37
  23. package/dist/cjs/summarization/node.cjs.map +1 -1
  24. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  25. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  26. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  27. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  28. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  29. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  30. package/dist/cjs/tools/ToolNode.cjs +76 -3
  31. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  32. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  33. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  34. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  35. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  36. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  37. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  38. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  39. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  40. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  41. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  42. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  43. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  44. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  45. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  46. package/dist/cjs/tools/search/search.cjs +30 -10
  47. package/dist/cjs/tools/search/search.cjs.map +1 -1
  48. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  49. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  50. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  51. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  52. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  53. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  54. package/dist/cjs/tools/search/tool.cjs +16 -2
  55. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  56. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  57. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  58. package/dist/cjs/utils/index.cjs +1 -0
  59. package/dist/cjs/utils/misc.cjs +12 -0
  60. package/dist/cjs/utils/misc.cjs.map +1 -1
  61. package/dist/cjs/utils/proxy.cjs +63 -0
  62. package/dist/cjs/utils/proxy.cjs.map +1 -0
  63. package/dist/esm/graphs/Graph.mjs +245 -24
  64. package/dist/esm/graphs/Graph.mjs.map +1 -1
  65. package/dist/esm/instrumentation.mjs +1 -0
  66. package/dist/esm/instrumentation.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +6 -3
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -1
  69. package/dist/esm/llm/bedrock/index.mjs +13 -2
  70. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  71. package/dist/esm/llm/invoke.mjs +146 -36
  72. package/dist/esm/llm/invoke.mjs.map +1 -1
  73. package/dist/esm/llm/openai/index.mjs +2 -2
  74. package/dist/esm/llm/openai/index.mjs.map +1 -1
  75. package/dist/esm/llm/streamLimits.mjs +704 -0
  76. package/dist/esm/llm/streamLimits.mjs.map +1 -0
  77. package/dist/esm/main.mjs +4 -2
  78. package/dist/esm/run.mjs +6 -2
  79. package/dist/esm/run.mjs.map +1 -1
  80. package/dist/esm/session/AgentSession.mjs +4 -1
  81. package/dist/esm/session/AgentSession.mjs.map +1 -1
  82. package/dist/esm/stream.mjs +103 -12
  83. package/dist/esm/stream.mjs.map +1 -1
  84. package/dist/esm/summarization/node.mjs +157 -37
  85. package/dist/esm/summarization/node.mjs.map +1 -1
  86. package/dist/esm/tools/BashExecutor.mjs +3 -2
  87. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  88. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  89. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  90. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  91. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  92. package/dist/esm/tools/ToolNode.mjs +76 -3
  93. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  94. package/dist/esm/tools/ToolSearch.mjs +3 -2
  95. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  96. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  97. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  98. package/dist/esm/tools/search/crw-search.mjs +3 -1
  99. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  100. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  101. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  102. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  103. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  104. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  105. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  106. package/dist/esm/tools/search/rerankers.mjs +26 -8
  107. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  108. package/dist/esm/tools/search/search.mjs +30 -10
  109. package/dist/esm/tools/search/search.mjs.map +1 -1
  110. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  111. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  112. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  113. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  114. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  115. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  116. package/dist/esm/tools/search/tool.mjs +16 -2
  117. package/dist/esm/tools/search/tool.mjs.map +1 -1
  118. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  119. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  120. package/dist/esm/utils/index.mjs +1 -0
  121. package/dist/esm/utils/misc.mjs +12 -1
  122. package/dist/esm/utils/misc.mjs.map +1 -1
  123. package/dist/esm/utils/proxy.mjs +62 -0
  124. package/dist/esm/utils/proxy.mjs.map +1 -0
  125. package/dist/types/graphs/Graph.d.ts +53 -1
  126. package/dist/types/index.d.ts +2 -0
  127. package/dist/types/llm/invoke.d.ts +25 -4
  128. package/dist/types/llm/openai/index.d.ts +3 -0
  129. package/dist/types/llm/streamLimits.d.ts +314 -0
  130. package/dist/types/run.d.ts +1 -0
  131. package/dist/types/summarization/node.d.ts +27 -2
  132. package/dist/types/tools/BashExecutor.d.ts +2 -2
  133. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  134. package/dist/types/tools/ToolNode.d.ts +11 -1
  135. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  136. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  137. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  138. package/dist/types/tools/search/rerankers.d.ts +9 -5
  139. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  140. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  141. package/dist/types/tools/search/types.d.ts +28 -33
  142. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  143. package/dist/types/types/graph.d.ts +12 -1
  144. package/dist/types/types/run.d.ts +46 -1
  145. package/dist/types/types/tools.d.ts +21 -0
  146. package/dist/types/utils/index.d.ts +1 -0
  147. package/dist/types/utils/misc.d.ts +7 -0
  148. package/dist/types/utils/proxy.d.ts +31 -0
  149. package/package.json +2 -1
  150. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  151. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  152. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  153. package/src/graphs/Graph.ts +302 -27
  154. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  155. package/src/index.ts +11 -0
  156. package/src/instrumentation.ts +1 -0
  157. package/src/langfuseSpanRegistry.ts +9 -0
  158. package/src/llm/bedrock/index.ts +22 -2
  159. package/src/llm/invoke.streamLimits.test.ts +142 -0
  160. package/src/llm/invoke.test.ts +89 -1
  161. package/src/llm/invoke.ts +197 -20
  162. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  163. package/src/llm/openai/index.ts +14 -6
  164. package/src/llm/streamLimits.test.ts +450 -0
  165. package/src/llm/streamLimits.ts +1158 -0
  166. package/src/run.ts +4 -0
  167. package/src/session/AgentSession.ts +5 -0
  168. package/src/specs/langfuse-instrumentation.test.ts +26 -0
  169. package/src/specs/langfuse-span-registry.test.ts +17 -0
  170. package/src/specs/summarization.test.ts +5 -2
  171. package/src/stream.ts +141 -6
  172. package/src/summarization/__tests__/node.test.ts +269 -0
  173. package/src/summarization/chunkHandler.test.ts +196 -0
  174. package/src/summarization/node.ts +203 -6
  175. package/src/tools/BashExecutor.ts +4 -3
  176. package/src/tools/CodeExecutor.ts +5 -4
  177. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  178. package/src/tools/ToolNode.ts +109 -6
  179. package/src/tools/ToolSearch.ts +4 -3
  180. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  181. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  182. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  183. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  184. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  185. package/src/tools/search/crw-scraper.ts +6 -0
  186. package/src/tools/search/crw-search.ts +6 -1
  187. package/src/tools/search/firecrawl.ts +6 -0
  188. package/src/tools/search/http-agent.test.ts +133 -0
  189. package/src/tools/search/keenable-scraper.ts +6 -0
  190. package/src/tools/search/keenable-search.ts +6 -1
  191. package/src/tools/search/rerankers.ts +36 -10
  192. package/src/tools/search/search.ts +29 -11
  193. package/src/tools/search/serper-scraper.ts +6 -0
  194. package/src/tools/search/tavily-scraper.ts +6 -0
  195. package/src/tools/search/tavily-search.ts +2 -0
  196. package/src/tools/search/tool.ts +16 -0
  197. package/src/tools/search/types.ts +31 -33
  198. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  199. package/src/types/graph.ts +12 -0
  200. package/src/types/run.ts +49 -1
  201. package/src/types/tools.ts +21 -0
  202. package/src/utils/index.ts +1 -0
  203. package/src/utils/misc.ts +19 -0
  204. package/src/utils/proxy.test.ts +176 -0
  205. package/src/utils/proxy.ts +93 -0
package/src/llm/invoke.ts CHANGED
@@ -12,6 +12,7 @@ import type { ChatGeneration } from '@langchain/core/outputs';
12
12
  import type { ToolCall } from '@langchain/core/messages/tool';
13
13
  import type { BaseMessage } from '@langchain/core/messages';
14
14
  import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
15
+ import type { StreamLimitState } from '@/llm/streamLimits';
15
16
  import type { ContextOverflowContext } from '@/utils/errors';
16
17
  import type * as t from '@/types';
17
18
  import {
@@ -37,6 +38,16 @@ import {
37
38
  } from '@/messages/cache';
38
39
  import { ChatModelStreamHandler, dispatchesChatModelStream } from '@/stream';
39
40
  import { Constants, ContentTypes, GraphEvents, Providers } from '@/common';
41
+ import {
42
+ enforceStreamLimitsForWireChunk,
43
+ registerActiveStreamLimitGeneration,
44
+ releaseStreamLimitGeneration,
45
+ resolveGenerationKey,
46
+ streamLimitAccountingEnabled,
47
+ StreamLimitExceededError,
48
+ STREAM_LIMIT_REDISPATCH_KEY,
49
+ STREAM_LIMIT_ATTEMPT_KEY,
50
+ } from '@/llm/streamLimits';
40
51
  import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
41
52
  import { assertNotTruncatedToolCall } from '@/llm/truncation';
42
53
  import { manualToolStreamProviders } from '@/llm/providers';
@@ -83,8 +94,18 @@ export type InvokeContext = NonNullable<
83
94
  /**
84
95
  * Per-chunk callback for custom stream processing.
85
96
  * When provided, replaces the default `ChatModelStreamHandler`.
97
+ *
98
+ * `metadata` is the attempt's callback metadata (carrying the provider and
99
+ * stream-limit attempt stamps), so consumers that count against the stream
100
+ * limits key each model attempt separately.
86
101
  */
87
- export type OnChunk = (chunk: AIMessageChunk) => void | Promise<void>;
102
+ export type OnChunk = (
103
+ chunk: AIMessageChunk,
104
+ metadata?: Record<string, unknown>
105
+ ) => void | Promise<void>;
106
+
107
+ /** Unique per-model-attempt sequence; see the stamp in `attemptInvoke`. */
108
+ let streamLimitAttemptSeq = 0;
88
109
 
89
110
  export function usesNativeOpenAIResponses(
90
111
  model: t.ChatModel,
@@ -659,21 +680,81 @@ function appendStreamChunk({
659
680
  * Pass an `onChunk` callback to override this with custom chunk processing
660
681
  * (e.g. summarization delta events).
661
682
  */
683
+ interface AttemptInvokeParams {
684
+ model: t.ChatModel;
685
+ messages: BaseMessage[];
686
+ provider: Providers;
687
+ context?: InvokeContext;
688
+ onChunk?: OnChunk;
689
+ /** Accounting owner for callers that deliberately pass no `context`
690
+ * (summarization) — used ONLY for the attempt's accounting lease, never
691
+ * for charge claims. */
692
+ streamLimitState?: StreamLimitState;
693
+ }
694
+
695
+ /**
696
+ * One model attempt. Stamps the attempt identity into callback metadata
697
+ * (see the generation-key notes in `streamLimits.ts`), leases the attempt's
698
+ * accounting for its LIFETIME, and releases both from `finally`: retention
699
+ * must follow the attempt — a cancellation-ignoring straggler keeps its
700
+ * original budget no matter how many runs start and reset while it drains.
701
+ */
662
702
  export async function attemptInvoke(
703
+ params: AttemptInvokeParams,
704
+ config?: RunnableConfig
705
+ ): Promise<Partial<t.BaseGraphState>> {
706
+ const stampedConfig: RunnableConfig = {
707
+ ...config,
708
+ metadata: {
709
+ ...(config?.metadata ?? {}),
710
+ [Constants.INVOKED_PROVIDER]: params.provider,
711
+ /**
712
+ * One `attemptInvoke` call is one model attempt; primary, fallback,
713
+ * and retry attempts within a node otherwise share the same langgraph
714
+ * metadata, so without a unique attempt stamp a fallback re-streaming
715
+ * a tool call from scratch would be charged the failed primary's
716
+ * partial bytes (or a same-named sibling fallback's) and could
717
+ * falsely trip the stream limits. The stamp rides the same metadata
718
+ * rebuild that already attributes the serving provider.
719
+ */
720
+ [STREAM_LIMIT_ATTEMPT_KEY]: ++streamLimitAttemptSeq,
721
+ },
722
+ };
723
+ const rawLeaseTarget = params.context ?? params.streamLimitState;
724
+ /** No lease when no guard can fire: the lease only protects accounting
725
+ * entries, and fully disabled guards must allocate no bookkeeping at
726
+ * all — per-attempt included. */
727
+ const leaseTarget =
728
+ rawLeaseTarget != null && streamLimitAccountingEnabled(rawLeaseTarget)
729
+ ? rawLeaseTarget
730
+ : undefined;
731
+ const generationKey =
732
+ leaseTarget != null
733
+ ? resolveGenerationKey(
734
+ stampedConfig.metadata as Record<string, unknown>
735
+ )
736
+ : undefined;
737
+ if (leaseTarget != null && generationKey != null) {
738
+ registerActiveStreamLimitGeneration(leaseTarget, generationKey);
739
+ }
740
+ try {
741
+ return await attemptInvokeBody(params, stampedConfig);
742
+ } finally {
743
+ if (leaseTarget != null && generationKey != null) {
744
+ releaseStreamLimitGeneration(leaseTarget, generationKey);
745
+ }
746
+ }
747
+ }
748
+
749
+ async function attemptInvokeBody(
663
750
  {
664
751
  model,
665
752
  messages,
666
753
  provider,
667
754
  context,
668
755
  onChunk,
669
- }: {
670
- model: t.ChatModel;
671
- messages: BaseMessage[];
672
- provider: Providers;
673
- context?: InvokeContext;
674
- onChunk?: OnChunk;
675
- },
676
- config?: RunnableConfig
756
+ }: AttemptInvokeParams,
757
+ config: RunnableConfig
677
758
  ): Promise<Partial<t.BaseGraphState>> {
678
759
  /**
679
760
  * Pull the run-scoped tool output registry off the graph (when one
@@ -689,7 +770,7 @@ export async function attemptInvoke(
689
770
  callOptions: config,
690
771
  });
691
772
  const registry = context?.getOrCreateToolOutputRegistry();
692
- const runId = config?.configurable?.run_id as string | undefined;
773
+ const runId = config.configurable?.run_id as string | undefined;
693
774
  const annotated = annotateMessagesForLLM(invocationMessages, registry, runId);
694
775
  /**
695
776
  * Keyed on the provider ACTUALLY serving this call, not the agent's primary.
@@ -748,14 +829,6 @@ export async function attemptInvoke(
748
829
  * wrong for fallback-served calls — or `ls_provider` — which derived
749
830
  * providers inherit from their base class.
750
831
  */
751
- config = {
752
- ...config,
753
- metadata: {
754
- ...(config?.metadata ?? {}),
755
- [Constants.INVOKED_PROVIDER]: provider,
756
- },
757
- };
758
-
759
832
  if (model.stream) {
760
833
  /**
761
834
  * Observed, not dictated. `handleChatModelStart` fires with the chat
@@ -787,10 +860,40 @@ export async function attemptInvoke(
787
860
  let preempted = false;
788
861
  const registeredStreamHandler =
789
862
  getRegisteredDefaultChatStreamHandler(context);
863
+ /** A sibling's trip aborts the composed signal, but an adapter that
864
+ * ignores cancellation keeps yielding — and text-only chunks with the
865
+ * event cap off never throw in enforcement, so nothing else would stop
866
+ * the drain. Checked on every yielded chunk in all three loops;
867
+ * throwing closes the iterator and tears down the provider stream. */
868
+ const throwIfBreakerTripped = (): void => {
869
+ const signal = config.signal;
870
+ if (
871
+ signal?.aborted === true &&
872
+ signal.reason instanceof StreamLimitExceededError
873
+ ) {
874
+ throw signal.reason;
875
+ }
876
+ };
790
877
 
791
878
  if (onChunk) {
879
+ const attemptMetadata = config.metadata as
880
+ | Record<string, unknown>
881
+ | undefined;
792
882
  for await (const chunk of stream) {
793
- await onChunk(chunk);
883
+ throwIfBreakerTripped();
884
+ /** An onChunk consumer replaces the stream handler entirely, so
885
+ * stream limits are enforced here for every such caller — public
886
+ * package consumers get no other accounting. The internal
887
+ * summarization onChunk charges producer-side itself and passes no
888
+ * context, precisely so this claim and its own never stack. */
889
+ if (context != null) {
890
+ enforceStreamLimitsForWireChunk({
891
+ graph: context,
892
+ metadata: attemptMetadata,
893
+ chunk,
894
+ });
895
+ }
896
+ await onChunk(chunk, attemptMetadata);
794
897
  finalChunk = appendStreamChunk({
795
898
  current: finalChunk,
796
899
  next: chunk,
@@ -801,6 +904,7 @@ export async function attemptInvoke(
801
904
  const metadata = config.metadata as Record<string, unknown> | undefined;
802
905
  const streamHandler = new ChatModelStreamHandler();
803
906
  for await (const chunk of stream) {
907
+ throwIfBreakerTripped();
804
908
  const handlingChunk = getStreamHandlingChunk({
805
909
  current: finalChunk,
806
910
  next: chunk,
@@ -813,6 +917,23 @@ export async function attemptInvoke(
813
917
  metadata,
814
918
  context
815
919
  );
920
+ } else if (context != null) {
921
+ /**
922
+ * A replay-skipped chunk yields no handling chunk, and in this
923
+ * local branch no `streamEvents` consumer judges the wire event
924
+ * either — yet a cumulative OpenRouter replay can still carry
925
+ * `tool_call_chunks` or complete `tool_calls` that are appended
926
+ * below. Charge the full limits (event budget and argument bytes)
927
+ * directly so neither cap can be bypassed. Consumer side: the
928
+ * local handler.handle call above claims as consumer, and one
929
+ * reused chunk object can alternate between these two arms.
930
+ */
931
+ enforceStreamLimitsForWireChunk({
932
+ graph: context,
933
+ metadata,
934
+ chunk,
935
+ side: 'consumer',
936
+ });
816
937
  }
817
938
  finalChunk = appendStreamChunk({
818
939
  current: finalChunk,
@@ -842,17 +963,40 @@ export async function attemptInvoke(
842
963
  }
843
964
  } else {
844
965
  const metadata = config.metadata as Record<string, unknown> | undefined;
966
+ /**
967
+ * The original wire chunk still reaches the registered handler through
968
+ * `streamEvents` (where the late-reasoning skip discards it AFTER the
969
+ * event guard counts it), so this inline re-dispatch of the transformed
970
+ * chunk is marked to not consume a second event-budget slot. Allocated
971
+ * once per attempt, only when a transformation occurs.
972
+ */
973
+ let redispatchMetadata: Record<string, unknown> | undefined;
845
974
  for await (const chunk of stream) {
975
+ throwIfBreakerTripped();
976
+ /**
977
+ * Charged synchronously, ahead of the decoupled `streamEvents`
978
+ * reader that will echo this same chunk to the registered handler:
979
+ * a lagging reader would otherwise let an oversized complete call
980
+ * return to LangGraph and reach ToolNode before the queued handler
981
+ * throws. The chunk is marked so the echo skips accounting.
982
+ */
983
+ if (context != null) {
984
+ enforceStreamLimitsForWireChunk({ graph: context, metadata, chunk });
985
+ }
846
986
  const handlingChunk = getStreamHandlingChunk({
847
987
  current: finalChunk,
848
988
  next: chunk,
849
989
  provider,
850
990
  });
851
991
  if (handlingChunk != null && handlingChunk !== chunk) {
992
+ redispatchMetadata ??= {
993
+ ...(metadata ?? {}),
994
+ [STREAM_LIMIT_REDISPATCH_KEY]: true,
995
+ };
852
996
  await registeredStreamHandler.handle(
853
997
  GraphEvents.CHAT_MODEL_STREAM,
854
998
  { chunk: handlingChunk },
855
- metadata,
999
+ redispatchMetadata,
856
1000
  context
857
1001
  );
858
1002
  }
@@ -994,6 +1138,7 @@ export async function tryFallbackProviders({
994
1138
  primaryError,
995
1139
  context,
996
1140
  onChunk,
1141
+ streamLimitState,
997
1142
  overflowContext,
998
1143
  prepareProviderMessages,
999
1144
  }: {
@@ -1004,6 +1149,9 @@ export async function tryFallbackProviders({
1004
1149
  primaryError: unknown;
1005
1150
  context?: InvokeContext;
1006
1151
  onChunk?: OnChunk;
1152
+ /** Accounting-lease owner forwarded to each fallback attempt (see
1153
+ * `AttemptInvokeParams.streamLimitState`). */
1154
+ streamLimitState?: StreamLimitState;
1007
1155
  /**
1008
1156
  * Prompt-size corroboration for signatures that are not self-describing.
1009
1157
  * Vertex AI's overflow is a bare `400` with no reason, so without this a
@@ -1074,6 +1222,16 @@ export async function tryFallbackProviders({
1074
1222
  maxContextTokens: fb.maxContextTokens,
1075
1223
  config: fbConfig,
1076
1224
  })) ?? messages;
1225
+ /** A sibling can trip the breaker while the preparation above is
1226
+ * awaited — and the catch below only sees attempts that THROW, so a
1227
+ * provider that ignores an aborted signal and succeeds would resolve
1228
+ * a run that must reject. Check before every fallback invocation. */
1229
+ if (
1230
+ config?.signal?.aborted === true &&
1231
+ config.signal.reason instanceof StreamLimitExceededError
1232
+ ) {
1233
+ throw config.signal.reason;
1234
+ }
1077
1235
  const result = await attemptInvoke(
1078
1236
  {
1079
1237
  model: fbModel as t.ChatModel,
@@ -1081,11 +1239,30 @@ export async function tryFallbackProviders({
1081
1239
  provider: fb.provider,
1082
1240
  context,
1083
1241
  onChunk,
1242
+ streamLimitState,
1084
1243
  },
1085
1244
  fbConfig
1086
1245
  );
1087
1246
  return result;
1088
1247
  } catch (e) {
1248
+ /**
1249
+ * A tripped stream circuit breaker is a deliberate abort, not a
1250
+ * provider failure. Continuing would try the remaining fallbacks and a
1251
+ * succeeding one would resolve a run that must reject.
1252
+ */
1253
+ if (e instanceof StreamLimitExceededError) {
1254
+ throw e;
1255
+ }
1256
+ /** A parallel sibling's trip aborts this branch's composed signal, and
1257
+ * a provider can surface that as a generic abort error; advancing to
1258
+ * the next fallback would start new provider work after the safety
1259
+ * abort. Rethrow the breaker's own reason instead. */
1260
+ if (
1261
+ config?.signal?.aborted === true &&
1262
+ config.signal.reason instanceof StreamLimitExceededError
1263
+ ) {
1264
+ throw config.signal.reason;
1265
+ }
1089
1266
  lastError = e;
1090
1267
  const fallbackOverflowContext: ContextOverflowContext = {
1091
1268
  provider: fb.provider,
@@ -0,0 +1,112 @@
1
+ import { AIMessage } from '@langchain/core/messages';
2
+ import type { OpenAIClient } from '@langchain/openai';
3
+
4
+ import {
5
+ getCacheWriteTokens,
6
+ attachCacheWriteUsage,
7
+ attachCacheWriteMetadata,
8
+ } from './index';
9
+
10
+ /**
11
+ * Regression coverage for a crash reported against OpenAI-compatible
12
+ * third-party servers (e.g. mlx_vlm.server) whose `/v1/responses` usage
13
+ * payload omits `input_tokens_details` entirely — a shape the OpenAI API
14
+ * itself always populates, but which `ResponsesUsageWithCacheWrite`
15
+ * declares optional. Reading `.cache_write_tokens` off that missing object
16
+ * without a second `?.` threw "Cannot read properties of undefined
17
+ * (reading 'cache_write_tokens')" on every completion from such a server.
18
+ */
19
+ describe('cache write token extraction (Responses API)', () => {
20
+ describe('getCacheWriteTokens', () => {
21
+ it('returns undefined without throwing when usage has no input_tokens_details', () => {
22
+ const message = new AIMessage({
23
+ content: 'hi',
24
+ response_metadata: {
25
+ usage: {
26
+ input_tokens: 10,
27
+ output_tokens: 2,
28
+ total_tokens: 12,
29
+ // input_tokens_details intentionally omitted, mirroring a
30
+ // minimal OpenAI-compatible server's usage payload.
31
+ },
32
+ },
33
+ });
34
+
35
+ expect(() => getCacheWriteTokens(message)).not.toThrow();
36
+ expect(getCacheWriteTokens(message)).toBeUndefined();
37
+ });
38
+
39
+ it('returns undefined without throwing when there is no usage at all', () => {
40
+ const message = new AIMessage({
41
+ content: 'hi',
42
+ response_metadata: {},
43
+ });
44
+
45
+ expect(() => getCacheWriteTokens(message)).not.toThrow();
46
+ expect(getCacheWriteTokens(message)).toBeUndefined();
47
+ });
48
+
49
+ it('still reports cache_write_tokens when the field is present', () => {
50
+ const message = new AIMessage({
51
+ content: 'hi',
52
+ response_metadata: {
53
+ usage: {
54
+ input_tokens: 10,
55
+ output_tokens: 2,
56
+ total_tokens: 12,
57
+ input_tokens_details: { cache_write_tokens: 5 },
58
+ },
59
+ },
60
+ });
61
+
62
+ expect(getCacheWriteTokens(message)).toBe(5);
63
+ });
64
+
65
+ it('falls back to the serialized metadata key when usage is absent', () => {
66
+ const message = new AIMessage({
67
+ content: 'hi',
68
+ response_metadata: {
69
+ metadata: { __librechat_cache_write_tokens: '7' },
70
+ },
71
+ });
72
+
73
+ expect(getCacheWriteTokens(message)).toBe(7);
74
+ });
75
+ });
76
+
77
+ describe('attachCacheWriteUsage', () => {
78
+ it('leaves usage_metadata untouched without throwing when input_tokens_details is missing', () => {
79
+ const message = new AIMessage({
80
+ content: 'hi',
81
+ response_metadata: {
82
+ usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 },
83
+ },
84
+ usage_metadata: {
85
+ input_tokens: 10,
86
+ output_tokens: 2,
87
+ total_tokens: 12,
88
+ },
89
+ });
90
+
91
+ expect(() => attachCacheWriteUsage(message)).not.toThrow();
92
+ expect(message.usage_metadata?.input_token_details).toBeUndefined();
93
+ });
94
+ });
95
+
96
+ describe('attachCacheWriteMetadata', () => {
97
+ it('returns the response unmodified without throwing when input_tokens_details is missing', () => {
98
+ const response = {
99
+ usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 },
100
+ } as OpenAIClient.Responses.Response;
101
+
102
+ expect(() => attachCacheWriteMetadata(response)).not.toThrow();
103
+ expect(attachCacheWriteMetadata(response)).toBe(response);
104
+ });
105
+
106
+ it('returns the response unmodified without throwing when usage is missing entirely', () => {
107
+ const response = {} as OpenAIClient.Responses.Response;
108
+
109
+ expect(() => attachCacheWriteMetadata(response)).not.toThrow();
110
+ });
111
+ });
112
+ });
@@ -245,7 +245,15 @@ type CacheableResponsePart = (
245
245
  ) & {
246
246
  prompt_cache_breakpoint?: { mode: 'explicit' };
247
247
  };
248
- type ResponsesUsageWithCacheWrite = OpenAIClient.Responses.ResponseUsage & {
248
+ // `Omit` before re-adding `input_tokens_details` as optional matters: the SDK's own
249
+ // `ResponseUsage` declares it required (true for OpenAI itself), so a plain intersection
250
+ // would keep it required in the merged type despite the `?:` here — masking, at the type
251
+ // level, that OpenAI-*compatible* servers (e.g. mlx_vlm.server) may omit it entirely.
252
+ // Mirrors `CompletionUsageWithCacheWrite`'s handling of the analogous Completions API field.
253
+ type ResponsesUsageWithCacheWrite = Omit<
254
+ OpenAIClient.Responses.ResponseUsage,
255
+ 'input_tokens_details'
256
+ > & {
249
257
  input_tokens_details?: OpenAIClient.Responses.ResponseUsage['input_tokens_details'] & {
250
258
  cache_write_tokens?: number;
251
259
  };
@@ -509,13 +517,13 @@ export function shouldIncludeEncryptedReasoning(
509
517
  );
510
518
  }
511
519
 
512
- function getCacheWriteTokens(message: BaseMessage): number | undefined {
520
+ export function getCacheWriteTokens(message: BaseMessage): number | undefined {
513
521
  const responseMetadata = message.response_metadata as {
514
522
  usage?: ResponsesUsageWithCacheWrite;
515
523
  metadata?: Record<string, string>;
516
524
  };
517
525
  const reported =
518
- responseMetadata.usage?.input_tokens_details.cache_write_tokens;
526
+ responseMetadata.usage?.input_tokens_details?.cache_write_tokens;
519
527
  if (reported != null) {
520
528
  return reported;
521
529
  }
@@ -527,7 +535,7 @@ function getCacheWriteTokens(message: BaseMessage): number | undefined {
527
535
  return Number.isFinite(parsed) ? parsed : undefined;
528
536
  }
529
537
 
530
- function attachCacheWriteUsage(message: BaseMessage): void {
538
+ export function attachCacheWriteUsage(message: BaseMessage): void {
531
539
  const cacheWriteTokens = getCacheWriteTokens(message);
532
540
  if (
533
541
  cacheWriteTokens == null ||
@@ -554,11 +562,11 @@ function attachCacheWriteUsage(message: BaseMessage): void {
554
562
  };
555
563
  }
556
564
 
557
- function attachCacheWriteMetadata(
565
+ export function attachCacheWriteMetadata(
558
566
  response: OpenAIClient.Responses.Response
559
567
  ): OpenAIClient.Responses.Response {
560
568
  const usage = response.usage as ResponsesUsageWithCacheWrite | undefined;
561
- const cacheWriteTokens = usage?.input_tokens_details.cache_write_tokens;
569
+ const cacheWriteTokens = usage?.input_tokens_details?.cache_write_tokens;
562
570
  if (cacheWriteTokens == null) {
563
571
  return response;
564
572
  }