@librechat/agents 3.3.11 → 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.
- package/dist/cjs/graphs/Graph.cjs +246 -25
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +13 -2
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +146 -36
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +2 -2
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/streamLimits.cjs +723 -0
- package/dist/cjs/llm/streamLimits.cjs.map +1 -0
- package/dist/cjs/main.cjs +8 -0
- package/dist/cjs/run.cjs +6 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +4 -1
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +103 -12
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +157 -37
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +3 -2
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +4 -3
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +76 -3
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +3 -2
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
- package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/crw-search.cjs +3 -1
- package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
- package/dist/cjs/tools/search/firecrawl.cjs +7 -1
- package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-search.cjs +3 -1
- package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
- package/dist/cjs/tools/search/rerankers.cjs +26 -8
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +30 -10
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
- package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-search.cjs +3 -1
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +16 -2
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -0
- package/dist/cjs/utils/misc.cjs +12 -0
- package/dist/cjs/utils/misc.cjs.map +1 -1
- package/dist/cjs/utils/proxy.cjs +63 -0
- package/dist/cjs/utils/proxy.cjs.map +1 -0
- package/dist/esm/graphs/Graph.mjs +245 -24
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +13 -2
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +146 -36
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +2 -2
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/streamLimits.mjs +704 -0
- package/dist/esm/llm/streamLimits.mjs.map +1 -0
- package/dist/esm/main.mjs +4 -2
- package/dist/esm/run.mjs +6 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +4 -1
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +103 -12
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +157 -37
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +3 -2
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +4 -3
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +76 -3
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +3 -2
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/search/crw-scraper.mjs +7 -1
- package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/crw-search.mjs +3 -1
- package/dist/esm/tools/search/crw-search.mjs.map +1 -1
- package/dist/esm/tools/search/firecrawl.mjs +7 -1
- package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-search.mjs +3 -1
- package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
- package/dist/esm/tools/search/rerankers.mjs +26 -8
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +30 -10
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/serper-scraper.mjs +7 -1
- package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-search.mjs +3 -1
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +16 -2
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -0
- package/dist/esm/utils/misc.mjs +12 -1
- package/dist/esm/utils/misc.mjs.map +1 -1
- package/dist/esm/utils/proxy.mjs +62 -0
- package/dist/esm/utils/proxy.mjs.map +1 -0
- package/dist/types/graphs/Graph.d.ts +53 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/llm/invoke.d.ts +25 -4
- package/dist/types/llm/openai/index.d.ts +3 -0
- package/dist/types/llm/streamLimits.d.ts +314 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/summarization/node.d.ts +27 -2
- package/dist/types/tools/BashExecutor.d.ts +2 -2
- package/dist/types/tools/CodeExecutor.d.ts +3 -3
- package/dist/types/tools/ToolNode.d.ts +11 -1
- package/dist/types/tools/search/crw-scraper.d.ts +2 -0
- package/dist/types/tools/search/firecrawl.d.ts +2 -0
- package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
- package/dist/types/tools/search/rerankers.d.ts +9 -5
- package/dist/types/tools/search/serper-scraper.d.ts +2 -0
- package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
- package/dist/types/tools/search/types.d.ts +28 -33
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
- package/dist/types/types/graph.d.ts +7 -1
- package/dist/types/types/run.d.ts +46 -1
- package/dist/types/types/tools.d.ts +21 -0
- package/dist/types/utils/index.d.ts +1 -0
- package/dist/types/utils/misc.d.ts +7 -0
- package/dist/types/utils/proxy.d.ts +31 -0
- package/package.json +2 -1
- package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
- package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
- package/src/__tests__/stream.streamLimits.test.ts +1982 -0
- package/src/graphs/Graph.ts +302 -27
- package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
- package/src/index.ts +11 -0
- package/src/llm/bedrock/index.ts +22 -2
- package/src/llm/invoke.streamLimits.test.ts +142 -0
- package/src/llm/invoke.test.ts +89 -1
- package/src/llm/invoke.ts +197 -20
- package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
- package/src/llm/openai/index.ts +14 -6
- package/src/llm/streamLimits.test.ts +450 -0
- package/src/llm/streamLimits.ts +1158 -0
- package/src/run.ts +4 -0
- package/src/session/AgentSession.ts +5 -0
- package/src/stream.ts +141 -6
- package/src/summarization/__tests__/node.test.ts +269 -0
- package/src/summarization/chunkHandler.test.ts +196 -0
- package/src/summarization/node.ts +203 -6
- package/src/tools/BashExecutor.ts +4 -3
- package/src/tools/CodeExecutor.ts +5 -4
- package/src/tools/ProgrammaticToolCalling.ts +7 -5
- package/src/tools/ToolNode.ts +109 -6
- package/src/tools/ToolSearch.ts +4 -3
- package/src/tools/__tests__/BashExecutor.test.ts +2 -2
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
- package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
- package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
- package/src/tools/search/crw-scraper.ts +6 -0
- package/src/tools/search/crw-search.ts +6 -1
- package/src/tools/search/firecrawl.ts +6 -0
- package/src/tools/search/http-agent.test.ts +133 -0
- package/src/tools/search/keenable-scraper.ts +6 -0
- package/src/tools/search/keenable-search.ts +6 -1
- package/src/tools/search/rerankers.ts +36 -10
- package/src/tools/search/search.ts +29 -11
- package/src/tools/search/serper-scraper.ts +6 -0
- package/src/tools/search/tavily-scraper.ts +6 -0
- package/src/tools/search/tavily-search.ts +2 -0
- package/src/tools/search/tool.ts +16 -0
- package/src/tools/search/types.ts +31 -33
- package/src/tools/subagent/SubagentExecutor.ts +96 -3
- package/src/types/graph.ts +7 -0
- package/src/types/run.ts +49 -1
- package/src/types/tools.ts +21 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/misc.ts +19 -0
- package/src/utils/proxy.test.ts +176 -0
- 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 = (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/llm/openai/index.ts
CHANGED
|
@@ -245,7 +245,15 @@ type CacheableResponsePart = (
|
|
|
245
245
|
) & {
|
|
246
246
|
prompt_cache_breakpoint?: { mode: 'explicit' };
|
|
247
247
|
};
|
|
248
|
-
|
|
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
|
|
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
|
|
569
|
+
const cacheWriteTokens = usage?.input_tokens_details?.cache_write_tokens;
|
|
562
570
|
if (cacheWriteTokens == null) {
|
|
563
571
|
return response;
|
|
564
572
|
}
|