antigravity_gateway_service_test.go 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  17. "github.com/gin-gonic/gin"
  18. "github.com/stretchr/testify/require"
  19. )
  20. // antigravityFailingWriter 模拟客户端断开连接的 gin.ResponseWriter
  21. type antigravityFailingWriter struct {
  22. gin.ResponseWriter
  23. failAfter int // 允许成功写入的次数,之后所有写入返回错误
  24. writes int
  25. }
  26. func (w *antigravityFailingWriter) Write(p []byte) (int, error) {
  27. if w.writes >= w.failAfter {
  28. return 0, errors.New("write failed: client disconnected")
  29. }
  30. w.writes++
  31. return w.ResponseWriter.Write(p)
  32. }
  33. // newAntigravityTestService 创建用于流式测试的 AntigravityGatewayService
  34. func newAntigravityTestService(cfg *config.Config) *AntigravityGatewayService {
  35. return &AntigravityGatewayService{
  36. settingService: &SettingService{cfg: cfg},
  37. }
  38. }
  39. func TestAntigravityUpstreamErrorBodyReadLimit_RespectsDiagnosticLimit(t *testing.T) {
  40. svc := newAntigravityTestService(&config.Config{Gateway: config.GatewayConfig{
  41. LogUpstreamErrorBody: true,
  42. LogUpstreamErrorBodyMaxBytes: int(gatewayUpstreamErrorBodyReadLimit) + 1024,
  43. }})
  44. require.Equal(t, int64(svc.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), svc.upstreamErrorBodyReadLimit())
  45. }
  46. func TestStripSignatureSensitiveBlocksFromClaudeRequest(t *testing.T) {
  47. req := &antigravity.ClaudeRequest{
  48. Model: "claude-sonnet-4-5",
  49. Thinking: &antigravity.ThinkingConfig{
  50. Type: "enabled",
  51. BudgetTokens: 1024,
  52. },
  53. Messages: []antigravity.ClaudeMessage{
  54. {
  55. Role: "assistant",
  56. Content: json.RawMessage(`[
  57. {"type":"thinking","thinking":"secret plan","signature":""},
  58. {"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}
  59. ]`),
  60. },
  61. {
  62. Role: "user",
  63. Content: json.RawMessage(`[
  64. {"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false},
  65. {"type":"redacted_thinking","data":"..."}
  66. ]`),
  67. },
  68. },
  69. }
  70. changed, err := stripSignatureSensitiveBlocksFromClaudeRequest(req)
  71. require.NoError(t, err)
  72. require.True(t, changed)
  73. require.Nil(t, req.Thinking)
  74. require.Len(t, req.Messages, 2)
  75. var blocks0 []map[string]any
  76. require.NoError(t, json.Unmarshal(req.Messages[0].Content, &blocks0))
  77. require.Len(t, blocks0, 2)
  78. require.Equal(t, "text", blocks0[0]["type"])
  79. require.Equal(t, "secret plan", blocks0[0]["text"])
  80. require.Equal(t, "text", blocks0[1]["type"])
  81. var blocks1 []map[string]any
  82. require.NoError(t, json.Unmarshal(req.Messages[1].Content, &blocks1))
  83. require.Len(t, blocks1, 1)
  84. require.Equal(t, "text", blocks1[0]["type"])
  85. require.NotEmpty(t, blocks1[0]["text"])
  86. }
  87. func TestStripThinkingFromClaudeRequest_DoesNotDowngradeTools(t *testing.T) {
  88. req := &antigravity.ClaudeRequest{
  89. Model: "claude-sonnet-4-5",
  90. Thinking: &antigravity.ThinkingConfig{
  91. Type: "enabled",
  92. BudgetTokens: 1024,
  93. },
  94. Messages: []antigravity.ClaudeMessage{
  95. {
  96. Role: "assistant",
  97. Content: json.RawMessage(`[{"type":"thinking","thinking":"secret plan"},{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]`),
  98. },
  99. },
  100. }
  101. changed, err := stripThinkingFromClaudeRequest(req)
  102. require.NoError(t, err)
  103. require.True(t, changed)
  104. require.Nil(t, req.Thinking)
  105. var blocks []map[string]any
  106. require.NoError(t, json.Unmarshal(req.Messages[0].Content, &blocks))
  107. require.Len(t, blocks, 2)
  108. require.Equal(t, "text", blocks[0]["type"])
  109. require.Equal(t, "secret plan", blocks[0]["text"])
  110. require.Equal(t, "tool_use", blocks[1]["type"])
  111. }
  112. func TestIsPromptTooLongError(t *testing.T) {
  113. require.True(t, isPromptTooLongError([]byte(`{"error":{"message":"Prompt is too long"}}`)))
  114. require.True(t, isPromptTooLongError([]byte(`{"message":"Prompt is too long"}`)))
  115. require.False(t, isPromptTooLongError([]byte(`{"error":{"message":"other"}}`)))
  116. }
  117. type httpUpstreamStub struct {
  118. resp *http.Response
  119. err error
  120. }
  121. func (s *httpUpstreamStub) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  122. return s.resp, s.err
  123. }
  124. func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  125. return s.resp, s.err
  126. }
  127. type queuedHTTPUpstreamStub struct {
  128. responses []*http.Response
  129. errors []error
  130. requestBodies [][]byte
  131. callCount int
  132. onCall func(*http.Request, *queuedHTTPUpstreamStub)
  133. }
  134. func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  135. if req != nil && req.Body != nil {
  136. body, _ := io.ReadAll(req.Body)
  137. s.requestBodies = append(s.requestBodies, body)
  138. req.Body = io.NopCloser(bytes.NewReader(body))
  139. } else {
  140. s.requestBodies = append(s.requestBodies, nil)
  141. }
  142. idx := s.callCount
  143. s.callCount++
  144. if s.onCall != nil {
  145. s.onCall(req, s)
  146. }
  147. var resp *http.Response
  148. if idx < len(s.responses) {
  149. resp = s.responses[idx]
  150. }
  151. var err error
  152. if idx < len(s.errors) {
  153. err = s.errors[idx]
  154. }
  155. if resp == nil && err == nil {
  156. return nil, errors.New("unexpected upstream call")
  157. }
  158. return resp, err
  159. }
  160. func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  161. return s.Do(req, proxyURL, accountID, concurrency)
  162. }
  163. type recordingInternal500CounterCache struct {
  164. incrementCalls []int64
  165. resetCalls []int64
  166. }
  167. func (c *recordingInternal500CounterCache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) {
  168. c.incrementCalls = append(c.incrementCalls, accountID)
  169. return int64(len(c.incrementCalls)), nil
  170. }
  171. func (c *recordingInternal500CounterCache) ResetInternal500Count(_ context.Context, accountID int64) error {
  172. c.resetCalls = append(c.resetCalls, accountID)
  173. return nil
  174. }
  175. type antigravitySettingRepoStub struct {
  176. values map[string]string
  177. }
  178. func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
  179. panic("unexpected Get call")
  180. }
  181. func (s *antigravitySettingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
  182. if value, ok := s.values[key]; ok {
  183. return value, nil
  184. }
  185. return "", ErrSettingNotFound
  186. }
  187. func (s *antigravitySettingRepoStub) Set(ctx context.Context, key, value string) error {
  188. panic("unexpected Set call")
  189. }
  190. func (s *antigravitySettingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
  191. panic("unexpected GetMultiple call")
  192. }
  193. func (s *antigravitySettingRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
  194. panic("unexpected SetMultiple call")
  195. }
  196. func (s *antigravitySettingRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
  197. panic("unexpected GetAll call")
  198. }
  199. func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) error {
  200. panic("unexpected Delete call")
  201. }
  202. func TestResolveAntigravityProjectID(t *testing.T) {
  203. tests := []struct {
  204. name string
  205. account *Account
  206. want string
  207. wantErr bool
  208. }{
  209. {
  210. name: "uses onboard project_id first",
  211. account: &Account{Credentials: map[string]any{
  212. "project_id": " onboard-project ",
  213. antigravityProjectIDFallbackCredentialKey: " configured-project ",
  214. }},
  215. want: "onboard-project",
  216. },
  217. {
  218. name: "uses configured credentials fallback",
  219. account: &Account{Credentials: map[string]any{
  220. antigravityProjectIDFallbackCredentialKey: " configured-project ",
  221. }},
  222. want: "configured-project",
  223. },
  224. {
  225. name: "uses configured extra fallback",
  226. account: &Account{Extra: map[string]any{
  227. antigravityProjectIDFallbackCredentialKey: " extra-project ",
  228. }},
  229. want: "extra-project",
  230. },
  231. {
  232. name: "missing project",
  233. account: &Account{Credentials: map[string]any{}},
  234. wantErr: true,
  235. },
  236. }
  237. for _, tc := range tests {
  238. t.Run(tc.name, func(t *testing.T) {
  239. got, err := resolveAntigravityProjectID(tc.account)
  240. if tc.wantErr {
  241. require.ErrorIs(t, err, errAntigravityProjectIDRequired)
  242. require.Empty(t, got)
  243. return
  244. }
  245. require.NoError(t, err)
  246. require.Equal(t, tc.want, got)
  247. })
  248. }
  249. }
  250. func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t *testing.T) {
  251. gin.SetMode(gin.TestMode)
  252. writer := httptest.NewRecorder()
  253. c, _ := gin.CreateTestContext(writer)
  254. body, err := json.Marshal(map[string]any{
  255. "contents": []map[string]any{
  256. {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
  257. },
  258. })
  259. require.NoError(t, err)
  260. c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body))
  261. upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n")
  262. upstream := &queuedHTTPUpstreamStub{
  263. responses: []*http.Response{
  264. {
  265. StatusCode: http.StatusOK,
  266. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  267. Body: io.NopCloser(bytes.NewReader(upstreamBody)),
  268. },
  269. },
  270. }
  271. svc := &AntigravityGatewayService{
  272. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  273. tokenProvider: &AntigravityTokenProvider{},
  274. httpUpstream: upstream,
  275. }
  276. account := &Account{
  277. ID: 101,
  278. Name: "acc-configured-project",
  279. Platform: PlatformAntigravity,
  280. Type: AccountTypeOAuth,
  281. Status: StatusActive,
  282. Concurrency: 1,
  283. Credentials: map[string]any{
  284. "access_token": "token",
  285. antigravityProjectIDFallbackCredentialKey: "configured-project",
  286. "model_mapping": map[string]any{
  287. "gemini-2.5-flash": "gemini-2.5-flash",
  288. },
  289. },
  290. }
  291. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false)
  292. require.NoError(t, err)
  293. require.NotNil(t, result)
  294. require.Len(t, upstream.requestBodies, 1)
  295. var wrapped map[string]any
  296. require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
  297. require.Equal(t, "configured-project", wrapped["project"])
  298. }
  299. func TestAntigravityGatewayService_ForwardGemini_ImageUsesDefaultMappingAndOAuth(t *testing.T) {
  300. gin.SetMode(gin.TestMode)
  301. writer := httptest.NewRecorder()
  302. c, _ := gin.CreateTestContext(writer)
  303. body := []byte(`{"contents":[{"role":"user","parts":[{"text":"draw a cat"}]}],"generationConfig":{"responseModalities":["TEXT","IMAGE"],"imageConfig":{"aspectRatio":"1:1"}}}`)
  304. c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.1-flash-image:generateContent", bytes.NewReader(body))
  305. upstream := &queuedHTTPUpstreamStub{
  306. responses: []*http.Response{{
  307. StatusCode: http.StatusOK,
  308. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  309. Body: io.NopCloser(strings.NewReader(
  310. "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"aGVsbG8=\"}}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n",
  311. )),
  312. }},
  313. onCall: func(req *http.Request, _ *queuedHTTPUpstreamStub) {
  314. require.Equal(t, "Bearer test-access-token", req.Header.Get("Authorization"))
  315. require.Equal(t, "application/json", req.Header.Get("Content-Type"))
  316. require.Contains(t, req.URL.String(), "/v1internal:streamGenerateContent?alt=sse")
  317. },
  318. }
  319. svc := &AntigravityGatewayService{
  320. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  321. tokenProvider: &AntigravityTokenProvider{},
  322. httpUpstream: upstream,
  323. }
  324. account := &Account{
  325. ID: 104,
  326. Name: "antigravity-image",
  327. Platform: PlatformAntigravity,
  328. Type: AccountTypeOAuth,
  329. Status: StatusActive,
  330. Concurrency: 1,
  331. Credentials: map[string]any{
  332. "access_token": "test-access-token",
  333. "project_id": "test-project",
  334. },
  335. }
  336. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-3.1-flash-image", "generateContent", true, body, false)
  337. require.NoError(t, err)
  338. require.NotNil(t, result)
  339. require.Equal(t, "gemini-3.1-flash-image", result.Model)
  340. require.Equal(t, "gemini-3.1-flash-image", result.UpstreamModel)
  341. require.Equal(t, 1, result.ImageCount)
  342. require.Len(t, upstream.requestBodies, 1)
  343. var wrapped map[string]any
  344. require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
  345. require.Equal(t, "test-project", wrapped["project"])
  346. require.Equal(t, "gemini-3.1-flash-image", wrapped["model"])
  347. request, ok := wrapped["request"].(map[string]any)
  348. require.True(t, ok)
  349. generationConfig, ok := request["generationConfig"].(map[string]any)
  350. require.True(t, ok)
  351. require.Equal(t, []any{"TEXT", "IMAGE"}, generationConfig["responseModalities"])
  352. }
  353. func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocationConfig(t *testing.T) {
  354. gin.SetMode(gin.TestMode)
  355. body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`)
  356. writer := httptest.NewRecorder()
  357. c, _ := gin.CreateTestContext(writer)
  358. body = bytes.ReplaceAll(body, []byte{92}, nil)
  359. c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
  360. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
  361. StatusCode: http.StatusOK,
  362. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  363. Body: io.NopCloser(strings.NewReader("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{}}}\n\n")),
  364. }}}
  365. svc := &AntigravityGatewayService{
  366. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  367. tokenProvider: &AntigravityTokenProvider{},
  368. httpUpstream: upstream,
  369. }
  370. account := &Account{
  371. ID: 103, Name: "native-gemini", Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 1,
  372. Credentials: map[string]any{"access_token": "token", "project_id": "project-103", "model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}},
  373. }
  374. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false)
  375. require.NoError(t, err)
  376. require.NotNil(t, result)
  377. require.Len(t, upstream.requestBodies, 1)
  378. var wrapped map[string]any
  379. require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
  380. request, ok := wrapped["request"].(map[string]any)
  381. require.True(t, ok)
  382. toolConfig, ok := request["toolConfig"].(map[string]any)
  383. require.True(t, ok)
  384. require.Equal(t, true, toolConfig["includeServerSideToolInvocations"])
  385. require.NotContains(t, toolConfig, "include_server_side_tool_invocations")
  386. }
  387. func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) {
  388. gin.SetMode(gin.TestMode)
  389. writer := httptest.NewRecorder()
  390. c, _ := gin.CreateTestContext(writer)
  391. body, err := json.Marshal(map[string]any{
  392. "contents": []map[string]any{
  393. {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
  394. },
  395. })
  396. require.NoError(t, err)
  397. c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body))
  398. upstream := &queuedHTTPUpstreamStub{}
  399. internal500Cache := &recordingInternal500CounterCache{}
  400. svc := &AntigravityGatewayService{
  401. tokenProvider: &AntigravityTokenProvider{},
  402. httpUpstream: upstream,
  403. internal500Cache: internal500Cache,
  404. }
  405. account := &Account{
  406. ID: 102,
  407. Name: "acc-missing-project",
  408. Platform: PlatformAntigravity,
  409. Type: AccountTypeOAuth,
  410. Status: StatusActive,
  411. Concurrency: 1,
  412. Credentials: map[string]any{
  413. "access_token": "token",
  414. "model_mapping": map[string]any{
  415. "gemini-2.5-flash": "gemini-2.5-flash",
  416. },
  417. },
  418. }
  419. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false)
  420. require.Nil(t, result)
  421. require.ErrorIs(t, err, errAntigravityProjectIDRequired)
  422. require.Equal(t, http.StatusBadRequest, writer.Code)
  423. require.Empty(t, upstream.requestBodies)
  424. require.Empty(t, internal500Cache.incrementCalls)
  425. require.Contains(t, writer.Body.String(), "project_id")
  426. require.NotContains(t, writer.Body.String(), `"project":""`)
  427. }
  428. func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
  429. gin.SetMode(gin.TestMode)
  430. writer := httptest.NewRecorder()
  431. c, _ := gin.CreateTestContext(writer)
  432. body, err := json.Marshal(map[string]any{
  433. "model": "claude-opus-4-6",
  434. "messages": []map[string]any{
  435. {"role": "user", "content": "hi"},
  436. },
  437. "max_tokens": 1,
  438. "stream": false,
  439. })
  440. require.NoError(t, err)
  441. req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  442. c.Request = req
  443. respBody := []byte(`{"error":{"message":"Prompt is too long"}}`)
  444. resp := &http.Response{
  445. StatusCode: http.StatusBadRequest,
  446. Header: http.Header{"X-Request-Id": []string{"req-1"}},
  447. Body: io.NopCloser(bytes.NewReader(respBody)),
  448. }
  449. svc := &AntigravityGatewayService{
  450. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  451. tokenProvider: &AntigravityTokenProvider{},
  452. httpUpstream: &httpUpstreamStub{resp: resp},
  453. }
  454. account := &Account{
  455. ID: 1,
  456. Name: "acc-1",
  457. Platform: PlatformAntigravity,
  458. Type: AccountTypeOAuth,
  459. Status: StatusActive,
  460. Concurrency: 1,
  461. Credentials: map[string]any{
  462. "access_token": "token",
  463. "project_id": "proj",
  464. },
  465. }
  466. result, err := svc.Forward(context.Background(), c, account, body, false)
  467. require.Nil(t, result)
  468. var promptErr *PromptTooLongError
  469. require.ErrorAs(t, err, &promptErr)
  470. require.Equal(t, http.StatusBadRequest, promptErr.StatusCode)
  471. require.Equal(t, "req-1", promptErr.RequestID)
  472. require.NotEmpty(t, promptErr.Body)
  473. raw, ok := c.Get(OpsUpstreamErrorsKey)
  474. require.True(t, ok)
  475. events, ok := raw.([]*OpsUpstreamErrorEvent)
  476. require.True(t, ok)
  477. require.Len(t, events, 1)
  478. require.Equal(t, "prompt_too_long", events[0].Kind)
  479. }
  480. // TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover
  481. // 验证:当账号存在模型限流且剩余时间 >= antigravityRateLimitThreshold 时,
  482. // Forward 方法应返回 UpstreamFailoverError,触发 Handler 切换账号
  483. func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *testing.T) {
  484. gin.SetMode(gin.TestMode)
  485. writer := httptest.NewRecorder()
  486. c, _ := gin.CreateTestContext(writer)
  487. body, err := json.Marshal(map[string]any{
  488. "model": "claude-opus-4-6",
  489. "messages": []map[string]any{
  490. {"role": "user", "content": "hi"},
  491. },
  492. "max_tokens": 1,
  493. "stream": false,
  494. })
  495. require.NoError(t, err)
  496. req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  497. c.Request = req
  498. // 不需要真正调用上游,因为预检查会直接返回切换信号
  499. svc := &AntigravityGatewayService{
  500. tokenProvider: &AntigravityTokenProvider{},
  501. httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
  502. }
  503. // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
  504. futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
  505. account := &Account{
  506. ID: 1,
  507. Name: "acc-rate-limited",
  508. Platform: PlatformAntigravity,
  509. Type: AccountTypeOAuth,
  510. Status: StatusActive,
  511. Concurrency: 1,
  512. Credentials: map[string]any{
  513. "access_token": "token",
  514. "project_id": "proj",
  515. },
  516. Extra: map[string]any{
  517. modelRateLimitsKey: map[string]any{
  518. "claude-opus-4-6-thinking": map[string]any{
  519. "rate_limit_reset_at": futureResetAt,
  520. },
  521. },
  522. },
  523. }
  524. result, err := svc.Forward(context.Background(), c, account, body, false)
  525. require.Nil(t, result, "Forward should not return result when model rate limited")
  526. require.NotNil(t, err, "Forward should return error")
  527. // 核心验证:错误应该是 UpstreamFailoverError,而不是普通 502 错误
  528. var failoverErr *UpstreamFailoverError
  529. require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
  530. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  531. // 非粘性会话请求,ForceCacheBilling 应为 false
  532. require.False(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be false for non-sticky session")
  533. }
  534. // TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover
  535. // 验证:ForwardGemini 方法同样能正确将 AntigravityAccountSwitchError 转换为 UpstreamFailoverError
  536. func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover(t *testing.T) {
  537. gin.SetMode(gin.TestMode)
  538. writer := httptest.NewRecorder()
  539. c, _ := gin.CreateTestContext(writer)
  540. body, err := json.Marshal(map[string]any{
  541. "contents": []map[string]any{
  542. {"role": "user", "parts": []map[string]any{{"text": "hi"}}},
  543. },
  544. })
  545. require.NoError(t, err)
  546. req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
  547. c.Request = req
  548. // 不需要真正调用上游,因为预检查会直接返回切换信号
  549. svc := &AntigravityGatewayService{
  550. tokenProvider: &AntigravityTokenProvider{},
  551. httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
  552. }
  553. // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
  554. futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
  555. account := &Account{
  556. ID: 2,
  557. Name: "acc-gemini-rate-limited",
  558. Platform: PlatformAntigravity,
  559. Type: AccountTypeOAuth,
  560. Status: StatusActive,
  561. Concurrency: 1,
  562. Credentials: map[string]any{
  563. "access_token": "token",
  564. "project_id": "proj",
  565. },
  566. Extra: map[string]any{
  567. modelRateLimitsKey: map[string]any{
  568. "gemini-2.5-flash": map[string]any{
  569. "rate_limit_reset_at": futureResetAt,
  570. },
  571. },
  572. },
  573. }
  574. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false)
  575. require.Nil(t, result, "ForwardGemini should not return result when model rate limited")
  576. require.NotNil(t, err, "ForwardGemini should return error")
  577. // 核心验证:错误应该是 UpstreamFailoverError,而不是普通 502 错误
  578. var failoverErr *UpstreamFailoverError
  579. require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
  580. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  581. // 非粘性会话请求,ForceCacheBilling 应为 false
  582. require.False(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be false for non-sticky session")
  583. }
  584. // TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling
  585. // 验证:粘性会话切换时,UpstreamFailoverError.ForceCacheBilling 应为 true
  586. func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *testing.T) {
  587. gin.SetMode(gin.TestMode)
  588. writer := httptest.NewRecorder()
  589. c, _ := gin.CreateTestContext(writer)
  590. body, err := json.Marshal(map[string]any{
  591. "model": "claude-opus-4-6",
  592. "messages": []map[string]string{{"role": "user", "content": "hello"}},
  593. })
  594. require.NoError(t, err)
  595. req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  596. c.Request = req
  597. svc := &AntigravityGatewayService{
  598. tokenProvider: &AntigravityTokenProvider{},
  599. httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
  600. }
  601. // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
  602. futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
  603. account := &Account{
  604. ID: 3,
  605. Name: "acc-sticky-rate-limited",
  606. Platform: PlatformAntigravity,
  607. Type: AccountTypeOAuth,
  608. Status: StatusActive,
  609. Concurrency: 1,
  610. Credentials: map[string]any{
  611. "access_token": "token",
  612. "project_id": "proj",
  613. },
  614. Extra: map[string]any{
  615. modelRateLimitsKey: map[string]any{
  616. "claude-opus-4-6-thinking": map[string]any{
  617. "rate_limit_reset_at": futureResetAt,
  618. },
  619. },
  620. },
  621. }
  622. // 传入 isStickySession = true
  623. result, err := svc.Forward(context.Background(), c, account, body, true)
  624. require.Nil(t, result, "Forward should not return result when model rate limited")
  625. require.NotNil(t, err, "Forward should return error")
  626. // 核心验证:粘性会话切换时,ForceCacheBilling 应为 true
  627. var failoverErr *UpstreamFailoverError
  628. require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
  629. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  630. require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
  631. }
  632. // TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling verifies
  633. // that ForwardGemini sets ForceCacheBilling=true for sticky session switch.
  634. func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(t *testing.T) {
  635. gin.SetMode(gin.TestMode)
  636. writer := httptest.NewRecorder()
  637. c, _ := gin.CreateTestContext(writer)
  638. body, err := json.Marshal(map[string]any{
  639. "contents": []map[string]any{
  640. {"role": "user", "parts": []map[string]any{{"text": "hi"}}},
  641. },
  642. })
  643. require.NoError(t, err)
  644. req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
  645. c.Request = req
  646. svc := &AntigravityGatewayService{
  647. tokenProvider: &AntigravityTokenProvider{},
  648. httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
  649. }
  650. // 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
  651. futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
  652. account := &Account{
  653. ID: 4,
  654. Name: "acc-gemini-sticky-rate-limited",
  655. Platform: PlatformAntigravity,
  656. Type: AccountTypeOAuth,
  657. Status: StatusActive,
  658. Concurrency: 1,
  659. Credentials: map[string]any{
  660. "access_token": "token",
  661. "project_id": "proj",
  662. },
  663. Extra: map[string]any{
  664. modelRateLimitsKey: map[string]any{
  665. "gemini-2.5-flash": map[string]any{
  666. "rate_limit_reset_at": futureResetAt,
  667. },
  668. },
  669. },
  670. }
  671. // 传入 isStickySession = true
  672. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, true)
  673. require.Nil(t, result, "ForwardGemini should not return result when model rate limited")
  674. require.NotNil(t, err, "ForwardGemini should return error")
  675. // 核心验证:粘性会话切换时,ForceCacheBilling 应为 true
  676. var failoverErr *UpstreamFailoverError
  677. require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
  678. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  679. require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
  680. }
  681. func TestAntigravityGatewayService_ForwardGemini_ClearsStickySessionOnGeminiRateLimit(t *testing.T) {
  682. gin.SetMode(gin.TestMode)
  683. writer := httptest.NewRecorder()
  684. c, _ := gin.CreateTestContext(writer)
  685. body, err := json.Marshal(map[string]any{
  686. "contents": []map[string]any{
  687. {"role": "user", "parts": []map[string]any{{"text": "hi"}}},
  688. },
  689. })
  690. require.NoError(t, err)
  691. req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", bytes.NewReader(body))
  692. c.Request = req
  693. respBody := []byte(`{
  694. "error": {
  695. "status": "RESOURCE_EXHAUSTED",
  696. "details": [
  697. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
  698. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
  699. ]
  700. }
  701. }`)
  702. upstream := &httpUpstreamStub{resp: &http.Response{
  703. StatusCode: http.StatusTooManyRequests,
  704. Header: http.Header{},
  705. Body: io.NopCloser(bytes.NewReader(respBody)),
  706. }}
  707. repo := &stubAntigravityAccountRepo{}
  708. cache := &stubSmartRetryCache{}
  709. svc := &AntigravityGatewayService{
  710. tokenProvider: &AntigravityTokenProvider{},
  711. httpUpstream: upstream,
  712. accountRepo: repo,
  713. cache: cache,
  714. }
  715. account := &Account{
  716. ID: 44,
  717. Name: "acc-gemini-runtime-rate-limited",
  718. Platform: PlatformAntigravity,
  719. Type: AccountTypeOAuth,
  720. Status: StatusActive,
  721. Schedulable: true,
  722. Concurrency: 1,
  723. Credentials: map[string]any{
  724. "access_token": "token",
  725. "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
  726. "project_id": "proj",
  727. },
  728. Extra: map[string]any{
  729. "mixed_scheduling": true,
  730. },
  731. }
  732. result, err := svc.ForwardGemini(
  733. context.Background(),
  734. c,
  735. account,
  736. "gemini-3-flash-preview",
  737. "generateContent",
  738. false,
  739. body,
  740. true,
  741. WithForwardGeminiSession(77, "gemini:sticky-runtime"),
  742. )
  743. require.Nil(t, result)
  744. var failoverErr *UpstreamFailoverError
  745. require.ErrorAs(t, err, &failoverErr)
  746. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  747. require.Len(t, repo.modelRateLimitCalls, 2)
  748. require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
  749. require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
  750. require.Len(t, cache.deleteCalls, 1)
  751. require.Equal(t, int64(77), cache.deleteCalls[0].groupID)
  752. require.Equal(t, "gemini:sticky-runtime", cache.deleteCalls[0].sessionHash)
  753. }
  754. // TestAntigravityGatewayService_Forward_BillsWithMappedModel
  755. // 验证:Antigravity Claude 转发返回的计费模型使用映射后的模型
  756. func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {
  757. gin.SetMode(gin.TestMode)
  758. writer := httptest.NewRecorder()
  759. c, _ := gin.CreateTestContext(writer)
  760. body, err := json.Marshal(map[string]any{
  761. "model": "claude-sonnet-4-5",
  762. "messages": []map[string]any{
  763. {"role": "user", "content": "hello"},
  764. },
  765. "max_tokens": 16,
  766. "stream": true,
  767. })
  768. require.NoError(t, err)
  769. req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  770. c.Request = req
  771. upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
  772. resp := &http.Response{
  773. StatusCode: http.StatusOK,
  774. Header: http.Header{"X-Request-Id": []string{"req-bill-1"}},
  775. Body: io.NopCloser(bytes.NewReader(upstreamBody)),
  776. }
  777. svc := &AntigravityGatewayService{
  778. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  779. tokenProvider: &AntigravityTokenProvider{},
  780. httpUpstream: &httpUpstreamStub{resp: resp},
  781. }
  782. const mappedModel = "gemini-3-pro-high"
  783. account := &Account{
  784. ID: 5,
  785. Name: "acc-forward-billing",
  786. Platform: PlatformAntigravity,
  787. Type: AccountTypeOAuth,
  788. Status: StatusActive,
  789. Concurrency: 1,
  790. Credentials: map[string]any{
  791. "access_token": "token",
  792. "project_id": "proj",
  793. "model_mapping": map[string]any{
  794. "claude-sonnet-4-5": mappedModel,
  795. },
  796. },
  797. }
  798. result, err := svc.Forward(context.Background(), c, account, body, false)
  799. require.NoError(t, err)
  800. require.NotNil(t, result)
  801. require.Equal(t, "claude-sonnet-4-5", result.Model)
  802. require.Equal(t, mappedModel, result.UpstreamModel)
  803. }
  804. // TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel
  805. // 验证:Antigravity Gemini 转发返回的计费模型使用映射后的模型
  806. func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing.T) {
  807. gin.SetMode(gin.TestMode)
  808. writer := httptest.NewRecorder()
  809. c, _ := gin.CreateTestContext(writer)
  810. body, err := json.Marshal(map[string]any{
  811. "contents": []map[string]any{
  812. {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
  813. },
  814. })
  815. require.NoError(t, err)
  816. req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
  817. c.Request = req
  818. upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
  819. resp := &http.Response{
  820. StatusCode: http.StatusOK,
  821. Header: http.Header{"X-Request-Id": []string{"req-bill-2"}},
  822. Body: io.NopCloser(bytes.NewReader(upstreamBody)),
  823. }
  824. svc := &AntigravityGatewayService{
  825. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  826. tokenProvider: &AntigravityTokenProvider{},
  827. httpUpstream: &httpUpstreamStub{resp: resp},
  828. }
  829. const mappedModel = "gemini-3-pro-high"
  830. account := &Account{
  831. ID: 6,
  832. Name: "acc-gemini-billing",
  833. Platform: PlatformAntigravity,
  834. Type: AccountTypeOAuth,
  835. Status: StatusActive,
  836. Concurrency: 1,
  837. Credentials: map[string]any{
  838. "access_token": "token",
  839. "project_id": "proj",
  840. "model_mapping": map[string]any{
  841. "gemini-2.5-flash": mappedModel,
  842. },
  843. },
  844. }
  845. result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", true, body, false)
  846. require.NoError(t, err)
  847. require.NotNil(t, result)
  848. require.Equal(t, "gemini-2.5-flash", result.Model)
  849. require.Equal(t, mappedModel, result.UpstreamModel)
  850. }
  851. func TestAntigravityGatewayService_ForwardGemini_FallbackReportsActualUpstreamModel(t *testing.T) {
  852. gin.SetMode(gin.TestMode)
  853. writer := httptest.NewRecorder()
  854. c, _ := gin.CreateTestContext(writer)
  855. body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
  856. c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-primary:generateContent", bytes.NewReader(body))
  857. const (
  858. originalModel = "gemini-primary"
  859. mappedModel = "gemini-primary-upstream"
  860. fallbackModel = "gemini-fallback-upstream"
  861. )
  862. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{
  863. {
  864. StatusCode: http.StatusNotFound,
  865. Header: http.Header{"Content-Type": []string{"application/json"}},
  866. Body: io.NopCloser(strings.NewReader(`{"error":{"code":404,"message":"model not found"}}`)),
  867. },
  868. {
  869. StatusCode: http.StatusOK,
  870. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  871. Body: io.NopCloser(strings.NewReader(
  872. `data: {"response":{"modelVersion":"gemini-fallback-upstream","candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3}}}` + "\n\n",
  873. )),
  874. },
  875. }}
  876. settings := &antigravitySettingRepoStub{values: map[string]string{
  877. SettingKeyEnableModelFallback: "true",
  878. SettingKeyFallbackModelAntigravity: fallbackModel,
  879. }}
  880. svc := &AntigravityGatewayService{
  881. settingService: NewSettingService(settings, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  882. tokenProvider: &AntigravityTokenProvider{},
  883. httpUpstream: upstream,
  884. }
  885. account := &Account{
  886. ID: 9,
  887. Name: "acc-gemini-fallback",
  888. Platform: PlatformAntigravity,
  889. Type: AccountTypeOAuth,
  890. Status: StatusActive,
  891. Concurrency: 1,
  892. Credentials: map[string]any{
  893. "access_token": "token",
  894. "project_id": "proj",
  895. "model_mapping": map[string]any{
  896. originalModel: mappedModel,
  897. },
  898. },
  899. }
  900. result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "generateContent", true, body, false)
  901. require.NoError(t, err)
  902. require.NotNil(t, result)
  903. require.Equal(t, originalModel, result.Model)
  904. require.Equal(t, fallbackModel, result.UpstreamModel)
  905. require.Equal(t, fallbackModel, result.UpstreamResponseModel)
  906. require.False(t, result.UpstreamResponseModelConflict)
  907. mismatch := upstreamModelMismatch(result.UpstreamModel, result.UpstreamResponseModel)
  908. require.NotNil(t, mismatch)
  909. require.False(t, *mismatch)
  910. require.Len(t, upstream.requestBodies, 2)
  911. require.Contains(t, string(upstream.requestBodies[0]), `"model":"`+mappedModel+`"`)
  912. require.Contains(t, string(upstream.requestBodies[1]), `"model":"`+fallbackModel+`"`)
  913. }
  914. func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature(t *testing.T) {
  915. gin.SetMode(gin.TestMode)
  916. writer := httptest.NewRecorder()
  917. c, _ := gin.CreateTestContext(writer)
  918. body, err := json.Marshal(map[string]any{
  919. "contents": []map[string]any{
  920. {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
  921. {"role": "model", "parts": []map[string]any{{"text": "thinking", "thought": true, "thoughtSignature": "sig_bad_1"}}},
  922. {"role": "model", "parts": []map[string]any{{"functionCall": map[string]any{"name": "toolA", "args": map[string]any{"x": 1}}, "thoughtSignature": "sig_bad_2"}}},
  923. },
  924. })
  925. require.NoError(t, err)
  926. req := httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent", bytes.NewReader(body))
  927. c.Request = req
  928. firstRespBody := []byte(`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}`)
  929. secondRespBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
  930. upstream := &queuedHTTPUpstreamStub{
  931. responses: []*http.Response{
  932. {
  933. StatusCode: http.StatusBadRequest,
  934. Header: http.Header{
  935. "Content-Type": []string{"application/json"},
  936. "X-Request-Id": []string{"req-sig-1"},
  937. },
  938. Body: io.NopCloser(bytes.NewReader(firstRespBody)),
  939. },
  940. {
  941. StatusCode: http.StatusOK,
  942. Header: http.Header{
  943. "Content-Type": []string{"text/event-stream"},
  944. "X-Request-Id": []string{"req-sig-2"},
  945. },
  946. Body: io.NopCloser(bytes.NewReader(secondRespBody)),
  947. },
  948. },
  949. }
  950. svc := &AntigravityGatewayService{
  951. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  952. tokenProvider: &AntigravityTokenProvider{},
  953. httpUpstream: upstream,
  954. }
  955. const originalModel = "gemini-3.1-pro-preview"
  956. const mappedModel = "gemini-3.1-pro-high"
  957. account := &Account{
  958. ID: 7,
  959. Name: "acc-gemini-signature",
  960. Platform: PlatformAntigravity,
  961. Type: AccountTypeOAuth,
  962. Status: StatusActive,
  963. Concurrency: 1,
  964. Credentials: map[string]any{
  965. "access_token": "token",
  966. "project_id": "proj",
  967. "model_mapping": map[string]any{
  968. originalModel: mappedModel,
  969. },
  970. },
  971. }
  972. result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "streamGenerateContent", true, body, false)
  973. require.NoError(t, err)
  974. require.NotNil(t, result)
  975. require.Equal(t, originalModel, result.Model)
  976. require.Equal(t, mappedModel, result.UpstreamModel)
  977. require.Len(t, upstream.requestBodies, 2, "signature error should trigger exactly one retry")
  978. firstReq := string(upstream.requestBodies[0])
  979. secondReq := string(upstream.requestBodies[1])
  980. require.Contains(t, firstReq, `"thoughtSignature":"sig_bad_1"`)
  981. require.Contains(t, firstReq, `"thoughtSignature":"sig_bad_2"`)
  982. require.Contains(t, secondReq, `"thoughtSignature":"skip_thought_signature_validator"`)
  983. require.NotContains(t, secondReq, `"thoughtSignature":"sig_bad_1"`)
  984. require.NotContains(t, secondReq, `"thoughtSignature":"sig_bad_2"`)
  985. raw, ok := c.Get(OpsUpstreamErrorsKey)
  986. require.True(t, ok)
  987. events, ok := raw.([]*OpsUpstreamErrorEvent)
  988. require.True(t, ok)
  989. require.NotEmpty(t, events)
  990. require.Equal(t, "signature_error", events[0].Kind)
  991. }
  992. func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailover(t *testing.T) {
  993. gin.SetMode(gin.TestMode)
  994. writer := httptest.NewRecorder()
  995. c, _ := gin.CreateTestContext(writer)
  996. body, err := json.Marshal(map[string]any{
  997. "contents": []map[string]any{
  998. {"role": "user", "parts": []map[string]any{{"text": "hello"}}},
  999. {"role": "model", "parts": []map[string]any{{"text": "thinking", "thought": true, "thoughtSignature": "sig_bad_1"}}},
  1000. },
  1001. })
  1002. require.NoError(t, err)
  1003. req := httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent", bytes.NewReader(body))
  1004. c.Request = req
  1005. firstRespBody := []byte(`{"response":{"error":{"code":400,"message":"Corrupted thought signature.","status":"INVALID_ARGUMENT"}}}`)
  1006. const originalModel = "gemini-3.1-pro-preview"
  1007. const mappedModel = "gemini-3.1-pro-high"
  1008. account := &Account{
  1009. ID: 8,
  1010. Name: "acc-gemini-signature-failover",
  1011. Platform: PlatformAntigravity,
  1012. Type: AccountTypeOAuth,
  1013. Status: StatusActive,
  1014. Concurrency: 1,
  1015. Credentials: map[string]any{
  1016. "access_token": "token",
  1017. "project_id": "proj",
  1018. "model_mapping": map[string]any{
  1019. originalModel: mappedModel,
  1020. },
  1021. },
  1022. }
  1023. upstream := &queuedHTTPUpstreamStub{
  1024. responses: []*http.Response{
  1025. {
  1026. StatusCode: http.StatusBadRequest,
  1027. Header: http.Header{
  1028. "Content-Type": []string{"application/json"},
  1029. "X-Request-Id": []string{"req-sig-failover-1"},
  1030. },
  1031. Body: io.NopCloser(bytes.NewReader(firstRespBody)),
  1032. },
  1033. },
  1034. onCall: func(_ *http.Request, stub *queuedHTTPUpstreamStub) {
  1035. if stub.callCount != 1 {
  1036. return
  1037. }
  1038. futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
  1039. account.Extra = map[string]any{
  1040. modelRateLimitsKey: map[string]any{
  1041. mappedModel: map[string]any{
  1042. "rate_limit_reset_at": futureResetAt,
  1043. },
  1044. },
  1045. }
  1046. },
  1047. }
  1048. svc := &AntigravityGatewayService{
  1049. settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
  1050. tokenProvider: &AntigravityTokenProvider{},
  1051. httpUpstream: upstream,
  1052. }
  1053. result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "streamGenerateContent", true, body, true)
  1054. require.Nil(t, result)
  1055. var failoverErr *UpstreamFailoverError
  1056. require.ErrorAs(t, err, &failoverErr, "signature retry should propagate failover instead of falling back to the original 400")
  1057. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  1058. require.True(t, failoverErr.ForceCacheBilling)
  1059. require.Len(t, upstream.requestBodies, 1, "retry should stop at preflight failover and not issue a second upstream request")
  1060. raw, ok := c.Get(OpsUpstreamErrorsKey)
  1061. require.True(t, ok)
  1062. events, ok := raw.([]*OpsUpstreamErrorEvent)
  1063. require.True(t, ok)
  1064. require.Len(t, events, 2)
  1065. require.Equal(t, "signature_error", events[0].Kind)
  1066. require.Equal(t, "failover", events[1].Kind)
  1067. }
  1068. // TestStreamUpstreamResponse_UsageAndFirstToken
  1069. // 验证:usage 字段可被累积/覆盖更新,并且能记录首 token 时间
  1070. func TestStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
  1071. gin.SetMode(gin.TestMode)
  1072. svc := newAntigravityTestService(&config.Config{
  1073. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1074. })
  1075. rec := httptest.NewRecorder()
  1076. c, _ := gin.CreateTestContext(rec)
  1077. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1078. pr, pw := io.Pipe()
  1079. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
  1080. go func() {
  1081. defer func() { _ = pw.Close() }()
  1082. fmt.Fprintln(pw, `data: {"usage":{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":4}}`)
  1083. fmt.Fprintln(pw, `data: {"usage":{"output_tokens":5}}`)
  1084. }()
  1085. start := time.Now().Add(-10 * time.Millisecond)
  1086. result := svc.streamUpstreamResponse(c, resp, start)
  1087. _ = pr.Close()
  1088. require.NotNil(t, result)
  1089. require.NotNil(t, result.usage)
  1090. require.Equal(t, 1, result.usage.InputTokens)
  1091. // 第二次事件覆盖 output_tokens
  1092. require.Equal(t, 5, result.usage.OutputTokens)
  1093. require.Equal(t, 3, result.usage.CacheReadInputTokens)
  1094. require.Equal(t, 4, result.usage.CacheCreationInputTokens)
  1095. require.NotNil(t, result.firstTokenMs)
  1096. // 确保有透传输出
  1097. require.Contains(t, rec.Body.String(), "data:")
  1098. }
  1099. // --- 流式 happy path 测试 ---
  1100. // TestStreamUpstreamResponse_NormalComplete
  1101. // 验证:正常流式转发完成时,数据正确透传、usage 正确收集、clientDisconnect=false
  1102. func TestStreamUpstreamResponse_NormalComplete(t *testing.T) {
  1103. gin.SetMode(gin.TestMode)
  1104. svc := newAntigravityTestService(&config.Config{
  1105. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1106. })
  1107. rec := httptest.NewRecorder()
  1108. c, _ := gin.CreateTestContext(rec)
  1109. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1110. pr, pw := io.Pipe()
  1111. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1112. go func() {
  1113. defer func() { _ = pw.Close() }()
  1114. fmt.Fprintln(pw, `event: message_start`)
  1115. fmt.Fprintln(pw, `data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}`)
  1116. fmt.Fprintln(pw, "")
  1117. fmt.Fprintln(pw, `event: content_block_delta`)
  1118. fmt.Fprintln(pw, `data: {"type":"content_block_delta","delta":{"text":"hello"}}`)
  1119. fmt.Fprintln(pw, "")
  1120. fmt.Fprintln(pw, `event: message_delta`)
  1121. fmt.Fprintln(pw, `data: {"type":"message_delta","usage":{"output_tokens":5}}`)
  1122. fmt.Fprintln(pw, "")
  1123. }()
  1124. result := svc.streamUpstreamResponse(c, resp, time.Now())
  1125. _ = pr.Close()
  1126. require.NotNil(t, result)
  1127. require.False(t, result.clientDisconnect, "normal completion should not set clientDisconnect")
  1128. require.NotNil(t, result.usage)
  1129. require.Equal(t, 5, result.usage.OutputTokens, "should collect output_tokens from message_delta")
  1130. require.NotNil(t, result.firstTokenMs, "should record first token time")
  1131. // 验证数据被透传到客户端
  1132. body := rec.Body.String()
  1133. require.Contains(t, body, "event: message_start")
  1134. require.Contains(t, body, "content_block_delta")
  1135. require.Contains(t, body, "message_delta")
  1136. }
  1137. // TestHandleGeminiStreamingResponse_NormalComplete
  1138. // 验证:正常 Gemini 流式转发,数据正确透传、usage 正确收集
  1139. func TestHandleGeminiStreamingResponse_NormalComplete(t *testing.T) {
  1140. gin.SetMode(gin.TestMode)
  1141. svc := newAntigravityTestService(&config.Config{
  1142. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1143. })
  1144. rec := httptest.NewRecorder()
  1145. c, _ := gin.CreateTestContext(rec)
  1146. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1147. pr, pw := io.Pipe()
  1148. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1149. go func() {
  1150. defer func() { _ = pw.Close() }()
  1151. // 第一个 chunk(部分内容)
  1152. fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3}}`)
  1153. fmt.Fprintln(pw, "")
  1154. // 第二个 chunk(最终内容+完整 usage)
  1155. fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":8,"cachedContentTokenCount":2}}`)
  1156. fmt.Fprintln(pw, "")
  1157. }()
  1158. result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
  1159. _ = pr.Close()
  1160. require.NoError(t, err)
  1161. require.NotNil(t, result)
  1162. require.False(t, result.clientDisconnect, "normal completion should not set clientDisconnect")
  1163. require.NotNil(t, result.usage)
  1164. // Gemini usage: promptTokenCount=10, candidatesTokenCount=8, cachedContentTokenCount=2
  1165. // → InputTokens=10-2=8, OutputTokens=8, CacheReadInputTokens=2
  1166. require.Equal(t, 8, result.usage.InputTokens)
  1167. require.Equal(t, 8, result.usage.OutputTokens)
  1168. require.Equal(t, 2, result.usage.CacheReadInputTokens)
  1169. require.NotNil(t, result.firstTokenMs, "should record first token time")
  1170. // 验证数据被透传到客户端
  1171. body := rec.Body.String()
  1172. require.Contains(t, body, "Hello")
  1173. require.Contains(t, body, "world")
  1174. // 不应包含错误事件
  1175. require.NotContains(t, body, "event: error")
  1176. }
  1177. // TestHandleClaudeStreamingResponse_NormalComplete
  1178. // 验证:正常 Claude 流式转发(Gemini→Claude 转换),数据正确转换并输出
  1179. func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
  1180. gin.SetMode(gin.TestMode)
  1181. svc := newAntigravityTestService(&config.Config{
  1182. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1183. })
  1184. rec := httptest.NewRecorder()
  1185. c, _ := gin.CreateTestContext(rec)
  1186. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1187. pr, pw := io.Pipe()
  1188. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1189. go func() {
  1190. defer func() { _ = pw.Close() }()
  1191. // v1internal 包装格式:Gemini 数据嵌套在 "response" 字段下
  1192. // ProcessLine 先尝试反序列化为 V1InternalResponse,裸格式会导致 Response.UsageMetadata 为空
  1193. fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hi there"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3}}}`)
  1194. fmt.Fprintln(pw, "")
  1195. }()
  1196. result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
  1197. _ = pr.Close()
  1198. require.NoError(t, err)
  1199. require.NotNil(t, result)
  1200. require.False(t, result.clientDisconnect, "normal completion should not set clientDisconnect")
  1201. require.NotNil(t, result.usage)
  1202. // Gemini→Claude 转换的 usage:promptTokenCount=5→InputTokens=5, candidatesTokenCount=3→OutputTokens=3
  1203. require.Equal(t, 5, result.usage.InputTokens)
  1204. require.Equal(t, 3, result.usage.OutputTokens)
  1205. require.NotNil(t, result.firstTokenMs, "should record first token time")
  1206. // 验证输出是 Claude SSE 格式(processor 会转换)
  1207. body := rec.Body.String()
  1208. require.Contains(t, body, "event: message_start", "should contain Claude message_start event")
  1209. require.Contains(t, body, "event: message_stop", "should contain Claude message_stop event")
  1210. // 不应包含错误事件
  1211. require.NotContains(t, body, "event: error")
  1212. }
  1213. // TestHandleGeminiStreamingResponse_ThoughtsTokenCount
  1214. // 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
  1215. func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
  1216. gin.SetMode(gin.TestMode)
  1217. svc := newAntigravityTestService(&config.Config{
  1218. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1219. })
  1220. rec := httptest.NewRecorder()
  1221. c, _ := gin.CreateTestContext(rec)
  1222. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1223. pr, pw := io.Pipe()
  1224. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1225. go func() {
  1226. defer func() { _ = pw.Close() }()
  1227. fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"thoughtsTokenCount":50}}`)
  1228. fmt.Fprintln(pw, "")
  1229. fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"thoughtsTokenCount":80,"cachedContentTokenCount":10}}`)
  1230. fmt.Fprintln(pw, "")
  1231. }()
  1232. result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
  1233. _ = pr.Close()
  1234. require.NoError(t, err)
  1235. require.NotNil(t, result)
  1236. require.NotNil(t, result.usage)
  1237. // promptTokenCount=100, cachedContentTokenCount=10 → InputTokens=90
  1238. require.Equal(t, 90, result.usage.InputTokens)
  1239. // candidatesTokenCount=30 + thoughtsTokenCount=80 → OutputTokens=110
  1240. require.Equal(t, 110, result.usage.OutputTokens)
  1241. require.Equal(t, 10, result.usage.CacheReadInputTokens)
  1242. }
  1243. // TestHandleClaudeStreamingResponse_ThoughtsTokenCount
  1244. // 验证:Gemini→Claude 流式转换时 thoughtsTokenCount 被计入 OutputTokens
  1245. func TestHandleClaudeStreamingResponse_ThoughtsTokenCount(t *testing.T) {
  1246. gin.SetMode(gin.TestMode)
  1247. svc := newAntigravityTestService(&config.Config{
  1248. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1249. })
  1250. rec := httptest.NewRecorder()
  1251. c, _ := gin.CreateTestContext(rec)
  1252. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1253. pr, pw := io.Pipe()
  1254. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1255. go func() {
  1256. defer func() { _ = pw.Close() }()
  1257. fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"candidatesTokenCount":10,"thoughtsTokenCount":25}}}`)
  1258. fmt.Fprintln(pw, "")
  1259. }()
  1260. result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "gemini-2.5-pro")
  1261. _ = pr.Close()
  1262. require.NoError(t, err)
  1263. require.NotNil(t, result)
  1264. require.NotNil(t, result.usage)
  1265. // promptTokenCount=50 → InputTokens=50
  1266. require.Equal(t, 50, result.usage.InputTokens)
  1267. // candidatesTokenCount=10 + thoughtsTokenCount=25 → OutputTokens=35
  1268. require.Equal(t, 35, result.usage.OutputTokens)
  1269. }
  1270. // --- 流式客户端断开检测测试 ---
  1271. // TestStreamUpstreamResponse_ClientDisconnectDrainsUsage
  1272. // 验证:客户端写入失败后,streamUpstreamResponse 继续读取上游以收集 usage
  1273. func TestStreamUpstreamResponse_ClientDisconnectDrainsUsage(t *testing.T) {
  1274. gin.SetMode(gin.TestMode)
  1275. svc := newAntigravityTestService(&config.Config{
  1276. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1277. })
  1278. rec := httptest.NewRecorder()
  1279. c, _ := gin.CreateTestContext(rec)
  1280. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1281. c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1282. pr, pw := io.Pipe()
  1283. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1284. go func() {
  1285. defer func() { _ = pw.Close() }()
  1286. fmt.Fprintln(pw, `event: message_start`)
  1287. fmt.Fprintln(pw, `data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}`)
  1288. fmt.Fprintln(pw, "")
  1289. fmt.Fprintln(pw, `event: message_delta`)
  1290. fmt.Fprintln(pw, `data: {"type":"message_delta","usage":{"output_tokens":20}}`)
  1291. fmt.Fprintln(pw, "")
  1292. }()
  1293. result := svc.streamUpstreamResponse(c, resp, time.Now())
  1294. _ = pr.Close()
  1295. require.NotNil(t, result)
  1296. require.True(t, result.clientDisconnect)
  1297. require.NotNil(t, result.usage)
  1298. require.Equal(t, 20, result.usage.OutputTokens)
  1299. }
  1300. // TestStreamUpstreamResponse_ContextCanceled
  1301. // 验证:context 取消时返回 usage 且标记 clientDisconnect
  1302. func TestStreamUpstreamResponse_ContextCanceled(t *testing.T) {
  1303. gin.SetMode(gin.TestMode)
  1304. svc := newAntigravityTestService(&config.Config{
  1305. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1306. })
  1307. rec := httptest.NewRecorder()
  1308. c, _ := gin.CreateTestContext(rec)
  1309. ctx, cancel := context.WithCancel(context.Background())
  1310. cancel()
  1311. c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
  1312. resp := &http.Response{StatusCode: http.StatusOK, Body: cancelReadCloser{}, Header: http.Header{}}
  1313. result := svc.streamUpstreamResponse(c, resp, time.Now())
  1314. require.NotNil(t, result)
  1315. require.True(t, result.clientDisconnect)
  1316. require.NotContains(t, rec.Body.String(), "event: error")
  1317. }
  1318. // TestStreamUpstreamResponse_Timeout
  1319. // 验证:上游超时时返回已收集的 usage
  1320. func TestStreamUpstreamResponse_Timeout(t *testing.T) {
  1321. gin.SetMode(gin.TestMode)
  1322. svc := newAntigravityTestService(&config.Config{
  1323. Gateway: config.GatewayConfig{StreamDataIntervalTimeout: 1, MaxLineSize: defaultMaxLineSize},
  1324. })
  1325. rec := httptest.NewRecorder()
  1326. c, _ := gin.CreateTestContext(rec)
  1327. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1328. pr, pw := io.Pipe()
  1329. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1330. result := svc.streamUpstreamResponse(c, resp, time.Now())
  1331. _ = pw.Close()
  1332. _ = pr.Close()
  1333. require.NotNil(t, result)
  1334. require.False(t, result.clientDisconnect)
  1335. }
  1336. // TestStreamUpstreamResponse_TimeoutAfterClientDisconnect
  1337. // 验证:客户端断开后上游超时,返回 usage 并标记 clientDisconnect
  1338. func TestStreamUpstreamResponse_TimeoutAfterClientDisconnect(t *testing.T) {
  1339. gin.SetMode(gin.TestMode)
  1340. svc := newAntigravityTestService(&config.Config{
  1341. Gateway: config.GatewayConfig{StreamDataIntervalTimeout: 1, MaxLineSize: defaultMaxLineSize},
  1342. })
  1343. rec := httptest.NewRecorder()
  1344. c, _ := gin.CreateTestContext(rec)
  1345. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1346. c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1347. pr, pw := io.Pipe()
  1348. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1349. go func() {
  1350. fmt.Fprintln(pw, `data: {"type":"message_start","message":{"usage":{"input_tokens":5}}}`)
  1351. fmt.Fprintln(pw, "")
  1352. // 不关闭 pw → 等待超时
  1353. }()
  1354. result := svc.streamUpstreamResponse(c, resp, time.Now())
  1355. _ = pw.Close()
  1356. _ = pr.Close()
  1357. require.NotNil(t, result)
  1358. require.True(t, result.clientDisconnect)
  1359. }
  1360. // TestHandleGeminiStreamingResponse_ClientDisconnect
  1361. // 验证:Gemini 流式转发中客户端断开后继续 drain 上游
  1362. func TestHandleGeminiStreamingResponse_ClientDisconnect(t *testing.T) {
  1363. gin.SetMode(gin.TestMode)
  1364. svc := newAntigravityTestService(&config.Config{
  1365. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1366. })
  1367. rec := httptest.NewRecorder()
  1368. c, _ := gin.CreateTestContext(rec)
  1369. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1370. c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1371. pr, pw := io.Pipe()
  1372. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1373. go func() {
  1374. defer func() { _ = pw.Close() }()
  1375. fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":10}}`)
  1376. fmt.Fprintln(pw, "")
  1377. }()
  1378. result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
  1379. _ = pr.Close()
  1380. require.NoError(t, err)
  1381. require.NotNil(t, result)
  1382. require.True(t, result.clientDisconnect)
  1383. require.NotContains(t, rec.Body.String(), "write_failed")
  1384. }
  1385. // TestHandleGeminiStreamingResponse_ContextCanceled
  1386. // 验证:context 取消时不注入错误事件
  1387. func TestHandleGeminiStreamingResponse_ContextCanceled(t *testing.T) {
  1388. gin.SetMode(gin.TestMode)
  1389. svc := newAntigravityTestService(&config.Config{
  1390. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1391. })
  1392. rec := httptest.NewRecorder()
  1393. c, _ := gin.CreateTestContext(rec)
  1394. ctx, cancel := context.WithCancel(context.Background())
  1395. cancel()
  1396. c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
  1397. resp := &http.Response{StatusCode: http.StatusOK, Body: cancelReadCloser{}, Header: http.Header{}}
  1398. result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
  1399. require.NoError(t, err)
  1400. require.NotNil(t, result)
  1401. require.True(t, result.clientDisconnect)
  1402. require.NotContains(t, rec.Body.String(), "event: error")
  1403. }
  1404. // TestHandleClaudeStreamingResponse_ClientDisconnect
  1405. // 验证:Claude 流式转发中客户端断开后继续 drain 上游
  1406. func TestHandleClaudeStreamingResponse_ClientDisconnect(t *testing.T) {
  1407. gin.SetMode(gin.TestMode)
  1408. svc := newAntigravityTestService(&config.Config{
  1409. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1410. })
  1411. rec := httptest.NewRecorder()
  1412. c, _ := gin.CreateTestContext(rec)
  1413. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1414. c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1415. pr, pw := io.Pipe()
  1416. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1417. go func() {
  1418. defer func() { _ = pw.Close() }()
  1419. // v1internal 包装格式
  1420. fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":15}}}`)
  1421. fmt.Fprintln(pw, "")
  1422. }()
  1423. result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
  1424. _ = pr.Close()
  1425. require.NoError(t, err)
  1426. require.NotNil(t, result)
  1427. require.True(t, result.clientDisconnect)
  1428. }
  1429. // TestHandleClaudeStreamingResponse_EmptyStream
  1430. // 验证:上游只返回无法解析的 SSE 行时,触发 UpstreamFailoverError 而不是向客户端发出残缺流
  1431. func TestHandleClaudeStreamingResponse_EmptyStream(t *testing.T) {
  1432. gin.SetMode(gin.TestMode)
  1433. svc := newAntigravityTestService(&config.Config{
  1434. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1435. })
  1436. rec := httptest.NewRecorder()
  1437. c, _ := gin.CreateTestContext(rec)
  1438. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  1439. pr, pw := io.Pipe()
  1440. resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
  1441. go func() {
  1442. defer func() { _ = pw.Close() }()
  1443. // 所有行均为无法 JSON 解析的内容,ProcessLine 全部返回 nil
  1444. fmt.Fprintln(pw, "data: not-valid-json")
  1445. fmt.Fprintln(pw, "")
  1446. fmt.Fprintln(pw, "data: also-invalid")
  1447. fmt.Fprintln(pw, "")
  1448. }()
  1449. _, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
  1450. _ = pr.Close()
  1451. // 应当返回 UpstreamFailoverError 而非 nil,以便上层触发 failover
  1452. require.Error(t, err)
  1453. var failoverErr *UpstreamFailoverError
  1454. require.ErrorAs(t, err, &failoverErr)
  1455. require.True(t, failoverErr.RetryableOnSameAccount)
  1456. // 客户端不应收到任何 SSE 事件(既无 message_start 也无 message_stop)
  1457. body := rec.Body.String()
  1458. require.NotContains(t, body, "event: message_start")
  1459. require.NotContains(t, body, "event: message_stop")
  1460. require.NotContains(t, body, "event: message_delta")
  1461. }
  1462. // TestHandleClaudeStreamingResponse_ContextCanceled
  1463. // 验证:context 取消时不注入错误事件
  1464. func TestHandleClaudeStreamingResponse_ContextCanceled(t *testing.T) {
  1465. gin.SetMode(gin.TestMode)
  1466. svc := newAntigravityTestService(&config.Config{
  1467. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  1468. })
  1469. rec := httptest.NewRecorder()
  1470. c, _ := gin.CreateTestContext(rec)
  1471. ctx, cancel := context.WithCancel(context.Background())
  1472. cancel()
  1473. c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
  1474. resp := &http.Response{StatusCode: http.StatusOK, Body: cancelReadCloser{}, Header: http.Header{}}
  1475. result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
  1476. require.NoError(t, err)
  1477. require.NotNil(t, result)
  1478. require.True(t, result.clientDisconnect)
  1479. require.NotContains(t, rec.Body.String(), "event: error")
  1480. }
  1481. // TestExtractSSEUsage 验证 extractSSEUsage 从 SSE data 行正确提取 usage
  1482. func TestExtractSSEUsage(t *testing.T) {
  1483. svc := &AntigravityGatewayService{}
  1484. tests := []struct {
  1485. name string
  1486. line string
  1487. expected ClaudeUsage
  1488. }{
  1489. {
  1490. name: "message_delta with output_tokens",
  1491. line: `data: {"type":"message_delta","usage":{"output_tokens":42}}`,
  1492. expected: ClaudeUsage{OutputTokens: 42},
  1493. },
  1494. {
  1495. name: "non-data line ignored",
  1496. line: `event: message_start`,
  1497. expected: ClaudeUsage{},
  1498. },
  1499. {
  1500. name: "top-level usage with all fields",
  1501. line: `data: {"usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":5,"cache_creation_input_tokens":3}}`,
  1502. expected: ClaudeUsage{InputTokens: 10, OutputTokens: 20, CacheReadInputTokens: 5, CacheCreationInputTokens: 3},
  1503. },
  1504. {
  1505. // Anthropic message_start 把 usage 嵌套在 message.usage 下,
  1506. // 必须从这里提取输入侧字段(含 cache_read/cache_creation_input_tokens)。
  1507. name: "message_start nested usage with input/cache tokens",
  1508. 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}}}`,
  1509. expected: ClaudeUsage{InputTokens: 35576, OutputTokens: 1, CacheReadInputTokens: 12000},
  1510. },
  1511. {
  1512. // message_start.message.usage.cache_creation 内的 5m/1h 明细也要解析。
  1513. name: "message_start nested usage with cache_creation breakdown",
  1514. line: `data: {"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`,
  1515. expected: ClaudeUsage{InputTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 70},
  1516. },
  1517. }
  1518. for _, tt := range tests {
  1519. t.Run(tt.name, func(t *testing.T) {
  1520. usage := &ClaudeUsage{}
  1521. svc.extractSSEUsage(tt.line, usage)
  1522. require.Equal(t, tt.expected, *usage)
  1523. })
  1524. }
  1525. }
  1526. // TestExtractSSEUsage_StreamingSequence 复现 issue #2332:完整的 Anthropic streaming
  1527. // 序列(message_start → message_delta)必须把两类事件中的 usage 字段都汇入同一份累计值,
  1528. // 否则透传账号产出的 usage_logs 会出现 input_tokens=0、仅有 output_tokens 的"残缺"记录。
  1529. func TestExtractSSEUsage_StreamingSequence(t *testing.T) {
  1530. svc := &AntigravityGatewayService{}
  1531. usage := &ClaudeUsage{}
  1532. // 1) message_start:携带完整输入侧 usage(input_tokens + cache_read)
  1533. svc.extractSSEUsage(
  1534. `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}}}`,
  1535. usage,
  1536. )
  1537. // 2) message_delta:流结束时只带 output_tokens(无 input_tokens 字段)
  1538. svc.extractSSEUsage(
  1539. `data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":816}}`,
  1540. usage,
  1541. )
  1542. require.Equal(t, 35576, usage.InputTokens, "message_start 的 input_tokens 必须被记录,否则记账会缺失输入侧 token (#2332)")
  1543. require.Equal(t, 12000, usage.CacheReadInputTokens, "message_start 的 cache_read_input_tokens 必须被记录")
  1544. require.Equal(t, 816, usage.OutputTokens, "message_delta 的最终 output_tokens 必须被记录")
  1545. }
  1546. // TestAntigravityClientWriter 验证 antigravityClientWriter 的断开检测
  1547. func TestAntigravityClientWriter(t *testing.T) {
  1548. t.Run("normal write succeeds", func(t *testing.T) {
  1549. gin.SetMode(gin.TestMode)
  1550. rec := httptest.NewRecorder()
  1551. c, _ := gin.CreateTestContext(rec)
  1552. flusher, _ := c.Writer.(http.Flusher)
  1553. cw := newAntigravityClientWriter(c.Writer, flusher, "test")
  1554. ok := cw.Write([]byte("hello"))
  1555. require.True(t, ok)
  1556. require.False(t, cw.Disconnected())
  1557. require.Contains(t, rec.Body.String(), "hello")
  1558. })
  1559. t.Run("write failure marks disconnected", func(t *testing.T) {
  1560. gin.SetMode(gin.TestMode)
  1561. rec := httptest.NewRecorder()
  1562. c, _ := gin.CreateTestContext(rec)
  1563. fw := &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1564. flusher, _ := c.Writer.(http.Flusher)
  1565. cw := newAntigravityClientWriter(fw, flusher, "test")
  1566. ok := cw.Write([]byte("hello"))
  1567. require.False(t, ok)
  1568. require.True(t, cw.Disconnected())
  1569. })
  1570. t.Run("subsequent writes are no-op", func(t *testing.T) {
  1571. gin.SetMode(gin.TestMode)
  1572. rec := httptest.NewRecorder()
  1573. c, _ := gin.CreateTestContext(rec)
  1574. fw := &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1575. flusher, _ := c.Writer.(http.Flusher)
  1576. cw := newAntigravityClientWriter(fw, flusher, "test")
  1577. cw.Write([]byte("first"))
  1578. ok := cw.Fprintf("second %d", 2)
  1579. require.False(t, ok)
  1580. require.True(t, cw.Disconnected())
  1581. })
  1582. }
  1583. // TestUnwrapV1InternalResponse 测试 unwrapV1InternalResponse 的各种输入场景
  1584. func TestUnwrapV1InternalResponse(t *testing.T) {
  1585. svc := &AntigravityGatewayService{}
  1586. // 构造 >50KB 的大型 JSON
  1587. largePadding := strings.Repeat("x", 50*1024)
  1588. largeInput := []byte(fmt.Sprintf(`{"response":{"id":"big","pad":"%s"}}`, largePadding))
  1589. largeExpected := fmt.Sprintf(`{"id":"big","pad":"%s"}`, largePadding)
  1590. tests := []struct {
  1591. name string
  1592. input []byte
  1593. expected string
  1594. wantErr bool
  1595. }{
  1596. {
  1597. name: "正常 response 包装",
  1598. input: []byte(`{"response":{"id":"123","content":"hello"}}`),
  1599. expected: `{"id":"123","content":"hello"}`,
  1600. },
  1601. {
  1602. name: "无 response 透传",
  1603. input: []byte(`{"id":"456"}`),
  1604. expected: `{"id":"456"}`,
  1605. },
  1606. {
  1607. name: "空 JSON",
  1608. input: []byte(`{}`),
  1609. expected: `{}`,
  1610. },
  1611. {
  1612. name: "response 为 null",
  1613. input: []byte(`{"response":null}`),
  1614. expected: `null`,
  1615. },
  1616. {
  1617. name: "response 为基础类型 string",
  1618. input: []byte(`{"response":"hello"}`),
  1619. expected: `"hello"`,
  1620. },
  1621. {
  1622. name: "非法 JSON",
  1623. input: []byte(`not json`),
  1624. expected: `not json`,
  1625. },
  1626. {
  1627. name: "嵌套 response 只解一层",
  1628. input: []byte(`{"response":{"response":{"inner":true}}}`),
  1629. expected: `{"response":{"inner":true}}`,
  1630. },
  1631. {
  1632. name: "大型 JSON >50KB",
  1633. input: largeInput,
  1634. expected: largeExpected,
  1635. },
  1636. }
  1637. for _, tt := range tests {
  1638. t.Run(tt.name, func(t *testing.T) {
  1639. got, err := svc.unwrapV1InternalResponse(tt.input)
  1640. if tt.wantErr {
  1641. require.Error(t, err)
  1642. return
  1643. }
  1644. require.NoError(t, err)
  1645. require.Equal(t, tt.expected, strings.TrimSpace(string(got)))
  1646. })
  1647. }
  1648. }
  1649. // --- unwrapV1InternalResponse benchmark 对照组 ---
  1650. // unwrapV1InternalResponseOld 旧实现:Unmarshal+Marshal 双重开销(仅用于 benchmark 对照)
  1651. func unwrapV1InternalResponseOld(body []byte) ([]byte, error) {
  1652. var outer map[string]any
  1653. if err := json.Unmarshal(body, &outer); err != nil {
  1654. return nil, err
  1655. }
  1656. if resp, ok := outer["response"]; ok {
  1657. return json.Marshal(resp)
  1658. }
  1659. return body, nil
  1660. }
  1661. func BenchmarkUnwrapV1Internal_Old_Small(b *testing.B) {
  1662. body := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello world"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`)
  1663. b.ResetTimer()
  1664. for i := 0; i < b.N; i++ {
  1665. _, _ = unwrapV1InternalResponseOld(body)
  1666. }
  1667. }
  1668. func BenchmarkUnwrapV1Internal_New_Small(b *testing.B) {
  1669. body := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello world"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`)
  1670. svc := &AntigravityGatewayService{}
  1671. b.ResetTimer()
  1672. for i := 0; i < b.N; i++ {
  1673. _, _ = svc.unwrapV1InternalResponse(body)
  1674. }
  1675. }
  1676. func BenchmarkUnwrapV1Internal_Old_Large(b *testing.B) {
  1677. body := generateLargeUnwrapJSON(10 * 1024) // ~10KB
  1678. b.ResetTimer()
  1679. for i := 0; i < b.N; i++ {
  1680. _, _ = unwrapV1InternalResponseOld(body)
  1681. }
  1682. }
  1683. func BenchmarkUnwrapV1Internal_New_Large(b *testing.B) {
  1684. body := generateLargeUnwrapJSON(10 * 1024) // ~10KB
  1685. svc := &AntigravityGatewayService{}
  1686. b.ResetTimer()
  1687. for i := 0; i < b.N; i++ {
  1688. _, _ = svc.unwrapV1InternalResponse(body)
  1689. }
  1690. }
  1691. // generateLargeUnwrapJSON 生成指定最小大小的包含 response 包装的 JSON
  1692. func generateLargeUnwrapJSON(minSize int) []byte {
  1693. parts := make([]map[string]string, 0)
  1694. current := 0
  1695. for current < minSize {
  1696. text := fmt.Sprintf("这是第 %d 段内容,用于填充 JSON 到目标大小。", len(parts)+1)
  1697. parts = append(parts, map[string]string{"text": text})
  1698. current += len(text) + 20 // 估算 JSON 编码开销
  1699. }
  1700. inner := map[string]any{
  1701. "candidates": []map[string]any{
  1702. {"content": map[string]any{"parts": parts}},
  1703. },
  1704. "usageMetadata": map[string]any{
  1705. "promptTokenCount": 100,
  1706. "candidatesTokenCount": 50,
  1707. },
  1708. }
  1709. outer := map[string]any{"response": inner}
  1710. b, _ := json.Marshal(outer)
  1711. return b
  1712. }