| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- // antigravityFailingWriter 模拟客户端断开连接的 gin.ResponseWriter
- type antigravityFailingWriter struct {
- gin.ResponseWriter
- failAfter int // 允许成功写入的次数,之后所有写入返回错误
- writes int
- }
- func (w *antigravityFailingWriter) Write(p []byte) (int, error) {
- if w.writes >= w.failAfter {
- return 0, errors.New("write failed: client disconnected")
- }
- w.writes++
- return w.ResponseWriter.Write(p)
- }
- // newAntigravityTestService 创建用于流式测试的 AntigravityGatewayService
- func newAntigravityTestService(cfg *config.Config) *AntigravityGatewayService {
- return &AntigravityGatewayService{
- settingService: &SettingService{cfg: cfg},
- }
- }
- func TestAntigravityUpstreamErrorBodyReadLimit_RespectsDiagnosticLimit(t *testing.T) {
- svc := newAntigravityTestService(&config.Config{Gateway: config.GatewayConfig{
- LogUpstreamErrorBody: true,
- LogUpstreamErrorBodyMaxBytes: int(gatewayUpstreamErrorBodyReadLimit) + 1024,
- }})
- require.Equal(t, int64(svc.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), svc.upstreamErrorBodyReadLimit())
- }
- func TestStripSignatureSensitiveBlocksFromClaudeRequest(t *testing.T) {
- req := &antigravity.ClaudeRequest{
- Model: "claude-sonnet-4-5",
- Thinking: &antigravity.ThinkingConfig{
- Type: "enabled",
- BudgetTokens: 1024,
- },
- Messages: []antigravity.ClaudeMessage{
- {
- Role: "assistant",
- Content: json.RawMessage(`[
- {"type":"thinking","thinking":"secret plan","signature":""},
- {"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}
- ]`),
- },
- {
- Role: "user",
- Content: json.RawMessage(`[
- {"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false},
- {"type":"redacted_thinking","data":"..."}
- ]`),
- },
- },
- }
- changed, err := stripSignatureSensitiveBlocksFromClaudeRequest(req)
- require.NoError(t, err)
- require.True(t, changed)
- require.Nil(t, req.Thinking)
- require.Len(t, req.Messages, 2)
- var blocks0 []map[string]any
- require.NoError(t, json.Unmarshal(req.Messages[0].Content, &blocks0))
- require.Len(t, blocks0, 2)
- require.Equal(t, "text", blocks0[0]["type"])
- require.Equal(t, "secret plan", blocks0[0]["text"])
- require.Equal(t, "text", blocks0[1]["type"])
- var blocks1 []map[string]any
- require.NoError(t, json.Unmarshal(req.Messages[1].Content, &blocks1))
- require.Len(t, blocks1, 1)
- require.Equal(t, "text", blocks1[0]["type"])
- require.NotEmpty(t, blocks1[0]["text"])
- }
- func TestStripThinkingFromClaudeRequest_DoesNotDowngradeTools(t *testing.T) {
- req := &antigravity.ClaudeRequest{
- Model: "claude-sonnet-4-5",
- Thinking: &antigravity.ThinkingConfig{
- Type: "enabled",
- BudgetTokens: 1024,
- },
- Messages: []antigravity.ClaudeMessage{
- {
- Role: "assistant",
- Content: json.RawMessage(`[{"type":"thinking","thinking":"secret plan"},{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]`),
- },
- },
- }
- changed, err := stripThinkingFromClaudeRequest(req)
- require.NoError(t, err)
- require.True(t, changed)
- require.Nil(t, req.Thinking)
- var blocks []map[string]any
- require.NoError(t, json.Unmarshal(req.Messages[0].Content, &blocks))
- require.Len(t, blocks, 2)
- require.Equal(t, "text", blocks[0]["type"])
- require.Equal(t, "secret plan", blocks[0]["text"])
- require.Equal(t, "tool_use", blocks[1]["type"])
- }
- func TestIsPromptTooLongError(t *testing.T) {
- require.True(t, isPromptTooLongError([]byte(`{"error":{"message":"Prompt is too long"}}`)))
- require.True(t, isPromptTooLongError([]byte(`{"message":"Prompt is too long"}`)))
- require.False(t, isPromptTooLongError([]byte(`{"error":{"message":"other"}}`)))
- }
- type httpUpstreamStub struct {
- resp *http.Response
- err error
- }
- func (s *httpUpstreamStub) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return s.resp, s.err
- }
- func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
- return s.resp, s.err
- }
- type queuedHTTPUpstreamStub struct {
- responses []*http.Response
- errors []error
- requestBodies [][]byte
- callCount int
- onCall func(*http.Request, *queuedHTTPUpstreamStub)
- }
- func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- if req != nil && req.Body != nil {
- body, _ := io.ReadAll(req.Body)
- s.requestBodies = append(s.requestBodies, body)
- req.Body = io.NopCloser(bytes.NewReader(body))
- } else {
- s.requestBodies = append(s.requestBodies, nil)
- }
- idx := s.callCount
- s.callCount++
- if s.onCall != nil {
- s.onCall(req, s)
- }
- var resp *http.Response
- if idx < len(s.responses) {
- resp = s.responses[idx]
- }
- var err error
- if idx < len(s.errors) {
- err = s.errors[idx]
- }
- if resp == nil && err == nil {
- return nil, errors.New("unexpected upstream call")
- }
- return resp, err
- }
- func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
- return s.Do(req, proxyURL, accountID, concurrency)
- }
- type recordingInternal500CounterCache struct {
- incrementCalls []int64
- resetCalls []int64
- }
- func (c *recordingInternal500CounterCache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) {
- c.incrementCalls = append(c.incrementCalls, accountID)
- return int64(len(c.incrementCalls)), nil
- }
- func (c *recordingInternal500CounterCache) ResetInternal500Count(_ context.Context, accountID int64) error {
- c.resetCalls = append(c.resetCalls, accountID)
- return nil
- }
- type antigravitySettingRepoStub struct {
- values map[string]string
- }
- func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
- panic("unexpected Get call")
- }
- func (s *antigravitySettingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
- if value, ok := s.values[key]; ok {
- return value, nil
- }
- return "", ErrSettingNotFound
- }
- func (s *antigravitySettingRepoStub) Set(ctx context.Context, key, value string) error {
- panic("unexpected Set call")
- }
- func (s *antigravitySettingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
- panic("unexpected GetMultiple call")
- }
- func (s *antigravitySettingRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
- panic("unexpected SetMultiple call")
- }
- func (s *antigravitySettingRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
- panic("unexpected GetAll call")
- }
- func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) error {
- panic("unexpected Delete call")
- }
- func TestResolveAntigravityProjectID(t *testing.T) {
- tests := []struct {
- name string
- account *Account
- want string
- wantErr bool
- }{
- {
- name: "uses onboard project_id first",
- account: &Account{Credentials: map[string]any{
- "project_id": " onboard-project ",
- antigravityProjectIDFallbackCredentialKey: " configured-project ",
- }},
- want: "onboard-project",
- },
- {
- name: "uses configured credentials fallback",
- account: &Account{Credentials: map[string]any{
- antigravityProjectIDFallbackCredentialKey: " configured-project ",
- }},
- want: "configured-project",
- },
- {
- name: "uses configured extra fallback",
- account: &Account{Extra: map[string]any{
- antigravityProjectIDFallbackCredentialKey: " extra-project ",
- }},
- want: "extra-project",
- },
- {
- name: "missing project",
- account: &Account{Credentials: map[string]any{}},
- wantErr: true,
- },
- }
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- got, err := resolveAntigravityProjectID(tc.account)
- if tc.wantErr {
- require.ErrorIs(t, err, errAntigravityProjectIDRequired)
- require.Empty(t, got)
- return
- }
- require.NoError(t, err)
- require.Equal(t, tc.want, got)
- })
- }
- }
- func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
- },
- })
- require.NoError(t, err)
- c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body))
- upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n")
- upstream := &queuedHTTPUpstreamStub{
- responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(bytes.NewReader(upstreamBody)),
- },
- },
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 101,
- Name: "acc-configured-project",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- antigravityProjectIDFallbackCredentialKey: "configured-project",
- "model_mapping": map[string]any{
- "gemini-2.5-flash": "gemini-2.5-flash",
- },
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.requestBodies, 1)
- var wrapped map[string]any
- require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
- require.Equal(t, "configured-project", wrapped["project"])
- }
- func TestAntigravityGatewayService_ForwardGemini_ImageUsesDefaultMappingAndOAuth(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body := []byte(`{"contents":[{"role":"user","parts":[{"text":"draw a cat"}]}],"generationConfig":{"responseModalities":["TEXT","IMAGE"],"imageConfig":{"aspectRatio":"1:1"}}}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.1-flash-image:generateContent", bytes.NewReader(body))
- upstream := &queuedHTTPUpstreamStub{
- responses: []*http.Response{{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(
- "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"aGVsbG8=\"}}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n",
- )),
- }},
- onCall: func(req *http.Request, _ *queuedHTTPUpstreamStub) {
- require.Equal(t, "Bearer test-access-token", req.Header.Get("Authorization"))
- require.Equal(t, "application/json", req.Header.Get("Content-Type"))
- require.Contains(t, req.URL.String(), "/v1internal:streamGenerateContent?alt=sse")
- },
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 104,
- Name: "antigravity-image",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "test-access-token",
- "project_id": "test-project",
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-3.1-flash-image", "generateContent", true, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "gemini-3.1-flash-image", result.Model)
- require.Equal(t, "gemini-3.1-flash-image", result.UpstreamModel)
- require.Equal(t, 1, result.ImageCount)
- require.Len(t, upstream.requestBodies, 1)
- var wrapped map[string]any
- require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
- require.Equal(t, "test-project", wrapped["project"])
- require.Equal(t, "gemini-3.1-flash-image", wrapped["model"])
- request, ok := wrapped["request"].(map[string]any)
- require.True(t, ok)
- generationConfig, ok := request["generationConfig"].(map[string]any)
- require.True(t, ok)
- require.Equal(t, []any{"TEXT", "IMAGE"}, generationConfig["responseModalities"])
- }
- func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocationConfig(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body = bytes.ReplaceAll(body, []byte{92}, nil)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
- upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{}}}\n\n")),
- }}}
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 103, Name: "native-gemini", Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 1,
- Credentials: map[string]any{"access_token": "token", "project_id": "project-103", "model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}},
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.requestBodies, 1)
- var wrapped map[string]any
- require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
- request, ok := wrapped["request"].(map[string]any)
- require.True(t, ok)
- toolConfig, ok := request["toolConfig"].(map[string]any)
- require.True(t, ok)
- require.Equal(t, true, toolConfig["includeServerSideToolInvocations"])
- require.NotContains(t, toolConfig, "include_server_side_tool_invocations")
- }
- func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
- },
- })
- require.NoError(t, err)
- c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body))
- upstream := &queuedHTTPUpstreamStub{}
- internal500Cache := &recordingInternal500CounterCache{}
- svc := &AntigravityGatewayService{
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- internal500Cache: internal500Cache,
- }
- account := &Account{
- ID: 102,
- Name: "acc-missing-project",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "model_mapping": map[string]any{
- "gemini-2.5-flash": "gemini-2.5-flash",
- },
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false)
- require.Nil(t, result)
- require.ErrorIs(t, err, errAntigravityProjectIDRequired)
- require.Equal(t, http.StatusBadRequest, writer.Code)
- require.Empty(t, upstream.requestBodies)
- require.Empty(t, internal500Cache.incrementCalls)
- require.Contains(t, writer.Body.String(), "project_id")
- require.NotContains(t, writer.Body.String(), `"project":""`)
- }
- func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "model": "claude-opus-4-6",
- "messages": []map[string]any{
- {"role": "user", "content": "hi"},
- },
- "max_tokens": 1,
- "stream": false,
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request = req
- respBody := []byte(`{"error":{"message":"Prompt is too long"}}`)
- resp := &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"X-Request-Id": []string{"req-1"}},
- Body: io.NopCloser(bytes.NewReader(respBody)),
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: resp},
- }
- account := &Account{
- ID: 1,
- Name: "acc-1",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- },
- }
- result, err := svc.Forward(context.Background(), c, account, body, false)
- require.Nil(t, result)
- var promptErr *PromptTooLongError
- require.ErrorAs(t, err, &promptErr)
- require.Equal(t, http.StatusBadRequest, promptErr.StatusCode)
- require.Equal(t, "req-1", promptErr.RequestID)
- require.NotEmpty(t, promptErr.Body)
- raw, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := raw.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 1)
- require.Equal(t, "prompt_too_long", events[0].Kind)
- }
- // TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover
- // 验证:当账号存在模型限流且剩余时间 >= antigravityRateLimitThreshold 时,
- // Forward 方法应返回 UpstreamFailoverError,触发 Handler 切换账号
- func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "model": "claude-opus-4-6",
- "messages": []map[string]any{
- {"role": "user", "content": "hi"},
- },
- "max_tokens": 1,
- "stream": false,
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request = req
- // 不需要真正调用上游,因为预检查会直接返回切换信号
- svc := &AntigravityGatewayService{
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
- }
- // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
- futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
- account := &Account{
- ID: 1,
- Name: "acc-rate-limited",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- },
- Extra: map[string]any{
- modelRateLimitsKey: map[string]any{
- "claude-opus-4-6-thinking": map[string]any{
- "rate_limit_reset_at": futureResetAt,
- },
- },
- },
- }
- result, err := svc.Forward(context.Background(), c, account, body, false)
- require.Nil(t, result, "Forward should not return result when model rate limited")
- require.NotNil(t, err, "Forward should return error")
- // 核心验证:错误应该是 UpstreamFailoverError,而不是普通 502 错误
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
- require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
- // 非粘性会话请求,ForceCacheBilling 应为 false
- require.False(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be false for non-sticky session")
- }
- // TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover
- // 验证:ForwardGemini 方法同样能正确将 AntigravityAccountSwitchError 转换为 UpstreamFailoverError
- func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hi"}}},
- },
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
- c.Request = req
- // 不需要真正调用上游,因为预检查会直接返回切换信号
- svc := &AntigravityGatewayService{
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
- }
- // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
- futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
- account := &Account{
- ID: 2,
- Name: "acc-gemini-rate-limited",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- },
- Extra: map[string]any{
- modelRateLimitsKey: map[string]any{
- "gemini-2.5-flash": map[string]any{
- "rate_limit_reset_at": futureResetAt,
- },
- },
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false)
- require.Nil(t, result, "ForwardGemini should not return result when model rate limited")
- require.NotNil(t, err, "ForwardGemini should return error")
- // 核心验证:错误应该是 UpstreamFailoverError,而不是普通 502 错误
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
- require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
- // 非粘性会话请求,ForceCacheBilling 应为 false
- require.False(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be false for non-sticky session")
- }
- // TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling
- // 验证:粘性会话切换时,UpstreamFailoverError.ForceCacheBilling 应为 true
- func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "model": "claude-opus-4-6",
- "messages": []map[string]string{{"role": "user", "content": "hello"}},
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request = req
- svc := &AntigravityGatewayService{
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
- }
- // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
- futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
- account := &Account{
- ID: 3,
- Name: "acc-sticky-rate-limited",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- },
- Extra: map[string]any{
- modelRateLimitsKey: map[string]any{
- "claude-opus-4-6-thinking": map[string]any{
- "rate_limit_reset_at": futureResetAt,
- },
- },
- },
- }
- // 传入 isStickySession = true
- result, err := svc.Forward(context.Background(), c, account, body, true)
- require.Nil(t, result, "Forward should not return result when model rate limited")
- require.NotNil(t, err, "Forward should return error")
- // 核心验证:粘性会话切换时,ForceCacheBilling 应为 true
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
- require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
- require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
- }
- // TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling verifies
- // that ForwardGemini sets ForceCacheBilling=true for sticky session switch.
- func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hi"}}},
- },
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
- c.Request = req
- svc := &AntigravityGatewayService{
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
- }
- // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
- futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
- account := &Account{
- ID: 4,
- Name: "acc-gemini-sticky-rate-limited",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- },
- Extra: map[string]any{
- modelRateLimitsKey: map[string]any{
- "gemini-2.5-flash": map[string]any{
- "rate_limit_reset_at": futureResetAt,
- },
- },
- },
- }
- // 传入 isStickySession = true
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, true)
- require.Nil(t, result, "ForwardGemini should not return result when model rate limited")
- require.NotNil(t, err, "ForwardGemini should return error")
- // 核心验证:粘性会话切换时,ForceCacheBilling 应为 true
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
- require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
- require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
- }
- func TestAntigravityGatewayService_ForwardGemini_ClearsStickySessionOnGeminiRateLimit(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hi"}}},
- },
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", bytes.NewReader(body))
- c.Request = req
- respBody := []byte(`{
- "error": {
- "status": "RESOURCE_EXHAUSTED",
- "details": [
- {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
- {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
- ]
- }
- }`)
- upstream := &httpUpstreamStub{resp: &http.Response{
- StatusCode: http.StatusTooManyRequests,
- Header: http.Header{},
- Body: io.NopCloser(bytes.NewReader(respBody)),
- }}
- repo := &stubAntigravityAccountRepo{}
- cache := &stubSmartRetryCache{}
- svc := &AntigravityGatewayService{
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- accountRepo: repo,
- cache: cache,
- }
- account := &Account{
- ID: 44,
- Name: "acc-gemini-runtime-rate-limited",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
- "project_id": "proj",
- },
- Extra: map[string]any{
- "mixed_scheduling": true,
- },
- }
- result, err := svc.ForwardGemini(
- context.Background(),
- c,
- account,
- "gemini-3-flash-preview",
- "generateContent",
- false,
- body,
- true,
- WithForwardGeminiSession(77, "gemini:sticky-runtime"),
- )
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
- require.Len(t, repo.modelRateLimitCalls, 2)
- require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
- require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
- require.Len(t, cache.deleteCalls, 1)
- require.Equal(t, int64(77), cache.deleteCalls[0].groupID)
- require.Equal(t, "gemini:sticky-runtime", cache.deleteCalls[0].sessionHash)
- }
- // TestAntigravityGatewayService_Forward_BillsWithMappedModel
- // 验证:Antigravity Claude 转发返回的计费模型使用映射后的模型
- func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "model": "claude-sonnet-4-5",
- "messages": []map[string]any{
- {"role": "user", "content": "hello"},
- },
- "max_tokens": 16,
- "stream": true,
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request = req
- upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"X-Request-Id": []string{"req-bill-1"}},
- Body: io.NopCloser(bytes.NewReader(upstreamBody)),
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: resp},
- }
- const mappedModel = "gemini-3-pro-high"
- account := &Account{
- ID: 5,
- Name: "acc-forward-billing",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- "model_mapping": map[string]any{
- "claude-sonnet-4-5": mappedModel,
- },
- },
- }
- result, err := svc.Forward(context.Background(), c, account, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "claude-sonnet-4-5", result.Model)
- require.Equal(t, mappedModel, result.UpstreamModel)
- }
- // TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel
- // 验证:Antigravity Gemini 转发返回的计费模型使用映射后的模型
- func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
- },
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
- c.Request = req
- upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"X-Request-Id": []string{"req-bill-2"}},
- Body: io.NopCloser(bytes.NewReader(upstreamBody)),
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: &httpUpstreamStub{resp: resp},
- }
- const mappedModel = "gemini-3-pro-high"
- account := &Account{
- ID: 6,
- Name: "acc-gemini-billing",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- "model_mapping": map[string]any{
- "gemini-2.5-flash": mappedModel,
- },
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", true, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "gemini-2.5-flash", result.Model)
- require.Equal(t, mappedModel, result.UpstreamModel)
- }
- func TestAntigravityGatewayService_ForwardGemini_FallbackReportsActualUpstreamModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-primary:generateContent", bytes.NewReader(body))
- const (
- originalModel = "gemini-primary"
- mappedModel = "gemini-primary-upstream"
- fallbackModel = "gemini-fallback-upstream"
- )
- upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{
- {
- StatusCode: http.StatusNotFound,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"code":404,"message":"model not found"}}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(
- `data: {"response":{"modelVersion":"gemini-fallback-upstream","candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3}}}` + "\n\n",
- )),
- },
- }}
- settings := &antigravitySettingRepoStub{values: map[string]string{
- SettingKeyEnableModelFallback: "true",
- SettingKeyFallbackModelAntigravity: fallbackModel,
- }}
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(settings, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 9,
- Name: "acc-gemini-fallback",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- "model_mapping": map[string]any{
- originalModel: mappedModel,
- },
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "generateContent", true, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, originalModel, result.Model)
- require.Equal(t, fallbackModel, result.UpstreamModel)
- require.Equal(t, fallbackModel, result.UpstreamResponseModel)
- require.False(t, result.UpstreamResponseModelConflict)
- mismatch := upstreamModelMismatch(result.UpstreamModel, result.UpstreamResponseModel)
- require.NotNil(t, mismatch)
- require.False(t, *mismatch)
- require.Len(t, upstream.requestBodies, 2)
- require.Contains(t, string(upstream.requestBodies[0]), `"model":"`+mappedModel+`"`)
- require.Contains(t, string(upstream.requestBodies[1]), `"model":"`+fallbackModel+`"`)
- }
- func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
- {"role": "model", "parts": []map[string]any{{"text": "thinking", "thought": true, "thoughtSignature": "sig_bad_1"}}},
- {"role": "model", "parts": []map[string]any{{"functionCall": map[string]any{"name": "toolA", "args": map[string]any{"x": 1}}, "thoughtSignature": "sig_bad_2"}}},
- },
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent", bytes.NewReader(body))
- c.Request = req
- firstRespBody := []byte(`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}`)
- secondRespBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
- upstream := &queuedHTTPUpstreamStub{
- responses: []*http.Response{
- {
- StatusCode: http.StatusBadRequest,
- Header: http.Header{
- "Content-Type": []string{"application/json"},
- "X-Request-Id": []string{"req-sig-1"},
- },
- Body: io.NopCloser(bytes.NewReader(firstRespBody)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "X-Request-Id": []string{"req-sig-2"},
- },
- Body: io.NopCloser(bytes.NewReader(secondRespBody)),
- },
- },
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- }
- const originalModel = "gemini-3.1-pro-preview"
- const mappedModel = "gemini-3.1-pro-high"
- account := &Account{
- ID: 7,
- Name: "acc-gemini-signature",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- "model_mapping": map[string]any{
- originalModel: mappedModel,
- },
- },
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "streamGenerateContent", true, body, false)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, originalModel, result.Model)
- require.Equal(t, mappedModel, result.UpstreamModel)
- require.Len(t, upstream.requestBodies, 2, "signature error should trigger exactly one retry")
- firstReq := string(upstream.requestBodies[0])
- secondReq := string(upstream.requestBodies[1])
- require.Contains(t, firstReq, `"thoughtSignature":"sig_bad_1"`)
- require.Contains(t, firstReq, `"thoughtSignature":"sig_bad_2"`)
- require.Contains(t, secondReq, `"thoughtSignature":"skip_thought_signature_validator"`)
- require.NotContains(t, secondReq, `"thoughtSignature":"sig_bad_1"`)
- require.NotContains(t, secondReq, `"thoughtSignature":"sig_bad_2"`)
- raw, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := raw.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.NotEmpty(t, events)
- require.Equal(t, "signature_error", events[0].Kind)
- }
- func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- writer := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(writer)
- body, err := json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
- {"role": "model", "parts": []map[string]any{{"text": "thinking", "thought": true, "thoughtSignature": "sig_bad_1"}}},
- },
- })
- require.NoError(t, err)
- req := httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent", bytes.NewReader(body))
- c.Request = req
- firstRespBody := []byte(`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}`)
- const originalModel = "gemini-3.1-pro-preview"
- const mappedModel = "gemini-3.1-pro-high"
- account := &Account{
- ID: 8,
- Name: "acc-gemini-signature-failover",
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "token",
- "project_id": "proj",
- "model_mapping": map[string]any{
- originalModel: mappedModel,
- },
- },
- }
- upstream := &queuedHTTPUpstreamStub{
- responses: []*http.Response{
- {
- StatusCode: http.StatusBadRequest,
- Header: http.Header{
- "Content-Type": []string{"application/json"},
- "X-Request-Id": []string{"req-sig-failover-1"},
- },
- Body: io.NopCloser(bytes.NewReader(firstRespBody)),
- },
- },
- onCall: func(_ *http.Request, stub *queuedHTTPUpstreamStub) {
- if stub.callCount != 1 {
- return
- }
- futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
- account.Extra = map[string]any{
- modelRateLimitsKey: map[string]any{
- mappedModel: map[string]any{
- "rate_limit_reset_at": futureResetAt,
- },
- },
- }
- },
- }
- svc := &AntigravityGatewayService{
- settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
- tokenProvider: &AntigravityTokenProvider{},
- httpUpstream: upstream,
- }
- result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "streamGenerateContent", true, body, true)
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr, "signature retry should propagate failover instead of falling back to the original 400")
- require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
- require.True(t, failoverErr.ForceCacheBilling)
- require.Len(t, upstream.requestBodies, 1, "retry should stop at preflight failover and not issue a second upstream request")
- raw, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := raw.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 2)
- require.Equal(t, "signature_error", events[0].Kind)
- require.Equal(t, "failover", events[1].Kind)
- }
- // TestStreamUpstreamResponse_UsageAndFirstToken
- // 验证:usage 字段可被累积/覆盖更新,并且能记录首 token 时间
- func TestStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
- go func() {
- defer func() { _ = pw.Close() }()
- fmt.Fprintln(pw, `data: {"usage":{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":4}}`)
- fmt.Fprintln(pw, `data: {"usage":{"output_tokens":5}}`)
- }()
- start := time.Now().Add(-10 * time.Millisecond)
- result := svc.streamUpstreamResponse(c, resp, start)
- _ = pr.Close()
- require.NotNil(t, result)
- require.NotNil(t, result.usage)
- require.Equal(t, 1, result.usage.InputTokens)
- // 第二次事件覆盖 output_tokens
- require.Equal(t, 5, result.usage.OutputTokens)
- require.Equal(t, 3, result.usage.CacheReadInputTokens)
- require.Equal(t, 4, result.usage.CacheCreationInputTokens)
- require.NotNil(t, result.firstTokenMs)
- // 确保有透传输出
- require.Contains(t, rec.Body.String(), "data:")
- }
- // --- 流式 happy path 测试 ---
- // TestStreamUpstreamResponse_NormalComplete
- // 验证:正常流式转发完成时,数据正确透传、usage 正确收集、clientDisconnect=false
- func TestStreamUpstreamResponse_NormalComplete(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- fmt.Fprintln(pw, `event: message_start`)
- fmt.Fprintln(pw, `data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}`)
- fmt.Fprintln(pw, "")
- fmt.Fprintln(pw, `event: content_block_delta`)
- fmt.Fprintln(pw, `data: {"type":"content_block_delta","delta":{"text":"hello"}}`)
- fmt.Fprintln(pw, "")
- fmt.Fprintln(pw, `event: message_delta`)
- fmt.Fprintln(pw, `data: {"type":"message_delta","usage":{"output_tokens":5}}`)
- fmt.Fprintln(pw, "")
- }()
- result := svc.streamUpstreamResponse(c, resp, time.Now())
- _ = pr.Close()
- require.NotNil(t, result)
- require.False(t, result.clientDisconnect, "normal completion should not set clientDisconnect")
- require.NotNil(t, result.usage)
- require.Equal(t, 5, result.usage.OutputTokens, "should collect output_tokens from message_delta")
- require.NotNil(t, result.firstTokenMs, "should record first token time")
- // 验证数据被透传到客户端
- body := rec.Body.String()
- require.Contains(t, body, "event: message_start")
- require.Contains(t, body, "content_block_delta")
- require.Contains(t, body, "message_delta")
- }
- // TestHandleGeminiStreamingResponse_NormalComplete
- // 验证:正常 Gemini 流式转发,数据正确透传、usage 正确收集
- func TestHandleGeminiStreamingResponse_NormalComplete(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- // 第一个 chunk(部分内容)
- fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3}}`)
- fmt.Fprintln(pw, "")
- // 第二个 chunk(最终内容+完整 usage)
- fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":8,"cachedContentTokenCount":2}}`)
- fmt.Fprintln(pw, "")
- }()
- result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
- _ = pr.Close()
- require.NoError(t, err)
- require.NotNil(t, result)
- require.False(t, result.clientDisconnect, "normal completion should not set clientDisconnect")
- require.NotNil(t, result.usage)
- // Gemini usage: promptTokenCount=10, candidatesTokenCount=8, cachedContentTokenCount=2
- // → InputTokens=10-2=8, OutputTokens=8, CacheReadInputTokens=2
- require.Equal(t, 8, result.usage.InputTokens)
- require.Equal(t, 8, result.usage.OutputTokens)
- require.Equal(t, 2, result.usage.CacheReadInputTokens)
- require.NotNil(t, result.firstTokenMs, "should record first token time")
- // 验证数据被透传到客户端
- body := rec.Body.String()
- require.Contains(t, body, "Hello")
- require.Contains(t, body, "world")
- // 不应包含错误事件
- require.NotContains(t, body, "event: error")
- }
- // TestHandleClaudeStreamingResponse_NormalComplete
- // 验证:正常 Claude 流式转发(Gemini→Claude 转换),数据正确转换并输出
- func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- // v1internal 包装格式:Gemini 数据嵌套在 "response" 字段下
- // ProcessLine 先尝试反序列化为 V1InternalResponse,裸格式会导致 Response.UsageMetadata 为空
- fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hi there"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3}}}`)
- fmt.Fprintln(pw, "")
- }()
- result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
- _ = pr.Close()
- require.NoError(t, err)
- require.NotNil(t, result)
- require.False(t, result.clientDisconnect, "normal completion should not set clientDisconnect")
- require.NotNil(t, result.usage)
- // Gemini→Claude 转换的 usage:promptTokenCount=5→InputTokens=5, candidatesTokenCount=3→OutputTokens=3
- require.Equal(t, 5, result.usage.InputTokens)
- require.Equal(t, 3, result.usage.OutputTokens)
- require.NotNil(t, result.firstTokenMs, "should record first token time")
- // 验证输出是 Claude SSE 格式(processor 会转换)
- body := rec.Body.String()
- require.Contains(t, body, "event: message_start", "should contain Claude message_start event")
- require.Contains(t, body, "event: message_stop", "should contain Claude message_stop event")
- // 不应包含错误事件
- require.NotContains(t, body, "event: error")
- }
- // TestHandleGeminiStreamingResponse_ThoughtsTokenCount
- // 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
- func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"thoughtsTokenCount":50}}`)
- fmt.Fprintln(pw, "")
- fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"thoughtsTokenCount":80,"cachedContentTokenCount":10}}`)
- fmt.Fprintln(pw, "")
- }()
- result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
- _ = pr.Close()
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.usage)
- // promptTokenCount=100, cachedContentTokenCount=10 → InputTokens=90
- require.Equal(t, 90, result.usage.InputTokens)
- // candidatesTokenCount=30 + thoughtsTokenCount=80 → OutputTokens=110
- require.Equal(t, 110, result.usage.OutputTokens)
- require.Equal(t, 10, result.usage.CacheReadInputTokens)
- }
- // TestHandleClaudeStreamingResponse_ThoughtsTokenCount
- // 验证:Gemini→Claude 流式转换时 thoughtsTokenCount 被计入 OutputTokens
- func TestHandleClaudeStreamingResponse_ThoughtsTokenCount(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"candidatesTokenCount":10,"thoughtsTokenCount":25}}}`)
- fmt.Fprintln(pw, "")
- }()
- result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "gemini-2.5-pro")
- _ = pr.Close()
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.usage)
- // promptTokenCount=50 → InputTokens=50
- require.Equal(t, 50, result.usage.InputTokens)
- // candidatesTokenCount=10 + thoughtsTokenCount=25 → OutputTokens=35
- require.Equal(t, 35, result.usage.OutputTokens)
- }
- // --- 流式客户端断开检测测试 ---
- // TestStreamUpstreamResponse_ClientDisconnectDrainsUsage
- // 验证:客户端写入失败后,streamUpstreamResponse 继续读取上游以收集 usage
- func TestStreamUpstreamResponse_ClientDisconnectDrainsUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- fmt.Fprintln(pw, `event: message_start`)
- fmt.Fprintln(pw, `data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}`)
- fmt.Fprintln(pw, "")
- fmt.Fprintln(pw, `event: message_delta`)
- fmt.Fprintln(pw, `data: {"type":"message_delta","usage":{"output_tokens":20}}`)
- fmt.Fprintln(pw, "")
- }()
- result := svc.streamUpstreamResponse(c, resp, time.Now())
- _ = pr.Close()
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- require.NotNil(t, result.usage)
- require.Equal(t, 20, result.usage.OutputTokens)
- }
- // TestStreamUpstreamResponse_ContextCanceled
- // 验证:context 取消时返回 usage 且标记 clientDisconnect
- func TestStreamUpstreamResponse_ContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
- resp := &http.Response{StatusCode: http.StatusOK, Body: cancelReadCloser{}, Header: http.Header{}}
- result := svc.streamUpstreamResponse(c, resp, time.Now())
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- require.NotContains(t, rec.Body.String(), "event: error")
- }
- // TestStreamUpstreamResponse_Timeout
- // 验证:上游超时时返回已收集的 usage
- func TestStreamUpstreamResponse_Timeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{StreamDataIntervalTimeout: 1, MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- result := svc.streamUpstreamResponse(c, resp, time.Now())
- _ = pw.Close()
- _ = pr.Close()
- require.NotNil(t, result)
- require.False(t, result.clientDisconnect)
- }
- // TestStreamUpstreamResponse_TimeoutAfterClientDisconnect
- // 验证:客户端断开后上游超时,返回 usage 并标记 clientDisconnect
- func TestStreamUpstreamResponse_TimeoutAfterClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{StreamDataIntervalTimeout: 1, MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- fmt.Fprintln(pw, `data: {"type":"message_start","message":{"usage":{"input_tokens":5}}}`)
- fmt.Fprintln(pw, "")
- // 不关闭 pw → 等待超时
- }()
- result := svc.streamUpstreamResponse(c, resp, time.Now())
- _ = pw.Close()
- _ = pr.Close()
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- }
- // TestHandleGeminiStreamingResponse_ClientDisconnect
- // 验证:Gemini 流式转发中客户端断开后继续 drain 上游
- func TestHandleGeminiStreamingResponse_ClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":10}}`)
- fmt.Fprintln(pw, "")
- }()
- result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
- _ = pr.Close()
- require.NoError(t, err)
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- require.NotContains(t, rec.Body.String(), "write_failed")
- }
- // TestHandleGeminiStreamingResponse_ContextCanceled
- // 验证:context 取消时不注入错误事件
- func TestHandleGeminiStreamingResponse_ContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
- resp := &http.Response{StatusCode: http.StatusOK, Body: cancelReadCloser{}, Header: http.Header{}}
- result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
- require.NoError(t, err)
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- require.NotContains(t, rec.Body.String(), "event: error")
- }
- // TestHandleClaudeStreamingResponse_ClientDisconnect
- // 验证:Claude 流式转发中客户端断开后继续 drain 上游
- func TestHandleClaudeStreamingResponse_ClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- // v1internal 包装格式
- fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":15}}}`)
- fmt.Fprintln(pw, "")
- }()
- result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
- _ = pr.Close()
- require.NoError(t, err)
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- }
- // TestHandleClaudeStreamingResponse_EmptyStream
- // 验证:上游只返回无法解析的 SSE 行时,触发 UpstreamFailoverError 而不是向客户端发出残缺流
- func TestHandleClaudeStreamingResponse_EmptyStream(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- pr, pw := io.Pipe()
- resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
- go func() {
- defer func() { _ = pw.Close() }()
- // 所有行均为无法 JSON 解析的内容,ProcessLine 全部返回 nil
- fmt.Fprintln(pw, "data: not-valid-json")
- fmt.Fprintln(pw, "")
- fmt.Fprintln(pw, "data: also-invalid")
- fmt.Fprintln(pw, "")
- }()
- _, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
- _ = pr.Close()
- // 应当返回 UpstreamFailoverError 而非 nil,以便上层触发 failover
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.True(t, failoverErr.RetryableOnSameAccount)
- // 客户端不应收到任何 SSE 事件(既无 message_start 也无 message_stop)
- body := rec.Body.String()
- require.NotContains(t, body, "event: message_start")
- require.NotContains(t, body, "event: message_stop")
- require.NotContains(t, body, "event: message_delta")
- }
- // TestHandleClaudeStreamingResponse_ContextCanceled
- // 验证:context 取消时不注入错误事件
- func TestHandleClaudeStreamingResponse_ContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := newAntigravityTestService(&config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- })
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
- resp := &http.Response{StatusCode: http.StatusOK, Body: cancelReadCloser{}, Header: http.Header{}}
- result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.True(t, result.clientDisconnect)
- require.NotContains(t, rec.Body.String(), "event: error")
- }
- // TestExtractSSEUsage 验证 extractSSEUsage 从 SSE data 行正确提取 usage
- func TestExtractSSEUsage(t *testing.T) {
- svc := &AntigravityGatewayService{}
- tests := []struct {
- name string
- line string
- expected ClaudeUsage
- }{
- {
- name: "message_delta with output_tokens",
- line: `data: {"type":"message_delta","usage":{"output_tokens":42}}`,
- expected: ClaudeUsage{OutputTokens: 42},
- },
- {
- name: "non-data line ignored",
- line: `event: message_start`,
- expected: ClaudeUsage{},
- },
- {
- name: "top-level usage with all fields",
- line: `data: {"usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":5,"cache_creation_input_tokens":3}}`,
- expected: ClaudeUsage{InputTokens: 10, OutputTokens: 20, CacheReadInputTokens: 5, CacheCreationInputTokens: 3},
- },
- {
- // Anthropic message_start 把 usage 嵌套在 message.usage 下,
- // 必须从这里提取输入侧字段(含 cache_read/cache_creation_input_tokens)。
- name: "message_start nested usage with input/cache tokens",
- line: `data: {"type":"message_start","message":{"id":"msg_01","usage":{"input_tokens":35576,"cache_creation_input_tokens":0,"cache_read_input_tokens":12000,"output_tokens":1}}}`,
- expected: ClaudeUsage{InputTokens: 35576, OutputTokens: 1, CacheReadInputTokens: 12000},
- },
- {
- // message_start.message.usage.cache_creation 内的 5m/1h 明细也要解析。
- name: "message_start nested usage with cache_creation breakdown",
- line: `data: {"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`,
- expected: ClaudeUsage{InputTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 70},
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- usage := &ClaudeUsage{}
- svc.extractSSEUsage(tt.line, usage)
- require.Equal(t, tt.expected, *usage)
- })
- }
- }
- // TestExtractSSEUsage_StreamingSequence 复现 issue #2332:完整的 Anthropic streaming
- // 序列(message_start → message_delta)必须把两类事件中的 usage 字段都汇入同一份累计值,
- // 否则透传账号产出的 usage_logs 会出现 input_tokens=0、仅有 output_tokens 的"残缺"记录。
- func TestExtractSSEUsage_StreamingSequence(t *testing.T) {
- svc := &AntigravityGatewayService{}
- usage := &ClaudeUsage{}
- // 1) message_start:携带完整输入侧 usage(input_tokens + cache_read)
- svc.extractSSEUsage(
- `data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","content":[],"model":"claude-opus-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":35576,"cache_creation_input_tokens":0,"cache_read_input_tokens":12000,"output_tokens":1}}}`,
- usage,
- )
- // 2) message_delta:流结束时只带 output_tokens(无 input_tokens 字段)
- svc.extractSSEUsage(
- `data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":816}}`,
- usage,
- )
- require.Equal(t, 35576, usage.InputTokens, "message_start 的 input_tokens 必须被记录,否则记账会缺失输入侧 token (#2332)")
- require.Equal(t, 12000, usage.CacheReadInputTokens, "message_start 的 cache_read_input_tokens 必须被记录")
- require.Equal(t, 816, usage.OutputTokens, "message_delta 的最终 output_tokens 必须被记录")
- }
- // TestAntigravityClientWriter 验证 antigravityClientWriter 的断开检测
- func TestAntigravityClientWriter(t *testing.T) {
- t.Run("normal write succeeds", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- flusher, _ := c.Writer.(http.Flusher)
- cw := newAntigravityClientWriter(c.Writer, flusher, "test")
- ok := cw.Write([]byte("hello"))
- require.True(t, ok)
- require.False(t, cw.Disconnected())
- require.Contains(t, rec.Body.String(), "hello")
- })
- t.Run("write failure marks disconnected", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- fw := &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- flusher, _ := c.Writer.(http.Flusher)
- cw := newAntigravityClientWriter(fw, flusher, "test")
- ok := cw.Write([]byte("hello"))
- require.False(t, ok)
- require.True(t, cw.Disconnected())
- })
- t.Run("subsequent writes are no-op", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- fw := &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- flusher, _ := c.Writer.(http.Flusher)
- cw := newAntigravityClientWriter(fw, flusher, "test")
- cw.Write([]byte("first"))
- ok := cw.Fprintf("second %d", 2)
- require.False(t, ok)
- require.True(t, cw.Disconnected())
- })
- }
- // TestUnwrapV1InternalResponse 测试 unwrapV1InternalResponse 的各种输入场景
- func TestUnwrapV1InternalResponse(t *testing.T) {
- svc := &AntigravityGatewayService{}
- // 构造 >50KB 的大型 JSON
- largePadding := strings.Repeat("x", 50*1024)
- largeInput := []byte(fmt.Sprintf(`{"response":{"id":"big","pad":"%s"}}`, largePadding))
- largeExpected := fmt.Sprintf(`{"id":"big","pad":"%s"}`, largePadding)
- tests := []struct {
- name string
- input []byte
- expected string
- wantErr bool
- }{
- {
- name: "正常 response 包装",
- input: []byte(`{"response":{"id":"123","content":"hello"}}`),
- expected: `{"id":"123","content":"hello"}`,
- },
- {
- name: "无 response 透传",
- input: []byte(`{"id":"456"}`),
- expected: `{"id":"456"}`,
- },
- {
- name: "空 JSON",
- input: []byte(`{}`),
- expected: `{}`,
- },
- {
- name: "response 为 null",
- input: []byte(`{"response":null}`),
- expected: `null`,
- },
- {
- name: "response 为基础类型 string",
- input: []byte(`{"response":"hello"}`),
- expected: `"hello"`,
- },
- {
- name: "非法 JSON",
- input: []byte(`not json`),
- expected: `not json`,
- },
- {
- name: "嵌套 response 只解一层",
- input: []byte(`{"response":{"response":{"inner":true}}}`),
- expected: `{"response":{"inner":true}}`,
- },
- {
- name: "大型 JSON >50KB",
- input: largeInput,
- expected: largeExpected,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := svc.unwrapV1InternalResponse(tt.input)
- if tt.wantErr {
- require.Error(t, err)
- return
- }
- require.NoError(t, err)
- require.Equal(t, tt.expected, strings.TrimSpace(string(got)))
- })
- }
- }
- // --- unwrapV1InternalResponse benchmark 对照组 ---
- // unwrapV1InternalResponseOld 旧实现:Unmarshal+Marshal 双重开销(仅用于 benchmark 对照)
- func unwrapV1InternalResponseOld(body []byte) ([]byte, error) {
- var outer map[string]any
- if err := json.Unmarshal(body, &outer); err != nil {
- return nil, err
- }
- if resp, ok := outer["response"]; ok {
- return json.Marshal(resp)
- }
- return body, nil
- }
- func BenchmarkUnwrapV1Internal_Old_Small(b *testing.B) {
- body := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello world"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`)
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, _ = unwrapV1InternalResponseOld(body)
- }
- }
- func BenchmarkUnwrapV1Internal_New_Small(b *testing.B) {
- body := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello world"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`)
- svc := &AntigravityGatewayService{}
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, _ = svc.unwrapV1InternalResponse(body)
- }
- }
- func BenchmarkUnwrapV1Internal_Old_Large(b *testing.B) {
- body := generateLargeUnwrapJSON(10 * 1024) // ~10KB
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, _ = unwrapV1InternalResponseOld(body)
- }
- }
- func BenchmarkUnwrapV1Internal_New_Large(b *testing.B) {
- body := generateLargeUnwrapJSON(10 * 1024) // ~10KB
- svc := &AntigravityGatewayService{}
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, _ = svc.unwrapV1InternalResponse(body)
- }
- }
- // generateLargeUnwrapJSON 生成指定最小大小的包含 response 包装的 JSON
- func generateLargeUnwrapJSON(minSize int) []byte {
- parts := make([]map[string]string, 0)
- current := 0
- for current < minSize {
- text := fmt.Sprintf("这是第 %d 段内容,用于填充 JSON 到目标大小。", len(parts)+1)
- parts = append(parts, map[string]string{"text": text})
- current += len(text) + 20 // 估算 JSON 编码开销
- }
- inner := map[string]any{
- "candidates": []map[string]any{
- {"content": map[string]any{"parts": parts}},
- },
- "usageMetadata": map[string]any{
- "promptTokenCount": 100,
- "candidatesTokenCount": 50,
- },
- }
- outer := map[string]any{"response": inner}
- b, _ := json.Marshal(outer)
- return b
- }
|