@@ -4643,6 +4643,141 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
46434643 expect ( fallbackCancelSpy ) . toHaveBeenCalledTimes ( 1 ) ;
46444644 } ) ;
46454645
4646+ it ( "records capability failure once for non-429 stream failover fallback errors" , async ( ) => {
4647+ const { AccountManager } = await import ( "../lib/accounts.js" ) ;
4648+ const { CapabilityPolicyStore } = await import ( "../lib/capability-policy.js" ) ;
4649+ const fetchHelpersModule = await import ( "../lib/request/fetch-helpers.js" ) ;
4650+ const streamFailoverModule = await import ( "../lib/request/stream-failover.js" ) ;
4651+ const currentAccount = {
4652+ index : 0 ,
4653+ accountId : "acc-1" ,
4654+ email : "alpha@example.com" ,
4655+ refreshToken : "refresh-1" ,
4656+ accessToken : "access-alpha" ,
4657+ } ;
4658+ const fallbackAccount = {
4659+ index : 1 ,
4660+ accountId : "acc-2" ,
4661+ email : "beta@example.com" ,
4662+ refreshToken : "refresh-2" ,
4663+ accessToken : "access-beta" ,
4664+ } ;
4665+ const recordFailure = vi . fn ( ) ;
4666+ const saveToDiskDebounced = vi . fn ( ) ;
4667+ const capabilityFailureSpy = vi . spyOn (
4668+ CapabilityPolicyStore . prototype ,
4669+ "recordFailure" ,
4670+ ) ;
4671+ const pendingFailovers : Array < Promise < unknown > > = [ ] ;
4672+ const fallbackErrorResponse = new Response (
4673+ new ReadableStream ( {
4674+ start ( controller ) {
4675+ controller . enqueue ( new TextEncoder ( ) . encode ( "server error" ) ) ;
4676+ controller . close ( ) ;
4677+ } ,
4678+ } ) ,
4679+ { status : 500 } ,
4680+ ) ;
4681+ const manager = {
4682+ getAccountCount : ( ) => 2 ,
4683+ getCurrentOrNextForFamilyHybrid : ( ) => currentAccount ,
4684+ getCurrentOrNextForFamily : ( ) => currentAccount ,
4685+ getCurrentWorkspace : ( ) => null ,
4686+ getAccountByIndex : ( index : number ) =>
4687+ index === fallbackAccount . index ? fallbackAccount : currentAccount ,
4688+ getAccountsSnapshot : ( ) => [ currentAccount , fallbackAccount ] ,
4689+ isAccountAvailableForFamily : ( index : number ) => index === fallbackAccount . index ,
4690+ toAuthDetails : ( account : typeof currentAccount | typeof fallbackAccount ) => ( {
4691+ type : "oauth" as const ,
4692+ access : account . accessToken ,
4693+ refresh : account . refreshToken ,
4694+ expires : Date . now ( ) + 60_000 ,
4695+ } ) ,
4696+ hasRefreshToken : ( ) => true ,
4697+ saveToDiskDebounced,
4698+ updateFromAuth : ( ) => { } ,
4699+ clearAuthFailures : ( ) => { } ,
4700+ incrementAuthFailures : ( ) => 1 ,
4701+ saveToDisk : async ( ) => { } ,
4702+ markAccountCoolingDown : ( ) => { } ,
4703+ markRateLimited : ( ) => { } ,
4704+ markRateLimitedWithReason : ( ) => { } ,
4705+ consumeToken : ( ) => true ,
4706+ refundToken : ( ) => { } ,
4707+ syncCodexCliActiveSelectionForIndex : async ( ) => { } ,
4708+ markSwitched : ( ) => { } ,
4709+ removeAccount : ( ) => { } ,
4710+ recordFailure,
4711+ recordSuccess : ( ) => { } ,
4712+ recordRateLimit : ( ) => { } ,
4713+ getMinWaitTimeForFamily : ( ) => 0 ,
4714+ shouldShowAccountToast : ( ) => false ,
4715+ markToastShown : ( ) => { } ,
4716+ setActiveIndex : ( ) => null ,
4717+ } ;
4718+ vi . spyOn ( AccountManager , "loadFromDisk" ) . mockResolvedValueOnce ( manager as never ) ;
4719+ vi . mocked ( fetchHelpersModule . handleErrorResponse ) . mockImplementation (
4720+ async ( response : Response ) =>
4721+ ( {
4722+ response,
4723+ errorBody : "server error" ,
4724+ } ) as never ,
4725+ ) ;
4726+ vi . mocked ( fetchHelpersModule . transformRequestForCodex ) . mockImplementation (
4727+ async ( init , _url , _userConfig , _codexMode , body ) => ( {
4728+ updatedInit : {
4729+ ...( init as RequestInit ) ,
4730+ body : JSON . stringify ( body ?? { } ) ,
4731+ } ,
4732+ body : ( body ?? {
4733+ model : "gpt-5.1" ,
4734+ stream : true ,
4735+ } ) as {
4736+ model : string ;
4737+ stream ?: boolean ;
4738+ } ,
4739+ } ) ,
4740+ ) ;
4741+ vi . spyOn ( streamFailoverModule , "withStreamingFailover" ) . mockImplementation (
4742+ ( initialResponse , getFallbackResponse ) => {
4743+ pendingFailovers . push ( getFallbackResponse ( 1 , 0 ) ) ;
4744+ return initialResponse ;
4745+ } ,
4746+ ) ;
4747+ let fetchCount = 0 ;
4748+ globalThis . fetch = vi . fn ( ) . mockImplementation ( async ( ) => {
4749+ fetchCount += 1 ;
4750+ if ( fetchCount === 1 ) {
4751+ return new Response ( "data: ok\n\n" , { status : 200 } ) ;
4752+ }
4753+ return fallbackErrorResponse ;
4754+ } ) ;
4755+
4756+ const mockClient = createMockClient ( ) ;
4757+ const { OpenAIOAuthPlugin } = await import ( "../index.js" ) ;
4758+ const plugin = await OpenAIOAuthPlugin ( { client : mockClient } as never ) as unknown as PluginType ;
4759+ const sdk = await plugin . auth . loader ( getOAuthAuth , { options : { } , models : { } } ) ;
4760+ const response = await sdk . fetch ! ( "https://api.openai.com/v1/chat/completions" , {
4761+ method : "POST" ,
4762+ body : JSON . stringify ( { model : "gpt-5.1" , stream : true } ) ,
4763+ } ) ;
4764+ await Promise . all ( pendingFailovers ) ;
4765+
4766+ expect ( response . status ) . toBe ( 200 ) ;
4767+ expect ( recordFailure ) . toHaveBeenCalledTimes ( 1 ) ;
4768+ expect ( recordFailure ) . toHaveBeenCalledWith (
4769+ fallbackAccount ,
4770+ "gpt-5.1" ,
4771+ "gpt-5.1" ,
4772+ ) ;
4773+ expect ( capabilityFailureSpy ) . toHaveBeenCalledTimes ( 1 ) ;
4774+ expect ( capabilityFailureSpy . mock . calls [ 0 ] ?. [ 0 ] ) . toMatch (
4775+ / ^ a c c o u n t : a c c - 2 : : e m a i l : / ,
4776+ ) ;
4777+ expect ( capabilityFailureSpy . mock . calls [ 0 ] ?. [ 1 ] ) . toBe ( "gpt-5.1" ) ;
4778+ expect ( saveToDiskDebounced ) . not . toHaveBeenCalled ( ) ;
4779+ } ) ;
4780+
46464781 it ( "forwards persistence error toast arguments through manual OAuth flow" , async ( ) => {
46474782 const authModule = await import ( "../lib/auth/auth.js" ) ;
46484783 const storageModule = await import ( "../lib/storage.js" ) ;
0 commit comments