@librechat/agents 3.2.2 → 3.2.21

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.
@@ -135,6 +135,22 @@ type OpenRouterReasoningStreamChoice = Omit<
135
135
  > & {
136
136
  delta: OpenRouterReasoningStreamDelta;
137
137
  };
138
+ type OpenAICompatibleReasoningStreamDelta =
139
+ OpenAIClient.Chat.Completions.ChatCompletionChunk.Choice.Delta & {
140
+ reasoning?: string;
141
+ reasoning_details?: Array<{
142
+ type: 'reasoning.text';
143
+ text?: string;
144
+ format?: string;
145
+ index?: number;
146
+ }>;
147
+ };
148
+ type OpenAICompatibleReasoningStreamChoice = Omit<
149
+ OpenAIClient.Chat.Completions.ChatCompletionChunk.Choice,
150
+ 'delta'
151
+ > & {
152
+ delta: OpenAICompatibleReasoningStreamDelta;
153
+ };
138
154
  type PromptTokensDetailsWithCacheWrite = NonNullable<
139
155
  OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
140
156
  > & {
@@ -472,6 +488,104 @@ describe('custom chat model class smoke tests', () => {
472
488
  );
473
489
  });
474
490
 
491
+ it('preserves OpenAI-compatible reasoning deltas during OpenAI streaming', async () => {
492
+ const model = new ChatOpenAI({
493
+ model: 'openai/gpt-oss-120b',
494
+ apiKey: 'test-key',
495
+ streaming: true,
496
+ });
497
+ const completions = (model as unknown as StreamingCompletionBackedModel)
498
+ .completions;
499
+ const createChunk = (
500
+ choice: OpenAICompatibleReasoningStreamChoice
501
+ ): OpenAIClient.Chat.Completions.ChatCompletionChunk => ({
502
+ id: 'chatcmpl-openai-compatible-reasoning',
503
+ object: 'chat.completion.chunk',
504
+ created: 0,
505
+ model: 'openai/gpt-oss-120b',
506
+ choices: [choice],
507
+ });
508
+
509
+ async function* streamChunks(): AsyncGenerator<OpenAIClient.Chat.Completions.ChatCompletionChunk> {
510
+ yield createChunk({
511
+ index: 0,
512
+ delta: {
513
+ role: 'assistant',
514
+ content: '',
515
+ },
516
+ finish_reason: null,
517
+ });
518
+ yield createChunk({
519
+ index: 0,
520
+ delta: {
521
+ reasoning: 'Think ',
522
+ reasoning_details: [
523
+ {
524
+ type: 'reasoning.text',
525
+ text: 'Think ',
526
+ format: 'text',
527
+ index: 0,
528
+ },
529
+ ],
530
+ },
531
+ finish_reason: null,
532
+ });
533
+ yield createChunk({
534
+ index: 0,
535
+ delta: {
536
+ reasoning: 'hard',
537
+ reasoning_details: [
538
+ {
539
+ type: 'reasoning.text',
540
+ text: 'hard',
541
+ format: 'text',
542
+ index: 0,
543
+ },
544
+ ],
545
+ },
546
+ finish_reason: null,
547
+ });
548
+ yield createChunk({
549
+ index: 0,
550
+ delta: { content: 'answer' },
551
+ finish_reason: 'stop',
552
+ });
553
+ }
554
+
555
+ completions.completionWithRetry = async (): Promise<
556
+ AsyncIterable<OpenAIClient.Chat.Completions.ChatCompletionChunk>
557
+ > => streamChunks();
558
+
559
+ const chunks: AIMessageChunk[] = [];
560
+ const stream = await model.stream([new HumanMessage('think')]);
561
+ for await (const chunk of stream) {
562
+ chunks.push(chunk);
563
+ }
564
+
565
+ expect(
566
+ chunks
567
+ .map((chunk) => chunk.additional_kwargs.reasoning_content)
568
+ .filter((reasoningContent) => reasoningContent != null)
569
+ ).toEqual(['Think ', 'hard']);
570
+ expect(chunks[1].additional_kwargs.reasoning_details).toEqual([
571
+ {
572
+ type: 'reasoning.text',
573
+ text: 'Think ',
574
+ format: 'text',
575
+ index: 0,
576
+ },
577
+ ]);
578
+ expect(chunks[2].additional_kwargs.reasoning_details).toEqual([
579
+ {
580
+ type: 'reasoning.text',
581
+ text: 'hard',
582
+ format: 'text',
583
+ index: 0,
584
+ },
585
+ ]);
586
+ expect(chunks.at(-1)?.content).toBe('answer');
587
+ });
588
+
475
589
  it('skips custom OpenAI-compatible SSE events during Azure streaming', async () => {
476
590
  await expectCustomSSEEventsSkipped(
477
591
  new AzureChatOpenAI({
package/src/run.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  // src/run.ts
2
- import { initializeLangfuseTracing } from './instrumentation';
2
+ import {
3
+ initializeLangfuseTracing,
4
+ runWithTraceIdSeed,
5
+ } from './instrumentation';
3
6
  import { PromptTemplate } from '@langchain/core/prompts';
4
7
  import { RunnableLambda } from '@langchain/core/runnables';
5
8
  import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai';
@@ -552,9 +555,7 @@ export class Run<_T extends t.BaseGraphState> {
552
555
  }
553
556
 
554
557
  private shouldClearHookSession(streamThrew: boolean): boolean {
555
- return (
556
- this._interrupt == null || this._haltedReason != null || streamThrew
557
- );
558
+ return this._interrupt == null || this._haltedReason != null || streamThrew;
558
559
  }
559
560
 
560
561
  private isAwaitingResume(streamThrew: boolean): boolean {
@@ -584,9 +585,7 @@ export class Run<_T extends t.BaseGraphState> {
584
585
  private getStreamToolOutputTracingLangfuseConfig(
585
586
  graph: StandardGraph | MultiAgentGraph
586
587
  ): t.LangfuseConfig | undefined {
587
- const toolOutputTracingConfigs = Array.from(
588
- graph.agentContexts.values()
589
- )
588
+ const toolOutputTracingConfigs = Array.from(graph.agentContexts.values())
590
589
  .map((context) => {
591
590
  return resolveLangfuseConfig(this.langfuse, context.langfuse)
592
591
  ?.toolOutputTracing;
@@ -906,10 +905,19 @@ export class Run<_T extends t.BaseGraphState> {
906
905
  };
907
906
 
908
907
  try {
909
- await withLangfuseToolOutputTracingConfig(
910
- streamLangfuseConfig,
911
- consumeStream,
912
- this.getStreamToolOutputTracingLangfuseConfig(graph)
908
+ // When opted in, seed the root trace id from this run's id so feedback /
909
+ // other external signals can be attached to the trace later without a
910
+ // lookup (see SeededTraceIdGenerator in ./instrumentation).
911
+ await runWithTraceIdSeed(
912
+ streamLangfuseConfig?.deterministicTraceId === true
913
+ ? this.id
914
+ : undefined,
915
+ () =>
916
+ withLangfuseToolOutputTracingConfig(
917
+ streamLangfuseConfig,
918
+ consumeStream,
919
+ this.getStreamToolOutputTracingLangfuseConfig(graph)
920
+ )
913
921
  );
914
922
  } catch (err) {
915
923
  streamThrew = true;
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ initializeLangfuseTracing,
4
+ runWithTraceIdSeed,
5
+ } from '@/instrumentation';
6
+
7
+ const langfuse = {
8
+ publicKey: 'pk-lf-test',
9
+ secretKey: 'sk-lf-test',
10
+ baseUrl: 'http://localhost:3999',
11
+ };
12
+
13
+ /** sha256(seed) → first 32 hex chars; what `@langfuse/tracing` `createTraceId` produces. */
14
+ const expectedTraceId = (seed: string): string =>
15
+ createHash('sha256').update(seed, 'utf8').digest('hex').slice(0, 32);
16
+
17
+ describe('deterministic Langfuse trace ids', () => {
18
+ it('derives the root trace id from the seed when run inside runWithTraceIdSeed', () => {
19
+ const provider = initializeLangfuseTracing(langfuse);
20
+ expect(provider).toBeDefined();
21
+ const tracer = provider!.getTracer('deterministic-trace-id-test');
22
+
23
+ const seed = 'response-message-id-1234';
24
+ let traceId: string | undefined;
25
+ runWithTraceIdSeed(seed, () => {
26
+ const span = tracer.startSpan('AgentRun');
27
+ traceId = span.spanContext().traceId;
28
+ span.end();
29
+ });
30
+
31
+ expect(traceId).toBe(expectedTraceId(seed));
32
+ });
33
+
34
+ it('falls back to a random trace id when no seed is active', () => {
35
+ const provider = initializeLangfuseTracing(langfuse);
36
+ const tracer = provider!.getTracer('deterministic-trace-id-test');
37
+
38
+ const span = tracer.startSpan('AgentRun');
39
+ const traceId = span.spanContext().traceId;
40
+ span.end();
41
+
42
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
43
+ expect(traceId).not.toBe(expectedTraceId('response-message-id-1234'));
44
+ });
45
+
46
+ it('runWithTraceIdSeed is a passthrough when the seed is undefined', () => {
47
+ const sentinel = {};
48
+ expect(runWithTraceIdSeed(undefined, () => sentinel)).toBe(sentinel);
49
+ });
50
+ });
@@ -445,6 +445,14 @@ export interface LangfuseConfig {
445
445
  baseUrl?: string;
446
446
  toolNodeTracing?: LangfuseToolNodeTracingConfig;
447
447
  toolOutputTracing?: LangfuseToolOutputTracingConfig;
448
+ /**
449
+ * When true, derive the run's root Langfuse trace id deterministically from
450
+ * its `runId` (`sha256(runId)` → 32 hex chars, matching `@langfuse/tracing`
451
+ * `createTraceId`) instead of a random id. This lets external systems attach
452
+ * scores or observations to the trace afterwards by regenerating the same id
453
+ * from the run/message id, without a trace lookup. Default: random ids.
454
+ */
455
+ deterministicTraceId?: boolean;
448
456
  }
449
457
 
450
458
  export interface AgentInputs {