@@ -224,7 +224,9 @@ let calls: Array<{ api: string; data: Record<string, unknown> }> = [];
224224let openCalls : Record < string , unknown > [ ] = [ ] ;
225225let failure = false ;
226226let alreadyOff = false ;
227+ let emptyModelList = false ;
227228let batchResult : unknown = batch ;
229+ let batchHandler : ( ( api : string , data : Record < string , unknown > ) => unknown ) | undefined ;
228230let overviewResult : unknown = stat ;
229231let modelRows : unknown [ ] = [
230232 { model : "qwen-test" , callSuccessCount : 3 , usage : { input_tokens : 2 } } ,
@@ -239,7 +241,9 @@ afterEach(() => {
239241 openCalls = [ ] ;
240242 failure = false ;
241243 alreadyOff = false ;
244+ emptyModelList = false ;
242245 batchResult = batch ;
246+ batchHandler = undefined ;
243247 overviewResult = stat ;
244248 modelRows = [ { model : "qwen-test" , callSuccessCount : 3 , usage : { input_tokens : 2 } } ] ;
245249} ) ;
@@ -291,7 +295,9 @@ async function invoke(
291295 calls . push ( { api, data : structuredClone ( data ) } ) ;
292296 if ( failure ) throw new BailianError ( "Original console failure" , ExitCode . GENERAL ) ;
293297 if ( api === MODEL_LIST_API )
294- return { list : [ { model : "qwen-test" , capabilities : [ "TG" ] } ] , total : 1 } ;
298+ return emptyModelList
299+ ? { list : [ ] , total : 0 }
300+ : { list : [ { model : "qwen-test" , capabilities : [ "TG" ] } ] , total : 1 } ;
295301 if ( api . includes ( "queryFreeTierQuota" ) )
296302 return { data : { data : { freeTierQuotas : [ quota ] } } } ;
297303 if ( api . includes ( "queryFreeTierOnlyStatus" ) )
@@ -300,8 +306,13 @@ async function invoke(
300306 data : { freeTierOnlyStatuses : [ { model : "qwen-test" , freeTierOnly : ! alreadyOff } ] } ,
301307 } ,
302308 } ;
303- if ( api . includes ( "batchActivateFreeTierOnly" ) || api . includes ( "batchDeactivateFreeTierOnly" ) )
309+ if (
310+ api . includes ( "batchActivateFreeTierOnly" ) ||
311+ api . includes ( "batchDeactivateFreeTierOnly" )
312+ ) {
313+ if ( batchHandler ) return batchHandler ( api , data ) ;
304314 return structuredClone ( batchResult ) ;
315+ }
305316 if ( api . includes ( "getModelUsageStatistic" ) ) return structuredClone ( overviewResult ) ;
306317 if ( api . includes ( "listModelUsageStatisticData" ) )
307318 return { list : structuredClone ( modelRows ) , totalCount : modelRows . length } ;
@@ -642,3 +653,131 @@ test.each(["text", "json"] as const)(
642653 }
643654 } ,
644655) ;
656+
657+ test ( "auto-stop fail-fast stops after the first model failure" , async ( ) => {
658+ batchResult = { failureModels : [ { failureModelId : "qwen-a" , errorCode : "Rejected" } ] } ;
659+ const result = await invoke (
660+ { ...cases [ 1 ] ! , flags : { model : "qwen-a,qwen-b,qwen-c" , off : true } } ,
661+ { format : "json" , quiet : true } ,
662+ ) ;
663+ expect ( result . exitCode ) . toBe ( ExitCode . GENERAL ) ;
664+ expect ( calls . filter ( ( call ) => call . api . includes ( "batchDeactivate" ) ) ) . toHaveLength ( 1 ) ;
665+ expect ( calls . find ( ( call ) => call . api . includes ( "batchDeactivate" ) ) ! . data ) . toEqual ( {
666+ BatchDeactivateFreeTierOnlyRequest : { models : [ "qwen-a" ] } ,
667+ } ) ;
668+ expect ( JSON . parse ( result . stdout ) ) . toEqual ( {
669+ processed : [ { model : "qwen-a" , result : batchResult } ] ,
670+ skipped : [ ] ,
671+ } ) ;
672+ expect ( result . stderr ) . toContain ( "Failed to disable auto-stop for 1 model(s): qwen-a" ) ;
673+ } ) ;
674+
675+ test ( "auto-stop empty success list is not treated as success" , async ( ) => {
676+ batchResult = { successCount : 0 , successModels : [ ] , failureModels : [ ] } ;
677+ const result = await invoke (
678+ { ...cases [ 1 ] ! , flags : { model : "qwen-test" , off : true } } ,
679+ { format : "json" , quiet : true } ,
680+ ) ;
681+ expect ( result . exitCode ) . toBe ( ExitCode . GENERAL ) ;
682+ expect ( result . stdout ) . not . toContain ( "Disabled auto-stop" ) ;
683+ expect ( JSON . parse ( result . stdout ) ) . toEqual ( {
684+ processed : [ { model : "qwen-test" , result : batchResult } ] ,
685+ skipped : [ ] ,
686+ } ) ;
687+ expect ( result . stderr ) . toContain ( "FREETIER_OPERATION_FAILED" ) ;
688+ expect ( result . stderr ) . toContain ( "was not confirmed by the server" ) ;
689+ expect ( result . stderr ) . toContain ( "Failed to disable auto-stop for 1 model(s): qwen-test" ) ;
690+ } ) ;
691+
692+ test ( "auto-stop network error keeps prior confirmed progress" , async ( ) => {
693+ let mutateCalls = 0 ;
694+ batchHandler = ( ) => {
695+ mutateCalls += 1 ;
696+ if ( mutateCalls === 1 ) {
697+ return {
698+ success : true ,
699+ successCount : 1 ,
700+ successModels : [ "qwen-test" ] ,
701+ failureModels : [ ] ,
702+ } ;
703+ }
704+ throw new BailianError ( "Upstream network failure" , ExitCode . NETWORK ) ;
705+ } ;
706+ const result = await invoke (
707+ { ...cases [ 1 ] ! , flags : { model : "qwen-test,qwen-other" } } ,
708+ { format : "json" , quiet : true } ,
709+ ) ;
710+ expect ( result . exitCode ) . toBe ( ExitCode . NETWORK ) ;
711+ expect ( mutateCalls ) . toBe ( 2 ) ;
712+ expect ( JSON . parse ( result . stdout ) ) . toEqual ( [
713+ {
714+ success : true ,
715+ successCount : 1 ,
716+ successModels : [ "qwen-test" ] ,
717+ failureModels : [ ] ,
718+ } ,
719+ ] ) ;
720+ expect ( result . stderr ) . toContain ( "Upstream network failure" ) ;
721+ expect ( result . stderr ) . toContain ( "FREETIER_PARTIAL_PROGRESS" ) ;
722+ expect ( result . stderr ) . toContain ( "Confirmed enable auto-stop for 1 model(s): qwen-test" ) ;
723+ expect ( result . stderr ) . toContain ( "qwen-other" ) ;
724+ expect ( result . stderr ) . toContain ( "outcome is unconfirmed" ) ;
725+ expect ( result . stderr ) . toContain ( "Later models were not executed" ) ;
726+ } ) ;
727+
728+ test . each ( [
729+ { off : false , want : [ ] } ,
730+ { off : true , want : { processed : [ ] , skipped : [ ] } } ,
731+ ] as const ) (
732+ "auto-stop --all with zero models still emits empty JSON (off=$off)" ,
733+ async ( { off, want } ) => {
734+ emptyModelList = true ;
735+ const result = await invoke (
736+ { ...cases [ 1 ] ! , flags : { all : true , ...( off ? { off : true } : { } ) } } ,
737+ { format : "json" , quiet : true } ,
738+ ) ;
739+ expect ( result . exitCode ) . toBe ( 0 ) ;
740+ expect ( JSON . parse ( result . stdout ) ) . toEqual ( want ) ;
741+ expect ( calls . some ( ( call ) => call . api . includes ( "batchActivate" ) ) ) . toBe ( false ) ;
742+ expect ( calls . some ( ( call ) => call . api . includes ( "batchDeactivate" ) ) ) . toBe ( false ) ;
743+ } ,
744+ ) ;
745+
746+ test ( "auto-stop transport error preserves cause.code and verbose ERROR_STACK" , async ( ) => {
747+ let mutateCalls = 0 ;
748+ batchHandler = ( ) => {
749+ mutateCalls += 1 ;
750+ if ( mutateCalls === 1 ) {
751+ return {
752+ success : true ,
753+ successCount : 1 ,
754+ successModels : [ "qwen-test" ] ,
755+ failureModels : [ ] ,
756+ } ;
757+ }
758+ const cause = Object . assign ( new Error ( "getaddrinfo ENOTFOUND example.invalid" ) , {
759+ code : "ENOTFOUND" ,
760+ } ) ;
761+ throw new TypeError ( "fetch failed" , { cause } ) ;
762+ } ;
763+ const result = await invoke (
764+ { ...cases [ 1 ] ! , flags : { model : "qwen-test,qwen-other" } } ,
765+ { format : "json" , quiet : true , verbose : true } ,
766+ ) ;
767+ expect ( result . exitCode ) . toBe ( ExitCode . NETWORK ) ;
768+ expect ( JSON . parse ( result . stdout ) ) . toHaveLength ( 1 ) ;
769+ const stderrLines = result . stderr
770+ . trim ( )
771+ . split ( "\n" )
772+ . map ( ( line ) => JSON . parse ( line ) as Record < string , unknown > ) ;
773+ expect (
774+ stderrLines . some (
775+ ( line ) => line . debug && ( line . debug as { code ?: string } ) . code === "ERROR_STACK" ,
776+ ) ,
777+ ) . toBe ( true ) ;
778+ const errorLine = stderrLines . find ( ( line ) => line . error ) as {
779+ error : { cause ?: { code ?: string ; message ?: string } } ;
780+ } ;
781+ expect ( errorLine . error . cause ?. code ) . toBe ( "ENOTFOUND" ) ;
782+ expect ( result . stderr ) . toContain ( "FREETIER_PARTIAL_PROGRESS" ) ;
783+ } ) ;
0 commit comments