@@ -25,6 +25,42 @@ const enabledHook: LifecycleHookStatus = {
2525 enabled : true ,
2626} ;
2727
28+ function attributionHarness ( selectedSource = { provider : "provider-a" , model : "model-a" } ) {
29+ const captured : { event : string ; properties : Record < string , unknown > } [ ] = [ ] ;
30+ const telemetry : Telemetry = {
31+ enabled : true ,
32+ installationId : "test" ,
33+ capture : ( capturedEvent , properties = { } ) => {
34+ captured . push ( { event : capturedEvent , properties } ) ;
35+ } ,
36+ captureIntentional : ( ) => false ,
37+ flush : async ( ) => { } ,
38+ discard : ( ) => { } ,
39+ } ;
40+ const observer = createTurnObserver ( {
41+ telemetry : ( ) => telemetry ,
42+ getSessionId : ( ) => "session-1" ,
43+ getSource : ( ) => selectedSource ,
44+ } ) ;
45+ const runSink = createRunSink ( {
46+ emitter : new EventEmitter ( ) ,
47+ hookManager : stubHookManager ( [ ] ) ,
48+ ...observer ,
49+ } ) ;
50+ return { captured, runSink } ;
51+ }
52+
53+ function failMessageRun ( runSink : ReturnType < typeof createRunSink > ) : void {
54+ runSink . sink ( event ( "inference.error" , { error : { message : "attempt failed" } } ) ) ;
55+ runSink . sink (
56+ event ( "message.run.ended" , {
57+ messageRunId : "run-1" ,
58+ messageId : "message-1" ,
59+ status : "failed" ,
60+ } ) ,
61+ ) ;
62+ }
63+
2864describe ( "createRunSink" , ( ) => {
2965 test ( "allocates no turn collector when no lifecycle hooks are configured" , ( ) => {
3066 const runSink = createRunSink ( {
@@ -138,52 +174,72 @@ describe("createRunSink", () => {
138174 expect ( failures ) . toEqual ( [ { turnIndex : 0 , error : "429 rate limit" } ] ) ;
139175 } ) ;
140176
141- test ( "attributes terminal retry failure to the latest attempted source" , ( ) => {
142- const captured : { event : string ; properties : Record < string , unknown > } [ ] = [ ] ;
143- const telemetry : Telemetry = {
144- enabled : true ,
145- installationId : "test" ,
146- capture : ( capturedEvent , properties = { } ) => {
147- captured . push ( { event : capturedEvent , properties } ) ;
148- } ,
149- captureIntentional : ( ) => false ,
150- flush : async ( ) => { } ,
151- discard : ( ) => { } ,
152- } ;
153- const selectedSource = { provider : "provider-a" , model : "model-a" } ;
154- const observer = createTurnObserver ( {
155- telemetry : ( ) => telemetry ,
156- getSessionId : ( ) => "session-1" ,
157- getSource : ( ) => selectedSource ,
158- } ) ;
159- const runSink = createRunSink ( {
160- emitter : new EventEmitter ( ) ,
161- hookManager : stubHookManager ( [ ] ) ,
162- ...observer ,
177+ test ( "uses unknown attribution when a fallback model fails before usage" , ( ) => {
178+ const { captured, runSink } = attributionHarness ( ) ;
179+
180+ runSink . sink ( event ( "inference.start" , { model : "model-b" } ) ) ;
181+ failMessageRun ( runSink ) ;
182+
183+ expect ( captured ) . toHaveLength ( 1 ) ;
184+ expect ( captured [ 0 ] ?. event ) . toBe ( "$ai_generation" ) ;
185+ expect ( captured [ 0 ] ?. properties ) . toMatchObject ( {
186+ $ai_provider : "unknown" ,
187+ $ai_model : "model-b" ,
188+ $ai_is_error : true ,
163189 } ) ;
190+ } ) ;
191+
192+ test ( "uses the selected source when its model fails before usage" , ( ) => {
193+ const { captured, runSink } = attributionHarness ( ) ;
164194
165195 runSink . sink ( event ( "inference.start" , { model : "model-a" } ) ) ;
166- runSink . sink ( event ( "inference.error" , { error : { message : "attempt a failed" } } ) ) ;
196+ failMessageRun ( runSink ) ;
197+
198+ expect ( captured ) . toHaveLength ( 1 ) ;
199+ expect ( captured [ 0 ] ?. properties ) . toMatchObject ( {
200+ $ai_provider : "provider-a" ,
201+ $ai_model : "model-a" ,
202+ $ai_is_error : true ,
203+ } ) ;
204+ } ) ;
205+
206+ test ( "uses authoritative usage attribution for a failed fallback" , ( ) => {
207+ const { captured, runSink } = attributionHarness ( ) ;
208+
167209 runSink . sink ( event ( "inference.start" , { model : "model-b" } ) ) ;
168210 runSink . sink (
169211 event ( "inference.usage" , {
170212 usage : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , thinking : 0 } ,
171213 source : { sourceId : "fallback" , provider : "provider-b" , model : "model-b" } ,
172214 } ) ,
173215 ) ;
174- runSink . sink ( event ( "inference.error" , { error : { message : "attempt b failed" } } ) ) ;
216+ failMessageRun ( runSink ) ;
217+
218+ expect ( captured ) . toHaveLength ( 1 ) ;
219+ expect ( captured [ 0 ] ?. properties ) . toMatchObject ( {
220+ $ai_provider : "provider-b" ,
221+ $ai_model : "model-b" ,
222+ $ai_is_error : true ,
223+ } ) ;
224+ } ) ;
225+
226+ test ( "does not leak authoritative source attribution across retry attempts" , ( ) => {
227+ const { captured, runSink } = attributionHarness ( ) ;
228+
229+ runSink . sink ( event ( "inference.start" , { model : "model-a" } ) ) ;
175230 runSink . sink (
176- event ( "message.run.ended" , {
177- messageRunId : "run-1" ,
178- messageId : "message-1" ,
179- status : "failed" ,
231+ event ( "inference.usage" , {
232+ usage : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , thinking : 0 } ,
233+ source : { sourceId : "selected" , provider : "provider-a" , model : "model-a" } ,
180234 } ) ,
181235 ) ;
236+ runSink . sink ( event ( "inference.error" , { error : { message : "retry" } } ) ) ;
237+ runSink . sink ( event ( "inference.start" , { model : "model-b" } ) ) ;
238+ failMessageRun ( runSink ) ;
182239
183240 expect ( captured ) . toHaveLength ( 1 ) ;
184- expect ( captured [ 0 ] ?. event ) . toBe ( "$ai_generation" ) ;
185241 expect ( captured [ 0 ] ?. properties ) . toMatchObject ( {
186- $ai_provider : "provider-b " ,
242+ $ai_provider : "unknown " ,
187243 $ai_model : "model-b" ,
188244 $ai_is_error : true ,
189245 } ) ;
0 commit comments