@@ -134,6 +134,47 @@ func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int,
134134 return s .resp , s .err
135135}
136136
137+ type queuedHTTPUpstreamStub struct {
138+ responses []* http.Response
139+ errors []error
140+ requestBodies [][]byte
141+ callCount int
142+ onCall func (* http.Request , * queuedHTTPUpstreamStub )
143+ }
144+
145+ func (s * queuedHTTPUpstreamStub ) Do (req * http.Request , _ string , _ int64 , _ int ) (* http.Response , error ) {
146+ if req != nil && req .Body != nil {
147+ body , _ := io .ReadAll (req .Body )
148+ s .requestBodies = append (s .requestBodies , body )
149+ req .Body = io .NopCloser (bytes .NewReader (body ))
150+ } else {
151+ s .requestBodies = append (s .requestBodies , nil )
152+ }
153+
154+ idx := s .callCount
155+ s .callCount ++
156+ if s .onCall != nil {
157+ s .onCall (req , s )
158+ }
159+
160+ var resp * http.Response
161+ if idx < len (s .responses ) {
162+ resp = s .responses [idx ]
163+ }
164+ var err error
165+ if idx < len (s .errors ) {
166+ err = s .errors [idx ]
167+ }
168+ if resp == nil && err == nil {
169+ return nil , errors .New ("unexpected upstream call" )
170+ }
171+ return resp , err
172+ }
173+
174+ func (s * queuedHTTPUpstreamStub ) DoWithTLS (req * http.Request , proxyURL string , accountID int64 , concurrency int , _ bool ) (* http.Response , error ) {
175+ return s .Do (req , proxyURL , accountID , concurrency )
176+ }
177+
137178type antigravitySettingRepoStub struct {}
138179
139180func (s * antigravitySettingRepoStub ) Get (ctx context.Context , key string ) (* Setting , error ) {
@@ -556,6 +597,177 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing
556597 require .Equal (t , mappedModel , result .Model )
557598}
558599
600+ func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature (t * testing.T ) {
601+ gin .SetMode (gin .TestMode )
602+ writer := httptest .NewRecorder ()
603+ c , _ := gin .CreateTestContext (writer )
604+
605+ body , err := json .Marshal (map [string ]any {
606+ "contents" : []map [string ]any {
607+ {"role" : "user" , "parts" : []map [string ]any {{"text" : "hello" }}},
608+ {"role" : "model" , "parts" : []map [string ]any {{"text" : "thinking" , "thought" : true , "thoughtSignature" : "sig_bad_1" }}},
609+ {"role" : "model" , "parts" : []map [string ]any {{"functionCall" : map [string ]any {"name" : "toolA" , "args" : map [string ]any {"x" : 1 }}, "thoughtSignature" : "sig_bad_2" }}},
610+ },
611+ })
612+ require .NoError (t , err )
613+
614+ req := httptest .NewRequest (http .MethodPost , "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent" , bytes .NewReader (body ))
615+ c .Request = req
616+
617+ firstRespBody := []byte (`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}` )
618+ secondRespBody := []byte ("data: {\" response\" :{\" candidates\" :[{\" content\" :{\" parts\" :[{\" text\" :\" ok\" }]},\" finishReason\" :\" STOP\" }],\" usageMetadata\" :{\" promptTokenCount\" :8,\" candidatesTokenCount\" :3}}}\n \n " )
619+
620+ upstream := & queuedHTTPUpstreamStub {
621+ responses : []* http.Response {
622+ {
623+ StatusCode : http .StatusBadRequest ,
624+ Header : http.Header {
625+ "Content-Type" : []string {"application/json" },
626+ "X-Request-Id" : []string {"req-sig-1" },
627+ },
628+ Body : io .NopCloser (bytes .NewReader (firstRespBody )),
629+ },
630+ {
631+ StatusCode : http .StatusOK ,
632+ Header : http.Header {
633+ "Content-Type" : []string {"text/event-stream" },
634+ "X-Request-Id" : []string {"req-sig-2" },
635+ },
636+ Body : io .NopCloser (bytes .NewReader (secondRespBody )),
637+ },
638+ },
639+ }
640+
641+ svc := & AntigravityGatewayService {
642+ settingService : NewSettingService (& antigravitySettingRepoStub {}, & config.Config {Gateway : config.GatewayConfig {MaxLineSize : defaultMaxLineSize }}),
643+ tokenProvider : & AntigravityTokenProvider {},
644+ httpUpstream : upstream ,
645+ }
646+
647+ const originalModel = "gemini-3.1-pro-preview"
648+ const mappedModel = "gemini-3.1-pro-high"
649+ account := & Account {
650+ ID : 7 ,
651+ Name : "acc-gemini-signature" ,
652+ Platform : PlatformAntigravity ,
653+ Type : AccountTypeOAuth ,
654+ Status : StatusActive ,
655+ Concurrency : 1 ,
656+ Credentials : map [string ]any {
657+ "access_token" : "token" ,
658+ "model_mapping" : map [string ]any {
659+ originalModel : mappedModel ,
660+ },
661+ },
662+ }
663+
664+ result , err := svc .ForwardGemini (context .Background (), c , account , originalModel , "streamGenerateContent" , true , body , false )
665+ require .NoError (t , err )
666+ require .NotNil (t , result )
667+ require .Equal (t , mappedModel , result .Model )
668+ require .Len (t , upstream .requestBodies , 2 , "signature error should trigger exactly one retry" )
669+
670+ firstReq := string (upstream .requestBodies [0 ])
671+ secondReq := string (upstream .requestBodies [1 ])
672+ require .Contains (t , firstReq , `"thoughtSignature":"sig_bad_1"` )
673+ require .Contains (t , firstReq , `"thoughtSignature":"sig_bad_2"` )
674+ require .Contains (t , secondReq , `"thoughtSignature":"skip_thought_signature_validator"` )
675+ require .NotContains (t , secondReq , `"thoughtSignature":"sig_bad_1"` )
676+ require .NotContains (t , secondReq , `"thoughtSignature":"sig_bad_2"` )
677+
678+ raw , ok := c .Get (OpsUpstreamErrorsKey )
679+ require .True (t , ok )
680+ events , ok := raw .([]* OpsUpstreamErrorEvent )
681+ require .True (t , ok )
682+ require .NotEmpty (t , events )
683+ require .Equal (t , "signature_error" , events [0 ].Kind )
684+ }
685+
686+ func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailover (t * testing.T ) {
687+ gin .SetMode (gin .TestMode )
688+ writer := httptest .NewRecorder ()
689+ c , _ := gin .CreateTestContext (writer )
690+
691+ body , err := json .Marshal (map [string ]any {
692+ "contents" : []map [string ]any {
693+ {"role" : "user" , "parts" : []map [string ]any {{"text" : "hello" }}},
694+ {"role" : "model" , "parts" : []map [string ]any {{"text" : "thinking" , "thought" : true , "thoughtSignature" : "sig_bad_1" }}},
695+ },
696+ })
697+ require .NoError (t , err )
698+
699+ req := httptest .NewRequest (http .MethodPost , "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent" , bytes .NewReader (body ))
700+ c .Request = req
701+
702+ firstRespBody := []byte (`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}` )
703+
704+ const originalModel = "gemini-3.1-pro-preview"
705+ const mappedModel = "gemini-3.1-pro-high"
706+ account := & Account {
707+ ID : 8 ,
708+ Name : "acc-gemini-signature-failover" ,
709+ Platform : PlatformAntigravity ,
710+ Type : AccountTypeOAuth ,
711+ Status : StatusActive ,
712+ Concurrency : 1 ,
713+ Credentials : map [string ]any {
714+ "access_token" : "token" ,
715+ "model_mapping" : map [string ]any {
716+ originalModel : mappedModel ,
717+ },
718+ },
719+ }
720+
721+ upstream := & queuedHTTPUpstreamStub {
722+ responses : []* http.Response {
723+ {
724+ StatusCode : http .StatusBadRequest ,
725+ Header : http.Header {
726+ "Content-Type" : []string {"application/json" },
727+ "X-Request-Id" : []string {"req-sig-failover-1" },
728+ },
729+ Body : io .NopCloser (bytes .NewReader (firstRespBody )),
730+ },
731+ },
732+ onCall : func (_ * http.Request , stub * queuedHTTPUpstreamStub ) {
733+ if stub .callCount != 1 {
734+ return
735+ }
736+ futureResetAt := time .Now ().Add (30 * time .Second ).Format (time .RFC3339 )
737+ account .Extra = map [string ]any {
738+ modelRateLimitsKey : map [string ]any {
739+ mappedModel : map [string ]any {
740+ "rate_limit_reset_at" : futureResetAt ,
741+ },
742+ },
743+ }
744+ },
745+ }
746+
747+ svc := & AntigravityGatewayService {
748+ settingService : NewSettingService (& antigravitySettingRepoStub {}, & config.Config {Gateway : config.GatewayConfig {MaxLineSize : defaultMaxLineSize }}),
749+ tokenProvider : & AntigravityTokenProvider {},
750+ httpUpstream : upstream ,
751+ }
752+
753+ result , err := svc .ForwardGemini (context .Background (), c , account , originalModel , "streamGenerateContent" , true , body , true )
754+ require .Nil (t , result )
755+
756+ var failoverErr * UpstreamFailoverError
757+ require .ErrorAs (t , err , & failoverErr , "signature retry should propagate failover instead of falling back to the original 400" )
758+ require .Equal (t , http .StatusServiceUnavailable , failoverErr .StatusCode )
759+ require .True (t , failoverErr .ForceCacheBilling )
760+ require .Len (t , upstream .requestBodies , 1 , "retry should stop at preflight failover and not issue a second upstream request" )
761+
762+ raw , ok := c .Get (OpsUpstreamErrorsKey )
763+ require .True (t , ok )
764+ events , ok := raw .([]* OpsUpstreamErrorEvent )
765+ require .True (t , ok )
766+ require .Len (t , events , 2 )
767+ require .Equal (t , "signature_error" , events [0 ].Kind )
768+ require .Equal (t , "failover" , events [1 ].Kind )
769+ }
770+
559771// TestStreamUpstreamResponse_UsageAndFirstToken
560772// 验证:usage 字段可被累积/覆盖更新,并且能记录首 token 时间
561773func TestStreamUpstreamResponse_UsageAndFirstToken (t * testing.T ) {
0 commit comments