@librechat/agents 3.2.56 → 3.2.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +7 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +7 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +172 -0
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
  9. package/dist/cjs/run.cjs +6 -2
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/stream.cjs +3 -2
  12. package/dist/cjs/stream.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +19 -5
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  16. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  17. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  18. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  19. package/dist/cjs/tools/search/search.cjs +30 -4
  20. package/dist/cjs/tools/search/search.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs +14 -6
  22. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  23. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  24. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  25. package/dist/cjs/utils/title.cjs +9 -9
  26. package/dist/cjs/utils/title.cjs.map +1 -1
  27. package/dist/esm/agents/AgentContext.mjs +7 -1
  28. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  29. package/dist/esm/graphs/Graph.mjs +7 -2
  30. package/dist/esm/graphs/Graph.mjs.map +1 -1
  31. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  32. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  33. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  34. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  35. package/dist/esm/run.mjs +6 -2
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +3 -2
  38. package/dist/esm/stream.mjs.map +1 -1
  39. package/dist/esm/tools/ToolNode.mjs +19 -5
  40. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  41. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  42. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  43. package/dist/esm/tools/search/rerankers.mjs +28 -11
  44. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  45. package/dist/esm/tools/search/search.mjs +30 -4
  46. package/dist/esm/tools/search/search.mjs.map +1 -1
  47. package/dist/esm/tools/search/tool.mjs +14 -6
  48. package/dist/esm/tools/search/tool.mjs.map +1 -1
  49. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  50. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  51. package/dist/esm/utils/title.mjs +9 -9
  52. package/dist/esm/utils/title.mjs.map +1 -1
  53. package/dist/types/graphs/Graph.d.ts +1 -0
  54. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  55. package/dist/types/run.d.ts +1 -0
  56. package/dist/types/tools/ToolNode.d.ts +11 -1
  57. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  58. package/dist/types/tools/search/rerankers.d.ts +7 -2
  59. package/dist/types/tools/search/types.d.ts +38 -1
  60. package/dist/types/types/graph.d.ts +22 -0
  61. package/dist/types/types/run.d.ts +9 -0
  62. package/dist/types/types/tools.d.ts +6 -0
  63. package/package.json +1 -1
  64. package/src/__tests__/stream.eagerEventExecution.test.ts +44 -0
  65. package/src/agents/AgentContext.ts +9 -0
  66. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  67. package/src/graphs/Graph.ts +31 -3
  68. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  69. package/src/langfuseToolOutputTracing.ts +11 -0
  70. package/src/langfuseTraceShaping.ts +280 -0
  71. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  72. package/src/run.ts +7 -3
  73. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  74. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  75. package/src/stream.ts +10 -2
  76. package/src/tools/ToolNode.ts +28 -4
  77. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  78. package/src/tools/__tests__/ToolNode.session.test.ts +160 -0
  79. package/src/tools/__tests__/hitl.test.ts +85 -0
  80. package/src/tools/search/jina-reranker.test.ts +70 -1
  81. package/src/tools/search/keenable-search.ts +98 -0
  82. package/src/tools/search/keenable.test.ts +183 -0
  83. package/src/tools/search/rerankers.ts +41 -4
  84. package/src/tools/search/search.ts +58 -2
  85. package/src/tools/search/source-processing.test.ts +86 -0
  86. package/src/tools/search/tool.ts +29 -4
  87. package/src/tools/search/types.ts +42 -1
  88. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  89. package/src/types/graph.ts +22 -0
  90. package/src/types/run.ts +9 -0
  91. package/src/types/tools.ts +6 -0
  92. package/src/utils/title.ts +9 -9
@@ -292,6 +292,50 @@ describe('buildChildInputs', () => {
292
292
  expect(result.toolDefinitions).toBeUndefined();
293
293
  });
294
294
 
295
+ it('scrubs INHERITED graphTools on self-spawn (parent-spread config) but keeps an explicit child config’s own', () => {
296
+ const askLikeTool = { name: 'ask_user_question' } as unknown as NonNullable<
297
+ AgentInputs['graphTools']
298
+ >[number];
299
+ const inputsWithGraphTools: AgentInputs = {
300
+ ...parentAgentInputs,
301
+ graphTools: [askLikeTool],
302
+ };
303
+
304
+ /**
305
+ * Self-spawn: `resolveSubagentConfigs` fills `agentInputs` as a shallow
306
+ * spread of the parent's `_sourceInputs`, so the parent-scoped direct
307
+ * tool would leak into a checkpointer-less child graph and fail with
308
+ * `No checkpointer set` — must be scrubbed.
309
+ */
310
+ const selfConfig: ResolvedSubagentConfig = {
311
+ type: 'self',
312
+ name: 'Self',
313
+ description: 'd',
314
+ self: true,
315
+ agentInputs: { ...inputsWithGraphTools },
316
+ };
317
+ expect(
318
+ buildChildInputs(selfConfig, 'child-self', 3).graphTools
319
+ ).toBeUndefined();
320
+
321
+ /**
322
+ * Explicit child config: a host that deliberately attaches its own
323
+ * in-process direct tools to a child keeps them (Codex #289 P2 —
324
+ * interrupt-capable tools still fail in children, but non-interrupting
325
+ * direct tools are legitimate there).
326
+ */
327
+ const explicitConfig: ResolvedSubagentConfig = {
328
+ type: 'researcher',
329
+ name: 'R',
330
+ description: 'd',
331
+ agentInputs: inputsWithGraphTools,
332
+ };
333
+ expect(
334
+ buildChildInputs(explicitConfig, 'child-explicit', 3).graphTools
335
+ ).toEqual([askLikeTool]);
336
+ expect(inputsWithGraphTools.graphTools).toHaveLength(1); // parent untouched
337
+ });
338
+
295
339
  it('strips parent-run-scoped initialSummary and discoveredTools from child inputs', () => {
296
340
  /**
297
341
  * Codex P1: a child inheriting `initialSummary` or `discoveredTools` from
@@ -140,6 +140,52 @@ describe('ToolNode code execution session management', () => {
140
140
  expect(capturedConfigs[0]._injected_files).toBeUndefined();
141
141
  });
142
142
 
143
+ it('injects the existing session into declared host tools on the direct path', async () => {
144
+ const capturedConfigs: Record<string, unknown>[] = [];
145
+ const sessions: t.ToolSessionMap = new Map();
146
+ sessions.set(Constants.EXECUTE_CODE, {
147
+ session_id: 'host-session',
148
+ files: [
149
+ { id: 'g1', name: 'gen.png', storage_session_id: 'host-session' },
150
+ ],
151
+ lastUpdated: Date.now(),
152
+ } satisfies t.CodeSessionContext);
153
+
154
+ const cfTool = tool(
155
+ async (_input, config) => {
156
+ capturedConfigs.push({ ...(config.toolCall ?? {}) });
157
+ return 'Created /mnt/data/x.py';
158
+ },
159
+ {
160
+ name: 'create_file',
161
+ description: 'write a file',
162
+ schema: z.object({ path: z.string(), content: z.string() }),
163
+ }
164
+ ) as unknown as StructuredToolInterface;
165
+
166
+ const toolNode = new ToolNode({
167
+ tools: [cfTool],
168
+ sessions,
169
+ codeSessionToolNames: ['create_file', 'edit_file'],
170
+ });
171
+
172
+ const aiMsg = new AIMessage({
173
+ content: '',
174
+ tool_calls: [
175
+ {
176
+ id: 'call_cf',
177
+ name: 'create_file',
178
+ args: { path: '/mnt/data/x.py', content: 'print(1)' },
179
+ },
180
+ ],
181
+ });
182
+ await toolNode.invoke({ messages: [aiMsg] });
183
+
184
+ // Declared host tool writes into the current sandbox, not a fresh one.
185
+ expect(capturedConfigs).toHaveLength(1);
186
+ expect(capturedConfigs[0].session_id).toBe('host-session');
187
+ });
188
+
143
189
  it('preserves per-file storage_session_id for multi-session files', async () => {
144
190
  const capturedConfigs: Record<string, unknown>[] = [];
145
191
  const sessions: t.ToolSessionMap = new Map();
@@ -774,6 +820,78 @@ describe('ToolNode code execution session management', () => {
774
820
  expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
775
821
  });
776
822
 
823
+ it('stores the exec session for host tools declared in codeSessionToolNames', () => {
824
+ const sessions: t.ToolSessionMap = new Map();
825
+ const toolNode = new ToolNode({
826
+ tools: [createMockCodeTool({ capturedConfigs: [] })],
827
+ sessions,
828
+ eventDrivenMode: true,
829
+ codeSessionToolNames: ['create_file', 'edit_file'],
830
+ });
831
+
832
+ const storeMethod = (
833
+ toolNode as unknown as {
834
+ storeCodeSessionFromResults: (
835
+ results: t.ToolExecuteResult[],
836
+ requestMap: Map<string, t.ToolCallRequest>
837
+ ) => void;
838
+ }
839
+ ).storeCodeSessionFromResults.bind(toolNode);
840
+
841
+ storeMethod(
842
+ [
843
+ {
844
+ toolCallId: 'tc-cf',
845
+ content: 'Created /mnt/data/x.py (10 chars).',
846
+ artifact: { session_id: 'authoring-session' },
847
+ status: 'success',
848
+ },
849
+ ],
850
+ new Map([['tc-cf', { id: 'tc-cf', name: 'create_file', args: {} }]])
851
+ );
852
+
853
+ // create_file's exec session is folded into the shared code session, so a
854
+ // later bash_tool/execute_code call reuses the same sandbox.
855
+ const stored = sessions.get(Constants.EXECUTE_CODE) as
856
+ | t.CodeSessionContext
857
+ | undefined;
858
+ expect(stored?.session_id).toBe('authoring-session');
859
+ });
860
+
861
+ it('does not store a host authoring tool session when not declared', () => {
862
+ const sessions: t.ToolSessionMap = new Map();
863
+ const toolNode = new ToolNode({
864
+ tools: [createMockCodeTool({ capturedConfigs: [] })],
865
+ sessions,
866
+ eventDrivenMode: true,
867
+ // codeSessionToolNames omitted — create_file must NOT be treated as a
868
+ // code-session participant.
869
+ });
870
+
871
+ const storeMethod = (
872
+ toolNode as unknown as {
873
+ storeCodeSessionFromResults: (
874
+ results: t.ToolExecuteResult[],
875
+ requestMap: Map<string, t.ToolCallRequest>
876
+ ) => void;
877
+ }
878
+ ).storeCodeSessionFromResults.bind(toolNode);
879
+
880
+ storeMethod(
881
+ [
882
+ {
883
+ toolCallId: 'tc-cf2',
884
+ content: 'Created /mnt/data/x.py (10 chars).',
885
+ artifact: { session_id: 'authoring-session' },
886
+ status: 'success',
887
+ },
888
+ ],
889
+ new Map([['tc-cf2', { id: 'tc-cf2', name: 'create_file', args: {} }]])
890
+ );
891
+
892
+ expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
893
+ });
894
+
777
895
  it('ignores error results', () => {
778
896
  const sessions: t.ToolSessionMap = new Map();
779
897
 
@@ -1102,5 +1220,47 @@ describe('ToolNode code execution session management', () => {
1102
1220
  expect(capturedRequests[0].name).toBe('web_search');
1103
1221
  expect(capturedRequests[0].codeSessionContext).toBeUndefined();
1104
1222
  });
1223
+
1224
+ it('attaches codeSessionContext to declared host tools so they write into the current sandbox', async () => {
1225
+ const sessions: t.ToolSessionMap = new Map();
1226
+ sessions.set(Constants.EXECUTE_CODE, {
1227
+ session_id: 'cf-session',
1228
+ files: [
1229
+ { id: 'g1', name: 'gen.png', storage_session_id: 'cf-session' },
1230
+ ],
1231
+ lastUpdated: Date.now(),
1232
+ } satisfies t.CodeSessionContext);
1233
+
1234
+ const { capturedRequests } = captureBatchRequests();
1235
+
1236
+ const toolNode = new ToolNode({
1237
+ tools: [createDummyTool('create_file')],
1238
+ sessions,
1239
+ eventDrivenMode: true,
1240
+ codeSessionToolNames: ['create_file', 'edit_file'],
1241
+ toolCallStepIds: new Map([['call_cf', 'step_cf']]),
1242
+ });
1243
+
1244
+ const aiMsg = new AIMessage({
1245
+ content: '',
1246
+ tool_calls: [
1247
+ {
1248
+ id: 'call_cf',
1249
+ name: 'create_file',
1250
+ args: { path: '/mnt/data/x.py', content: 'print(1)' },
1251
+ },
1252
+ ],
1253
+ });
1254
+
1255
+ await toolNode.invoke({ messages: [aiMsg] });
1256
+
1257
+ expect(capturedRequests).toHaveLength(1);
1258
+ expect(capturedRequests[0].name).toBe('create_file');
1259
+ // Existing session is injected so create_file writes into the current
1260
+ // sandbox instead of spawning a separate one.
1261
+ expect(capturedRequests[0].codeSessionContext?.session_id).toBe(
1262
+ 'cf-session'
1263
+ );
1264
+ });
1105
1265
  });
1106
1266
  });
@@ -3996,6 +3996,91 @@ describe('AskUserQuestion — interrupt + resume', () => {
3996
3996
  expect(resumedAnswer).toBe('production');
3997
3997
  });
3998
3998
 
3999
+ it('a DIRECT tool in event-driven mode can raise ask_user_question from its body and resume with the answer as its ToolMessage', async () => {
4000
+ /**
4001
+ * The production host shape (e.g. LibreChat's `AgentInputs.graphTools`
4002
+ * plumb): the run is event-driven (other tools are schema-only
4003
+ * definitions dispatched to the host), but an interrupt-capable tool is
4004
+ * supplied as a real instance and marked direct so it executes inside
4005
+ * the Pregel task frame. Event dispatch must never see the call — a
4006
+ * host-side handler runs outside the graph, where `interrupt()` throws.
4007
+ */
4008
+ const { askUserQuestion } = await import('@/hitl');
4009
+
4010
+ const dispatchSpy = jest
4011
+ .spyOn(events, 'safeDispatchCustomEvent')
4012
+ .mockImplementation(async () => {});
4013
+
4014
+ let bodyRuns = 0;
4015
+ const askTool = tool(
4016
+ async (input: { question: string }) => {
4017
+ bodyRuns += 1;
4018
+ const resolution = askUserQuestion(input);
4019
+ return resolution.answer;
4020
+ },
4021
+ {
4022
+ name: 'ask_user_question',
4023
+ description: 'Ask the user a clarifying question.',
4024
+ schema: z.object({ question: z.string() }),
4025
+ }
4026
+ ) as unknown as StructuredToolInterface;
4027
+
4028
+ const node = new ToolNode({
4029
+ tools: [createSchemaStub('echo'), askTool],
4030
+ toolMap: new Map([
4031
+ ['echo', createSchemaStub('echo')],
4032
+ ['ask_user_question', askTool],
4033
+ ]),
4034
+ eventDrivenMode: true,
4035
+ agentId: 'agent-ask-direct',
4036
+ toolCallStepIds: new Map([['call_ask_1', 'step_call_ask_1']]),
4037
+ directToolNames: new Set(['ask_user_question']),
4038
+ });
4039
+
4040
+ const graph = buildHITLGraph(node, [
4041
+ {
4042
+ id: 'call_ask_1',
4043
+ name: 'ask_user_question',
4044
+ args: { question: 'Which environment?' },
4045
+ },
4046
+ ]);
4047
+ const config = { configurable: { thread_id: 'thread-ask-direct' } };
4048
+
4049
+ const interrupted = await graph.invoke({ messages: [] }, config);
4050
+ expect(isInterrupted<t.HumanInterruptPayload>(interrupted)).toBe(true);
4051
+ if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
4052
+ throw new Error('expected interrupt');
4053
+ }
4054
+ const payload = interrupted.__interrupt__[0].value!;
4055
+ if (payload.type !== 'ask_user_question') {
4056
+ throw new Error('expected ask_user_question payload');
4057
+ }
4058
+ expect(payload.question.question).toBe('Which environment?');
4059
+ expect(bodyRuns).toBe(1);
4060
+
4061
+ /** The interrupt came from the direct path — never dispatched to the host. */
4062
+ const toolExecuteDispatches = dispatchSpy.mock.calls.filter(
4063
+ ([event]) => event === 'on_tool_execute'
4064
+ );
4065
+ expect(toolExecuteDispatches).toHaveLength(0);
4066
+
4067
+ const resumed = (await resumeGraph(
4068
+ graph,
4069
+ interrupted,
4070
+ { answer: 'staging' } satisfies t.AskUserQuestionResolution,
4071
+ config
4072
+ )) as MessagesUpdate;
4073
+
4074
+ expect(bodyRuns).toBe(2); // body re-runs from the top on the resume pass
4075
+ const toolMessage = resumed.messages.find(
4076
+ (m): m is ToolMessage =>
4077
+ m._getType() === 'tool' &&
4078
+ (m as ToolMessage).tool_call_id === 'call_ask_1'
4079
+ );
4080
+ expect(toolMessage).toBeDefined();
4081
+ expect(String(toolMessage!.content)).toBe('staging');
4082
+ });
4083
+
3999
4084
  it('isAskUserQuestionInterrupt narrows the payload union correctly', async () => {
4000
4085
  const { isAskUserQuestionInterrupt, isToolApprovalInterrupt } =
4001
4086
  await import('@/types/hitl');
@@ -150,7 +150,8 @@ describe('JinaReranker', () => {
150
150
  });
151
151
 
152
152
  it('should log compact Axios errors without request internals', async () => {
153
- const customUrl = 'https://test-jina-endpoint.com/v1/rerank?api_key=hidden';
153
+ const customUrl =
154
+ 'https://test-jina-endpoint.com/v1/rerank?api_key=hidden';
154
155
  const reranker = new JinaReranker({
155
156
  apiKey: 'test-key',
156
157
  apiUrl: customUrl,
@@ -201,6 +202,49 @@ describe('JinaReranker', () => {
201
202
  expect(serializedMetadata).not.toContain('test-key');
202
203
  expect(serializedMetadata.length).toBeLessThan(500);
203
204
  });
205
+
206
+ it('should bound the rerank request with the default timeout', async () => {
207
+ const customUrl = 'https://test-jina-endpoint.com/v1/rerank';
208
+ const reranker = new JinaReranker({
209
+ apiKey: 'test-key',
210
+ apiUrl: customUrl,
211
+ logger: mockLogger,
212
+ });
213
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
214
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
215
+ data: { results: [{ index: 0, relevance_score: 0.9 }] },
216
+ });
217
+
218
+ await reranker.rerank('test query', ['document1'], 1);
219
+
220
+ expect(postSpy).toHaveBeenCalledWith(
221
+ customUrl,
222
+ expect.any(Object),
223
+ expect.objectContaining({ timeout: 10000 })
224
+ );
225
+ });
226
+
227
+ it('should bound the rerank request with a custom timeout', async () => {
228
+ const customUrl = 'https://test-jina-endpoint.com/v1/rerank';
229
+ const reranker = new JinaReranker({
230
+ apiKey: 'test-key',
231
+ apiUrl: customUrl,
232
+ timeout: 2500,
233
+ logger: mockLogger,
234
+ });
235
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
236
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
237
+ data: { results: [{ index: 0, relevance_score: 0.9 }] },
238
+ });
239
+
240
+ await reranker.rerank('test query', ['document1'], 1);
241
+
242
+ expect(postSpy).toHaveBeenCalledWith(
243
+ customUrl,
244
+ expect.any(Object),
245
+ expect.objectContaining({ timeout: 2500 })
246
+ );
247
+ });
204
248
  });
205
249
  });
206
250
 
@@ -234,4 +278,29 @@ describe('createReranker', () => {
234
278
  const apiUrl = getApiUrl(reranker);
235
279
  expect(apiUrl).toBe('https://api.jina.ai/v1/rerank');
236
280
  });
281
+
282
+ it('should pass rerankerTimeout through to the rerank request', async () => {
283
+ const customUrl = 'https://custom-jina-endpoint.com/v1/rerank';
284
+ const reranker = createReranker({
285
+ rerankerType: 'jina',
286
+ jinaApiKey: 'test-key',
287
+ jinaApiUrl: customUrl,
288
+ rerankerTimeout: 4000,
289
+ logger: createDefaultLogger(),
290
+ });
291
+ if (!(reranker instanceof JinaReranker)) {
292
+ throw new Error('Expected createReranker to return a JinaReranker.');
293
+ }
294
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
295
+ data: { results: [{ index: 0, relevance_score: 0.9 }] },
296
+ });
297
+
298
+ await reranker.rerank('test query', ['document1'], 1);
299
+
300
+ expect(postSpy).toHaveBeenCalledWith(
301
+ customUrl,
302
+ expect.any(Object),
303
+ expect.objectContaining({ timeout: 4000 })
304
+ );
305
+ });
237
306
  });
@@ -0,0 +1,98 @@
1
+ import axios from 'axios';
2
+ import type * as t from './types';
3
+ import { DATE_RANGE } from './schema';
4
+
5
+ const DEFAULT_KEENABLE_TIMEOUT = 15000;
6
+
7
+ /** Authenticated and keyless endpoints. Keenable works without an API key by
8
+ * falling back to the public endpoint; a key only lifts rate limits. */
9
+ const KEENABLE_DEFAULT_API_URL = 'https://api.keenable.ai/v1/search';
10
+ const KEENABLE_PUBLIC_API_URL = 'https://api.keenable.ai/v1/search/public';
11
+ const KEENABLE_DATE_RANGES: Record<DATE_RANGE, string> = {
12
+ [DATE_RANGE.PAST_HOUR]: '1h',
13
+ [DATE_RANGE.PAST_24_HOURS]: '1d',
14
+ [DATE_RANGE.PAST_WEEK]: '7d',
15
+ [DATE_RANGE.PAST_MONTH]: '1mo',
16
+ [DATE_RANGE.PAST_YEAR]: '1y',
17
+ };
18
+
19
+ export const createKeenableAPI = (
20
+ apiKey?: string,
21
+ apiUrl?: string,
22
+ options?: t.KeenableSearchOptions
23
+ ): {
24
+ getSources: (params: t.GetSourcesParams) => Promise<t.SearchResult>;
25
+ } => {
26
+ const resolvedKey = apiKey ?? process.env.KEENABLE_API_KEY;
27
+ const hasKey = resolvedKey != null && resolvedKey !== '';
28
+ const timeout = options?.timeout ?? DEFAULT_KEENABLE_TIMEOUT;
29
+ const resolvedUrl =
30
+ apiUrl ??
31
+ process.env.KEENABLE_API_URL ??
32
+ (hasKey ? KEENABLE_DEFAULT_API_URL : KEENABLE_PUBLIC_API_URL);
33
+
34
+ /** Constant for the provider's lifetime. X-Keenable-Title is required for
35
+ * keyless requests and used for traffic attribution; the API key only lifts
36
+ * rate limits, so it is sent only when present. */
37
+ const headers: Record<string, string> = {
38
+ 'Content-Type': 'application/json',
39
+ 'X-Keenable-Title': options?.attributionTitle ?? 'LibreChat',
40
+ };
41
+ if (hasKey) {
42
+ headers['X-API-Key'] = resolvedKey;
43
+ }
44
+
45
+ const getSources = async ({
46
+ query,
47
+ date,
48
+ numResults = 8,
49
+ }: t.GetSourcesParams): Promise<t.SearchResult> => {
50
+ if (!query.trim()) {
51
+ return { success: false, error: 'Query cannot be empty' };
52
+ }
53
+
54
+ try {
55
+ /** Keenable's endpoint has no result-count parameter; the count is
56
+ * applied client-side after the response (see slice below). */
57
+ const payload: t.KeenableSearchPayload = { query };
58
+ if (options?.site != null && options.site !== '') {
59
+ payload.site = options.site;
60
+ }
61
+ if (date != null) {
62
+ payload.published_after = KEENABLE_DATE_RANGES[date];
63
+ }
64
+
65
+ const response = await axios.post<t.KeenableSearchResponse>(
66
+ resolvedUrl,
67
+ payload,
68
+ { headers, timeout }
69
+ );
70
+
71
+ const maxResults = Math.min(
72
+ Math.max(1, options?.maxResults ?? numResults),
73
+ 20
74
+ );
75
+ const rawResults = Array.isArray(response.data.results)
76
+ ? response.data.results.slice(0, maxResults)
77
+ : [];
78
+
79
+ const organic: t.OrganicResult[] = rawResults.map((result) => ({
80
+ title: result.title ?? '',
81
+ link: result.url ?? '',
82
+ snippet: result.description ?? result.snippet ?? '',
83
+ date: result.published_at,
84
+ }));
85
+
86
+ return { success: true, data: { organic } };
87
+ } catch (error) {
88
+ const errorMessage =
89
+ error instanceof Error ? error.message : String(error);
90
+ return {
91
+ success: false,
92
+ error: `Keenable API request failed: ${errorMessage}`,
93
+ };
94
+ }
95
+ };
96
+
97
+ return { getSources };
98
+ };
@@ -0,0 +1,183 @@
1
+ import axios from 'axios';
2
+ import { createSearchAPI } from './search';
3
+ import { createSearchTool } from './tool';
4
+ import { DATE_RANGE } from './schema';
5
+
6
+ jest.mock('axios');
7
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
8
+
9
+ const sampleResponse = {
10
+ data: {
11
+ results: [
12
+ {
13
+ title: 'TypeScript Best Practices 2026',
14
+ url: 'https://example.com/ts',
15
+ description: 'A comprehensive guide to TypeScript.',
16
+ published_at: '2026-01-15T10:30:00Z',
17
+ },
18
+ {
19
+ title: 'Second result',
20
+ url: 'https://example.com/second',
21
+ snippet: 'Snippet fallback when description is absent.',
22
+ },
23
+ ],
24
+ },
25
+ };
26
+
27
+ describe('Keenable search API', () => {
28
+ beforeEach(() => {
29
+ jest.clearAllMocks();
30
+ delete process.env.KEENABLE_API_KEY;
31
+ delete process.env.KEENABLE_API_URL;
32
+ });
33
+
34
+ it('returns an error for empty queries without calling the API', async () => {
35
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
36
+ const result = await searchAPI.getSources({ query: ' ' });
37
+
38
+ expect(result).toEqual({ success: false, error: 'Query cannot be empty' });
39
+ expect(mockedAxios.post).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('hits the public endpoint and omits the API key header when keyless', async () => {
43
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
44
+
45
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
46
+ const result = await searchAPI.getSources({ query: 'typescript' });
47
+
48
+ expect(mockedAxios.post).toHaveBeenCalledWith(
49
+ 'https://api.keenable.ai/v1/search/public',
50
+ { query: 'typescript' },
51
+ expect.objectContaining({
52
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
53
+ })
54
+ );
55
+ const headers = mockedAxios.post.mock.calls[0][2]?.headers as Record<
56
+ string,
57
+ string
58
+ >;
59
+ expect(headers['X-API-Key']).toBeUndefined();
60
+ expect(result.success).toBe(true);
61
+ });
62
+
63
+ it('hits the authenticated endpoint and sends the API key when a key is set', async () => {
64
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
65
+
66
+ const searchAPI = createSearchAPI({
67
+ searchProvider: 'keenable',
68
+ keenableApiKey: 'secret-key',
69
+ });
70
+ await searchAPI.getSources({ query: 'typescript' });
71
+
72
+ expect(mockedAxios.post).toHaveBeenCalledWith(
73
+ 'https://api.keenable.ai/v1/search',
74
+ { query: 'typescript' },
75
+ expect.objectContaining({
76
+ headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
77
+ })
78
+ );
79
+ });
80
+
81
+ it('maps results into organic sources (description and snippet fallback)', async () => {
82
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
83
+
84
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
85
+ const result = await searchAPI.getSources({ query: 'typescript' });
86
+
87
+ expect(result.data?.organic).toEqual([
88
+ {
89
+ title: 'TypeScript Best Practices 2026',
90
+ link: 'https://example.com/ts',
91
+ snippet: 'A comprehensive guide to TypeScript.',
92
+ date: '2026-01-15T10:30:00Z',
93
+ },
94
+ {
95
+ title: 'Second result',
96
+ link: 'https://example.com/second',
97
+ snippet: 'Snippet fallback when description is absent.',
98
+ date: undefined,
99
+ },
100
+ ]);
101
+ });
102
+
103
+ it('applies the site filter and limits results client-side', async () => {
104
+ mockedAxios.post.mockResolvedValueOnce({
105
+ data: {
106
+ results: [{ url: '1' }, { url: '2' }, { url: '3' }],
107
+ },
108
+ });
109
+
110
+ const searchAPI = createSearchAPI({
111
+ searchProvider: 'keenable',
112
+ keenableSearchOptions: { site: 'github.com', maxResults: 2 },
113
+ });
114
+ const result = await searchAPI.getSources({ query: 'typescript' });
115
+
116
+ expect(mockedAxios.post).toHaveBeenCalledWith(
117
+ expect.any(String),
118
+ { query: 'typescript', site: 'github.com' },
119
+ expect.any(Object)
120
+ );
121
+ expect(result.data?.organic).toHaveLength(2);
122
+ });
123
+
124
+ it('maps date ranges to Keenable published filters', async () => {
125
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
126
+
127
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
128
+ await searchAPI.getSources({
129
+ query: 'typescript',
130
+ date: DATE_RANGE.PAST_WEEK,
131
+ });
132
+
133
+ expect(mockedAxios.post).toHaveBeenCalledWith(
134
+ expect.any(String),
135
+ { query: 'typescript', published_after: '7d' },
136
+ expect.any(Object)
137
+ );
138
+ });
139
+
140
+ it('surfaces request failures as a structured error', async () => {
141
+ mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
142
+
143
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
144
+ const result = await searchAPI.getSources({ query: 'typescript' });
145
+
146
+ expect(result.success).toBe(false);
147
+ expect(result.error).toBe('Keenable API request failed: Network error');
148
+ });
149
+ });
150
+
151
+ describe('Keenable capability gating', () => {
152
+ beforeEach(() => {
153
+ jest.clearAllMocks();
154
+ delete process.env.KEENABLE_API_KEY;
155
+ delete process.env.KEENABLE_API_URL;
156
+ });
157
+
158
+ it('skips image/news/video sub-searches (organic-only provider)', async () => {
159
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse).mockResolvedValue({
160
+ data: { success: true, data: { markdown: '# A', html: '<p>a</p>' } },
161
+ });
162
+
163
+ const searchTool = createSearchTool({
164
+ searchProvider: 'keenable',
165
+ scraperProvider: 'firecrawl',
166
+ firecrawlApiKey: 'k',
167
+ topResults: 1,
168
+ rerankerType: 'none',
169
+ });
170
+
171
+ await searchTool.invoke({
172
+ query: 'typescript',
173
+ images: true,
174
+ news: true,
175
+ videos: true,
176
+ });
177
+
178
+ const searchCalls = mockedAxios.post.mock.calls.filter(([url]) =>
179
+ (url as string).includes('keenable')
180
+ );
181
+ expect(searchCalls).toHaveLength(1);
182
+ });
183
+ });