@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.
- package/dist/cjs/agents/AgentContext.cjs +7 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +2 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/langfuseTraceShaping.cjs +172 -0
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
- package/dist/cjs/run.cjs +2 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-search.cjs +68 -0
- package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
- package/dist/cjs/tools/search/rerankers.cjs +28 -11
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +30 -4
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +14 -6
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/title.cjs +9 -9
- package/dist/cjs/utils/title.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +7 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +2 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/langfuseTraceShaping.mjs +171 -0
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
- package/dist/esm/run.mjs +2 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-search.mjs +66 -0
- package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
- package/dist/esm/tools/search/rerankers.mjs +28 -11
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +30 -4
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +14 -6
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/title.mjs +9 -9
- package/dist/esm/utils/title.mjs.map +1 -1
- package/dist/types/langfuseTraceShaping.d.ts +20 -0
- package/dist/types/tools/search/keenable-search.d.ts +4 -0
- package/dist/types/tools/search/rerankers.d.ts +7 -2
- package/dist/types/tools/search/types.d.ts +38 -1
- package/dist/types/types/graph.d.ts +22 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +9 -0
- package/src/agents/__tests__/AgentContext.test.ts +40 -0
- package/src/graphs/Graph.ts +25 -2
- package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
- package/src/langfuseToolOutputTracing.ts +11 -0
- package/src/langfuseTraceShaping.ts +280 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
- package/src/run.ts +3 -3
- package/src/specs/langfuse-routing.integration.test.ts +1 -1
- package/src/specs/langfuse-trace-shaping.test.ts +194 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
- package/src/tools/__tests__/hitl.test.ts +85 -0
- package/src/tools/search/jina-reranker.test.ts +70 -1
- package/src/tools/search/keenable-search.ts +98 -0
- package/src/tools/search/keenable.test.ts +183 -0
- package/src/tools/search/rerankers.ts +41 -4
- package/src/tools/search/search.ts +58 -2
- package/src/tools/search/source-processing.test.ts +86 -0
- package/src/tools/search/tool.ts +29 -4
- package/src/tools/search/types.ts +42 -1
- package/src/tools/subagent/SubagentExecutor.ts +11 -0
- package/src/types/graph.ts +22 -0
- 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 =
|
|
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
|
+
};
|