antigravity_gateway_compat_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. type antigravityCompatTokenCache struct {
  19. token string
  20. }
  21. type antigravityCompatErrorReader struct {
  22. data []byte
  23. off int
  24. err error
  25. }
  26. func (r *antigravityCompatErrorReader) Read(p []byte) (int, error) {
  27. if r.off < len(r.data) {
  28. n := copy(p, r.data[r.off:])
  29. r.off += n
  30. return n, nil
  31. }
  32. return 0, r.err
  33. }
  34. func (r *antigravityCompatErrorReader) Close() error { return nil }
  35. func (c *antigravityCompatTokenCache) GetAccessToken(context.Context, string) (string, error) {
  36. return c.token, nil
  37. }
  38. func (c *antigravityCompatTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
  39. return nil
  40. }
  41. func (c *antigravityCompatTokenCache) DeleteAccessToken(context.Context, string) error {
  42. return nil
  43. }
  44. func (c *antigravityCompatTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
  45. return true, nil
  46. }
  47. func (c *antigravityCompatTokenCache) ReleaseRefreshLock(context.Context, string) error {
  48. return nil
  49. }
  50. func newAntigravityCompatService(cfg config.GatewayConfig, upstream HTTPUpstream) *AntigravityGatewayService {
  51. tokenProvider := NewAntigravityTokenProvider(
  52. nil,
  53. &antigravityCompatTokenCache{token: "fresh-oauth-token"},
  54. nil,
  55. )
  56. return NewAntigravityGatewayService(
  57. nil,
  58. nil,
  59. nil,
  60. tokenProvider,
  61. nil,
  62. upstream,
  63. NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: cfg}),
  64. nil,
  65. )
  66. }
  67. func newAntigravityCompatAccount(accountType string) *Account {
  68. return &Account{
  69. ID: 3757,
  70. Name: "antigravity-compat",
  71. Platform: PlatformAntigravity,
  72. Type: accountType,
  73. Status: StatusActive,
  74. Concurrency: 1,
  75. Credentials: map[string]any{
  76. "access_token": "stale-account-token",
  77. "project_id": "project-3757",
  78. "model_mapping": map[string]any{
  79. "gemini-3.1-pro-high": "gemini-3.1-pro-high",
  80. "claude-sonnet-4-5": "claude-sonnet-4-5",
  81. "claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
  82. },
  83. },
  84. }
  85. }
  86. func newAntigravityCompatContext(method, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
  87. recorder := httptest.NewRecorder()
  88. c, _ := gin.CreateTestContext(recorder)
  89. c.Request = httptest.NewRequest(method, path, bytes.NewReader(body))
  90. return c, recorder
  91. }
  92. func antigravityCompatSuccessResponse() *http.Response {
  93. body := `data: {"response":{"responseId":"resp_3757","candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3}}}` + "\n\n"
  94. return &http.Response{
  95. StatusCode: http.StatusOK,
  96. Header: http.Header{
  97. "Content-Type": []string{"text/event-stream"},
  98. "X-Request-Id": []string{"request-3757"},
  99. },
  100. Body: io.NopCloser(strings.NewReader(body)),
  101. }
  102. }
  103. func TestAntigravityCompatOAuthUsesNativeTokenAndRoute(t *testing.T) {
  104. gin.SetMode(gin.TestMode)
  105. tests := []struct {
  106. name string
  107. path string
  108. body []byte
  109. call func(*AntigravityGatewayService, context.Context, *gin.Context, *Account, []byte) (*ForwardResult, error)
  110. }{
  111. {
  112. name: "chat completions",
  113. path: "/v1/chat/completions",
  114. body: []byte(`{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"Reply exactly: ok"}]}`),
  115. call: func(svc *AntigravityGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
  116. return svc.ForwardAsChatCompletions(ctx, c, account, body, nil)
  117. },
  118. },
  119. {
  120. name: "responses",
  121. path: "/v1/responses",
  122. body: []byte(`{"model":"gemini-3.1-pro-high","input":"Reply exactly: ok"}`),
  123. call: func(svc *AntigravityGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
  124. return svc.ForwardAsResponses(ctx, c, account, body, nil)
  125. },
  126. },
  127. }
  128. for _, tt := range tests {
  129. t.Run(tt.name, func(t *testing.T) {
  130. var authorization string
  131. var upstreamPath string
  132. var upstreamAlt string
  133. upstream := &queuedHTTPUpstreamStub{
  134. responses: []*http.Response{antigravityCompatSuccessResponse()},
  135. onCall: func(req *http.Request, _ *queuedHTTPUpstreamStub) {
  136. authorization = req.Header.Get("Authorization")
  137. upstreamPath = req.URL.Path
  138. upstreamAlt = req.URL.Query().Get("alt")
  139. },
  140. }
  141. svc := newAntigravityCompatService(
  142. config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  143. upstream,
  144. )
  145. c, recorder := newAntigravityCompatContext(http.MethodPost, tt.path, tt.body)
  146. result, err := tt.call(svc, context.Background(), c, newAntigravityCompatAccount(AccountTypeOAuth), tt.body)
  147. require.NoError(t, err)
  148. require.NotNil(t, result)
  149. require.Equal(t, "Bearer fresh-oauth-token", authorization)
  150. require.Equal(t, "/v1internal:streamGenerateContent", upstreamPath)
  151. require.Equal(t, "sse", upstreamAlt)
  152. require.Equal(t, "request-3757", result.RequestID)
  153. require.Equal(t, 8, result.Usage.InputTokens)
  154. require.Equal(t, 3, result.Usage.OutputTokens)
  155. require.Equal(t, http.StatusOK, recorder.Code)
  156. require.Contains(t, recorder.Body.String(), "ok")
  157. if tt.name == "chat completions" {
  158. require.Equal(t, "stop", gjson.Get(recorder.Body.String(), "choices.0.finish_reason").String())
  159. require.Equal(t, int64(8), gjson.Get(recorder.Body.String(), "usage.prompt_tokens").Int())
  160. require.Equal(t, int64(3), gjson.Get(recorder.Body.String(), "usage.completion_tokens").Int())
  161. }
  162. })
  163. }
  164. }
  165. func TestAntigravityCompatRejectsUnsupportedAccountType(t *testing.T) {
  166. gin.SetMode(gin.TestMode)
  167. tests := []struct {
  168. name string
  169. path string
  170. accountType string
  171. call func(*AntigravityGatewayService, context.Context, *gin.Context, *Account, []byte) (*ForwardResult, error)
  172. }{
  173. {
  174. name: "chat completions upstream",
  175. path: "/v1/chat/completions",
  176. accountType: AccountTypeUpstream,
  177. call: func(svc *AntigravityGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
  178. return svc.ForwardAsChatCompletions(ctx, c, account, body, nil)
  179. },
  180. },
  181. {
  182. name: "responses setup token",
  183. path: "/v1/responses",
  184. accountType: AccountTypeSetupToken,
  185. call: func(svc *AntigravityGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
  186. return svc.ForwardAsResponses(ctx, c, account, body, nil)
  187. },
  188. },
  189. }
  190. for _, tt := range tests {
  191. t.Run(tt.name, func(t *testing.T) {
  192. body := []byte(`{"model":"gemini-3.1-pro-high"}`)
  193. c, recorder := newAntigravityCompatContext(http.MethodPost, tt.path, body)
  194. result, err := tt.call(&AntigravityGatewayService{}, context.Background(), c, newAntigravityCompatAccount(tt.accountType), body)
  195. require.Error(t, err)
  196. require.Nil(t, result)
  197. require.Equal(t, http.StatusBadRequest, recorder.Code)
  198. require.Contains(t, recorder.Body.String(), "native OAuth account required for antigravity compatibility mode")
  199. })
  200. }
  201. }
  202. func TestBuildAntigravityCompatGeminiBody_ConfiguresMixedToolInvocations(t *testing.T) {
  203. svc := &AntigravityGatewayService{}
  204. tests := []struct {
  205. name string
  206. tools string
  207. wantField bool
  208. }{
  209. {
  210. name: "mixed server and client tools",
  211. tools: `[{"name":"get_weather","input_schema":{"type":"object"}},{"type":"web_search_20250305","name":"web_search"}]`,
  212. wantField: true,
  213. },
  214. {
  215. name: "client tools only",
  216. tools: `[{"name":"get_weather","input_schema":{"type":"object"}}]`,
  217. },
  218. {
  219. name: "server tools only",
  220. tools: `[{"type":"web_search_20250305","name":"web_search"}]`,
  221. },
  222. }
  223. for _, tt := range tests {
  224. t.Run(tt.name, func(t *testing.T) {
  225. claudeBody := []byte(`{"messages":[{"role":"user","content":"hello"}],"tools":` + tt.tools + `}`)
  226. claudeBody = bytes.ReplaceAll(claudeBody, []byte{92}, nil)
  227. body, err := svc.buildAntigravityCompatGeminiBody(context.Background(), claudeBody, nil, "project-1", "gemini-2.5-flash")
  228. require.NoError(t, err)
  229. var wrapped map[string]any
  230. require.NoError(t, json.Unmarshal(body, &wrapped))
  231. request, ok := wrapped["request"].(map[string]any)
  232. require.True(t, ok)
  233. toolConfig, exists := request["toolConfig"].(map[string]any)
  234. if !tt.wantField {
  235. require.False(t, exists)
  236. return
  237. }
  238. require.True(t, exists)
  239. require.Equal(t, true, toolConfig["includeServerSideToolInvocations"])
  240. require.NotContains(t, toolConfig, "include_server_side_tool_invocations")
  241. })
  242. }
  243. }
  244. func TestAntigravityCompatChatMixedBuiltInToolsEnableServerSideInvocations(t *testing.T) {
  245. gin.SetMode(gin.TestMode)
  246. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{antigravityCompatSuccessResponse()}}
  247. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream)
  248. body := []byte(`{
  249. "model":"claude-opus-4-6-thinking",
  250. "messages":[{"role":"user","content":"hello"}],
  251. "stream":true,
  252. "tools":[
  253. {"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}},
  254. {"type":"function","function":{"name":"terminal","parameters":{"type":"object","properties":{"command":{"type":"string"}}}}},
  255. {"type":"web_search"},
  256. {"type":"code_execution"}
  257. ]
  258. }`)
  259. c, _ := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
  260. result, err := svc.ForwardAsChatCompletions(context.Background(), c, newAntigravityCompatAccount(AccountTypeOAuth), body, nil)
  261. require.NoError(t, err)
  262. require.NotNil(t, result)
  263. require.Len(t, upstream.requestBodies, 1)
  264. requestBody := upstream.requestBodies[0]
  265. require.True(t, gjson.GetBytes(requestBody, "request.toolConfig.includeServerSideToolInvocations").Bool())
  266. require.Len(t, gjson.GetBytes(requestBody, "request.tools.0.functionDeclarations").Array(), 2)
  267. require.True(t, gjson.GetBytes(requestBody, "request.tools.1.googleSearch").Exists())
  268. require.True(t, gjson.GetBytes(requestBody, "request.tools.2.codeExecution").Exists())
  269. }
  270. func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) {
  271. gin.SetMode(gin.TestMode)
  272. tests := []struct {
  273. name string
  274. body string
  275. want int64
  276. }{
  277. {
  278. name: "legacy max_tokens below bridge floor",
  279. body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8}`,
  280. want: 8,
  281. },
  282. {
  283. name: "max_completion_tokens takes precedence",
  284. body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8,"max_completion_tokens":13}`,
  285. want: 13,
  286. },
  287. {
  288. name: "max_tokens at safe ceiling is preserved",
  289. body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":64000}`,
  290. want: 64000,
  291. },
  292. {
  293. name: "max_tokens above safe ceiling is clamped",
  294. body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":64001}`,
  295. want: 64000,
  296. },
  297. {
  298. name: "precedence applies before clamping",
  299. body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8,"max_completion_tokens":64001}`,
  300. want: 64000,
  301. },
  302. }
  303. for _, tt := range tests {
  304. t.Run(tt.name, func(t *testing.T) {
  305. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{antigravityCompatSuccessResponse()}}
  306. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream)
  307. body := []byte(tt.body)
  308. c, _ := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
  309. result, err := svc.ForwardAsChatCompletions(
  310. context.Background(),
  311. c,
  312. newAntigravityCompatAccount(AccountTypeOAuth),
  313. body,
  314. nil,
  315. )
  316. require.NoError(t, err)
  317. require.NotNil(t, result)
  318. require.Len(t, upstream.requestBodies, 1)
  319. require.Equal(t, tt.want, gjson.GetBytes(upstream.requestBodies[0], "request.generationConfig.maxOutputTokens").Int())
  320. })
  321. }
  322. }
  323. func TestPreserveChatCompletionTokenLimitIgnoresAbsentAndNonPositiveValues(t *testing.T) {
  324. tests := []struct {
  325. name string
  326. request apicompat.ChatCompletionsRequest
  327. }{
  328. {name: "absent"},
  329. {name: "zero max_tokens", request: apicompat.ChatCompletionsRequest{MaxTokens: antigravityCompatIntPtr(0)}},
  330. {name: "negative max_completion_tokens takes precedence", request: apicompat.ChatCompletionsRequest{MaxTokens: antigravityCompatIntPtr(12), MaxCompletionTokens: antigravityCompatIntPtr(-1)}},
  331. }
  332. for _, tt := range tests {
  333. t.Run(tt.name, func(t *testing.T) {
  334. claudeRequest := &apicompat.AnthropicRequest{MaxTokens: 99}
  335. preserveChatCompletionTokenLimit(&tt.request, claudeRequest)
  336. require.Equal(t, 99, claudeRequest.MaxTokens)
  337. })
  338. }
  339. }
  340. func antigravityCompatIntPtr(v int) *int { return &v }
  341. func TestAntigravityCompatRoutesByMappedModelFamily(t *testing.T) {
  342. gin.SetMode(gin.TestMode)
  343. tests := []struct {
  344. model string
  345. wantSessionID bool
  346. }{
  347. {model: "gemini-3.1-pro-high", wantSessionID: false},
  348. {model: "claude-sonnet-4-5", wantSessionID: true},
  349. }
  350. for _, tt := range tests {
  351. t.Run(tt.model, func(t *testing.T) {
  352. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{antigravityCompatSuccessResponse()}}
  353. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream)
  354. body := []byte(`{"model":"` + tt.model + `","messages":[{"role":"user","content":"ok"}],"max_tokens":8}`)
  355. c, _ := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
  356. result, err := svc.ForwardAsChatCompletions(
  357. context.Background(),
  358. c,
  359. newAntigravityCompatAccount(AccountTypeOAuth),
  360. body,
  361. nil,
  362. )
  363. require.NoError(t, err)
  364. require.NotNil(t, result)
  365. require.Len(t, upstream.requestBodies, 1)
  366. require.Equal(t, tt.model, gjson.GetBytes(upstream.requestBodies[0], "model").String())
  367. require.Equal(t, tt.wantSessionID, gjson.GetBytes(upstream.requestBodies[0], "request.sessionId").Exists())
  368. })
  369. }
  370. }
  371. func TestAntigravityCompatUnauthorizedIsCredentialFailure(t *testing.T) {
  372. gin.SetMode(gin.TestMode)
  373. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
  374. StatusCode: http.StatusUnauthorized,
  375. Header: http.Header{"X-Request-Id": []string{"auth-3757"}},
  376. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Invalid bearer token"}}`)),
  377. }}}
  378. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream)
  379. body := []byte(`{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}]}`)
  380. c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
  381. result, err := svc.ForwardAsChatCompletions(
  382. context.Background(),
  383. c,
  384. newAntigravityCompatAccount(AccountTypeOAuth),
  385. body,
  386. nil,
  387. )
  388. require.Nil(t, result)
  389. var failoverErr *UpstreamFailoverError
  390. require.ErrorAs(t, err, &failoverErr)
  391. require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
  392. require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
  393. require.Equal(t, AntigravityCredentialRejectedReason, failoverErr.Reason)
  394. require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
  395. require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
  396. require.Equal(t, AntigravityCredentialRejectedClientMessage, failoverErr.ClientMessage)
  397. require.Equal(t, "auth-3757", failoverErr.ResponseHeaders.Get("X-Request-Id"))
  398. require.Empty(t, recorder.Body.String())
  399. }
  400. func TestAntigravityCompatEmptyStreamTriggersFailover(t *testing.T) {
  401. gin.SetMode(gin.TestMode)
  402. tests := []struct {
  403. name string
  404. run func(*AntigravityGatewayService, *gin.Context, *http.Response) (*antigravityStreamResult, error)
  405. }{
  406. {
  407. name: "chat completions",
  408. run: func(svc *AntigravityGatewayService, c *gin.Context, resp *http.Response) (*antigravityStreamResult, error) {
  409. return svc.handleChatCompletionsStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high", true)
  410. },
  411. },
  412. {
  413. name: "responses",
  414. run: func(svc *AntigravityGatewayService, c *gin.Context, resp *http.Response) (*antigravityStreamResult, error) {
  415. return svc.handleResponsesStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high")
  416. },
  417. },
  418. }
  419. for _, tt := range tests {
  420. t.Run(tt.name, func(t *testing.T) {
  421. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil)
  422. c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil)
  423. resp := &http.Response{
  424. StatusCode: http.StatusOK,
  425. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  426. Body: io.NopCloser(strings.NewReader("data: malformed\n\ndata: [DONE]\n\n")),
  427. }
  428. result, err := tt.run(svc, c, resp)
  429. require.Nil(t, result)
  430. var failoverErr *UpstreamFailoverError
  431. require.ErrorAs(t, err, &failoverErr)
  432. require.True(t, failoverErr.RetryableOnSameAccount)
  433. require.Empty(t, recorder.Body.String())
  434. require.Empty(t, recorder.Header().Get("Content-Type"))
  435. })
  436. }
  437. }
  438. func TestAntigravityCompatUsageOnlyStreamTriggersFailover(t *testing.T) {
  439. gin.SetMode(gin.TestMode)
  440. tests := []struct {
  441. name string
  442. run func(*AntigravityGatewayService, *gin.Context, *http.Response) (*antigravityStreamResult, error)
  443. }{
  444. {
  445. name: "chat completions",
  446. run: func(svc *AntigravityGatewayService, c *gin.Context, resp *http.Response) (*antigravityStreamResult, error) {
  447. return svc.handleChatCompletionsStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high", true)
  448. },
  449. },
  450. {
  451. name: "responses",
  452. run: func(svc *AntigravityGatewayService, c *gin.Context, resp *http.Response) (*antigravityStreamResult, error) {
  453. return svc.handleResponsesStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high")
  454. },
  455. },
  456. }
  457. for _, tt := range tests {
  458. t.Run(tt.name, func(t *testing.T) {
  459. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil)
  460. c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil)
  461. resp := &http.Response{
  462. StatusCode: http.StatusOK,
  463. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  464. Body: io.NopCloser(strings.NewReader(
  465. `data: {"response":{"responseId":"resp_3757","usageMetadata":{"promptTokenCount":8}}}` + "\n\n",
  466. )),
  467. }
  468. result, err := tt.run(svc, c, resp)
  469. require.Nil(t, result)
  470. var failoverErr *UpstreamFailoverError
  471. require.ErrorAs(t, err, &failoverErr)
  472. require.True(t, failoverErr.RetryableOnSameAccount)
  473. require.Empty(t, recorder.Body.String())
  474. require.Empty(t, recorder.Header().Get("Content-Type"))
  475. })
  476. }
  477. }
  478. func TestAntigravityCompatUsageOnlyNonStreamingTriggersFailover(t *testing.T) {
  479. gin.SetMode(gin.TestMode)
  480. tests := []struct {
  481. name string
  482. path string
  483. body []byte
  484. call func(*AntigravityGatewayService, context.Context, *gin.Context, *Account, []byte) (*ForwardResult, error)
  485. }{
  486. {
  487. name: "chat completions",
  488. path: "/v1/chat/completions",
  489. body: []byte(`{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}]}`),
  490. call: func(svc *AntigravityGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
  491. return svc.ForwardAsChatCompletions(ctx, c, account, body, nil)
  492. },
  493. },
  494. {
  495. name: "responses",
  496. path: "/v1/responses",
  497. body: []byte(`{"model":"gemini-3.1-pro-high","input":"ok"}`),
  498. call: func(svc *AntigravityGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
  499. return svc.ForwardAsResponses(ctx, c, account, body, nil)
  500. },
  501. },
  502. }
  503. for _, tt := range tests {
  504. t.Run(tt.name, func(t *testing.T) {
  505. upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
  506. StatusCode: http.StatusOK,
  507. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  508. Body: io.NopCloser(strings.NewReader(
  509. `data: {"response":{"responseId":"resp_3757","usageMetadata":{"promptTokenCount":8}}}` + "\n\n",
  510. )),
  511. }}}
  512. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream)
  513. c, recorder := newAntigravityCompatContext(http.MethodPost, tt.path, tt.body)
  514. result, err := tt.call(
  515. svc,
  516. context.Background(),
  517. c,
  518. newAntigravityCompatAccount(AccountTypeOAuth),
  519. tt.body,
  520. )
  521. require.Nil(t, result)
  522. var failoverErr *UpstreamFailoverError
  523. require.ErrorAs(t, err, &failoverErr)
  524. require.True(t, failoverErr.RetryableOnSameAccount)
  525. require.Empty(t, recorder.Body.String())
  526. require.Empty(t, recorder.Header().Get("Content-Type"))
  527. })
  528. }
  529. }
  530. func TestAntigravityCompatChatStreamMapsToolCallAndUsage(t *testing.T) {
  531. gin.SetMode(gin.TestMode)
  532. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil)
  533. c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", nil)
  534. body := `data: {"response":{"responseId":"resp_3757","candidates":[{"content":{"parts":[{"functionCall":{"id":"call_3757","name":"get_weather","args":{"city":"Tokyo"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3}}}` + "\n\n"
  535. resp := &http.Response{
  536. StatusCode: http.StatusOK,
  537. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  538. Body: io.NopCloser(strings.NewReader(body)),
  539. }
  540. result, err := svc.handleChatCompletionsStreamingFromAntigravity(
  541. c,
  542. resp,
  543. time.Now(),
  544. "gemini-3.1-pro-high",
  545. true,
  546. )
  547. require.NoError(t, err)
  548. require.NotNil(t, result)
  549. require.Equal(t, 8, result.usage.InputTokens)
  550. require.Equal(t, 3, result.usage.OutputTokens)
  551. require.Contains(t, recorder.Body.String(), `"tool_calls"`)
  552. require.Contains(t, recorder.Body.String(), `"get_weather"`)
  553. require.Contains(t, recorder.Body.String(), `"finish_reason":"tool_calls"`)
  554. require.Contains(t, recorder.Body.String(), `"prompt_tokens":8`)
  555. require.Equal(t, 1, strings.Count(recorder.Body.String(), "data: [DONE]"))
  556. }
  557. func TestAntigravityCompatFirstEventTimeoutTriggersFailover(t *testing.T) {
  558. gin.SetMode(gin.TestMode)
  559. svc := newAntigravityCompatService(
  560. config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 1},
  561. nil,
  562. )
  563. c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", nil)
  564. reader, writer := io.Pipe()
  565. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
  566. type outcome struct {
  567. result *antigravityStreamResult
  568. err error
  569. }
  570. done := make(chan outcome, 1)
  571. go func() {
  572. result, err := svc.handleChatCompletionsStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high", false)
  573. done <- outcome{result: result, err: err}
  574. }()
  575. select {
  576. case got := <-done:
  577. require.Nil(t, got.result)
  578. var failoverErr *UpstreamFailoverError
  579. require.ErrorAs(t, got.err, &failoverErr)
  580. require.True(t, failoverErr.RetryableOnSameAccount)
  581. require.Empty(t, recorder.Header().Get("Content-Type"))
  582. case <-time.After(2 * time.Second):
  583. _ = writer.Close()
  584. _ = reader.Close()
  585. t.Fatal("compat stream ignored StreamDataIntervalTimeout")
  586. }
  587. _ = writer.Close()
  588. _ = reader.Close()
  589. }
  590. func TestAntigravityCompatClientDisconnectDrainsUsage(t *testing.T) {
  591. gin.SetMode(gin.TestMode)
  592. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil)
  593. c, _ := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", nil)
  594. c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  595. body := strings.Join([]string{
  596. `data: {"response":{"responseId":"resp_3757","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}`,
  597. "",
  598. `data: {"response":{"responseId":"resp_3757","candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":15}}}`,
  599. "",
  600. }, "\n")
  601. resp := &http.Response{
  602. StatusCode: http.StatusOK,
  603. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  604. Body: io.NopCloser(strings.NewReader(body)),
  605. }
  606. result, err := svc.handleChatCompletionsStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high", false)
  607. require.NoError(t, err)
  608. require.NotNil(t, result)
  609. require.True(t, result.clientDisconnect)
  610. require.Equal(t, 15, result.usage.OutputTokens)
  611. }
  612. func TestAntigravityCompatStreamErrorCommitsSingleTerminalFrame(t *testing.T) {
  613. gin.SetMode(gin.TestMode)
  614. svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil)
  615. c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil)
  616. body := []byte(`data: {"response":{"responseId":"resp_3757","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}` + "\n\n")
  617. resp := &http.Response{
  618. StatusCode: http.StatusOK,
  619. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  620. Body: &antigravityCompatErrorReader{
  621. data: body,
  622. err: io.ErrUnexpectedEOF,
  623. },
  624. }
  625. result, err := svc.handleResponsesStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high")
  626. require.Error(t, err)
  627. require.Nil(t, result)
  628. require.True(t, IsResponseCommitted(c))
  629. require.Equal(t, 1, strings.Count(recorder.Body.String(), "event: error"))
  630. }
  631. func TestAntigravityCompatKeepaliveAfterFirstEvent(t *testing.T) {
  632. gin.SetMode(gin.TestMode)
  633. svc := newAntigravityCompatService(
  634. config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamKeepaliveInterval: 1},
  635. nil,
  636. )
  637. c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil)
  638. reader, writer := io.Pipe()
  639. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
  640. done := make(chan error, 1)
  641. go func() {
  642. _, err := svc.handleResponsesStreamingFromAntigravity(c, resp, time.Now(), "gemini-3.1-pro-high")
  643. done <- err
  644. }()
  645. _, err := io.WriteString(
  646. writer,
  647. `data: {"response":{"responseId":"resp_3757","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}`+"\n\n",
  648. )
  649. require.NoError(t, err)
  650. time.Sleep(1200 * time.Millisecond)
  651. require.NoError(t, writer.Close())
  652. require.NoError(t, <-done)
  653. require.Contains(t, recorder.Body.String(), ": ping\n\n")
  654. require.Contains(t, recorder.Header().Get("Content-Type"), "text/event-stream")
  655. require.NoError(t, reader.Close())
  656. }