@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.
- package/dist/cjs/graphs/Graph.cjs +248 -27
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
- package/dist/cjs/langfuseToolOutputTracing.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/init.cjs +1 -1
- package/dist/cjs/llm/invoke.cjs +160 -43
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +190 -13
- 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 +12 -3
- package/dist/cjs/messages/core.cjs +592 -27
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/run.cjs +7 -3
- 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 +105 -14
- 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 +77 -4
- 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 +17 -3
- 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 +2 -1
- 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 +247 -26
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
- package/dist/esm/langfuseToolOutputTracing.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/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +160 -43
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +192 -15
- 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 +8 -6
- package/dist/esm/messages/core.mjs +592 -28
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/run.mjs +7 -3
- 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 +105 -14
- 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 +77 -4
- 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 +17 -3
- 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 +2 -1
- 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/langfuseToolOutputTracing.d.ts +1 -0
- package/dist/types/llm/invoke.d.ts +26 -5
- package/dist/types/llm/openai/index.d.ts +3 -0
- package/dist/types/llm/streamLimits.d.ts +314 -0
- package/dist/types/messages/core.d.ts +11 -6
- 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/langfuseToolOutputTracing.ts +410 -14
- package/src/llm/bedrock/index.ts +22 -2
- package/src/llm/custom-chat-models.smoke.test.ts +747 -0
- package/src/llm/invoke.streamLimits.test.ts +142 -0
- package/src/llm/invoke.test.ts +187 -1
- package/src/llm/invoke.ts +231 -43
- package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
- package/src/llm/openai/index.ts +348 -31
- package/src/llm/openai/llm.spec.ts +107 -6
- package/src/llm/streamLimits.test.ts +450 -0
- package/src/llm/streamLimits.ts +1158 -0
- package/src/messages/core.ts +1290 -42
- package/src/messages/formatAgentMessages.test.ts +2623 -0
- package/src/run.ts +4 -0
- package/src/session/AgentSession.ts +5 -0
- package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
- package/src/specs/preemptSeal.test.ts +374 -5
- 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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `attemptInvoke`'s local stream branch (no registered SDK dispatcher) must
|
|
3
|
+
* charge the event budget even for chunks that produce no handling chunk:
|
|
4
|
+
* a pure OpenRouter reasoning-replay chunk is skipped for content handling,
|
|
5
|
+
* and in this branch no `streamEvents` consumer counts the wire event
|
|
6
|
+
* either, so a looping replay stream would otherwise bypass an enabled
|
|
7
|
+
* `maxDeltaEventsPerTurn` indefinitely.
|
|
8
|
+
*/
|
|
9
|
+
import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
|
|
10
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
11
|
+
import type { AgentContext } from '@/agents/AgentContext';
|
|
12
|
+
import type { StandardGraph } from '@/graphs';
|
|
13
|
+
import type * as t from '@/types';
|
|
14
|
+
import {
|
|
15
|
+
StreamLimitExceededError,
|
|
16
|
+
resolveStreamLimits,
|
|
17
|
+
} from '@/llm/streamLimits';
|
|
18
|
+
import { attemptInvoke } from '@/llm/invoke';
|
|
19
|
+
import { ContentTypes, Providers } from '@/common';
|
|
20
|
+
|
|
21
|
+
function createContext(
|
|
22
|
+
overrides: Partial<StandardGraph> = {}
|
|
23
|
+
): StandardGraph {
|
|
24
|
+
const runSteps = new Map<string, t.RunStep>();
|
|
25
|
+
const stepIdsByKey = new Map<string, string>();
|
|
26
|
+
let stepCounter = 0;
|
|
27
|
+
|
|
28
|
+
const graph = {
|
|
29
|
+
config: { configurable: { user_id: 'user_1' }, metadata: { run_id: 'r1' } },
|
|
30
|
+
eagerEventToolExecution: undefined,
|
|
31
|
+
eagerEventToolExecutions: new Map(),
|
|
32
|
+
eagerEventToolCallChunks: new Map(),
|
|
33
|
+
eagerEventToolSuppressions: new Set<string>(),
|
|
34
|
+
handlerRegistry: undefined,
|
|
35
|
+
hookRegistry: undefined,
|
|
36
|
+
humanInTheLoop: undefined,
|
|
37
|
+
toolOutputReferences: undefined,
|
|
38
|
+
sessions: new Map(),
|
|
39
|
+
toolCallStepIds: new Map(),
|
|
40
|
+
messageIdsByStepKey: new Map(),
|
|
41
|
+
messageStepHasToolCalls: new Map(),
|
|
42
|
+
prelimMessageIdsByStepKey: new Map([['step-key', 'msg_1']]),
|
|
43
|
+
getAgentContext: jest.fn(
|
|
44
|
+
(): Partial<AgentContext> => ({
|
|
45
|
+
provider: Providers.OPENAI,
|
|
46
|
+
reasoningKey: 'reasoning_content',
|
|
47
|
+
currentTokenType: ContentTypes.TEXT,
|
|
48
|
+
toolDefinitions: [],
|
|
49
|
+
graphTools: [],
|
|
50
|
+
agentId: 'agent_1',
|
|
51
|
+
})
|
|
52
|
+
),
|
|
53
|
+
getOrCreateToolOutputRegistry: jest.fn(() => undefined),
|
|
54
|
+
shouldPreemptStream: jest.fn(() => false),
|
|
55
|
+
claimPreemptSeal: jest.fn(() => false),
|
|
56
|
+
getStepKey: jest.fn(() => 'step-key'),
|
|
57
|
+
getStepIdByKey: jest.fn((stepKey: string) => {
|
|
58
|
+
const stepId = stepIdsByKey.get(stepKey);
|
|
59
|
+
if (stepId == null) {
|
|
60
|
+
throw new Error('no current step');
|
|
61
|
+
}
|
|
62
|
+
return stepId;
|
|
63
|
+
}),
|
|
64
|
+
getRunStep: jest.fn((stepId: string) => runSteps.get(stepId)),
|
|
65
|
+
dispatchRunStep: jest.fn(async (stepKey: string, details: unknown) => {
|
|
66
|
+
const id = `step_${++stepCounter}`;
|
|
67
|
+
stepIdsByKey.set(stepKey, id);
|
|
68
|
+
runSteps.set(id, {
|
|
69
|
+
id,
|
|
70
|
+
type: (details as { type: t.RunStep['type'] }).type,
|
|
71
|
+
stepDetails: details as t.RunStep['stepDetails'],
|
|
72
|
+
} as t.RunStep);
|
|
73
|
+
return id;
|
|
74
|
+
}),
|
|
75
|
+
dispatchRunStepDelta: jest.fn(async () => undefined),
|
|
76
|
+
dispatchMessageDelta: jest.fn(async () => undefined),
|
|
77
|
+
dispatchReasoningDelta: jest.fn(async () => undefined),
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
return graph as unknown as StandardGraph;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const replayChunk = (): AIMessageChunk =>
|
|
84
|
+
new AIMessageChunk({
|
|
85
|
+
content: 'abc',
|
|
86
|
+
additional_kwargs: { reasoning_details: [{ type: 'reasoning.text' }] },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function createReplayModel(replays: number): t.ChatModel {
|
|
90
|
+
return {
|
|
91
|
+
stream: async function* stream(): AsyncGenerator<AIMessageChunk> {
|
|
92
|
+
yield new AIMessageChunk({ content: 'abc' });
|
|
93
|
+
for (let i = 0; i < replays; i++) {
|
|
94
|
+
yield replayChunk();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
} as unknown as t.ChatModel;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('attemptInvoke local branch stream limits', () => {
|
|
101
|
+
it('charges skipped OpenRouter replay chunks against the event budget', async () => {
|
|
102
|
+
const context = createContext({
|
|
103
|
+
streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 3 }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let caught: unknown;
|
|
107
|
+
try {
|
|
108
|
+
await attemptInvoke(
|
|
109
|
+
{
|
|
110
|
+
model: createReplayModel(10),
|
|
111
|
+
messages: [new HumanMessage('hi')],
|
|
112
|
+
provider: Providers.OPENROUTER,
|
|
113
|
+
context: context as unknown as Parameters<
|
|
114
|
+
typeof attemptInvoke
|
|
115
|
+
>[0]['context'],
|
|
116
|
+
},
|
|
117
|
+
{ metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
|
|
118
|
+
);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
caught = error;
|
|
121
|
+
}
|
|
122
|
+
expect(caught).toBeInstanceOf(StreamLimitExceededError);
|
|
123
|
+
expect((caught as StreamLimitExceededError).kind).toBe('delta_events');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('leaves replay streams unbounded when the cap is disabled', async () => {
|
|
127
|
+
const context = createContext({ streamLimits: resolveStreamLimits() });
|
|
128
|
+
|
|
129
|
+
const result = await attemptInvoke(
|
|
130
|
+
{
|
|
131
|
+
model: createReplayModel(10),
|
|
132
|
+
messages: [new HumanMessage('hi')],
|
|
133
|
+
provider: Providers.OPENROUTER,
|
|
134
|
+
context: context as unknown as Parameters<
|
|
135
|
+
typeof attemptInvoke
|
|
136
|
+
>[0]['context'],
|
|
137
|
+
},
|
|
138
|
+
{ metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
|
|
139
|
+
);
|
|
140
|
+
expect(result.messages).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/llm/invoke.test.ts
CHANGED
|
@@ -18,6 +18,8 @@ import type * as t from '@/types';
|
|
|
18
18
|
import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
|
|
19
19
|
import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
|
|
20
20
|
import { convertMessageContentToParts } from '@/llm/google/utils/common';
|
|
21
|
+
import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
|
|
22
|
+
import { StreamLimitExceededError } from '@/llm/streamLimits';
|
|
21
23
|
import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
|
|
22
24
|
import { toLangChainContent } from '@/messages/langchain';
|
|
23
25
|
import { Constants, Providers } from '@/common';
|
|
@@ -555,7 +557,7 @@ describe('attemptInvoke applies lazy ref annotation', () => {
|
|
|
555
557
|
responses.model.defaultOptions = { tools: [] };
|
|
556
558
|
responses.model._useResponsesApi = (options) =>
|
|
557
559
|
Array.isArray((options as { tools?: unknown[] } | undefined)?.tools) &&
|
|
558
|
-
(
|
|
560
|
+
(options as { tools: unknown[] }).tools.length > 0;
|
|
559
561
|
|
|
560
562
|
await attemptInvoke(
|
|
561
563
|
{
|
|
@@ -827,6 +829,190 @@ describe('tryFallbackProviders applies the same lazy annotation transform', () =
|
|
|
827
829
|
jest.dontMock('@/llm/init');
|
|
828
830
|
jest.resetModules();
|
|
829
831
|
});
|
|
832
|
+
|
|
833
|
+
it('stops draining a cancellation-ignoring stream once the breaker trips', async () => {
|
|
834
|
+
const abort = new AbortController();
|
|
835
|
+
/** Statically imported class: neighboring tests reset the module
|
|
836
|
+
* registry, and a dynamically imported instance would fail the
|
|
837
|
+
* statically-linked drain loop's instanceof check. */
|
|
838
|
+
const trip = new StreamLimitExceededError({
|
|
839
|
+
kind: 'tool_call_args',
|
|
840
|
+
limit: 10,
|
|
841
|
+
observed: 11,
|
|
842
|
+
toolName: 'db_query',
|
|
843
|
+
});
|
|
844
|
+
let yields = 0;
|
|
845
|
+
const model: StubModel = {
|
|
846
|
+
stream: jest.fn(async function* (): AsyncGenerator<AIMessageChunk> {
|
|
847
|
+
for (let i = 0; i < 5; i++) {
|
|
848
|
+
yields += 1;
|
|
849
|
+
yield new AIMessageChunk({ content: `c${i}` });
|
|
850
|
+
}
|
|
851
|
+
}),
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
/** The stub ignores the abort signal entirely; only the per-chunk
|
|
855
|
+
* breaker check in the drain loop can stop it. */
|
|
856
|
+
await expect(
|
|
857
|
+
attemptInvoke(
|
|
858
|
+
{
|
|
859
|
+
model: model as t.ChatModel,
|
|
860
|
+
messages: [new HumanMessage('hi')],
|
|
861
|
+
provider: Providers.ANTHROPIC,
|
|
862
|
+
onChunk: () => {
|
|
863
|
+
abort.abort(trip);
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
{ signal: abort.signal }
|
|
867
|
+
)
|
|
868
|
+
).rejects.toBe(trip);
|
|
869
|
+
expect(yields).toBeLessThanOrEqual(2);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('rejects before invoking a fallback when the breaker tripped during preparation', async () => {
|
|
873
|
+
const abort = new AbortController();
|
|
874
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
875
|
+
await jest.isolateModulesAsync(async () => {
|
|
876
|
+
jest.doMock('@/llm/init', () => ({
|
|
877
|
+
initializeModel: (): unknown => model,
|
|
878
|
+
}));
|
|
879
|
+
const { tryFallbackProviders: freshTry } = (await import(
|
|
880
|
+
'@/llm/invoke'
|
|
881
|
+
)) as { tryFallbackProviders: typeof tryFallbackProviders };
|
|
882
|
+
const { StreamLimitExceededError: FreshStreamLimitError } = (await import(
|
|
883
|
+
'@/llm/streamLimits'
|
|
884
|
+
)) as typeof import('@/llm/streamLimits');
|
|
885
|
+
const trip = new FreshStreamLimitError({
|
|
886
|
+
kind: 'tool_call_args',
|
|
887
|
+
limit: 10,
|
|
888
|
+
observed: 11,
|
|
889
|
+
toolName: 'db_query',
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
/** The sibling's trip lands while fallback preparation is awaited; the
|
|
893
|
+
* fallback must not be invoked at all — a provider that ignores the
|
|
894
|
+
* aborted signal and SUCCEEDS would resolve a run that must reject. */
|
|
895
|
+
await expect(
|
|
896
|
+
freshTry({
|
|
897
|
+
fallbacks: [{ provider: Providers.ANTHROPIC }],
|
|
898
|
+
messages: [
|
|
899
|
+
new ToolMessage({
|
|
900
|
+
name: 'echo',
|
|
901
|
+
tool_call_id: 'tc1',
|
|
902
|
+
status: 'success',
|
|
903
|
+
content: 'output',
|
|
904
|
+
}),
|
|
905
|
+
],
|
|
906
|
+
primaryError: new Error('primary failed'),
|
|
907
|
+
config: { signal: abort.signal },
|
|
908
|
+
prepareProviderMessages: async ({ messages: preparedMessages }) => {
|
|
909
|
+
abort.abort(trip);
|
|
910
|
+
return preparedMessages;
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
).rejects.toBe(trip);
|
|
914
|
+
});
|
|
915
|
+
expect(invokeMessages).toHaveLength(0);
|
|
916
|
+
|
|
917
|
+
jest.dontMock('@/llm/init');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('neutralizes preempted Responses history before an OpenAI Chat fallback', async () => {
|
|
921
|
+
const reasoning = {
|
|
922
|
+
id: 'rs_fallback',
|
|
923
|
+
type: 'reasoning',
|
|
924
|
+
status: 'completed',
|
|
925
|
+
summary: [],
|
|
926
|
+
encrypted_content: 'opaque-fallback-reasoning',
|
|
927
|
+
};
|
|
928
|
+
const toolOutput = {
|
|
929
|
+
id: 'ci_fallback',
|
|
930
|
+
type: 'code_interpreter_call',
|
|
931
|
+
status: 'completed',
|
|
932
|
+
code: 'print("fallback result")',
|
|
933
|
+
outputs: [{ type: 'logs', logs: 'fallback result' }],
|
|
934
|
+
};
|
|
935
|
+
const translatedMessage = new AIMessage({
|
|
936
|
+
content: [{ type: 'text', text: 'Partial answer.' }],
|
|
937
|
+
additional_kwargs: {
|
|
938
|
+
reasoning,
|
|
939
|
+
tool_outputs: [toolOutput],
|
|
940
|
+
},
|
|
941
|
+
response_metadata: { model_provider: 'openai' },
|
|
942
|
+
});
|
|
943
|
+
const message = new AIMessage({
|
|
944
|
+
contentBlocks: [
|
|
945
|
+
...translatedMessage.contentBlocks,
|
|
946
|
+
{
|
|
947
|
+
type: 'non_standard',
|
|
948
|
+
value: {
|
|
949
|
+
id: 'rs_bare_fallback',
|
|
950
|
+
type: 'reasoning',
|
|
951
|
+
summary: [],
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
type: 'image',
|
|
956
|
+
mimeType: 'image/png',
|
|
957
|
+
data: 'AA==',
|
|
958
|
+
id: 'ig_fallback',
|
|
959
|
+
metadata: { status: 'completed' },
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
additional_kwargs: {
|
|
963
|
+
reasoning,
|
|
964
|
+
tool_outputs: [toolOutput],
|
|
965
|
+
},
|
|
966
|
+
response_metadata: {
|
|
967
|
+
model_provider: 'openai',
|
|
968
|
+
output_version: 'v1',
|
|
969
|
+
preempted: true,
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
973
|
+
model._useResponsesApi = () => false;
|
|
974
|
+
jest.doMock('@/llm/init', () => ({
|
|
975
|
+
initializeModel: (): unknown => model,
|
|
976
|
+
}));
|
|
977
|
+
jest.resetModules();
|
|
978
|
+
const { tryFallbackProviders: freshTry } = (await import(
|
|
979
|
+
'@/llm/invoke'
|
|
980
|
+
)) as { tryFallbackProviders: typeof tryFallbackProviders };
|
|
981
|
+
|
|
982
|
+
await freshTry({
|
|
983
|
+
fallbacks: [
|
|
984
|
+
{
|
|
985
|
+
provider: Providers.OPENAI,
|
|
986
|
+
clientOptions: { model: 'gpt-5.6' },
|
|
987
|
+
},
|
|
988
|
+
],
|
|
989
|
+
messages: [message],
|
|
990
|
+
primaryError: new Error('primary failed'),
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
const sent = invokeMessages[invokeMessages.length - 1][0] as AIMessage;
|
|
994
|
+
expect(sent).not.toBe(message);
|
|
995
|
+
expect(sent.content).toEqual([
|
|
996
|
+
{ type: 'text', text: 'Partial answer.' },
|
|
997
|
+
{ type: 'text', text: expect.stringContaining('fallback result') },
|
|
998
|
+
]);
|
|
999
|
+
expect(sent.additional_kwargs).toEqual({});
|
|
1000
|
+
expect(JSON.stringify(sent.toJSON())).not.toMatch(/rs_|ci_|ig_/);
|
|
1001
|
+
const chatPayload = _convertMessagesToOpenAIParams([sent], 'gpt-5.6');
|
|
1002
|
+
expect(chatPayload).toEqual([
|
|
1003
|
+
{
|
|
1004
|
+
role: 'assistant',
|
|
1005
|
+
content: sent.content,
|
|
1006
|
+
},
|
|
1007
|
+
]);
|
|
1008
|
+
expect(JSON.stringify(chatPayload)).not.toMatch(/rs_|ci_|ig_/);
|
|
1009
|
+
expect(JSON.stringify(message.toJSON())).toContain('rs_fallback');
|
|
1010
|
+
expect(JSON.stringify(message.toJSON())).toContain('ci_fallback');
|
|
1011
|
+
expect(JSON.stringify(message.toJSON())).toContain('ig_fallback');
|
|
1012
|
+
|
|
1013
|
+
jest.dontMock('@/llm/init');
|
|
1014
|
+
jest.resetModules();
|
|
1015
|
+
});
|
|
830
1016
|
});
|
|
831
1017
|
|
|
832
1018
|
describe('invocation attribution metadata', () => {
|