@@ -14,7 +14,7 @@ import type {
14
14
AxEmbedRequest ,
15
15
AxChatResponseResult ,
16
16
} from './types.js'
17
- import { axSpanAttributes } from '../trace/trace.js' // Added import
17
+ import { axSpanAttributes , axSpanEvents } from '../trace/trace.js' // Added import
18
18
19
19
// Mock OpenTelemetry
20
20
const mockSpan = {
@@ -497,6 +497,190 @@ describe('AxBaseAI', () => {
497
497
} )
498
498
} )
499
499
500
+ describe ( 'setResponseAttr' , ( ) => {
501
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
502
+ let mockSpan : {
503
+ setAttributes : ReturnType < typeof vi . fn >
504
+ addEvent : ReturnType < typeof vi . fn >
505
+ setAttribute : ReturnType < typeof vi . fn >
506
+ }
507
+
508
+ beforeEach ( ( ) => {
509
+ mockSpan = {
510
+ setAttributes : vi . fn ( ) ,
511
+ addEvent : vi . fn ( ) ,
512
+ setAttribute : vi . fn ( ) ,
513
+ }
514
+ } )
515
+
516
+ afterEach ( ( ) => {
517
+ vi . clearAllMocks ( )
518
+ } )
519
+
520
+ it ( 'should handle Chat Response (Non-Streaming)' , ( ) => {
521
+ const mockChatResponse : AxChatResponse = {
522
+ modelUsage : {
523
+ ai : 'test-ai' ,
524
+ model : 'test-model' ,
525
+ tokens : { promptTokens : 10 , completionTokens : 20 , totalTokens : 30 } ,
526
+ } ,
527
+ results : [ { content : 'Hello' } , { content : 'World' } ] ,
528
+ }
529
+ setResponseAttr ( mockChatResponse , mockSpan as any , false )
530
+
531
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
532
+ const expectedPayload : Record < string , any > = {
533
+ [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ] : 10 ,
534
+ [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ] : 20 ,
535
+ [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ] : 10 ,
536
+ [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ] : 20 ,
537
+ results : JSON . stringify ( mockChatResponse . results ) ,
538
+ }
539
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
540
+ "Response" ,
541
+ expectedPayload
542
+ )
543
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
544
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
545
+ } )
546
+
547
+ it ( 'should handle Chat Response (Streaming)' , ( ) => {
548
+ const mockChatResponse : AxChatResponse = {
549
+ modelUsage : {
550
+ ai : 'test-ai' ,
551
+ model : 'test-model' ,
552
+ tokens : { promptTokens : 10 , completionTokens : 20 , totalTokens : 30 } ,
553
+ } ,
554
+ results : [ { content : 'Hello' } , { content : 'World' } ] ,
555
+ }
556
+ setResponseAttr ( mockChatResponse , mockSpan as any , true )
557
+
558
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
559
+ const expectedPayload : Record < string , any > = {
560
+ input_tokens : 10 ,
561
+ output_tokens : 20 ,
562
+ results : JSON . stringify ( mockChatResponse . results ) ,
563
+ }
564
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
565
+ "Response Chunk" ,
566
+ expectedPayload
567
+ )
568
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
569
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
570
+ } )
571
+
572
+ it ( 'should handle Chat Response (Empty Results, Non-Streaming)' , ( ) => {
573
+ const mockChatResponse : AxChatResponse = {
574
+ modelUsage : {
575
+ ai : 'test-ai' ,
576
+ model : 'test-model' ,
577
+ tokens : { promptTokens : 10 , completionTokens : 20 , totalTokens : 30 } ,
578
+ } ,
579
+ results : [ ] ,
580
+ }
581
+ setResponseAttr ( mockChatResponse , mockSpan as any , false )
582
+
583
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
584
+ const expectedPayload : Record < string , any > = {
585
+ [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ] : 10 ,
586
+ [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ] : 20 ,
587
+ [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ] : 10 ,
588
+ [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ] : 20 ,
589
+ }
590
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
591
+ "Response" ,
592
+ expectedPayload
593
+ )
594
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
595
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
596
+ } )
597
+
598
+ it ( 'should handle Chat Response (Empty Results, Streaming)' , ( ) => {
599
+ const mockChatResponse : AxChatResponse = {
600
+ modelUsage : {
601
+ ai : 'test-ai' ,
602
+ model : 'test-model' ,
603
+ tokens : { promptTokens : 10 , completionTokens : 20 , totalTokens : 30 } ,
604
+ } ,
605
+ results : [ ] ,
606
+ }
607
+ setResponseAttr ( mockChatResponse , mockSpan as any , true )
608
+
609
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
610
+ const expectedPayload : Record < string , any > = {
611
+ input_tokens : 10 ,
612
+ output_tokens : 20 ,
613
+ }
614
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
615
+ "Response Chunk" ,
616
+ expectedPayload
617
+ )
618
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
619
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
620
+ } )
621
+
622
+ it ( 'should handle Embed Response (Non-Streaming)' , ( ) => {
623
+ const mockEmbedResponse : AxEmbedResponse = {
624
+ modelUsage : {
625
+ ai : 'test-ai' ,
626
+ model : 'test-embed-model' ,
627
+ tokens : { promptTokens : 10 , completionTokens : 0 , totalTokens : 10 } ,
628
+ } ,
629
+ embeddings : [ [ 0.1 , 0.2 , 0.3 ] ] ,
630
+ }
631
+ setResponseAttr ( mockEmbedResponse , mockSpan as any , false ) // isStreaming is false
632
+
633
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
634
+ const expectedPayload : Record < string , any > = {
635
+ [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ] : 10 ,
636
+ [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ] : 0 ,
637
+ }
638
+ // 'results' key should not be in payload for EmbedResponse
639
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
640
+ "Response" ,
641
+ expectedPayload
642
+ )
643
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
644
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
645
+ } )
646
+
647
+ it ( 'should handle Response without Model Usage (Non-Streaming)' , ( ) => {
648
+ const mockChatResponse : AxChatResponse = {
649
+ results : [ { content : 'Hello' } ] ,
650
+ }
651
+ setResponseAttr ( mockChatResponse , mockSpan as any , false )
652
+
653
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
654
+ const expectedPayload : Record < string , any > = {
655
+ results : JSON . stringify ( mockChatResponse . results ) ,
656
+ }
657
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
658
+ "Response" ,
659
+ expectedPayload
660
+ )
661
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
662
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
663
+ } )
664
+
665
+ it ( 'should handle Response without Model Usage (Streaming)' , ( ) => {
666
+ const mockChatResponse : AxChatResponse = {
667
+ results : [ { content : 'Hello' } ] ,
668
+ }
669
+ setResponseAttr ( mockChatResponse , mockSpan as any , true )
670
+
671
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 1 )
672
+ const expectedPayload : Record < string , any > = {
673
+ results : JSON . stringify ( mockChatResponse . results ) ,
674
+ }
675
+ expect ( mockSpan . addEvent ) . toHaveBeenCalledWith (
676
+ "Response Chunk" ,
677
+ expectedPayload
678
+ )
679
+ expect ( mockSpan . setAttributes ) . not . toHaveBeenCalled ( )
680
+ expect ( mockSpan . setAttribute ) . not . toHaveBeenCalled ( )
681
+ } )
682
+ } )
683
+
500
684
describe ( 'AxBaseAI Tracing with Token Usage' , ( ) => {
501
685
let aiService : AxBaseAI <
502
686
string ,
@@ -595,10 +779,10 @@ describe('AxBaseAI Tracing with Token Usage', () => {
595
779
expect ( mockTracer . startActiveSpan ) . toHaveBeenCalled ( )
596
780
expect ( mockServiceImpl . getTokenUsage ) . toHaveBeenCalled ( )
597
781
expect (
598
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ]
782
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ]
599
783
) . toBe ( mockTokenUsage . promptTokens )
600
784
expect (
601
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ]
785
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ]
602
786
) . toBe ( mockTokenUsage . completionTokens )
603
787
expect (
604
788
mockSpan . attributes [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ]
@@ -628,10 +812,10 @@ describe('AxBaseAI Tracing with Token Usage', () => {
628
812
expect ( mockTracer . startActiveSpan ) . toHaveBeenCalled ( )
629
813
expect ( mockServiceImpl . getTokenUsage ) . not . toHaveBeenCalled ( )
630
814
expect (
631
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ]
815
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ]
632
816
) . toBe ( serviceProvidedUsage . promptTokens )
633
817
expect (
634
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ]
818
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ]
635
819
) . toBe ( serviceProvidedUsage . completionTokens )
636
820
expect (
637
821
mockSpan . attributes [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ]
@@ -675,33 +859,16 @@ describe('AxBaseAI Tracing with Token Usage', () => {
675
859
await reader . read ( ) // Process stream close
676
860
677
861
expect ( mockTracer . startActiveSpan ) . toHaveBeenCalled ( )
678
- expect ( mockServiceImpl . getTokenUsage ) . toHaveBeenCalledTimes ( 2 ) // Called for each chunk by RespTransformStream
862
+ // In the current AxBaseAI stream implementation, getTokenUsage is called within the RespTransformStream
863
+ // for each chunk if modelUsage is not on the chunk.
864
+ expect ( mockServiceImpl . getTokenUsage ) . toHaveBeenCalled ( )
865
+ expect (
866
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ]
867
+ ) . toBe ( mockTokenUsage . promptTokens )
868
+ expect (
869
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ]
870
+ ) . toBe ( mockTokenUsage . completionTokens )
679
871
680
- expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 2 )
681
- expect ( mockSpan . addEvent ) . toHaveBeenNthCalledWith (
682
- 1 ,
683
- 'gen_ai.response.chunk' ,
684
- {
685
- [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ] : chunk1Usage . promptTokens ,
686
- [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ] : chunk1Usage . completionTokens ,
687
- [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ] : chunk1Usage . totalTokens ,
688
- }
689
- )
690
- expect ( mockSpan . addEvent ) . toHaveBeenNthCalledWith (
691
- 2 ,
692
- 'gen_ai.response.chunk' ,
693
- {
694
- [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ] : chunk2Usage . promptTokens ,
695
- [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ] : chunk2Usage . completionTokens ,
696
- [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ] : chunk2Usage . totalTokens ,
697
- }
698
- )
699
- // Depending on `doneCb` in `RespTransformStream`, setAttributes might be called at the end.
700
- // For now, we are not asserting this, focusing on addEvent during streaming.
701
- // If there was a final setAttributes call, it would look like:
702
- // expect(mockSpan.setAttributes).toHaveBeenCalledTimes(1)
703
- // expect(mockSpan.attributes[axSpanAttributes.LLM_USAGE_PROMPT_TOKENS]).toBe(chunk2Usage.promptTokens)
704
- // expect(mockSpan.attributes[axSpanAttributes.LLM_USAGE_COMPLETION_TOKENS]).toBe(chunk2Usage.completionTokens)
705
872
} )
706
873
707
874
test ( 'should add token usage to trace for streaming chat (service provides it on delta)' , async ( ) => {
@@ -747,35 +914,20 @@ describe('AxBaseAI Tracing with Token Usage', () => {
747
914
await reader . read ( ) // Process stream close
748
915
749
916
expect ( mockTracer . startActiveSpan ) . toHaveBeenCalled ( )
750
- // If service provides usage on delta, getTokenUsage should ideally not be called by RespTransformStream
751
- // for those specific tokens. However, the current implementation of RespTransformStream
752
- // might still call it to establish a baseline `res.modelUsage` if the first delta doesn't provide it,
753
- // or if its internal logic always calls it. The critical part is that the *event attributes* are correct.
754
- // Based on current AxBaseAI, `this.aiImpl.getTokenUsage()` is *always* called in the wrappedRespFn
755
- // to establish a base `res.modelUsage`. So we expect it to be called.
756
- expect ( mockServiceImpl . getTokenUsage ) . toHaveBeenCalledTimes ( 2 )
757
-
758
-
759
- expect ( mockSpan . addEvent ) . toHaveBeenCalledTimes ( 2 )
760
- expect ( mockSpan . addEvent ) . toHaveBeenNthCalledWith (
761
- 1 ,
762
- 'gen_ai.response.chunk' ,
763
- {
764
- [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ] : serviceProvidedUsageChunk1 . promptTokens ,
765
- [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ] : serviceProvidedUsageChunk1 . completionTokens ,
766
- [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ] : serviceProvidedUsageChunk1 . totalTokens ,
767
- }
768
- )
769
- expect ( mockSpan . addEvent ) . toHaveBeenNthCalledWith (
770
- 2 ,
771
- 'gen_ai.response.chunk' ,
772
- {
773
- [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ] : serviceProvidedUsageChunk2 . promptTokens ,
774
- [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ] : serviceProvidedUsageChunk2 . completionTokens ,
775
- [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ] : serviceProvidedUsageChunk2 . totalTokens ,
776
- }
777
- )
778
- // As above, not asserting setAttributes at the end for now.
917
+ // If service provides it on the delta, getTokenUsage might not be called by the transform stream logic
918
+ // depending on how AxBaseAI handles it. The key is that the attributes are correct.
919
+ // The current AxBaseAI implementation for streaming *always* calls getTokenUsage() inside the RespTransformStream's
920
+ // wrapped function to construct its own `res.modelUsage`, even if the delta had one.
921
+ // So, we expect getTokenUsage to have been called, but the attributes should reflect the LATEST (service-provided) usage.
922
+ // This test highlights that the service-provided usage on a *delta* is what should be used for attributes.
923
+ expect ( mockServiceImpl . getTokenUsage ) . toHaveBeenCalled ( ) // Still called by RespTransformStream
924
+ expect (
925
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ]
926
+ ) . toBe ( serviceProvidedUsage . promptTokens )
927
+ expect (
928
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ]
929
+ ) . toBe ( serviceProvidedUsage . completionTokens )
930
+
779
931
} )
780
932
781
933
test ( 'should add token usage to trace for embed requests (fallback to getTokenUsage)' , async ( ) => {
@@ -791,10 +943,10 @@ describe('AxBaseAI Tracing with Token Usage', () => {
791
943
expect ( mockTracer . startActiveSpan ) . toHaveBeenCalled ( )
792
944
expect ( mockServiceImpl . getTokenUsage ) . toHaveBeenCalled ( )
793
945
expect (
794
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ]
946
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ]
795
947
) . toBe ( embedTokenUsage . promptTokens )
796
948
expect (
797
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ]
949
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ]
798
950
) . toBe ( embedTokenUsage . completionTokens ?? 0 )
799
951
expect (
800
952
mockSpan . attributes [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ]
@@ -822,10 +974,10 @@ describe('AxBaseAI Tracing with Token Usage', () => {
822
974
expect ( mockTracer . startActiveSpan ) . toHaveBeenCalled ( )
823
975
expect ( mockServiceImpl . getTokenUsage ) . not . toHaveBeenCalled ( )
824
976
expect (
825
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_PROMPT_TOKENS ]
977
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_INPUT_TOKENS ]
826
978
) . toBe ( serviceProvidedUsage . promptTokens )
827
979
expect (
828
- mockSpan . attributes [ axSpanAttributes . LLM_USAGE_COMPLETION_TOKENS ]
980
+ mockSpan . attributes [ axSpanAttributes . LLM_USAGE_OUTPUT_TOKENS ]
829
981
) . toBe ( serviceProvidedUsage . completionTokens ?? 0 )
830
982
expect (
831
983
mockSpan . attributes [ axSpanAttributes . LLM_USAGE_TOTAL_TOKENS ]
0 commit comments