@librechat/agents 3.2.57 → 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 (73) 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 +2 -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 +2 -2
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  12. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  13. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  14. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  15. package/dist/cjs/tools/search/search.cjs +30 -4
  16. package/dist/cjs/tools/search/search.cjs.map +1 -1
  17. package/dist/cjs/tools/search/tool.cjs +14 -6
  18. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  19. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  20. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  21. package/dist/cjs/utils/title.cjs +9 -9
  22. package/dist/cjs/utils/title.cjs.map +1 -1
  23. package/dist/esm/agents/AgentContext.mjs +7 -1
  24. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  25. package/dist/esm/graphs/Graph.mjs +2 -2
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  28. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  29. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  30. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  31. package/dist/esm/run.mjs +2 -2
  32. package/dist/esm/run.mjs.map +1 -1
  33. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  34. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  35. package/dist/esm/tools/search/rerankers.mjs +28 -11
  36. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  37. package/dist/esm/tools/search/search.mjs +30 -4
  38. package/dist/esm/tools/search/search.mjs.map +1 -1
  39. package/dist/esm/tools/search/tool.mjs +14 -6
  40. package/dist/esm/tools/search/tool.mjs.map +1 -1
  41. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  42. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  43. package/dist/esm/utils/title.mjs +9 -9
  44. package/dist/esm/utils/title.mjs.map +1 -1
  45. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  46. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  47. package/dist/types/tools/search/rerankers.d.ts +7 -2
  48. package/dist/types/tools/search/types.d.ts +38 -1
  49. package/dist/types/types/graph.d.ts +22 -0
  50. package/package.json +1 -1
  51. package/src/agents/AgentContext.ts +9 -0
  52. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  53. package/src/graphs/Graph.ts +25 -2
  54. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  55. package/src/langfuseToolOutputTracing.ts +11 -0
  56. package/src/langfuseTraceShaping.ts +280 -0
  57. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  58. package/src/run.ts +3 -3
  59. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  60. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  61. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  62. package/src/tools/__tests__/hitl.test.ts +85 -0
  63. package/src/tools/search/jina-reranker.test.ts +70 -1
  64. package/src/tools/search/keenable-search.ts +98 -0
  65. package/src/tools/search/keenable.test.ts +183 -0
  66. package/src/tools/search/rerankers.ts +41 -4
  67. package/src/tools/search/search.ts +58 -2
  68. package/src/tools/search/source-processing.test.ts +86 -0
  69. package/src/tools/search/tool.ts +29 -4
  70. package/src/tools/search/types.ts +42 -1
  71. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  72. package/src/types/graph.ts +22 -0
  73. package/src/utils/title.ts +9 -9
@@ -0,0 +1,194 @@
1
+ import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
2
+ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
+ import {
4
+ shapeLangfuseSpan,
5
+ shouldDropLangfuseSpan,
6
+ } from '@/langfuseTraceShaping';
7
+
8
+ type TestSpan = ReadableSpan & {
9
+ name: string;
10
+ attributes: Record<string, unknown>;
11
+ };
12
+
13
+ function createSpan(
14
+ name: string,
15
+ attributes: Record<string, unknown> = {},
16
+ parentSpanId?: string
17
+ ): TestSpan {
18
+ return {
19
+ name,
20
+ attributes,
21
+ ...(parentSpanId != null ? { parentSpanId } : {}),
22
+ } as unknown as TestSpan;
23
+ }
24
+
25
+ const INPUT = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
26
+ const OUTPUT = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
27
+ const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
28
+ const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
29
+
30
+ describe('shouldDropLangfuseSpan', () => {
31
+ it('drops langgraph __start__ seed spans', () => {
32
+ expect(shouldDropLangfuseSpan('__start__')).toBe(true);
33
+ });
34
+
35
+ it('drops anonymous RunnableLambda pass-throughs', () => {
36
+ expect(shouldDropLangfuseSpan('RunnableLambda')).toBe(true);
37
+ });
38
+
39
+ it('keeps named observations', () => {
40
+ expect(shouldDropLangfuseSpan('GenerateTitle')).toBe(false);
41
+ expect(shouldDropLangfuseSpan('agent=openAI__gpt-5.4')).toBe(false);
42
+ expect(shouldDropLangfuseSpan('ChatOpenAI')).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('shapeLangfuseSpan', () => {
47
+ it('strips the ephemeral agent id (provider__model) from agent node names', () => {
48
+ const span = createSpan('agent=openAI__gpt-5.4', {}, 'parent-1');
49
+ shapeLangfuseSpan(span);
50
+ expect(span.name).toBe('agent');
51
+ });
52
+
53
+ it('renames tool node spans to the pending tool names and scopes input to args', () => {
54
+ const messages = [
55
+ { type: 'human', content: 'hello' },
56
+ {
57
+ type: 'ai',
58
+ content: '',
59
+ tool_calls: [
60
+ {
61
+ name: 'get_service_details',
62
+ args: { path: 'organizations/1' },
63
+ id: 'call_1',
64
+ },
65
+ ],
66
+ },
67
+ ];
68
+ const span = createSpan(
69
+ 'tools=openAI__gpt-5.4',
70
+ { [INPUT]: JSON.stringify({ messages }) },
71
+ 'parent-1'
72
+ );
73
+ shapeLangfuseSpan(span);
74
+ expect(span.name).toBe('get_service_details');
75
+ expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
76
+ { name: 'get_service_details', args: { path: 'organizations/1' } },
77
+ ]);
78
+ });
79
+
80
+ it('joins multiple pending tool names and dedupes repeats', () => {
81
+ const messages = [
82
+ {
83
+ type: 'ai',
84
+ tool_calls: [
85
+ { name: 'web_search', args: { q: 'a' }, id: '1' },
86
+ { name: 'web_search', args: { q: 'b' }, id: '2' },
87
+ { name: 'execute_code', args: { code: '1+1' }, id: '3' },
88
+ ],
89
+ },
90
+ ];
91
+ const span = createSpan(
92
+ 'tool_batch',
93
+ { [INPUT]: JSON.stringify({ messages }) },
94
+ 'parent-1'
95
+ );
96
+ shapeLangfuseSpan(span);
97
+ expect(span.name).toBe('web_search, execute_code');
98
+ });
99
+
100
+ it('reads tool calls from serialized langchain message kwargs', () => {
101
+ const messages = [
102
+ {
103
+ lc: 1,
104
+ type: 'constructor',
105
+ id: ['langchain_core', 'messages', 'AIMessage'],
106
+ kwargs: {
107
+ content: '',
108
+ tool_calls: [{ name: 'lookup', args: { id: 7 }, id: 'call_7' }],
109
+ },
110
+ },
111
+ ];
112
+ const span = createSpan(
113
+ 'tools=agent_abc',
114
+ { [INPUT]: JSON.stringify({ messages }) },
115
+ 'parent-1'
116
+ );
117
+ shapeLangfuseSpan(span);
118
+ expect(span.name).toBe('lookup');
119
+ });
120
+
121
+ it('leaves tool node spans untouched when no tool calls are found', () => {
122
+ const original = JSON.stringify({
123
+ messages: [{ type: 'human', content: 'hi' }],
124
+ });
125
+ const span = createSpan(
126
+ 'tools=agent_abc',
127
+ { [INPUT]: original },
128
+ 'parent-1'
129
+ );
130
+ shapeLangfuseSpan(span);
131
+ expect(span.name).toBe('tools=agent_abc');
132
+ expect(span.attributes[INPUT]).toBe(original);
133
+ });
134
+
135
+ it('sets root span and trace input/output to the question and answer', () => {
136
+ const span = createSpan('LibreChat Agent', {
137
+ [INPUT]: JSON.stringify({
138
+ messages: [
139
+ { type: 'system', content: 'You are helpful.' },
140
+ { type: 'human', content: 'What is ClickHouse?' },
141
+ ],
142
+ }),
143
+ [OUTPUT]: JSON.stringify({
144
+ messages: [
145
+ { type: 'human', content: 'What is ClickHouse?' },
146
+ { type: 'ai', content: 'A columnar OLAP database.' },
147
+ ],
148
+ }),
149
+ });
150
+ shapeLangfuseSpan(span);
151
+ expect(span.attributes[INPUT]).toBe('What is ClickHouse?');
152
+ expect(span.attributes[TRACE_INPUT]).toBe('What is ClickHouse?');
153
+ expect(span.attributes[OUTPUT]).toBe('A columnar OLAP database.');
154
+ expect(span.attributes[TRACE_OUTPUT]).toBe('A columnar OLAP database.');
155
+ });
156
+
157
+ it('extracts answer text from content part arrays', () => {
158
+ const span = createSpan('LibreChat Agent', {
159
+ [INPUT]: JSON.stringify([{ type: 'human', content: 'hi' }]),
160
+ [OUTPUT]: JSON.stringify({
161
+ messages: [
162
+ {
163
+ id: ['langchain_core', 'messages', 'AIMessage'],
164
+ kwargs: {
165
+ content: [
166
+ { type: 'text', text: 'Hello ' },
167
+ { type: 'text', text: 'there.' },
168
+ ],
169
+ },
170
+ },
171
+ ],
172
+ }),
173
+ });
174
+ shapeLangfuseSpan(span);
175
+ expect(span.attributes[INPUT]).toBe('hi');
176
+ expect(span.attributes[OUTPUT]).toBe('Hello there.');
177
+ });
178
+
179
+ it('does not rewrite non-root spans with message payloads', () => {
180
+ const original = JSON.stringify({
181
+ messages: [{ type: 'human', content: 'hi' }],
182
+ });
183
+ const span = createSpan('ChatOpenAI', { [INPUT]: original }, 'parent-1');
184
+ shapeLangfuseSpan(span);
185
+ expect(span.attributes[INPUT]).toBe(original);
186
+ });
187
+
188
+ it('preserves root attributes when extraction finds nothing', () => {
189
+ const span = createSpan('LibreChat Agent', { [INPUT]: 'plain text' });
190
+ shapeLangfuseSpan(span);
191
+ expect(span.attributes[INPUT]).toBe('plain text');
192
+ expect(span.attributes[TRACE_INPUT]).toBeUndefined();
193
+ });
194
+ });
@@ -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
@@ -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
+ };