Skip to content

Commit f44927b

Browse files
committed
add test for fix Wei-Shaw#935
1 parent c0110cb commit f44927b

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

backend/internal/service/antigravity_gateway_service.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,6 +2164,98 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
21642164
}
21652165
}
21662166

2167+
// Gemini 原生请求中的 thoughtSignature 可能来自旧上下文/旧账号,触发上游严格校验后返回
2168+
// "Corrupted thought signature."。检测到此类 400 时,将 thoughtSignature 清理为 dummy 值后重试一次。
2169+
signatureCheckBody := respBody
2170+
if unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody); unwrapErr == nil && len(unwrapped) > 0 {
2171+
signatureCheckBody = unwrapped
2172+
}
2173+
if resp.StatusCode == http.StatusBadRequest &&
2174+
s.settingService != nil &&
2175+
s.settingService.IsSignatureRectifierEnabled(ctx) &&
2176+
isSignatureRelatedError(signatureCheckBody) &&
2177+
bytes.Contains(injectedBody, []byte(`"thoughtSignature"`)) {
2178+
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(signatureCheckBody)))
2179+
upstreamDetail := s.getUpstreamErrorDetail(signatureCheckBody)
2180+
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
2181+
Platform: account.Platform,
2182+
AccountID: account.ID,
2183+
AccountName: account.Name,
2184+
UpstreamStatusCode: resp.StatusCode,
2185+
UpstreamRequestID: resp.Header.Get("x-request-id"),
2186+
Kind: "signature_error",
2187+
Message: upstreamMsg,
2188+
Detail: upstreamDetail,
2189+
})
2190+
2191+
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: detected signature-related 400, retrying with cleaned thought signatures", account.ID)
2192+
2193+
cleanedInjectedBody := CleanGeminiNativeThoughtSignatures(injectedBody)
2194+
retryWrappedBody, wrapErr := s.wrapV1InternalRequest(projectID, mappedModel, cleanedInjectedBody)
2195+
if wrapErr == nil {
2196+
retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{
2197+
ctx: ctx,
2198+
prefix: prefix,
2199+
account: account,
2200+
proxyURL: proxyURL,
2201+
accessToken: accessToken,
2202+
action: upstreamAction,
2203+
body: retryWrappedBody,
2204+
c: c,
2205+
httpUpstream: s.httpUpstream,
2206+
settingService: s.settingService,
2207+
accountRepo: s.accountRepo,
2208+
handleError: s.handleUpstreamError,
2209+
requestedModel: originalModel,
2210+
isStickySession: isStickySession,
2211+
groupID: 0,
2212+
sessionHash: "",
2213+
})
2214+
if retryErr == nil {
2215+
retryResp := retryResult.resp
2216+
if retryResp.StatusCode < 400 {
2217+
resp = retryResp
2218+
} else {
2219+
retryRespBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20))
2220+
_ = retryResp.Body.Close()
2221+
retryOpsBody := retryRespBody
2222+
if retryUnwrapped, unwrapErr := s.unwrapV1InternalResponse(retryRespBody); unwrapErr == nil && len(retryUnwrapped) > 0 {
2223+
retryOpsBody = retryUnwrapped
2224+
}
2225+
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
2226+
Platform: account.Platform,
2227+
AccountID: account.ID,
2228+
AccountName: account.Name,
2229+
UpstreamStatusCode: retryResp.StatusCode,
2230+
UpstreamRequestID: retryResp.Header.Get("x-request-id"),
2231+
Kind: "signature_retry",
2232+
Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(retryOpsBody))),
2233+
Detail: s.getUpstreamErrorDetail(retryOpsBody),
2234+
})
2235+
respBody = retryRespBody
2236+
resp = &http.Response{
2237+
StatusCode: retryResp.StatusCode,
2238+
Header: retryResp.Header.Clone(),
2239+
Body: io.NopCloser(bytes.NewReader(retryRespBody)),
2240+
}
2241+
contentType = resp.Header.Get("Content-Type")
2242+
}
2243+
} else {
2244+
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
2245+
Platform: account.Platform,
2246+
AccountID: account.ID,
2247+
AccountName: account.Name,
2248+
UpstreamStatusCode: 0,
2249+
Kind: "signature_retry_request_error",
2250+
Message: sanitizeUpstreamErrorMessage(retryErr.Error()),
2251+
})
2252+
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry request failed: %v", account.ID, retryErr)
2253+
}
2254+
} else {
2255+
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry wrap failed: %v", account.ID, wrapErr)
2256+
}
2257+
}
2258+
21672259
// fallback 成功:继续按正常响应处理
21682260
if resp.StatusCode < 400 {
21692261
goto handleSuccess

backend/internal/service/antigravity_gateway_service_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,43 @@ 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+
}
143+
144+
func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
145+
if req != nil && req.Body != nil {
146+
body, _ := io.ReadAll(req.Body)
147+
s.requestBodies = append(s.requestBodies, body)
148+
req.Body = io.NopCloser(bytes.NewReader(body))
149+
} else {
150+
s.requestBodies = append(s.requestBodies, nil)
151+
}
152+
153+
idx := s.callCount
154+
s.callCount++
155+
156+
var resp *http.Response
157+
if idx < len(s.responses) {
158+
resp = s.responses[idx]
159+
}
160+
var err error
161+
if idx < len(s.errors) {
162+
err = s.errors[idx]
163+
}
164+
if resp == nil && err == nil {
165+
return nil, errors.New("unexpected upstream call")
166+
}
167+
return resp, err
168+
}
169+
170+
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ bool) (*http.Response, error) {
171+
return s.Do(req, proxyURL, accountID, concurrency)
172+
}
173+
137174
type antigravitySettingRepoStub struct{}
138175

139176
func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
@@ -556,6 +593,92 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing
556593
require.Equal(t, mappedModel, result.Model)
557594
}
558595

596+
func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature(t *testing.T) {
597+
gin.SetMode(gin.TestMode)
598+
writer := httptest.NewRecorder()
599+
c, _ := gin.CreateTestContext(writer)
600+
601+
body, err := json.Marshal(map[string]any{
602+
"contents": []map[string]any{
603+
{"role": "user", "parts": []map[string]any{{"text": "hello"}}},
604+
{"role": "model", "parts": []map[string]any{{"text": "thinking", "thought": true, "thoughtSignature": "sig_bad_1"}}},
605+
{"role": "model", "parts": []map[string]any{{"functionCall": map[string]any{"name": "toolA", "args": map[string]any{"x": 1}}, "thoughtSignature": "sig_bad_2"}}},
606+
},
607+
})
608+
require.NoError(t, err)
609+
610+
req := httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent", bytes.NewReader(body))
611+
c.Request = req
612+
613+
firstRespBody := []byte(`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}`)
614+
secondRespBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
615+
616+
upstream := &queuedHTTPUpstreamStub{
617+
responses: []*http.Response{
618+
{
619+
StatusCode: http.StatusBadRequest,
620+
Header: http.Header{
621+
"Content-Type": []string{"application/json"},
622+
"X-Request-Id": []string{"req-sig-1"},
623+
},
624+
Body: io.NopCloser(bytes.NewReader(firstRespBody)),
625+
},
626+
{
627+
StatusCode: http.StatusOK,
628+
Header: http.Header{
629+
"Content-Type": []string{"text/event-stream"},
630+
"X-Request-Id": []string{"req-sig-2"},
631+
},
632+
Body: io.NopCloser(bytes.NewReader(secondRespBody)),
633+
},
634+
},
635+
}
636+
637+
svc := &AntigravityGatewayService{
638+
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
639+
tokenProvider: &AntigravityTokenProvider{},
640+
httpUpstream: upstream,
641+
}
642+
643+
const originalModel = "gemini-3.1-pro-preview"
644+
const mappedModel = "gemini-3.1-pro-high"
645+
account := &Account{
646+
ID: 7,
647+
Name: "acc-gemini-signature",
648+
Platform: PlatformAntigravity,
649+
Type: AccountTypeOAuth,
650+
Status: StatusActive,
651+
Concurrency: 1,
652+
Credentials: map[string]any{
653+
"access_token": "token",
654+
"model_mapping": map[string]any{
655+
originalModel: mappedModel,
656+
},
657+
},
658+
}
659+
660+
result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "streamGenerateContent", true, body, false)
661+
require.NoError(t, err)
662+
require.NotNil(t, result)
663+
require.Equal(t, mappedModel, result.Model)
664+
require.Len(t, upstream.requestBodies, 2, "signature error should trigger exactly one retry")
665+
666+
firstReq := string(upstream.requestBodies[0])
667+
secondReq := string(upstream.requestBodies[1])
668+
require.Contains(t, firstReq, `"thoughtSignature":"sig_bad_1"`)
669+
require.Contains(t, firstReq, `"thoughtSignature":"sig_bad_2"`)
670+
require.Contains(t, secondReq, `"thoughtSignature":"skip_thought_signature_validator"`)
671+
require.NotContains(t, secondReq, `"thoughtSignature":"sig_bad_1"`)
672+
require.NotContains(t, secondReq, `"thoughtSignature":"sig_bad_2"`)
673+
674+
raw, ok := c.Get(OpsUpstreamErrorsKey)
675+
require.True(t, ok)
676+
events, ok := raw.([]*OpsUpstreamErrorEvent)
677+
require.True(t, ok)
678+
require.NotEmpty(t, events)
679+
require.Equal(t, "signature_error", events[0].Kind)
680+
}
681+
559682
// TestStreamUpstreamResponse_UsageAndFirstToken
560683
// 验证:usage 字段可被累积/覆盖更新,并且能记录首 token 时间
561684
func TestStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestCleanGeminiNativeThoughtSignatures_ReplacesNestedThoughtSignatures(t *testing.T) {
12+
input := []byte(`{
13+
"contents": [
14+
{
15+
"role": "user",
16+
"parts": [{"text": "hello"}]
17+
},
18+
{
19+
"role": "model",
20+
"parts": [
21+
{"text": "thinking", "thought": true, "thoughtSignature": "sig_1"},
22+
{"functionCall": {"name": "toolA", "args": {"k": "v"}}, "thoughtSignature": "sig_2"}
23+
]
24+
}
25+
],
26+
"cachedContent": {
27+
"parts": [{"text": "cached", "thoughtSignature": "sig_3"}]
28+
},
29+
"signature": "keep_me"
30+
}`)
31+
32+
cleaned := CleanGeminiNativeThoughtSignatures(input)
33+
34+
var got map[string]any
35+
require.NoError(t, json.Unmarshal(cleaned, &got))
36+
37+
require.NotContains(t, string(cleaned), `"thoughtSignature":"sig_1"`)
38+
require.NotContains(t, string(cleaned), `"thoughtSignature":"sig_2"`)
39+
require.NotContains(t, string(cleaned), `"thoughtSignature":"sig_3"`)
40+
require.Contains(t, string(cleaned), `"thoughtSignature":"`+antigravity.DummyThoughtSignature+`"`)
41+
require.Contains(t, string(cleaned), `"signature":"keep_me"`)
42+
}
43+
44+
func TestCleanGeminiNativeThoughtSignatures_InvalidJSONReturnsOriginal(t *testing.T) {
45+
input := []byte(`{"contents":[invalid-json]}`)
46+
47+
cleaned := CleanGeminiNativeThoughtSignatures(input)
48+
49+
require.Equal(t, input, cleaned)
50+
}
51+
52+
func TestReplaceThoughtSignaturesRecursive_OnlyReplacesTargetField(t *testing.T) {
53+
input := map[string]any{
54+
"thoughtSignature": "sig_root",
55+
"signature": "keep_signature",
56+
"nested": []any{
57+
map[string]any{
58+
"thoughtSignature": "sig_nested",
59+
"signature": "keep_nested_signature",
60+
},
61+
},
62+
}
63+
64+
got, ok := replaceThoughtSignaturesRecursive(input).(map[string]any)
65+
require.True(t, ok)
66+
require.Equal(t, antigravity.DummyThoughtSignature, got["thoughtSignature"])
67+
require.Equal(t, "keep_signature", got["signature"])
68+
69+
nested, ok := got["nested"].([]any)
70+
require.True(t, ok)
71+
nestedMap, ok := nested[0].(map[string]any)
72+
require.True(t, ok)
73+
require.Equal(t, antigravity.DummyThoughtSignature, nestedMap["thoughtSignature"])
74+
require.Equal(t, "keep_nested_signature", nestedMap["signature"])
75+
}

0 commit comments

Comments
 (0)