22 * @vitest -environment node
33 */
44import { describe , expect , it , vi } from 'vitest'
5+
6+ const { mockRecordCostCharged } = vi . hoisted ( ( ) => ( { mockRecordCostCharged : vi . fn ( ) } ) )
7+
8+ vi . mock ( '@/providers/utils' , ( ) => ( {
9+ calculateCost : vi . fn ( ( ) => ( { input : 1 , output : 2 , total : 3 , pricing : { } } ) ) ,
10+ } ) )
11+ vi . mock ( '@/lib/core/config/env-flags' , ( ) => ( { getCostMultiplier : ( ) => 1 } ) )
12+ vi . mock ( '@/lib/monitoring/metrics' , ( ) => ( {
13+ hostedKeyMetrics : { recordCostCharged : mockRecordCostCharged } ,
14+ } ) )
15+
516import type { NormalizedBlockOutput } from '@/executor/types'
617import { createStreamingExecution } from '@/providers/streaming-execution'
718
@@ -27,6 +38,51 @@ describe('createStreamingExecution', () => {
2738 const providerStartTime = 1_000
2839 const providerStartTimeISO = new Date ( providerStartTime ) . toISOString ( )
2940
41+ it ( 'settles hosted-key cost on stream drain even when finalizeTiming is never called (post-tool path)' , async ( ) => {
42+ mockRecordCostCharged . mockClear ( )
43+ // A source stream that closes immediately, mirroring a drained provider stream.
44+ const sourceStream = new ReadableStream ( { start : ( c ) => c . close ( ) } )
45+
46+ const result = createStreamingExecution ( {
47+ model : 'test-model' ,
48+ providerStartTime,
49+ providerStartTimeISO,
50+ timing : {
51+ kind : 'accumulated' ,
52+ modelTime : 1 ,
53+ toolsTime : 0 ,
54+ firstResponseTime : 1 ,
55+ iterations : 1 ,
56+ timeSegments : [ ] ,
57+ } ,
58+ initialTokens : { input : 0 , output : 0 , total : 0 } ,
59+ initialCost : { input : 0 , output : 0 , total : 0 } ,
60+ hostedKey : { provider : 'openai' , envVar : 'OPENAI_API_KEY_1' } ,
61+ cached : false ,
62+ // Post-tool streaming path: sets final tokens but never calls finalizeTiming.
63+ createStream : ( { output } ) => {
64+ output . tokens = { input : 100 , output : 50 , total : 150 }
65+ return sourceStream
66+ } ,
67+ } )
68+
69+ // Cost not settled until the stream is actually drained.
70+ expect ( mockRecordCostCharged ) . not . toHaveBeenCalled ( )
71+
72+ const reader = result . stream . getReader ( )
73+ while ( ! ( await reader . read ( ) ) . done ) {
74+ // drain
75+ }
76+
77+ // Settlement ran on drain: cost recomputed from final tokens, metric emitted once.
78+ expect ( result . execution . output . cost ) . toEqual ( { input : 1 , output : 2 , total : 3 , pricing : { } } )
79+ expect ( mockRecordCostCharged ) . toHaveBeenCalledTimes ( 1 )
80+ expect ( mockRecordCostCharged ) . toHaveBeenCalledWith ( 3 , {
81+ provider : 'openai' ,
82+ tool : 'test-model' ,
83+ } )
84+ } )
85+
3086 it ( 'assembles the simple (no-tools) shape and finalizes timing on drain' , ( ) => {
3187 const drainTime = 5_000
3288 vi . spyOn ( Date , 'now' ) . mockReturnValue ( drainTime )
0 commit comments