@librechat/agents 3.3.10 → 3.3.12

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 (211) hide show
  1. package/dist/cjs/graphs/Graph.cjs +248 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  4. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/init.cjs +1 -1
  8. package/dist/cjs/llm/invoke.cjs +160 -43
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/llm/openai/index.cjs +190 -13
  11. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  12. package/dist/cjs/llm/streamLimits.cjs +723 -0
  13. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  14. package/dist/cjs/main.cjs +12 -3
  15. package/dist/cjs/messages/core.cjs +592 -27
  16. package/dist/cjs/messages/core.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +7 -3
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/session/AgentSession.cjs +4 -1
  20. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  21. package/dist/cjs/stream.cjs +105 -14
  22. package/dist/cjs/stream.cjs.map +1 -1
  23. package/dist/cjs/summarization/node.cjs +157 -37
  24. package/dist/cjs/summarization/node.cjs.map +1 -1
  25. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  26. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  27. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  28. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  30. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/ToolNode.cjs +77 -4
  32. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  33. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  34. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  35. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  36. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  37. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  38. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  39. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  40. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  41. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  42. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  43. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  44. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  45. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  46. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  47. package/dist/cjs/tools/search/search.cjs +30 -10
  48. package/dist/cjs/tools/search/search.cjs.map +1 -1
  49. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  50. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  51. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  52. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  53. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  54. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  55. package/dist/cjs/tools/search/tool.cjs +17 -3
  56. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  57. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  58. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  59. package/dist/cjs/utils/index.cjs +2 -1
  60. package/dist/cjs/utils/misc.cjs +12 -0
  61. package/dist/cjs/utils/misc.cjs.map +1 -1
  62. package/dist/cjs/utils/proxy.cjs +63 -0
  63. package/dist/cjs/utils/proxy.cjs.map +1 -0
  64. package/dist/esm/graphs/Graph.mjs +247 -26
  65. package/dist/esm/graphs/Graph.mjs.map +1 -1
  66. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  67. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  68. package/dist/esm/llm/bedrock/index.mjs +13 -2
  69. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  70. package/dist/esm/llm/init.mjs +1 -1
  71. package/dist/esm/llm/invoke.mjs +160 -43
  72. package/dist/esm/llm/invoke.mjs.map +1 -1
  73. package/dist/esm/llm/openai/index.mjs +192 -15
  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 +8 -6
  78. package/dist/esm/messages/core.mjs +592 -28
  79. package/dist/esm/messages/core.mjs.map +1 -1
  80. package/dist/esm/run.mjs +7 -3
  81. package/dist/esm/run.mjs.map +1 -1
  82. package/dist/esm/session/AgentSession.mjs +4 -1
  83. package/dist/esm/session/AgentSession.mjs.map +1 -1
  84. package/dist/esm/stream.mjs +105 -14
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +157 -37
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/BashExecutor.mjs +3 -2
  89. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  90. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  91. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  92. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  93. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  94. package/dist/esm/tools/ToolNode.mjs +77 -4
  95. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  96. package/dist/esm/tools/ToolSearch.mjs +3 -2
  97. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  98. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  99. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  100. package/dist/esm/tools/search/crw-search.mjs +3 -1
  101. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  102. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  103. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  104. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  105. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  106. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  107. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  108. package/dist/esm/tools/search/rerankers.mjs +26 -8
  109. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  110. package/dist/esm/tools/search/search.mjs +30 -10
  111. package/dist/esm/tools/search/search.mjs.map +1 -1
  112. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  113. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  114. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  115. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  116. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  117. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  118. package/dist/esm/tools/search/tool.mjs +17 -3
  119. package/dist/esm/tools/search/tool.mjs.map +1 -1
  120. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  121. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  122. package/dist/esm/utils/index.mjs +2 -1
  123. package/dist/esm/utils/misc.mjs +12 -1
  124. package/dist/esm/utils/misc.mjs.map +1 -1
  125. package/dist/esm/utils/proxy.mjs +62 -0
  126. package/dist/esm/utils/proxy.mjs.map +1 -0
  127. package/dist/types/graphs/Graph.d.ts +53 -1
  128. package/dist/types/index.d.ts +2 -0
  129. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  130. package/dist/types/llm/invoke.d.ts +26 -5
  131. package/dist/types/llm/openai/index.d.ts +3 -0
  132. package/dist/types/llm/streamLimits.d.ts +314 -0
  133. package/dist/types/messages/core.d.ts +11 -6
  134. package/dist/types/run.d.ts +1 -0
  135. package/dist/types/summarization/node.d.ts +27 -2
  136. package/dist/types/tools/BashExecutor.d.ts +2 -2
  137. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  138. package/dist/types/tools/ToolNode.d.ts +11 -1
  139. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  140. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  141. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  142. package/dist/types/tools/search/rerankers.d.ts +9 -5
  143. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  144. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  145. package/dist/types/tools/search/types.d.ts +28 -33
  146. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  147. package/dist/types/types/graph.d.ts +7 -1
  148. package/dist/types/types/run.d.ts +46 -1
  149. package/dist/types/types/tools.d.ts +21 -0
  150. package/dist/types/utils/index.d.ts +1 -0
  151. package/dist/types/utils/misc.d.ts +7 -0
  152. package/dist/types/utils/proxy.d.ts +31 -0
  153. package/package.json +2 -1
  154. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  155. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  156. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  157. package/src/graphs/Graph.ts +302 -27
  158. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  159. package/src/index.ts +11 -0
  160. package/src/langfuseToolOutputTracing.ts +410 -14
  161. package/src/llm/bedrock/index.ts +22 -2
  162. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  163. package/src/llm/invoke.streamLimits.test.ts +142 -0
  164. package/src/llm/invoke.test.ts +187 -1
  165. package/src/llm/invoke.ts +231 -43
  166. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  167. package/src/llm/openai/index.ts +348 -31
  168. package/src/llm/openai/llm.spec.ts +107 -6
  169. package/src/llm/streamLimits.test.ts +450 -0
  170. package/src/llm/streamLimits.ts +1158 -0
  171. package/src/messages/core.ts +1290 -42
  172. package/src/messages/formatAgentMessages.test.ts +2623 -0
  173. package/src/run.ts +4 -0
  174. package/src/session/AgentSession.ts +5 -0
  175. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  176. package/src/specs/preemptSeal.test.ts +374 -5
  177. package/src/stream.ts +141 -6
  178. package/src/summarization/__tests__/node.test.ts +269 -0
  179. package/src/summarization/chunkHandler.test.ts +196 -0
  180. package/src/summarization/node.ts +203 -6
  181. package/src/tools/BashExecutor.ts +4 -3
  182. package/src/tools/CodeExecutor.ts +5 -4
  183. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  184. package/src/tools/ToolNode.ts +109 -6
  185. package/src/tools/ToolSearch.ts +4 -3
  186. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  187. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  188. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  189. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  190. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  191. package/src/tools/search/crw-scraper.ts +6 -0
  192. package/src/tools/search/crw-search.ts +6 -1
  193. package/src/tools/search/firecrawl.ts +6 -0
  194. package/src/tools/search/http-agent.test.ts +133 -0
  195. package/src/tools/search/keenable-scraper.ts +6 -0
  196. package/src/tools/search/keenable-search.ts +6 -1
  197. package/src/tools/search/rerankers.ts +36 -10
  198. package/src/tools/search/search.ts +29 -11
  199. package/src/tools/search/serper-scraper.ts +6 -0
  200. package/src/tools/search/tavily-scraper.ts +6 -0
  201. package/src/tools/search/tavily-search.ts +2 -0
  202. package/src/tools/search/tool.ts +16 -0
  203. package/src/tools/search/types.ts +31 -33
  204. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  205. package/src/types/graph.ts +7 -0
  206. package/src/types/run.ts +49 -1
  207. package/src/types/tools.ts +21 -0
  208. package/src/utils/index.ts +1 -0
  209. package/src/utils/misc.ts +19 -0
  210. package/src/utils/proxy.test.ts +176 -0
  211. package/src/utils/proxy.ts +93 -0
package/src/llm/invoke.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import { concat } from '@langchain/core/utils/stream';
2
2
  import { AIMessageChunk } from '@langchain/core/messages';
3
+ import { getCallbackManagerForConfig } from '@langchain/core/runnables';
3
4
  import {
4
5
  CallbackManager,
5
6
  CallbackManagerForLLMRun,
6
7
  type Callbacks,
7
8
  } from '@langchain/core/callbacks/manager';
8
- import { getCallbackManagerForConfig } from '@langchain/core/runnables';
9
9
  import type { Serialized } from '@langchain/core/load/serializable';
10
- import type { ChatGeneration } from '@langchain/core/outputs';
11
10
  import type { RunnableConfig } from '@langchain/core/runnables';
11
+ 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 {
@@ -24,28 +25,38 @@ import {
24
25
  projectStructuredToolOutputsToText,
25
26
  projectToolStreamContentForProvider,
26
27
  } from '@/messages/core';
28
+ import {
29
+ modifyDeltaProperties,
30
+ coalesceAdjacentUserTurns,
31
+ strictAlternationProviders,
32
+ appendPredecessorHandoffCue,
33
+ removePredecessorHandoffCue,
34
+ } from '@/messages';
27
35
  import {
28
36
  stripAnthropicCacheControl,
29
37
  stripBedrockCacheControl,
30
38
  } from '@/messages/cache';
39
+ import { ChatModelStreamHandler, dispatchesChatModelStream } from '@/stream';
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';
31
51
  import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
32
52
  import { assertNotTruncatedToolCall } from '@/llm/truncation';
33
- import { Constants, ContentTypes, GraphEvents, Providers } from '@/common';
34
53
  import { manualToolStreamProviders } from '@/llm/providers';
35
- import { appendCallbacks } from '@/utils/callbacks';
54
+ import { isAnthropicLike, isOpenAILike } from '@/utils/llm';
36
55
  import { safeDispatchCustomEvent } from '@/utils/events';
37
56
  import { getContextOverflowInfo } from '@/utils/errors';
38
- import {
39
- modifyDeltaProperties,
40
- coalesceAdjacentUserTurns,
41
- strictAlternationProviders,
42
- appendPredecessorHandoffCue,
43
- removePredecessorHandoffCue,
44
- } from '@/messages';
57
+ import { appendCallbacks } from '@/utils/callbacks';
45
58
  import { canSealPreempt } from '@/llm/preempt';
46
- import { ChatModelStreamHandler, dispatchesChatModelStream } from '@/stream';
47
59
  import { initializeModel } from '@/llm/init';
48
- import { isAnthropicLike, isOpenAILike } from '@/utils/llm';
49
60
 
50
61
  /**
51
62
  * Context passed to `attemptInvoke`. Matches the subset of Graph that
@@ -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,
@@ -174,8 +195,17 @@ export function projectMessagesForProvider({
174
195
  maxToolResultChars?: number;
175
196
  callOptions?: unknown;
176
197
  }): BaseMessage[] {
177
- const providerInputMessages = projectToolStreamContentForProvider(messages);
178
- if (usesNativeOpenAIResponses(model, provider, callOptions)) {
198
+ const nativeOpenAIResponses = usesNativeOpenAIResponses(
199
+ model,
200
+ provider,
201
+ callOptions
202
+ );
203
+ const providerInputMessages = projectToolStreamContentForProvider(
204
+ messages,
205
+ nativeOpenAIResponses ? 'native' : 'fallback',
206
+ maxToolResultChars
207
+ );
208
+ if (nativeOpenAIResponses) {
179
209
  return projectOpenAIResponsesToolMessageContent(
180
210
  stripAnthropicCacheControl(
181
211
  stripBedrockCacheControl(providerInputMessages)
@@ -433,15 +463,19 @@ function synthesizeSealedUsage(
433
463
  const inputTokens =
434
464
  (countSealedTokens(context, metadata, prompt) ?? 0) +
435
465
  sealedInstructionOverhead(context, metadata);
436
- chunk.usage_metadata = {
466
+ const usageMetadata = {
437
467
  input_tokens: inputTokens,
438
468
  output_tokens: outputTokens,
439
469
  total_tokens: inputTokens + outputTokens,
440
470
  };
441
- chunk.response_metadata = {
471
+ chunk.usage_metadata = usageMetadata;
472
+ chunk.lc_kwargs.usage_metadata = usageMetadata;
473
+ const responseMetadata = {
442
474
  ...chunk.response_metadata,
443
475
  estimated_usage: true,
444
476
  };
477
+ chunk.response_metadata = responseMetadata;
478
+ chunk.lc_kwargs.response_metadata = responseMetadata;
445
479
  }
446
480
 
447
481
  function getMessageText(chunk: AIMessageChunk): string {
@@ -646,21 +680,81 @@ function appendStreamChunk({
646
680
  * Pass an `onChunk` callback to override this with custom chunk processing
647
681
  * (e.g. summarization delta events).
648
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
+ */
649
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(
650
750
  {
651
751
  model,
652
752
  messages,
653
753
  provider,
654
754
  context,
655
755
  onChunk,
656
- }: {
657
- model: t.ChatModel;
658
- messages: BaseMessage[];
659
- provider: Providers;
660
- context?: InvokeContext;
661
- onChunk?: OnChunk;
662
- },
663
- config?: RunnableConfig
756
+ }: AttemptInvokeParams,
757
+ config: RunnableConfig
664
758
  ): Promise<Partial<t.BaseGraphState>> {
665
759
  /**
666
760
  * Pull the run-scoped tool output registry off the graph (when one
@@ -676,12 +770,8 @@ export async function attemptInvoke(
676
770
  callOptions: config,
677
771
  });
678
772
  const registry = context?.getOrCreateToolOutputRegistry();
679
- const runId = config?.configurable?.run_id as string | undefined;
680
- const annotated = annotateMessagesForLLM(
681
- invocationMessages,
682
- registry,
683
- runId
684
- );
773
+ const runId = config.configurable?.run_id as string | undefined;
774
+ const annotated = annotateMessagesForLLM(invocationMessages, registry, runId);
685
775
  /**
686
776
  * Keyed on the provider ACTUALLY serving this call, not the agent's primary.
687
777
  * `createCallModel` normalizes for the primary, but `tryFallbackProviders`
@@ -739,14 +829,6 @@ export async function attemptInvoke(
739
829
  * wrong for fallback-served calls — or `ls_provider` — which derived
740
830
  * providers inherit from their base class.
741
831
  */
742
- config = {
743
- ...config,
744
- metadata: {
745
- ...(config?.metadata ?? {}),
746
- [Constants.INVOKED_PROVIDER]: provider,
747
- },
748
- };
749
-
750
832
  if (model.stream) {
751
833
  /**
752
834
  * Observed, not dictated. `handleChatModelStart` fires with the chat
@@ -778,10 +860,40 @@ export async function attemptInvoke(
778
860
  let preempted = false;
779
861
  const registeredStreamHandler =
780
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
+ };
781
877
 
782
878
  if (onChunk) {
879
+ const attemptMetadata = config.metadata as
880
+ | Record<string, unknown>
881
+ | undefined;
783
882
  for await (const chunk of stream) {
784
- 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);
785
897
  finalChunk = appendStreamChunk({
786
898
  current: finalChunk,
787
899
  next: chunk,
@@ -792,6 +904,7 @@ export async function attemptInvoke(
792
904
  const metadata = config.metadata as Record<string, unknown> | undefined;
793
905
  const streamHandler = new ChatModelStreamHandler();
794
906
  for await (const chunk of stream) {
907
+ throwIfBreakerTripped();
795
908
  const handlingChunk = getStreamHandlingChunk({
796
909
  current: finalChunk,
797
910
  next: chunk,
@@ -804,6 +917,23 @@ export async function attemptInvoke(
804
917
  metadata,
805
918
  context
806
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
+ });
807
937
  }
808
938
  finalChunk = appendStreamChunk({
809
939
  current: finalChunk,
@@ -833,17 +963,40 @@ export async function attemptInvoke(
833
963
  }
834
964
  } else {
835
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;
836
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
+ }
837
986
  const handlingChunk = getStreamHandlingChunk({
838
987
  current: finalChunk,
839
988
  next: chunk,
840
989
  provider,
841
990
  });
842
991
  if (handlingChunk != null && handlingChunk !== chunk) {
992
+ redispatchMetadata ??= {
993
+ ...(metadata ?? {}),
994
+ [STREAM_LIMIT_REDISPATCH_KEY]: true,
995
+ };
843
996
  await registeredStreamHandler.handle(
844
997
  GraphEvents.CHAT_MODEL_STREAM,
845
998
  { chunk: handlingChunk },
846
- metadata,
999
+ redispatchMetadata,
847
1000
  context
848
1001
  );
849
1002
  }
@@ -860,10 +1013,12 @@ export async function attemptInvoke(
860
1013
  }
861
1014
 
862
1015
  if (preempted && finalChunk != null) {
863
- finalChunk.response_metadata = {
1016
+ const responseMetadata = {
864
1017
  ...finalChunk.response_metadata,
865
1018
  preempted: true,
866
1019
  };
1020
+ finalChunk.response_metadata = responseMetadata;
1021
+ finalChunk.lc_kwargs.response_metadata = responseMetadata;
867
1022
  await endSealedModelRun(
868
1023
  context,
869
1024
  finalChunk,
@@ -983,6 +1138,7 @@ export async function tryFallbackProviders({
983
1138
  primaryError,
984
1139
  context,
985
1140
  onChunk,
1141
+ streamLimitState,
986
1142
  overflowContext,
987
1143
  prepareProviderMessages,
988
1144
  }: {
@@ -993,6 +1149,9 @@ export async function tryFallbackProviders({
993
1149
  primaryError: unknown;
994
1150
  context?: InvokeContext;
995
1151
  onChunk?: OnChunk;
1152
+ /** Accounting-lease owner forwarded to each fallback attempt (see
1153
+ * `AttemptInvokeParams.streamLimitState`). */
1154
+ streamLimitState?: StreamLimitState;
996
1155
  /**
997
1156
  * Prompt-size corroboration for signatures that are not self-describing.
998
1157
  * Vertex AI's overflow is a bare `400` with no reason, so without this a
@@ -1063,6 +1222,16 @@ export async function tryFallbackProviders({
1063
1222
  maxContextTokens: fb.maxContextTokens,
1064
1223
  config: fbConfig,
1065
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
+ }
1066
1235
  const result = await attemptInvoke(
1067
1236
  {
1068
1237
  model: fbModel as t.ChatModel,
@@ -1070,11 +1239,30 @@ export async function tryFallbackProviders({
1070
1239
  provider: fb.provider,
1071
1240
  context,
1072
1241
  onChunk,
1242
+ streamLimitState,
1073
1243
  },
1074
1244
  fbConfig
1075
1245
  );
1076
1246
  return result;
1077
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
+ }
1078
1266
  lastError = e;
1079
1267
  const fallbackOverflowContext: ContextOverflowContext = {
1080
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
+ });