@librechat/agents 3.2.33 → 3.2.35
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/agents/AgentContext.cjs +47 -10
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +13 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +121 -3
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +21 -2
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +38 -2
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +6 -0
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +49 -8
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +48 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/vertexai/index.cjs +19 -0
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/messages/content.cjs +12 -14
- package/dist/cjs/messages/content.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +31 -13
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +7 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +20 -2
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +12 -1
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +41 -4
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/streamedToolCallSeals.cjs +30 -1
- package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +30 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +47 -10
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +13 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +122 -4
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +22 -3
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs +38 -3
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +6 -0
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +49 -8
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +48 -1
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/vertexai/index.mjs +19 -0
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/content.mjs +12 -15
- package/dist/esm/messages/content.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +31 -13
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +7 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +12 -1
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +41 -4
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/streamedToolCallSeals.mjs +25 -2
- package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +30 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +7 -3
- package/dist/types/common/enum.d.ts +13 -0
- package/dist/types/graphs/Graph.d.ts +8 -1
- package/dist/types/llm/bedrock/utils/index.d.ts +1 -1
- package/dist/types/llm/bedrock/utils/message_outputs.d.ts +9 -0
- package/dist/types/llm/invoke.d.ts +1 -1
- package/dist/types/llm/vertexai/index.d.ts +10 -0
- package/dist/types/messages/content.d.ts +5 -0
- package/dist/types/messages/prune.d.ts +4 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +8 -0
- package/dist/types/tools/streamedToolCallSeals.d.ts +5 -1
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
- package/dist/types/types/graph.d.ts +89 -3
- package/dist/types/types/run.d.ts +13 -0
- package/dist/types/types/tools.d.ts +10 -0
- package/dist/types/utils/tokens.d.ts +7 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +703 -0
- package/src/agents/AgentContext.ts +69 -6
- package/src/agents/__tests__/AgentContext.test.ts +6 -2
- package/src/common/enum.ts +13 -0
- package/src/graphs/Graph.ts +196 -0
- package/src/llm/bedrock/index.ts +40 -0
- package/src/llm/bedrock/streamSealDispatch.test.ts +158 -0
- package/src/llm/bedrock/utils/index.ts +1 -0
- package/src/llm/bedrock/utils/message_outputs.test.ts +85 -0
- package/src/llm/bedrock/utils/message_outputs.ts +43 -0
- package/src/llm/google/utils/common.test.ts +64 -0
- package/src/llm/google/utils/common.ts +18 -0
- package/src/llm/invoke.test.ts +79 -1
- package/src/llm/invoke.ts +58 -4
- package/src/llm/openai/index.ts +95 -1
- package/src/llm/openai/sequentialToolCallSeals.test.ts +199 -0
- package/src/llm/vertexai/index.ts +31 -0
- package/src/llm/vertexai/sealStreamedToolCalls.test.ts +88 -0
- package/src/llm/vertexai/streamSealDispatch.test.ts +148 -0
- package/src/messages/content.ts +24 -32
- package/src/messages/prune.ts +39 -2
- package/src/run.ts +5 -0
- package/src/scripts/subagent-usage-sink.ts +176 -0
- package/src/specs/context-accuracy.live.test.ts +409 -0
- package/src/specs/context-usage-event.test.ts +117 -0
- package/src/specs/context-usage.live.test.ts +297 -0
- package/src/specs/prune.test.ts +51 -1
- package/src/specs/subagent.test.ts +124 -1
- package/src/stream.ts +40 -6
- package/src/summarization/__tests__/node.test.ts +60 -1
- package/src/summarization/node.ts +20 -1
- package/src/tools/ToolNode.ts +85 -3
- package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +368 -0
- package/src/tools/streamedToolCallSeals.ts +37 -9
- package/src/tools/subagent/SubagentExecutor.ts +221 -3
- package/src/types/graph.ts +94 -1
- package/src/types/run.ts +13 -0
- package/src/types/tools.ts +10 -0
- package/src/utils/__tests__/apportion.test.ts +32 -0
- package/src/utils/tokens.ts +33 -0
package/src/llm/openai/index.ts
CHANGED
|
@@ -34,6 +34,10 @@ import type { ChatGeneration, ChatResult } from '@langchain/core/outputs';
|
|
|
34
34
|
import type { ChatXAIInput } from '@langchain/xai';
|
|
35
35
|
import type * as t from '@langchain/openai';
|
|
36
36
|
import type { HeaderValue, HeadersLike } from './types';
|
|
37
|
+
import {
|
|
38
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
39
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER,
|
|
40
|
+
} from '@/tools/streamedToolCallSeals';
|
|
37
41
|
import { isReasoningModel, _convertMessagesToOpenAIParams } from './utils';
|
|
38
42
|
|
|
39
43
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
@@ -670,6 +674,69 @@ export class CustomAzureOpenAIClient extends AzureOpenAIClient {
|
|
|
670
674
|
}
|
|
671
675
|
}
|
|
672
676
|
|
|
677
|
+
const OFFICIAL_OPENAI_BASE_URL_PATTERN = /^https:\/\/api\.openai\.com(\/|$)/;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Official OpenAI (api.openai.com) and Azure OpenAI Chat Completions streams
|
|
681
|
+
* emit tool-call deltas strictly sequentially by index: once a delta for a
|
|
682
|
+
* later index appears, a prior index's arguments never change. Stamping this
|
|
683
|
+
* adapter lets the stream handler seal a prior call for eager execution the
|
|
684
|
+
* moment the next call begins. OpenAI-compatible endpoints (custom baseURL)
|
|
685
|
+
* must NOT be stamped — e.g. live Kimi/Moonshot streams revise prior-index
|
|
686
|
+
* args after advancing — so callers gate on the wire endpoint, not the class.
|
|
687
|
+
*/
|
|
688
|
+
function stampSequentialStreamedToolCallAdapter(
|
|
689
|
+
message: BaseMessageChunk
|
|
690
|
+
): BaseMessageChunk {
|
|
691
|
+
if (
|
|
692
|
+
message instanceof AIMessageChunk &&
|
|
693
|
+
(message.tool_call_chunks?.length ?? 0) > 0
|
|
694
|
+
) {
|
|
695
|
+
message.response_metadata = {
|
|
696
|
+
...message.response_metadata,
|
|
697
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
698
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
return message;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function isOfficialOpenAIBaseURL(baseURL: string | null | undefined): boolean {
|
|
705
|
+
// The OpenAI SDK falls back to OPENAI_BASE_URL when the client has no
|
|
706
|
+
// explicit baseURL, so an unset constructor value can still route to an
|
|
707
|
+
// OpenAI-compatible endpoint.
|
|
708
|
+
const effectiveBaseURL =
|
|
709
|
+
baseURL != null && baseURL !== '' ? baseURL : process.env.OPENAI_BASE_URL;
|
|
710
|
+
if (effectiveBaseURL == null || effectiveBaseURL === '') {
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
return OFFICIAL_OPENAI_BASE_URL_PATTERN.test(effectiveBaseURL);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const AZURE_FIRST_PARTY_BASE_PATH_PATTERN =
|
|
717
|
+
/^https:\/\/[^/]+\.(openai\.azure\.com|cognitiveservices\.azure\.com|api\.cognitive\.microsoft\.com)(:\d+)?(\/|$)/;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Azure OpenAI is first-party when requests resolve to an instance-name
|
|
721
|
+
* endpoint or an *.openai.azure.com / *.cognitiveservices.azure.com /
|
|
722
|
+
* regional *.api.cognitive.microsoft.com base path. A custom
|
|
723
|
+
* `clientConfig.baseURL` or a non-Azure `azureOpenAIBasePath` routes through
|
|
724
|
+
* a proxy or Azure-compatible endpoint whose stream contract is unknown, so
|
|
725
|
+
* those are not stamped.
|
|
726
|
+
*/
|
|
727
|
+
function isFirstPartyAzureEndpoint(args: {
|
|
728
|
+
baseURL: string | null | undefined;
|
|
729
|
+
azureOpenAIBasePath: string | undefined;
|
|
730
|
+
}): boolean {
|
|
731
|
+
if (args.baseURL != null && args.baseURL !== '') {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
if (args.azureOpenAIBasePath == null || args.azureOpenAIBasePath === '') {
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
return AZURE_FIRST_PARTY_BASE_PATH_PATTERN.test(args.azureOpenAIBasePath);
|
|
738
|
+
}
|
|
739
|
+
|
|
673
740
|
class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
|
|
674
741
|
private includeReasoningContent?: boolean;
|
|
675
742
|
private includeReasoningDetails?: boolean;
|
|
@@ -721,7 +788,7 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
|
|
|
721
788
|
rawResponse: OpenAIClient.Chat.Completions.ChatCompletionChunk,
|
|
722
789
|
defaultRole?: OpenAIClient.Chat.ChatCompletionRole
|
|
723
790
|
): BaseMessageChunk {
|
|
724
|
-
|
|
791
|
+
const message = attachLibreChatDeltaFields(
|
|
725
792
|
super._convertCompletionsDeltaToBaseMessageChunk(
|
|
726
793
|
delta,
|
|
727
794
|
rawResponse,
|
|
@@ -729,6 +796,10 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
|
|
|
729
796
|
),
|
|
730
797
|
delta
|
|
731
798
|
);
|
|
799
|
+
if (isOfficialOpenAIBaseURL(this.clientConfig.baseURL)) {
|
|
800
|
+
return stampSequentialStreamedToolCallAdapter(message);
|
|
801
|
+
}
|
|
802
|
+
return message;
|
|
732
803
|
}
|
|
733
804
|
|
|
734
805
|
protected _convertCompletionsMessageToBaseMessage(
|
|
@@ -1090,6 +1161,29 @@ class LibreChatAzureOpenAICompletions extends OriginalAzureChatOpenAICompletions
|
|
|
1090
1161
|
return getGatedReasoningParams(this.model, this.reasoning, options);
|
|
1091
1162
|
}
|
|
1092
1163
|
|
|
1164
|
+
protected _convertCompletionsDeltaToBaseMessageChunk(
|
|
1165
|
+
delta: Record<string, unknown>,
|
|
1166
|
+
rawResponse: OpenAIClient.Chat.Completions.ChatCompletionChunk,
|
|
1167
|
+
defaultRole?: OpenAIClient.Chat.ChatCompletionRole
|
|
1168
|
+
): BaseMessageChunk {
|
|
1169
|
+
const message = super._convertCompletionsDeltaToBaseMessageChunk(
|
|
1170
|
+
delta,
|
|
1171
|
+
rawResponse,
|
|
1172
|
+
defaultRole
|
|
1173
|
+
);
|
|
1174
|
+
if (
|
|
1175
|
+
isFirstPartyAzureEndpoint({
|
|
1176
|
+
baseURL: this.clientConfig.baseURL,
|
|
1177
|
+
azureOpenAIBasePath: this.azureOpenAIBasePath,
|
|
1178
|
+
})
|
|
1179
|
+
) {
|
|
1180
|
+
// First-party Azure OpenAI: same sequential-by-index stream contract
|
|
1181
|
+
// as api.openai.com.
|
|
1182
|
+
return stampSequentialStreamedToolCallAdapter(message);
|
|
1183
|
+
}
|
|
1184
|
+
return message;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1093
1187
|
_getClientOptions(
|
|
1094
1188
|
options: OpenAICoreRequestOptions | undefined
|
|
1095
1189
|
): OpenAICoreRequestOptions {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { AIMessageChunk } from '@langchain/core/messages';
|
|
2
|
+
import { expect, test, describe, beforeEach, afterAll } from '@jest/globals';
|
|
3
|
+
import type { BaseMessageChunk } from '@langchain/core/messages';
|
|
4
|
+
import {
|
|
5
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
6
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER,
|
|
7
|
+
} from '@/tools/streamedToolCallSeals';
|
|
8
|
+
import { ChatOpenAI, AzureChatOpenAI } from './index';
|
|
9
|
+
|
|
10
|
+
type DeltaConverter = {
|
|
11
|
+
_convertCompletionsDeltaToBaseMessageChunk(
|
|
12
|
+
delta: Record<string, unknown>,
|
|
13
|
+
rawResponse: Record<string, unknown>
|
|
14
|
+
): BaseMessageChunk;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const rawResponse = {
|
|
18
|
+
id: 'chatcmpl-1',
|
|
19
|
+
object: 'chat.completion.chunk',
|
|
20
|
+
created: 1,
|
|
21
|
+
model: 'gpt-5.5',
|
|
22
|
+
choices: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const toolCallDelta = {
|
|
26
|
+
role: 'assistant',
|
|
27
|
+
tool_calls: [
|
|
28
|
+
{
|
|
29
|
+
index: 0,
|
|
30
|
+
id: 'call_1',
|
|
31
|
+
type: 'function',
|
|
32
|
+
function: { name: 'weather', arguments: '{"ci' },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function convertDelta(
|
|
38
|
+
model: unknown,
|
|
39
|
+
delta: Record<string, unknown>
|
|
40
|
+
): AIMessageChunk {
|
|
41
|
+
const converter = (model as { completions: DeltaConverter }).completions;
|
|
42
|
+
const message = converter._convertCompletionsDeltaToBaseMessageChunk(
|
|
43
|
+
delta,
|
|
44
|
+
rawResponse
|
|
45
|
+
);
|
|
46
|
+
expect(message).toBeInstanceOf(AIMessageChunk);
|
|
47
|
+
return message as AIMessageChunk;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function adapterOf(message: AIMessageChunk): unknown {
|
|
51
|
+
return (message.response_metadata as Record<string, unknown>)[
|
|
52
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('Chat Completions sequential tool-call seal stamping', () => {
|
|
57
|
+
// Both the implementation (OPENAI_BASE_URL fallback) and the Azure
|
|
58
|
+
// constructor (AZURE_OPENAI_BASE_PATH fallback) read the environment, so
|
|
59
|
+
// isolate these vars to keep the suite deterministic across shells.
|
|
60
|
+
const ISOLATED_ENV_VARS = ['OPENAI_BASE_URL', 'AZURE_OPENAI_BASE_PATH'];
|
|
61
|
+
const originalEnv = new Map(
|
|
62
|
+
ISOLATED_ENV_VARS.map((name) => [name, process.env[name]])
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
for (const name of ISOLATED_ENV_VARS) {
|
|
67
|
+
delete process.env[name];
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
for (const [name, value] of originalEnv) {
|
|
73
|
+
if (value == null) {
|
|
74
|
+
delete process.env[name];
|
|
75
|
+
} else {
|
|
76
|
+
process.env[name] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('stamps tool-call deltas when no baseURL is configured (official)', () => {
|
|
82
|
+
const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
|
|
83
|
+
const message = convertDelta(model, toolCallDelta);
|
|
84
|
+
expect(adapterOf(message)).toBe(
|
|
85
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('stamps tool-call deltas for an explicit api.openai.com baseURL', () => {
|
|
90
|
+
const model = new ChatOpenAI({
|
|
91
|
+
model: 'gpt-5.5',
|
|
92
|
+
apiKey: 'test',
|
|
93
|
+
configuration: { baseURL: 'https://api.openai.com/v1' },
|
|
94
|
+
});
|
|
95
|
+
const message = convertDelta(model, toolCallDelta);
|
|
96
|
+
expect(adapterOf(message)).toBe(
|
|
97
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('does not stamp tool-call deltas for OpenAI-compatible endpoints', () => {
|
|
102
|
+
const model = new ChatOpenAI({
|
|
103
|
+
model: 'kimi-k2',
|
|
104
|
+
apiKey: 'test',
|
|
105
|
+
configuration: { baseURL: 'https://api.moonshot.ai/v1' },
|
|
106
|
+
});
|
|
107
|
+
const message = convertDelta(model, toolCallDelta);
|
|
108
|
+
expect(adapterOf(message)).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('does not stamp text-only deltas', () => {
|
|
112
|
+
const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
|
|
113
|
+
const message = convertDelta(model, {
|
|
114
|
+
role: 'assistant',
|
|
115
|
+
content: 'hello',
|
|
116
|
+
});
|
|
117
|
+
expect(adapterOf(message)).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('does not stamp when OPENAI_BASE_URL routes to a compatible endpoint', () => {
|
|
121
|
+
process.env.OPENAI_BASE_URL = 'https://api.moonshot.ai/v1';
|
|
122
|
+
const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
|
|
123
|
+
const message = convertDelta(model, toolCallDelta);
|
|
124
|
+
expect(adapterOf(message)).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('stamps when OPENAI_BASE_URL points at api.openai.com', () => {
|
|
128
|
+
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
129
|
+
const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
|
|
130
|
+
const message = convertDelta(model, toolCallDelta);
|
|
131
|
+
expect(adapterOf(message)).toBe(
|
|
132
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('stamps Azure OpenAI tool-call deltas (first-party endpoint)', () => {
|
|
137
|
+
const model = new AzureChatOpenAI({
|
|
138
|
+
azureOpenAIApiKey: 'test',
|
|
139
|
+
azureOpenAIApiInstanceName: 'test-instance',
|
|
140
|
+
azureOpenAIApiDeploymentName: 'test-deployment',
|
|
141
|
+
azureOpenAIApiVersion: '2024-08-01-preview',
|
|
142
|
+
});
|
|
143
|
+
const message = convertDelta(model, toolCallDelta);
|
|
144
|
+
expect(adapterOf(message)).toBe(
|
|
145
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('stamps Azure deltas for an *.openai.azure.com base path', () => {
|
|
150
|
+
const model = new AzureChatOpenAI({
|
|
151
|
+
azureOpenAIApiKey: 'test',
|
|
152
|
+
azureOpenAIApiDeploymentName: 'test-deployment',
|
|
153
|
+
azureOpenAIApiVersion: '2024-08-01-preview',
|
|
154
|
+
azureOpenAIBasePath:
|
|
155
|
+
'https://test-resource.openai.azure.com/openai/deployments',
|
|
156
|
+
});
|
|
157
|
+
const message = convertDelta(model, toolCallDelta);
|
|
158
|
+
expect(adapterOf(message)).toBe(
|
|
159
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('stamps Azure deltas for a regional cognitive services base path', () => {
|
|
164
|
+
const model = new AzureChatOpenAI({
|
|
165
|
+
azureOpenAIApiKey: 'test',
|
|
166
|
+
azureOpenAIApiDeploymentName: 'test-deployment',
|
|
167
|
+
azureOpenAIApiVersion: '2024-08-01-preview',
|
|
168
|
+
azureOpenAIBasePath:
|
|
169
|
+
'https://westeurope.api.cognitive.microsoft.com/openai/deployments',
|
|
170
|
+
});
|
|
171
|
+
const message = convertDelta(model, toolCallDelta);
|
|
172
|
+
expect(adapterOf(message)).toBe(
|
|
173
|
+
OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('does not stamp Azure deltas routed through a proxy base path', () => {
|
|
178
|
+
const model = new AzureChatOpenAI({
|
|
179
|
+
azureOpenAIApiKey: 'test',
|
|
180
|
+
azureOpenAIApiDeploymentName: 'test-deployment',
|
|
181
|
+
azureOpenAIApiVersion: '2024-08-01-preview',
|
|
182
|
+
azureOpenAIBasePath: 'https://proxy.example.com/openai/deployments',
|
|
183
|
+
});
|
|
184
|
+
const message = convertDelta(model, toolCallDelta);
|
|
185
|
+
expect(adapterOf(message)).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('does not stamp Azure deltas with a custom client baseURL', () => {
|
|
189
|
+
const model = new AzureChatOpenAI({
|
|
190
|
+
azureOpenAIApiKey: 'test',
|
|
191
|
+
azureOpenAIApiInstanceName: 'test-instance',
|
|
192
|
+
azureOpenAIApiDeploymentName: 'test-deployment',
|
|
193
|
+
azureOpenAIApiVersion: '2024-08-01-preview',
|
|
194
|
+
configuration: { baseURL: 'https://gateway.example.com/azure' },
|
|
195
|
+
} as unknown as ConstructorParameters<typeof AzureChatOpenAI>[0]);
|
|
196
|
+
const message = convertDelta(model, toolCallDelta);
|
|
197
|
+
expect(adapterOf(message)).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -11,6 +11,11 @@ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager
|
|
|
11
11
|
import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
|
|
12
12
|
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
13
13
|
import type { GoogleThinkingConfig, VertexAIClientOptions } from '@/types';
|
|
14
|
+
import {
|
|
15
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
16
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
17
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
18
|
+
} from '@/tools/streamedToolCallSeals';
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* `@langchain/google-common`'s `_streamResponseChunks` emits usage on TWO
|
|
@@ -48,6 +53,31 @@ export function repairStreamUsageMetadata(
|
|
|
48
53
|
return generationInfoUsage;
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The Gemini API delivers function calls as complete objects — never as
|
|
58
|
+
* partial arg deltas. `@langchain/google-common` pre-parses each streamed
|
|
59
|
+
* functionCall part into `tool_calls` (invalid args land in
|
|
60
|
+
* `invalid_tool_calls` instead), so a chunk whose tool-call chunks all parsed
|
|
61
|
+
* cleanly is sealed on arrival for eager tool execution. Anything that fails
|
|
62
|
+
* the parse check is left unstamped and falls back to the lazy path.
|
|
63
|
+
*/
|
|
64
|
+
export function sealCompleteStreamedToolCalls(message: AIMessageChunk): void {
|
|
65
|
+
const chunkCount = message.tool_call_chunks?.length ?? 0;
|
|
66
|
+
if (
|
|
67
|
+
chunkCount === 0 ||
|
|
68
|
+
(message.invalid_tool_calls?.length ?? 0) > 0 ||
|
|
69
|
+
(message.tool_calls?.length ?? 0) !== chunkCount
|
|
70
|
+
) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
message.response_metadata = {
|
|
74
|
+
...message.response_metadata,
|
|
75
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
76
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
77
|
+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
51
81
|
type AdditionalKwargs =
|
|
52
82
|
| undefined
|
|
53
83
|
| (BaseMessage['additional_kwargs'] & {
|
|
@@ -503,6 +533,7 @@ export class ChatVertexAI extends ChatGoogle {
|
|
|
503
533
|
if (repaired !== chunk.message.usage_metadata) {
|
|
504
534
|
chunk.message.usage_metadata = repaired;
|
|
505
535
|
}
|
|
536
|
+
sealCompleteStreamedToolCalls(chunk.message);
|
|
506
537
|
}
|
|
507
538
|
yield chunk;
|
|
508
539
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test, describe } from '@jest/globals';
|
|
2
|
+
import { AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import {
|
|
4
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
5
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
6
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
7
|
+
} from '@/tools/streamedToolCallSeals';
|
|
8
|
+
import { sealCompleteStreamedToolCalls } from './index';
|
|
9
|
+
|
|
10
|
+
describe('sealCompleteStreamedToolCalls', () => {
|
|
11
|
+
test('stamps an on-arrival seal when every tool-call chunk parsed cleanly', () => {
|
|
12
|
+
const message = new AIMessageChunk({
|
|
13
|
+
content: '',
|
|
14
|
+
tool_call_chunks: [
|
|
15
|
+
{
|
|
16
|
+
id: 'call_1',
|
|
17
|
+
name: 'weather',
|
|
18
|
+
args: '{"city":"NYC"}',
|
|
19
|
+
type: 'tool_call_chunk',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
sealCompleteStreamedToolCalls(message);
|
|
25
|
+
|
|
26
|
+
expect(message.response_metadata).toMatchObject({
|
|
27
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
28
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
29
|
+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('stamps multi-call chunks when all calls are complete', () => {
|
|
34
|
+
const message = new AIMessageChunk({
|
|
35
|
+
content: '',
|
|
36
|
+
tool_call_chunks: [
|
|
37
|
+
{
|
|
38
|
+
id: 'call_1',
|
|
39
|
+
name: 'weather',
|
|
40
|
+
args: '{"city":"NYC"}',
|
|
41
|
+
type: 'tool_call_chunk',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'call_2',
|
|
45
|
+
name: 'stock',
|
|
46
|
+
args: '{"ticker":"CH"}',
|
|
47
|
+
type: 'tool_call_chunk',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
sealCompleteStreamedToolCalls(message);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
message.response_metadata[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]
|
|
56
|
+
).toEqual({ kind: 'all' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('leaves chunks without tool calls unstamped', () => {
|
|
60
|
+
const message = new AIMessageChunk({ content: 'hello' });
|
|
61
|
+
|
|
62
|
+
sealCompleteStreamedToolCalls(message);
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
message.response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
|
|
66
|
+
).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('leaves chunks with unparsable tool calls unstamped', () => {
|
|
70
|
+
// No id forces the parse into invalid_tool_calls.
|
|
71
|
+
const message = new AIMessageChunk({
|
|
72
|
+
content: '',
|
|
73
|
+
tool_call_chunks: [
|
|
74
|
+
{
|
|
75
|
+
name: 'weather',
|
|
76
|
+
args: '{"city":',
|
|
77
|
+
type: 'tool_call_chunk',
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
sealCompleteStreamedToolCalls(message);
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
message.response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
|
|
86
|
+
).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { expect, test, describe, jest } from '@jest/globals';
|
|
2
|
+
import { HumanMessage, AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
4
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
5
|
+
import {
|
|
6
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
7
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
8
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
9
|
+
} from '@/tools/streamedToolCallSeals';
|
|
10
|
+
import { ChatVertexAI } from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registered stream handlers consume chunks through `handleLLMNewToken`
|
|
14
|
+
* callback events. `@langchain/google-common` yields each chunk BEFORE
|
|
15
|
+
* dispatching that callback, and the generator only resumes (firing the
|
|
16
|
+
* callback) after this package's `_streamResponseChunks` override has
|
|
17
|
+
* stamped the seal on the same message object — so callback consumers must
|
|
18
|
+
* observe sealed chunks. This drives the real google-common stream loop and
|
|
19
|
+
* conversion with a stubbed connection to lock that ordering in.
|
|
20
|
+
*/
|
|
21
|
+
describe('Vertex stream seal dispatch', () => {
|
|
22
|
+
async function runStream(outputs: unknown[]): Promise<{
|
|
23
|
+
yielded: AIMessageChunk[];
|
|
24
|
+
dispatched: AIMessageChunk[];
|
|
25
|
+
}> {
|
|
26
|
+
const model = new ChatVertexAI({
|
|
27
|
+
model: 'gemini-2.5-flash',
|
|
28
|
+
authOptions: {
|
|
29
|
+
projectId: 'test-project',
|
|
30
|
+
credentials: { client_email: 'test@test', private_key: 'test' },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let index = 0;
|
|
35
|
+
const fakeStream = {
|
|
36
|
+
get streamDone(): boolean {
|
|
37
|
+
return index > outputs.length;
|
|
38
|
+
},
|
|
39
|
+
async nextChunk(): Promise<unknown> {
|
|
40
|
+
const output = index < outputs.length ? outputs[index] : null;
|
|
41
|
+
index += 1;
|
|
42
|
+
return output;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
(
|
|
46
|
+
model as unknown as {
|
|
47
|
+
streamedConnection: { request: unknown };
|
|
48
|
+
}
|
|
49
|
+
).streamedConnection.request = jest.fn(async () => ({ data: fakeStream }));
|
|
50
|
+
|
|
51
|
+
const dispatched: AIMessageChunk[] = [];
|
|
52
|
+
const runManager = {
|
|
53
|
+
handleCustomEvent: jest.fn(async () => undefined),
|
|
54
|
+
handleLLMNewToken: jest.fn(
|
|
55
|
+
async (
|
|
56
|
+
_token: string,
|
|
57
|
+
_idx?: unknown,
|
|
58
|
+
_runId?: unknown,
|
|
59
|
+
_parentRunId?: unknown,
|
|
60
|
+
_tags?: unknown,
|
|
61
|
+
fields?: { chunk?: ChatGenerationChunk }
|
|
62
|
+
) => {
|
|
63
|
+
const message = fields?.chunk?.message;
|
|
64
|
+
if (message instanceof AIMessageChunk) {
|
|
65
|
+
dispatched.push(message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
} as unknown as CallbackManagerForLLMRun;
|
|
70
|
+
|
|
71
|
+
const yielded: AIMessageChunk[] = [];
|
|
72
|
+
for await (const chunk of model._streamResponseChunks(
|
|
73
|
+
[new HumanMessage('hi')],
|
|
74
|
+
{} as Parameters<ChatVertexAI['_streamResponseChunks']>[1],
|
|
75
|
+
runManager
|
|
76
|
+
)) {
|
|
77
|
+
if (chunk.message instanceof AIMessageChunk) {
|
|
78
|
+
yielded.push(chunk.message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { yielded, dispatched };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('callback consumers receive function-call chunks already sealed', async () => {
|
|
85
|
+
const { yielded, dispatched } = await runStream([
|
|
86
|
+
{
|
|
87
|
+
candidates: [
|
|
88
|
+
{
|
|
89
|
+
content: {
|
|
90
|
+
role: 'model',
|
|
91
|
+
parts: [
|
|
92
|
+
{ functionCall: { name: 'weather', args: { city: 'NYC' } } },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
index: 0,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const metadataOf = (m: AIMessageChunk): Record<string, unknown> =>
|
|
102
|
+
m.response_metadata as Record<string, unknown>;
|
|
103
|
+
|
|
104
|
+
const yieldedCall = yielded.find(
|
|
105
|
+
(m) => (m.tool_call_chunks?.length ?? 0) > 0
|
|
106
|
+
);
|
|
107
|
+
expect(yieldedCall).toBeDefined();
|
|
108
|
+
expect(
|
|
109
|
+
metadataOf(yieldedCall!)[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]
|
|
110
|
+
).toEqual({ kind: 'all' });
|
|
111
|
+
|
|
112
|
+
const dispatchedCall = dispatched.find(
|
|
113
|
+
(m) => (m.tool_call_chunks?.length ?? 0) > 0
|
|
114
|
+
);
|
|
115
|
+
expect(dispatchedCall).toBeDefined();
|
|
116
|
+
expect(dispatchedCall!.tool_calls?.[0]).toMatchObject({
|
|
117
|
+
name: 'weather',
|
|
118
|
+
args: { city: 'NYC' },
|
|
119
|
+
});
|
|
120
|
+
expect(
|
|
121
|
+
metadataOf(dispatchedCall!)[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]
|
|
122
|
+
).toEqual({ kind: 'all' });
|
|
123
|
+
expect(
|
|
124
|
+
metadataOf(dispatchedCall!)[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
|
|
125
|
+
).toBe(GOOGLE_STREAMED_TOOL_CALL_ADAPTER);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('text-only chunks are not sealed on either path', async () => {
|
|
129
|
+
const { yielded, dispatched } = await runStream([
|
|
130
|
+
{
|
|
131
|
+
candidates: [
|
|
132
|
+
{
|
|
133
|
+
content: { role: 'model', parts: [{ text: 'hello' }] },
|
|
134
|
+
index: 0,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const hasSeal = (m: AIMessageChunk): boolean =>
|
|
141
|
+
(m.response_metadata as Record<string, unknown>)[
|
|
142
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY
|
|
143
|
+
] != null;
|
|
144
|
+
|
|
145
|
+
expect(yielded.some(hasSeal)).toBe(false);
|
|
146
|
+
expect(dispatched.some(hasSeal)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/messages/content.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BaseMessage,
|
|
3
|
+
MessageContentComplex,
|
|
4
|
+
} from '@langchain/core/messages';
|
|
2
5
|
import { ContentTypes } from '@/common';
|
|
3
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Whether {@link formatContentStrings} will flatten this message's content:
|
|
9
|
+
* a human/ai/system message whose content is an array of text-only blocks.
|
|
10
|
+
*/
|
|
11
|
+
export const isLegacyConvertible = (message: BaseMessage): boolean => {
|
|
12
|
+
const messageType = message.getType();
|
|
13
|
+
const isValidMessage =
|
|
14
|
+
messageType === 'human' || messageType === 'ai' || messageType === 'system';
|
|
15
|
+
if (!isValidMessage) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(message.content)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return message.content.every((block) => block.type === ContentTypes.TEXT);
|
|
22
|
+
};
|
|
23
|
+
|
|
4
24
|
/**
|
|
5
25
|
* Formats an array of messages for LangChain, making sure all content fields are strings
|
|
6
26
|
* @param {Array<HumanMessage | AIMessage | SystemMessage | ToolMessage>} payload - The array of messages to format.
|
|
@@ -13,42 +33,14 @@ export const formatContentStrings = (
|
|
|
13
33
|
const result: Array<BaseMessage> = [];
|
|
14
34
|
|
|
15
35
|
for (const message of payload) {
|
|
16
|
-
|
|
17
|
-
const isValidMessage =
|
|
18
|
-
messageType === 'human' ||
|
|
19
|
-
messageType === 'ai' ||
|
|
20
|
-
messageType === 'system';
|
|
21
|
-
|
|
22
|
-
if (!isValidMessage) {
|
|
23
|
-
result.push(message);
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// If content is already a string, add as-is
|
|
28
|
-
if (typeof message.content === 'string') {
|
|
29
|
-
result.push(message);
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// If content is not an array, add as-is
|
|
34
|
-
if (!Array.isArray(message.content)) {
|
|
35
|
-
result.push(message);
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Check if all content blocks are text type
|
|
40
|
-
const allTextBlocks = message.content.every(
|
|
41
|
-
(block) => block.type === ContentTypes.TEXT
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
// Only convert to string if all blocks are text type
|
|
45
|
-
if (!allTextBlocks) {
|
|
36
|
+
if (!isLegacyConvertible(message)) {
|
|
46
37
|
result.push(message);
|
|
47
38
|
continue;
|
|
48
39
|
}
|
|
49
40
|
|
|
50
41
|
// Reduce text types to a single string
|
|
51
|
-
const
|
|
42
|
+
const blocks = message.content as MessageContentComplex[];
|
|
43
|
+
const content = blocks.reduce((acc, curr) => {
|
|
52
44
|
if (curr.type === ContentTypes.TEXT) {
|
|
53
45
|
return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
|
|
54
46
|
}
|