@librechat/agents 3.3.3 → 3.3.4
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 +2 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +342 -109
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +37 -10
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +19 -7
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +64 -3
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +41 -5
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +25 -4
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +4 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +19 -2
- package/dist/cjs/messages/cache.cjs +54 -33
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +17 -43
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +315 -23
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +218 -60
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/index.cjs +1 -1
- package/dist/cjs/messages/prune.cjs +503 -127
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/stream.cjs +7 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +28 -14
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +54 -41
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -1
- package/dist/cjs/utils/llm.cjs +1 -1
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +307 -20
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/toolContent.cjs +1514 -0
- package/dist/cjs/utils/toolContent.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +2 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +349 -116
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +37 -10
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +19 -7
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +65 -5
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +41 -5
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +25 -4
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +4 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/main.mjs +7 -7
- package/dist/esm/messages/cache.mjs +55 -34
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +17 -43
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +305 -24
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +218 -61
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/index.mjs +1 -1
- package/dist/esm/messages/prune.mjs +500 -128
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/stream.mjs +7 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +28 -14
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +54 -41
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +6 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -1
- package/dist/esm/utils/llm.mjs +1 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +307 -21
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/toolContent.mjs +1503 -0
- package/dist/esm/utils/toolContent.mjs.map +1 -0
- package/dist/types/graphs/Graph.d.ts +1 -1
- package/dist/types/llm/invoke.d.ts +27 -1
- package/dist/types/llm/openai/index.d.ts +4 -0
- package/dist/types/llm/openai/utils/index.d.ts +2 -0
- package/dist/types/llm/openrouter/index.d.ts +5 -0
- package/dist/types/messages/contextPruning.d.ts +2 -1
- package/dist/types/messages/core.d.ts +36 -0
- package/dist/types/messages/format.d.ts +6 -0
- package/dist/types/messages/prune.d.ts +16 -12
- package/dist/types/types/stream.d.ts +1 -1
- package/dist/types/utils/tokens.d.ts +7 -0
- package/dist/types/utils/toolContent.d.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
- package/src/agents/AgentContext.ts +1 -0
- package/src/graphs/Graph.ts +698 -200
- package/src/graphs/MultiAgentGraph.ts +6 -1
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1128 -4
- package/src/llm/anthropic/utils/message_inputs.ts +158 -12
- package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +49 -1
- package/src/llm/bedrock/utils/message_inputs.ts +86 -13
- package/src/llm/google/utils/common.test.ts +75 -9
- package/src/llm/google/utils/common.ts +34 -3
- package/src/llm/invoke.test.ts +488 -0
- package/src/llm/invoke.ts +196 -2
- package/src/llm/openai/index.ts +106 -6
- package/src/llm/openai/utils/index.ts +109 -53
- package/src/llm/openai/utils/messages.test.ts +330 -1
- package/src/llm/openrouter/index.ts +12 -2
- package/src/llm/openrouter/reasoning.test.ts +314 -0
- package/src/messages/__tests__/observationMasking.test.ts +93 -2
- package/src/messages/cache.tail.test.ts +193 -0
- package/src/messages/cache.test.ts +113 -0
- package/src/messages/cache.ts +92 -60
- package/src/messages/contextPruning.test.ts +184 -0
- package/src/messages/contextPruning.ts +49 -42
- package/src/messages/core.ts +653 -40
- package/src/messages/ensureThinkingBlock.test.ts +49 -1
- package/src/messages/foldToollessToolBlocks.test.ts +163 -5
- package/src/messages/format.ts +363 -91
- package/src/messages/formatAgentMessages.test.ts +604 -14
- package/src/messages/formatAgentMessages.tools.test.ts +103 -0
- package/src/messages/prune.ts +996 -183
- package/src/specs/prune.test.ts +1083 -6
- package/src/specs/summarization-unit.test.ts +105 -0
- package/src/specs/tokens.test.ts +609 -32
- package/src/stream.ts +23 -15
- package/src/summarization/__tests__/node.test.ts +77 -0
- package/src/summarization/node.ts +46 -18
- package/src/tools/ToolNode.ts +147 -68
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
- package/src/tools/__tests__/directToolHooks.test.ts +255 -7
- package/src/tools/toolOutputReferences.ts +6 -0
- package/src/types/stream.ts +1 -1
- package/src/utils/llm.test.ts +18 -0
- package/src/utils/llm.ts +4 -1
- package/src/utils/tokens.ts +586 -31
- package/src/utils/toolContent.test.ts +1432 -0
- package/src/utils/toolContent.ts +2707 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { tools as openAITools } from '@langchain/openai';
|
|
2
|
+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
1
3
|
import type { OpenAIChatInput } from '@langchain/openai';
|
|
2
4
|
import type { OpenRouterReasoning, ChatOpenRouterCallOptions } from './index';
|
|
5
|
+
import type { GraphTools } from '@/types';
|
|
6
|
+
import { partitionAndMarkOpenRouterToolCache } from './toolCache';
|
|
7
|
+
import { addTailCacheControl } from '@/messages/cache';
|
|
3
8
|
import { ChatOpenRouter } from './index';
|
|
4
9
|
|
|
5
10
|
type CreateRouterOptions = Partial<
|
|
@@ -245,3 +250,312 @@ describe('ChatOpenRouter reasoning handling', () => {
|
|
|
245
250
|
});
|
|
246
251
|
});
|
|
247
252
|
});
|
|
253
|
+
|
|
254
|
+
describe('ChatOpenRouter Responses prompt caching', () => {
|
|
255
|
+
it('maps promptCache to the Responses top-level cache control', () => {
|
|
256
|
+
const router = new ChatOpenRouter({
|
|
257
|
+
model: 'anthropic/claude-sonnet-4',
|
|
258
|
+
apiKey: 'test-key',
|
|
259
|
+
useResponsesApi: true,
|
|
260
|
+
promptCache: true,
|
|
261
|
+
promptCacheTtl: '1h',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(router.invocationParams().cache_control).toEqual({
|
|
265
|
+
type: 'ephemeral',
|
|
266
|
+
ttl: '1h',
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('sends cache control on the actual Responses request', async () => {
|
|
271
|
+
const router = new ChatOpenRouter({
|
|
272
|
+
model: 'openai/gpt-4.1',
|
|
273
|
+
apiKey: 'test-key',
|
|
274
|
+
useResponsesApi: true,
|
|
275
|
+
promptCache: true,
|
|
276
|
+
promptCacheTtl: '1h',
|
|
277
|
+
streaming: false,
|
|
278
|
+
});
|
|
279
|
+
const sentinel = new Error('captured request');
|
|
280
|
+
let capturedRequest: unknown;
|
|
281
|
+
const responses = (
|
|
282
|
+
router as unknown as {
|
|
283
|
+
responses: {
|
|
284
|
+
completionWithRetry: (request: unknown) => Promise<never>;
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
).responses;
|
|
288
|
+
responses.completionWithRetry = async (request) => {
|
|
289
|
+
capturedRequest = request;
|
|
290
|
+
throw sentinel;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
await expect(router.invoke([new HumanMessage('hello')])).rejects.toThrow(
|
|
294
|
+
sentinel
|
|
295
|
+
);
|
|
296
|
+
expect(capturedRequest).toMatchObject({
|
|
297
|
+
cache_control: { type: 'ephemeral', ttl: '1h' },
|
|
298
|
+
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('honors per-call Responses cache overrides', async () => {
|
|
303
|
+
const router = new ChatOpenRouter({
|
|
304
|
+
model: 'openai/gpt-4.1',
|
|
305
|
+
apiKey: 'test-key',
|
|
306
|
+
useResponsesApi: true,
|
|
307
|
+
promptCache: false,
|
|
308
|
+
streaming: false,
|
|
309
|
+
});
|
|
310
|
+
const sentinel = new Error('captured override');
|
|
311
|
+
let capturedRequest: unknown;
|
|
312
|
+
const responses = (
|
|
313
|
+
router as unknown as {
|
|
314
|
+
responses: {
|
|
315
|
+
completionWithRetry: (request: unknown) => Promise<never>;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
).responses;
|
|
319
|
+
responses.completionWithRetry = async (request) => {
|
|
320
|
+
capturedRequest = request;
|
|
321
|
+
throw sentinel;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
await expect(
|
|
325
|
+
router.invoke([new HumanMessage('hello')], {
|
|
326
|
+
promptCache: true,
|
|
327
|
+
promptCacheTtl: '5m',
|
|
328
|
+
} as unknown as Parameters<typeof router.invoke>[1])
|
|
329
|
+
).rejects.toThrow(sentinel);
|
|
330
|
+
expect(capturedRequest).toMatchObject({
|
|
331
|
+
cache_control: { type: 'ephemeral' },
|
|
332
|
+
});
|
|
333
|
+
expect(JSON.stringify(capturedRequest)).not.toContain('"ttl"');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('removes tail block markers added after invocation normalization', async () => {
|
|
337
|
+
const router = new ChatOpenRouter({
|
|
338
|
+
model: 'openai/gpt-4.1',
|
|
339
|
+
apiKey: 'test-key',
|
|
340
|
+
useResponsesApi: true,
|
|
341
|
+
promptCache: true,
|
|
342
|
+
streaming: false,
|
|
343
|
+
});
|
|
344
|
+
const sentinel = new Error('captured tail cache');
|
|
345
|
+
let capturedRequest: unknown;
|
|
346
|
+
const responses = (
|
|
347
|
+
router as unknown as {
|
|
348
|
+
responses: {
|
|
349
|
+
completionWithRetry: (request: unknown) => Promise<never>;
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
).responses;
|
|
353
|
+
responses.completionWithRetry = async (request) => {
|
|
354
|
+
capturedRequest = request;
|
|
355
|
+
throw sentinel;
|
|
356
|
+
};
|
|
357
|
+
const messages = addTailCacheControl([
|
|
358
|
+
new HumanMessage('search'),
|
|
359
|
+
new AIMessage({
|
|
360
|
+
content: '',
|
|
361
|
+
tool_calls: [
|
|
362
|
+
{
|
|
363
|
+
id: 'call_cached_result',
|
|
364
|
+
name: 'search',
|
|
365
|
+
args: {},
|
|
366
|
+
type: 'tool_call',
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
}),
|
|
370
|
+
new ToolMessage({
|
|
371
|
+
content: 'result body',
|
|
372
|
+
tool_call_id: 'call_cached_result',
|
|
373
|
+
name: 'search',
|
|
374
|
+
}),
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
await expect(router.invoke(messages)).rejects.toThrow(sentinel);
|
|
378
|
+
|
|
379
|
+
const request = capturedRequest as {
|
|
380
|
+
cache_control?: unknown;
|
|
381
|
+
input?: Array<{ type?: string; output?: unknown }>;
|
|
382
|
+
};
|
|
383
|
+
expect(
|
|
384
|
+
request.input?.find((item) => item.type === 'function_call_output')
|
|
385
|
+
).toMatchObject({ output: 'result body' });
|
|
386
|
+
expect(request.cache_control).toEqual({
|
|
387
|
+
type: 'ephemeral',
|
|
388
|
+
ttl: '1h',
|
|
389
|
+
});
|
|
390
|
+
expect(JSON.stringify(request.input)).not.toContain('cache_control');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('normalizes cache markers for native Responses event streaming', async () => {
|
|
394
|
+
const router = new ChatOpenRouter({
|
|
395
|
+
model: 'openai/gpt-4.1',
|
|
396
|
+
apiKey: 'test-key',
|
|
397
|
+
useResponsesApi: true,
|
|
398
|
+
promptCache: true,
|
|
399
|
+
streaming: true,
|
|
400
|
+
});
|
|
401
|
+
let capturedRequest: unknown;
|
|
402
|
+
const responses = (
|
|
403
|
+
router as unknown as {
|
|
404
|
+
responses: {
|
|
405
|
+
completionWithRetry: (
|
|
406
|
+
request: unknown
|
|
407
|
+
) => Promise<AsyncIterable<never>>;
|
|
408
|
+
_streamChatModelEvents: (
|
|
409
|
+
messages: unknown[],
|
|
410
|
+
options: Record<string, unknown>
|
|
411
|
+
) => AsyncIterable<unknown>;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
).responses;
|
|
415
|
+
responses.completionWithRetry = async (request) => {
|
|
416
|
+
capturedRequest = request;
|
|
417
|
+
return {
|
|
418
|
+
async *[Symbol.asyncIterator]() {
|
|
419
|
+
yield* [];
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
const messages = addTailCacheControl([
|
|
424
|
+
new AIMessage({
|
|
425
|
+
content: '',
|
|
426
|
+
tool_calls: [
|
|
427
|
+
{
|
|
428
|
+
id: 'call_stream_cache',
|
|
429
|
+
name: 'search',
|
|
430
|
+
args: {},
|
|
431
|
+
type: 'tool_call',
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
}),
|
|
435
|
+
new ToolMessage({
|
|
436
|
+
content: 'stream result',
|
|
437
|
+
tool_call_id: 'call_stream_cache',
|
|
438
|
+
name: 'search',
|
|
439
|
+
}),
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
for await (const _event of responses._streamChatModelEvents(messages, {})) {
|
|
443
|
+
// The stubbed provider stream is empty; consuming it triggers request creation.
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const request = capturedRequest as {
|
|
447
|
+
input?: Array<{ type?: string; output?: unknown }>;
|
|
448
|
+
cache_control?: unknown;
|
|
449
|
+
};
|
|
450
|
+
expect(
|
|
451
|
+
request.input?.find((item) => item.type === 'function_call_output')
|
|
452
|
+
).toMatchObject({ output: 'stream result' });
|
|
453
|
+
expect(request.cache_control).toEqual({
|
|
454
|
+
type: 'ephemeral',
|
|
455
|
+
ttl: '1h',
|
|
456
|
+
});
|
|
457
|
+
expect(JSON.stringify(request.input)).not.toContain('cache_control');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('removes Chat-style tool cache markers from Responses tools', async () => {
|
|
461
|
+
const router = new ChatOpenRouter({
|
|
462
|
+
model: 'openai/gpt-4.1',
|
|
463
|
+
apiKey: 'test-key',
|
|
464
|
+
useResponsesApi: true,
|
|
465
|
+
promptCache: true,
|
|
466
|
+
streaming: false,
|
|
467
|
+
});
|
|
468
|
+
const sentinel = new Error('captured tool cache');
|
|
469
|
+
let capturedRequest: unknown;
|
|
470
|
+
const responses = (
|
|
471
|
+
router as unknown as {
|
|
472
|
+
responses: {
|
|
473
|
+
completionWithRetry: (request: unknown) => Promise<never>;
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
).responses;
|
|
477
|
+
responses.completionWithRetry = async (request) => {
|
|
478
|
+
capturedRequest = request;
|
|
479
|
+
throw sentinel;
|
|
480
|
+
};
|
|
481
|
+
const markedTools = partitionAndMarkOpenRouterToolCache(
|
|
482
|
+
[
|
|
483
|
+
{
|
|
484
|
+
type: 'function',
|
|
485
|
+
function: {
|
|
486
|
+
name: 'search',
|
|
487
|
+
description: 'Search records',
|
|
488
|
+
parameters: { type: 'object', properties: {} },
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
] as GraphTools,
|
|
492
|
+
() => false,
|
|
493
|
+
'1h'
|
|
494
|
+
)!;
|
|
495
|
+
const bound = router.bindTools(markedTools);
|
|
496
|
+
|
|
497
|
+
await expect(bound.invoke([new HumanMessage('search')])).rejects.toThrow(
|
|
498
|
+
sentinel
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const request = capturedRequest as {
|
|
502
|
+
cache_control?: unknown;
|
|
503
|
+
tools?: unknown[];
|
|
504
|
+
};
|
|
505
|
+
expect(request.cache_control).toEqual({
|
|
506
|
+
type: 'ephemeral',
|
|
507
|
+
ttl: '1h',
|
|
508
|
+
});
|
|
509
|
+
expect(JSON.stringify(request.tools)).not.toContain('cache_control');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('keeps top-level cache control when a computer tool auto-selects Responses', async () => {
|
|
513
|
+
const router = new ChatOpenRouter({
|
|
514
|
+
model: 'openai/gpt-4.1',
|
|
515
|
+
apiKey: 'test-key',
|
|
516
|
+
useResponsesApi: false,
|
|
517
|
+
promptCache: true,
|
|
518
|
+
streaming: false,
|
|
519
|
+
});
|
|
520
|
+
const sentinel = new Error('captured computer cache');
|
|
521
|
+
let capturedRequest: unknown;
|
|
522
|
+
const responses = (
|
|
523
|
+
router as unknown as {
|
|
524
|
+
responses: {
|
|
525
|
+
completionWithRetry: (request: unknown) => Promise<never>;
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
).responses;
|
|
529
|
+
responses.completionWithRetry = async (request) => {
|
|
530
|
+
capturedRequest = request;
|
|
531
|
+
throw sentinel;
|
|
532
|
+
};
|
|
533
|
+
const bound = router.bindTools([
|
|
534
|
+
openAITools.computerUse({
|
|
535
|
+
displayWidth: 1024,
|
|
536
|
+
displayHeight: 768,
|
|
537
|
+
environment: 'browser',
|
|
538
|
+
execute: async () => 'data:image/png;base64,AA==',
|
|
539
|
+
}),
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
await expect(bound.invoke([new HumanMessage('inspect')])).rejects.toThrow(
|
|
543
|
+
sentinel
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
expect(capturedRequest).toMatchObject({
|
|
547
|
+
cache_control: { type: 'ephemeral', ttl: '1h' },
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('keeps Chat prompt caching on content blocks', () => {
|
|
552
|
+
const router = new ChatOpenRouter({
|
|
553
|
+
model: 'anthropic/claude-sonnet-4',
|
|
554
|
+
apiKey: 'test-key',
|
|
555
|
+
useResponsesApi: false,
|
|
556
|
+
promptCache: true,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
expect(router.invocationParams().cache_control).toBeUndefined();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
3
|
import type { TokenCounter } from '@/types/run';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
maskConsumedToolResults,
|
|
6
|
+
ORIGINAL_CONTENT_MAX_CHARS,
|
|
7
|
+
} from '@/messages/prune';
|
|
5
8
|
|
|
6
9
|
const charCounter: TokenCounter = (msg) => {
|
|
7
10
|
const raw = msg.content;
|
|
8
11
|
if (typeof raw === 'string') return raw.length;
|
|
9
|
-
return
|
|
12
|
+
return JSON.stringify(raw).length;
|
|
10
13
|
};
|
|
11
14
|
|
|
12
15
|
function toolMsg(
|
|
@@ -209,6 +212,94 @@ describe('maskConsumedToolResults', () => {
|
|
|
209
212
|
expect(map[2]).toBe((messages[2].content as string).length);
|
|
210
213
|
});
|
|
211
214
|
|
|
215
|
+
it('masks consumed structured tool results with a bounded text preview', () => {
|
|
216
|
+
const tcId = 'tc-structured';
|
|
217
|
+
const toolMessage = new ToolMessage({
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: 'json',
|
|
221
|
+
rows: Array.from({ length: 20 }, (_, index) => ({
|
|
222
|
+
id: index,
|
|
223
|
+
value: `${'x'.repeat(100)}-${index}`,
|
|
224
|
+
})),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
tool_call_id: tcId,
|
|
228
|
+
name: 'run_select_query',
|
|
229
|
+
status: 'success',
|
|
230
|
+
artifact: { source: 'clickhouse' },
|
|
231
|
+
});
|
|
232
|
+
const messages: BaseMessage[] = [
|
|
233
|
+
new HumanMessage('query the table'),
|
|
234
|
+
aiToolCall(tcId, 'run_select_query'),
|
|
235
|
+
toolMessage,
|
|
236
|
+
aiWithText('The query returned 20 rows.'),
|
|
237
|
+
];
|
|
238
|
+
const map: Record<string, number | undefined> = {
|
|
239
|
+
0: 5,
|
|
240
|
+
1: 20,
|
|
241
|
+
2: 0,
|
|
242
|
+
3: 30,
|
|
243
|
+
};
|
|
244
|
+
const originalContentStore = new Map<number, string>();
|
|
245
|
+
const originalContent = JSON.stringify(toolMessage.content);
|
|
246
|
+
|
|
247
|
+
const count = maskConsumedToolResults({
|
|
248
|
+
messages,
|
|
249
|
+
indexTokenCountMap: map,
|
|
250
|
+
tokenCounter: charCounter,
|
|
251
|
+
originalContentStore,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(count).toBe(1);
|
|
255
|
+
expect(typeof messages[2].content).toBe('string');
|
|
256
|
+
expect(messages[2].content).toContain('truncated');
|
|
257
|
+
expect((messages[2].content as string).length).toBeLessThanOrEqual(300);
|
|
258
|
+
const masked = messages[2] as ToolMessage;
|
|
259
|
+
expect(masked.tool_call_id).toBe(tcId);
|
|
260
|
+
expect(masked.name).toBe('run_select_query');
|
|
261
|
+
expect(masked.status).toBe('success');
|
|
262
|
+
expect(masked.artifact).toEqual({ source: 'clickhouse' });
|
|
263
|
+
expect(map[2]).toBe(charCounter(masked));
|
|
264
|
+
expect(originalContentStore.get(2)).toBe(originalContent);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('bounds stored structured originals without invoking custom toJSON', () => {
|
|
268
|
+
const tcId = 'tc-large-structured';
|
|
269
|
+
const toJSON = jest.fn(() => ({ expanded: 'should not run' }));
|
|
270
|
+
const messages: BaseMessage[] = [
|
|
271
|
+
new HumanMessage('query the table'),
|
|
272
|
+
aiToolCall(tcId, 'run_select_query'),
|
|
273
|
+
new ToolMessage({
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: 'json',
|
|
277
|
+
payload: 'x'.repeat(ORIGINAL_CONTENT_MAX_CHARS + 1_000),
|
|
278
|
+
toJSON,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
tool_call_id: tcId,
|
|
282
|
+
name: 'run_select_query',
|
|
283
|
+
}),
|
|
284
|
+
aiWithText('The query completed.'),
|
|
285
|
+
];
|
|
286
|
+
const originalContentStore = new Map<number, string>();
|
|
287
|
+
|
|
288
|
+
const count = maskConsumedToolResults({
|
|
289
|
+
messages,
|
|
290
|
+
indexTokenCountMap: { 0: 1, 1: 1, 2: 1, 3: 1 },
|
|
291
|
+
tokenCounter: () => 1,
|
|
292
|
+
originalContentStore,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(count).toBe(1);
|
|
296
|
+
expect(toJSON).not.toHaveBeenCalled();
|
|
297
|
+
expect(originalContentStore.get(2)?.length).toBeLessThanOrEqual(
|
|
298
|
+
ORIGINAL_CONTENT_MAX_CHARS
|
|
299
|
+
);
|
|
300
|
+
expect(originalContentStore.get(2)).toContain('truncated');
|
|
301
|
+
});
|
|
302
|
+
|
|
212
303
|
it('handles empty messages array', () => {
|
|
213
304
|
const map: Record<string, number | undefined> = {};
|
|
214
305
|
const count = maskConsumedToolResults({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { convertMessagesToCompletionsMessageParams } from '@langchain/openai';
|
|
1
2
|
import {
|
|
2
3
|
AIMessage,
|
|
3
4
|
HumanMessage,
|
|
@@ -10,7 +11,15 @@ import type {
|
|
|
10
11
|
} from '@langchain/core/messages';
|
|
11
12
|
import type Anthropic from '@anthropic-ai/sdk';
|
|
12
13
|
import type { AnthropicMessages } from '@/types/messages';
|
|
14
|
+
import {
|
|
15
|
+
projectOpenAIChatToolMessageContent,
|
|
16
|
+
projectOpenAIToolMessageContent,
|
|
17
|
+
projectOpenAIResponsesToolMessageContent,
|
|
18
|
+
projectOpenRouterToolMessageContent,
|
|
19
|
+
projectComputerCallOutputsToText,
|
|
20
|
+
} from './core';
|
|
13
21
|
import { addTailCacheControl, addBedrockTailCacheControl } from './cache';
|
|
22
|
+
import { convertToConverseMessages } from '@/llm/bedrock/utils';
|
|
14
23
|
import { toLangChainContent } from './langchain';
|
|
15
24
|
|
|
16
25
|
type CacheControlBlock = MessageContentComplex & {
|
|
@@ -86,6 +95,154 @@ describe('addTailCacheControl (single tail breakpoint)', () => {
|
|
|
86
95
|
expect(blocksOf(result[2])[0].cache_control).toEqual({ type: 'ephemeral' });
|
|
87
96
|
});
|
|
88
97
|
|
|
98
|
+
test('skips native computer outputs and removes stale screenshot markers', () => {
|
|
99
|
+
const computerCall = new AIMessage({
|
|
100
|
+
content: '',
|
|
101
|
+
response_metadata: {
|
|
102
|
+
output: [
|
|
103
|
+
{
|
|
104
|
+
type: 'computer_call',
|
|
105
|
+
call_id: 'call_computer_cache',
|
|
106
|
+
action: { type: 'screenshot' },
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const computerOutput = new ToolMessage({
|
|
112
|
+
content: 'data:image/png;base64,AA==',
|
|
113
|
+
tool_call_id: 'call_computer_cache',
|
|
114
|
+
additional_kwargs: { type: 'computer_call_output' },
|
|
115
|
+
});
|
|
116
|
+
const canonical = projectOpenAIToolMessageContent([
|
|
117
|
+
new HumanMessage('Take a screenshot'),
|
|
118
|
+
computerCall,
|
|
119
|
+
computerOutput,
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const cached = addTailCacheControl(canonical);
|
|
123
|
+
|
|
124
|
+
expect(countCacheMarkers(cached)).toBe(1);
|
|
125
|
+
expect(blocksOf(cached[0])[0].cache_control).toEqual({
|
|
126
|
+
type: 'ephemeral',
|
|
127
|
+
});
|
|
128
|
+
expect(blocksOf(cached[2])[0]).not.toHaveProperty('cache_control');
|
|
129
|
+
expect(() =>
|
|
130
|
+
projectOpenAIResponsesToolMessageContent(cached)
|
|
131
|
+
).not.toThrow();
|
|
132
|
+
|
|
133
|
+
const canonicalOutput = canonical[2] as ToolMessage;
|
|
134
|
+
const staleOutput = new ToolMessage({
|
|
135
|
+
content: toLangChainContent([
|
|
136
|
+
{
|
|
137
|
+
...(canonicalOutput.content[0] as MessageContentComplex),
|
|
138
|
+
cache_control: { type: 'ephemeral' },
|
|
139
|
+
},
|
|
140
|
+
]),
|
|
141
|
+
tool_call_id: canonicalOutput.tool_call_id,
|
|
142
|
+
additional_kwargs: canonicalOutput.additional_kwargs,
|
|
143
|
+
});
|
|
144
|
+
const cleaned = addTailCacheControl([
|
|
145
|
+
canonical[0],
|
|
146
|
+
canonical[1],
|
|
147
|
+
staleOutput,
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
expect(countCacheMarkers(cleaned)).toBe(1);
|
|
151
|
+
expect(blocksOf(cleaned[2])[0]).not.toHaveProperty('cache_control');
|
|
152
|
+
expect(blocksOf(staleOutput)[0]).toHaveProperty('cache_control');
|
|
153
|
+
expect(() =>
|
|
154
|
+
projectOpenAIResponsesToolMessageContent(cleaned)
|
|
155
|
+
).not.toThrow();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('keeps a cached tool result structured through Chat projection', () => {
|
|
159
|
+
const toolCall = new AIMessage({
|
|
160
|
+
content: '',
|
|
161
|
+
tool_calls: [{ id: 'call_cached_tool', name: 'search', args: {} }],
|
|
162
|
+
});
|
|
163
|
+
const toolOutput = new ToolMessage({
|
|
164
|
+
content: 'result body',
|
|
165
|
+
tool_call_id: 'call_cached_tool',
|
|
166
|
+
});
|
|
167
|
+
const graphProjected = projectOpenAIToolMessageContent([
|
|
168
|
+
new HumanMessage('Search'),
|
|
169
|
+
toolCall,
|
|
170
|
+
toolOutput,
|
|
171
|
+
]);
|
|
172
|
+
const cached = addTailCacheControl(graphProjected);
|
|
173
|
+
const attemptProjected = projectOpenRouterToolMessageContent(cached);
|
|
174
|
+
const payload = convertMessagesToCompletionsMessageParams({
|
|
175
|
+
messages: attemptProjected,
|
|
176
|
+
model: 'gpt-4o',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(blocksOf(cached[2])[0]).toMatchObject({
|
|
180
|
+
type: 'text',
|
|
181
|
+
text: 'result body',
|
|
182
|
+
cache_control: { type: 'ephemeral' },
|
|
183
|
+
});
|
|
184
|
+
expect((attemptProjected[2] as ToolMessage).content).toEqual(
|
|
185
|
+
(cached[2] as ToolMessage).content
|
|
186
|
+
);
|
|
187
|
+
expect(payload[2]).toMatchObject({
|
|
188
|
+
role: 'tool',
|
|
189
|
+
tool_call_id: 'call_cached_tool',
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: 'text',
|
|
193
|
+
text: 'result body',
|
|
194
|
+
cache_control: { type: 'ephemeral' },
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const fallbackProjected = projectOpenAIChatToolMessageContent(cached);
|
|
200
|
+
const fallbackPayload = convertMessagesToCompletionsMessageParams({
|
|
201
|
+
messages: fallbackProjected,
|
|
202
|
+
model: 'gpt-4o',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect((fallbackProjected[2] as ToolMessage).content).toBe('result body');
|
|
206
|
+
expect(fallbackPayload[2]).toMatchObject({
|
|
207
|
+
role: 'tool',
|
|
208
|
+
tool_call_id: 'call_cached_tool',
|
|
209
|
+
content: 'result body',
|
|
210
|
+
});
|
|
211
|
+
expect(JSON.stringify(fallbackPayload[2])).not.toContain('cache_control');
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
typeof (projectOpenAIToolMessageContent(cached)[2] as ToolMessage).content
|
|
215
|
+
).toBe('string');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('does not stringify noncanonical cache metadata into tool output', () => {
|
|
219
|
+
const messages = addTailCacheControl<BaseMessage>([
|
|
220
|
+
new HumanMessage('Search'),
|
|
221
|
+
new AIMessage({
|
|
222
|
+
content: '',
|
|
223
|
+
tool_calls: [{ id: 'call_nested_cache', name: 'search', args: {} }],
|
|
224
|
+
}),
|
|
225
|
+
new ToolMessage({
|
|
226
|
+
content: toLangChainContent([
|
|
227
|
+
{
|
|
228
|
+
type: 'tool_result',
|
|
229
|
+
tool_use_id: 'call_nested_cache',
|
|
230
|
+
content: 'result body',
|
|
231
|
+
},
|
|
232
|
+
] as MessageContentComplex[]),
|
|
233
|
+
tool_call_id: 'call_nested_cache',
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const projected = projectOpenRouterToolMessageContent(messages);
|
|
238
|
+
const content = (projected[2] as ToolMessage).content;
|
|
239
|
+
|
|
240
|
+
expect(typeof content).toBe('string');
|
|
241
|
+
expect(content).toContain('result body');
|
|
242
|
+
expect(content).not.toContain('cache_control');
|
|
243
|
+
expect(JSON.stringify(messages[2].content)).toContain('cache_control');
|
|
244
|
+
});
|
|
245
|
+
|
|
89
246
|
test('strips ALL stale markers and re-anchors a single one at the tail', () => {
|
|
90
247
|
const messages: BaseMessage[] = [
|
|
91
248
|
new HumanMessage({
|
|
@@ -337,4 +494,40 @@ describe('addBedrockTailCacheControl (single tail cachePoint)', () => {
|
|
|
337
494
|
{ cachePoint: { type: 'default' } },
|
|
338
495
|
]);
|
|
339
496
|
});
|
|
497
|
+
|
|
498
|
+
test('keeps the cachePoint before an omitted computer screenshot', () => {
|
|
499
|
+
const computerOutput = new ToolMessage({
|
|
500
|
+
content: 'data:image/png;base64,AA==',
|
|
501
|
+
tool_call_id: 'call_bedrock_computer',
|
|
502
|
+
additional_kwargs: { type: 'computer_call_output' },
|
|
503
|
+
});
|
|
504
|
+
const cached = addBedrockTailCacheControl([
|
|
505
|
+
new HumanMessage('Take a screenshot'),
|
|
506
|
+
new AIMessage({
|
|
507
|
+
content: '',
|
|
508
|
+
tool_calls: [
|
|
509
|
+
{
|
|
510
|
+
id: 'call_bedrock_computer',
|
|
511
|
+
name: 'computer',
|
|
512
|
+
args: { action: 'screenshot' },
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
}),
|
|
516
|
+
computerOutput,
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
expect(countCachePoints(cached)).toBe(1);
|
|
520
|
+
expect(blocksOf(cached[0])[1]).toEqual({
|
|
521
|
+
cachePoint: { type: 'default' },
|
|
522
|
+
});
|
|
523
|
+
expect(cached[2].content).toBe(computerOutput.content);
|
|
524
|
+
|
|
525
|
+
const projected = projectComputerCallOutputsToText(cached);
|
|
526
|
+
const converse = convertToConverseMessages(projected);
|
|
527
|
+
expect((projected[2] as ToolMessage).content).toBe(
|
|
528
|
+
'[Computer screenshot omitted for this provider]'
|
|
529
|
+
);
|
|
530
|
+
expect(JSON.stringify(converse).match(/"cachePoint"/g)).toHaveLength(1);
|
|
531
|
+
expect(computerOutput.content).toBe('data:image/png;base64,AA==');
|
|
532
|
+
});
|
|
340
533
|
});
|