antigravity_rate_limit_test.go 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // 编译期接口断言
  16. var _ HTTPUpstream = (*stubAntigravityUpstream)(nil)
  17. var _ HTTPUpstream = (*recordingOKUpstream)(nil)
  18. var _ AccountRepository = (*stubAntigravityAccountRepo)(nil)
  19. var _ SchedulerCache = (*stubSchedulerCache)(nil)
  20. type stubAntigravityUpstream struct {
  21. firstBase string
  22. secondBase string
  23. calls []string
  24. }
  25. type recordingOKUpstream struct {
  26. calls int
  27. }
  28. func (r *recordingOKUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  29. r.calls++
  30. return &http.Response{
  31. StatusCode: http.StatusOK,
  32. Header: http.Header{},
  33. Body: io.NopCloser(strings.NewReader("ok")),
  34. }, nil
  35. }
  36. func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  37. return r.Do(req, proxyURL, accountID, accountConcurrency)
  38. }
  39. func (s *stubAntigravityUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  40. url := req.URL.String()
  41. s.calls = append(s.calls, url)
  42. if strings.HasPrefix(url, s.firstBase) {
  43. return &http.Response{
  44. StatusCode: http.StatusTooManyRequests,
  45. Header: http.Header{},
  46. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Resource has been exhausted"}}`)),
  47. }, nil
  48. }
  49. return &http.Response{
  50. StatusCode: http.StatusOK,
  51. Header: http.Header{},
  52. Body: io.NopCloser(strings.NewReader("ok")),
  53. }, nil
  54. }
  55. func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  56. return s.Do(req, proxyURL, accountID, accountConcurrency)
  57. }
  58. type rateLimitCall struct {
  59. accountID int64
  60. resetAt time.Time
  61. }
  62. type modelRateLimitCall struct {
  63. accountID int64
  64. modelKey string // 存储的 key(应该是官方模型 ID,如 "claude-sonnet-4-5")
  65. resetAt time.Time
  66. }
  67. type extraUpdateCall struct {
  68. accountID int64
  69. updates map[string]any
  70. }
  71. type stubAntigravityAccountRepo struct {
  72. AccountRepository
  73. rateCalls []rateLimitCall
  74. modelRateLimitCalls []modelRateLimitCall
  75. extraUpdateCalls []extraUpdateCall
  76. }
  77. func (s *stubAntigravityAccountRepo) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
  78. s.rateCalls = append(s.rateCalls, rateLimitCall{accountID: id, resetAt: resetAt})
  79. return nil
  80. }
  81. func (s *stubAntigravityAccountRepo) SetModelRateLimit(ctx context.Context, id int64, modelKey string, resetAt time.Time, reason ...string) error {
  82. s.modelRateLimitCalls = append(s.modelRateLimitCalls, modelRateLimitCall{accountID: id, modelKey: modelKey, resetAt: resetAt})
  83. return nil
  84. }
  85. func (s *stubAntigravityAccountRepo) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
  86. s.extraUpdateCalls = append(s.extraUpdateCalls, extraUpdateCall{accountID: id, updates: updates})
  87. return nil
  88. }
  89. func TestAntigravityRetryLoop_NoURLFallback_UsesConfiguredBaseURL(t *testing.T) {
  90. t.Setenv(antigravityForwardBaseURLEnv, "")
  91. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  92. oldAvailability := antigravity.DefaultURLAvailability
  93. defer func() {
  94. antigravity.BaseURLs = oldBaseURLs
  95. antigravity.DefaultURLAvailability = oldAvailability
  96. }()
  97. base1 := "https://ag-1.test"
  98. base2 := "https://ag-2.test"
  99. antigravity.BaseURLs = []string{base1, base2}
  100. antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
  101. upstream := &stubAntigravityUpstream{firstBase: base1, secondBase: base2}
  102. account := &Account{
  103. ID: 1,
  104. Name: "acc-1",
  105. Platform: PlatformAntigravity,
  106. Schedulable: true,
  107. Status: StatusActive,
  108. Concurrency: 1,
  109. }
  110. var handleErrorCalled bool
  111. svc := &AntigravityGatewayService{}
  112. result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
  113. prefix: "[test]",
  114. ctx: context.Background(),
  115. account: account,
  116. proxyURL: "",
  117. accessToken: "token",
  118. action: "generateContent",
  119. body: []byte(`{"input":"test"}`),
  120. httpUpstream: upstream,
  121. requestedModel: "claude-sonnet-4-5",
  122. handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
  123. handleErrorCalled = true
  124. return nil
  125. },
  126. })
  127. require.NoError(t, err)
  128. require.NotNil(t, result)
  129. require.NotNil(t, result.resp)
  130. defer func() { _ = result.resp.Body.Close() }()
  131. require.Equal(t, http.StatusTooManyRequests, result.resp.StatusCode)
  132. require.True(t, handleErrorCalled)
  133. require.Len(t, upstream.calls, antigravityMaxRetries)
  134. for _, callURL := range upstream.calls {
  135. require.True(t, strings.HasPrefix(callURL, base1))
  136. }
  137. available := antigravity.DefaultURLAvailability.GetAvailableURLs()
  138. require.NotEmpty(t, available)
  139. require.Equal(t, base1, available[0])
  140. }
  141. // TestHandleUpstreamError_429_ModelRateLimit 测试 429 模型限流场景
  142. func TestHandleUpstreamError_429_ModelRateLimit(t *testing.T) {
  143. repo := &stubAntigravityAccountRepo{}
  144. svc := &AntigravityGatewayService{accountRepo: repo}
  145. account := &Account{ID: 1, Name: "acc-1", Platform: PlatformAntigravity}
  146. // 429 + RATE_LIMIT_EXCEEDED + 模型名 → 模型限流
  147. body := []byte(`{
  148. "error": {
  149. "status": "RESOURCE_EXHAUSTED",
  150. "details": [
  151. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
  152. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
  153. ]
  154. }
  155. }`)
  156. result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusTooManyRequests, http.Header{}, body, "claude-sonnet-4-5", 0, "", false)
  157. // 应该触发模型限流
  158. require.NotNil(t, result)
  159. require.True(t, result.Handled)
  160. require.NotNil(t, result.SwitchError)
  161. require.Equal(t, "claude-sonnet-4-5", result.SwitchError.RateLimitedModel)
  162. require.Len(t, repo.modelRateLimitCalls, 1)
  163. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
  164. }
  165. // TestHandleUpstreamError_429_NonModelRateLimit 测试 429 非模型限流场景(走模型级限流兜底)
  166. func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) {
  167. repo := &stubAntigravityAccountRepo{}
  168. svc := &AntigravityGatewayService{accountRepo: repo}
  169. account := &Account{ID: 2, Name: "acc-2", Platform: PlatformAntigravity}
  170. // 429 + 普通限流响应(无 RATE_LIMIT_EXCEEDED reason)→ 走模型级限流兜底
  171. body := buildGeminiRateLimitBody("5s")
  172. result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusTooManyRequests, http.Header{}, body, "claude-sonnet-4-5", 0, "", false)
  173. // handleModelRateLimit 不会处理(因为没有 RATE_LIMIT_EXCEEDED),
  174. // 但 429 兜底逻辑会使用 requestedModel 设置模型级限流
  175. require.Nil(t, result)
  176. require.Len(t, repo.modelRateLimitCalls, 1)
  177. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
  178. }
  179. // TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey 测试 429 非模型限流场景
  180. // 验证:requestedModel 会被映射到 Antigravity 最终模型(例如 claude-opus-4-6 -> claude-opus-4-6-thinking)
  181. func TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey(t *testing.T) {
  182. repo := &stubAntigravityAccountRepo{}
  183. svc := &AntigravityGatewayService{accountRepo: repo}
  184. account := &Account{ID: 20, Name: "acc-20", Platform: PlatformAntigravity}
  185. body := buildGeminiRateLimitBody("5s")
  186. result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusTooManyRequests, http.Header{}, body, "claude-opus-4-6", 0, "", false)
  187. require.Nil(t, result)
  188. require.Len(t, repo.modelRateLimitCalls, 1)
  189. require.Equal(t, "claude-opus-4-6-thinking", repo.modelRateLimitCalls[0].modelKey)
  190. }
  191. // TestHandleUpstreamError_503_ModelCapacityExhausted 测试 503 模型容量不足场景
  192. // MODEL_CAPACITY_EXHAUSTED 时应等待重试,不切换账号
  193. func TestHandleUpstreamError_503_ModelCapacityExhausted(t *testing.T) {
  194. repo := &stubAntigravityAccountRepo{}
  195. svc := &AntigravityGatewayService{accountRepo: repo}
  196. account := &Account{ID: 3, Name: "acc-3", Platform: PlatformAntigravity}
  197. // 503 + MODEL_CAPACITY_EXHAUSTED → 等待重试,不切换账号
  198. body := []byte(`{
  199. "error": {
  200. "status": "UNAVAILABLE",
  201. "details": [
  202. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
  203. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "30s"}
  204. ]
  205. }
  206. }`)
  207. result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusServiceUnavailable, http.Header{}, body, "gemini-3-pro-high", 0, "", false)
  208. // MODEL_CAPACITY_EXHAUSTED 应该标记为已处理,不切换账号,不设置模型限流
  209. // 实际重试由 handleSmartRetry 处理
  210. require.NotNil(t, result)
  211. require.True(t, result.Handled)
  212. require.False(t, result.ShouldRetry, "MODEL_CAPACITY_EXHAUSTED should not trigger retry from handleModelRateLimit path")
  213. require.Nil(t, result.SwitchError, "MODEL_CAPACITY_EXHAUSTED should not trigger account switch")
  214. require.Empty(t, repo.modelRateLimitCalls, "MODEL_CAPACITY_EXHAUSTED should not set model rate limit")
  215. }
  216. // TestHandleUpstreamError_503_NonModelRateLimit 测试 503 非模型限流场景(不处理)
  217. func TestHandleUpstreamError_503_NonModelRateLimit(t *testing.T) {
  218. repo := &stubAntigravityAccountRepo{}
  219. svc := &AntigravityGatewayService{accountRepo: repo}
  220. account := &Account{ID: 4, Name: "acc-4", Platform: PlatformAntigravity}
  221. // 503 + 普通错误(非 MODEL_CAPACITY_EXHAUSTED)→ 不做任何处理
  222. body := []byte(`{
  223. "error": {
  224. "status": "UNAVAILABLE",
  225. "message": "Service temporarily unavailable",
  226. "details": [
  227. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "SERVICE_UNAVAILABLE"}
  228. ]
  229. }
  230. }`)
  231. result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusServiceUnavailable, http.Header{}, body, "gemini-3-pro-high", 0, "", false)
  232. // 503 非模型限流不应该做任何处理
  233. require.Nil(t, result)
  234. require.Empty(t, repo.modelRateLimitCalls, "503 non-model rate limit should not trigger model rate limit")
  235. require.Empty(t, repo.rateCalls, "503 non-model rate limit should not trigger account rate limit")
  236. }
  237. // TestHandleUpstreamError_503_EmptyBody 测试 503 空响应体(不处理)
  238. func TestHandleUpstreamError_503_EmptyBody(t *testing.T) {
  239. repo := &stubAntigravityAccountRepo{}
  240. svc := &AntigravityGatewayService{accountRepo: repo}
  241. account := &Account{ID: 5, Name: "acc-5", Platform: PlatformAntigravity}
  242. // 503 + 空响应体 → 不做任何处理
  243. body := []byte(`{}`)
  244. result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusServiceUnavailable, http.Header{}, body, "gemini-3-pro-high", 0, "", false)
  245. // 503 空响应不应该做任何处理
  246. require.Nil(t, result)
  247. require.Empty(t, repo.modelRateLimitCalls)
  248. require.Empty(t, repo.rateCalls)
  249. }
  250. func TestAccountIsSchedulableForModel_AntigravityRateLimits(t *testing.T) {
  251. now := time.Now()
  252. future := now.Add(10 * time.Minute)
  253. account := &Account{
  254. ID: 1,
  255. Name: "acc",
  256. Platform: PlatformAntigravity,
  257. Status: StatusActive,
  258. Schedulable: true,
  259. }
  260. account.RateLimitResetAt = &future
  261. require.False(t, account.IsSchedulableForModel("claude-sonnet-4-5"))
  262. require.False(t, account.IsSchedulableForModel("gemini-3-flash"))
  263. account.RateLimitResetAt = nil
  264. require.True(t, account.IsSchedulableForModel("claude-sonnet-4-5"))
  265. require.True(t, account.IsSchedulableForModel("gemini-3-flash"))
  266. }
  267. func buildGeminiRateLimitBody(delay string) []byte {
  268. return []byte(fmt.Sprintf(`{"error":{"message":"too many requests","details":[{"metadata":{"quotaResetDelay":%q}}]}}`, delay))
  269. }
  270. func TestParseGeminiRateLimitResetTime_QuotaResetDelay_RoundsUp(t *testing.T) {
  271. // Avoid flakiness around Unix second boundaries.
  272. for {
  273. now := time.Now()
  274. if now.Nanosecond() < 800*1e6 {
  275. break
  276. }
  277. time.Sleep(5 * time.Millisecond)
  278. }
  279. baseUnix := time.Now().Unix()
  280. ts := ParseGeminiRateLimitResetTime(buildGeminiRateLimitBody("0.1s"))
  281. require.NotNil(t, ts)
  282. require.Equal(t, baseUnix+1, *ts, "fractional seconds should be rounded up to the next second")
  283. }
  284. func TestParseAntigravitySmartRetryInfo(t *testing.T) {
  285. tests := []struct {
  286. name string
  287. body string
  288. expectedDelay time.Duration
  289. expectedModel string
  290. expectedNil bool
  291. expectedIsModelCapacityExhausted bool
  292. }{
  293. {
  294. name: "valid complete response with RATE_LIMIT_EXCEEDED",
  295. body: `{
  296. "error": {
  297. "code": 429,
  298. "details": [
  299. {
  300. "@type": "type.googleapis.com/google.rpc.ErrorInfo",
  301. "domain": "cloudcode-pa.googleapis.com",
  302. "metadata": {
  303. "model": "claude-sonnet-4-5",
  304. "quotaResetDelay": "201.506475ms"
  305. },
  306. "reason": "RATE_LIMIT_EXCEEDED"
  307. },
  308. {
  309. "@type": "type.googleapis.com/google.rpc.RetryInfo",
  310. "retryDelay": "0.201506475s"
  311. }
  312. ],
  313. "message": "You have exhausted your capacity on this model.",
  314. "status": "RESOURCE_EXHAUSTED"
  315. }
  316. }`,
  317. expectedDelay: 201506475 * time.Nanosecond,
  318. expectedModel: "claude-sonnet-4-5",
  319. },
  320. {
  321. name: "429 RESOURCE_EXHAUSTED without RATE_LIMIT_EXCEEDED - should return nil",
  322. body: `{
  323. "error": {
  324. "code": 429,
  325. "status": "RESOURCE_EXHAUSTED",
  326. "details": [
  327. {
  328. "@type": "type.googleapis.com/google.rpc.ErrorInfo",
  329. "metadata": {"model": "claude-sonnet-4-5"},
  330. "reason": "QUOTA_EXCEEDED"
  331. },
  332. {
  333. "@type": "type.googleapis.com/google.rpc.RetryInfo",
  334. "retryDelay": "3s"
  335. }
  336. ]
  337. }
  338. }`,
  339. expectedNil: true,
  340. },
  341. {
  342. name: "503 UNAVAILABLE with MODEL_CAPACITY_EXHAUSTED - long delay",
  343. body: `{
  344. "error": {
  345. "code": 503,
  346. "status": "UNAVAILABLE",
  347. "details": [
  348. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
  349. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"}
  350. ],
  351. "message": "No capacity available for model gemini-3-pro-high on the server"
  352. }
  353. }`,
  354. expectedDelay: 39 * time.Second,
  355. expectedModel: "gemini-3-pro-high",
  356. expectedIsModelCapacityExhausted: true,
  357. },
  358. {
  359. name: "503 UNAVAILABLE without MODEL_CAPACITY_EXHAUSTED - should return nil",
  360. body: `{
  361. "error": {
  362. "code": 503,
  363. "status": "UNAVAILABLE",
  364. "details": [
  365. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-pro"}, "reason": "SERVICE_UNAVAILABLE"},
  366. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "5s"}
  367. ]
  368. }
  369. }`,
  370. expectedNil: true,
  371. },
  372. {
  373. name: "wrong status - should return nil",
  374. body: `{
  375. "error": {
  376. "code": 429,
  377. "status": "INVALID_ARGUMENT",
  378. "details": [
  379. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "3s"}
  380. ]
  381. }
  382. }`,
  383. expectedNil: true,
  384. },
  385. {
  386. name: "missing status - should return nil",
  387. body: `{
  388. "error": {
  389. "code": 429,
  390. "details": [
  391. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "3s"}
  392. ]
  393. }
  394. }`,
  395. expectedNil: true,
  396. },
  397. {
  398. name: "milliseconds format is now supported",
  399. body: `{
  400. "error": {
  401. "code": 429,
  402. "status": "RESOURCE_EXHAUSTED",
  403. "details": [
  404. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "test-model"}, "reason": "RATE_LIMIT_EXCEEDED"},
  405. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "500ms"}
  406. ]
  407. }
  408. }`,
  409. expectedDelay: 500 * time.Millisecond,
  410. expectedModel: "test-model",
  411. },
  412. {
  413. name: "minutes format is supported",
  414. body: `{
  415. "error": {
  416. "code": 429,
  417. "status": "RESOURCE_EXHAUSTED",
  418. "details": [
  419. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "RATE_LIMIT_EXCEEDED"},
  420. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "4m50s"}
  421. ]
  422. }
  423. }`,
  424. expectedDelay: 4*time.Minute + 50*time.Second,
  425. expectedModel: "gemini-3-pro",
  426. },
  427. {
  428. name: "missing model name - should return nil",
  429. body: `{
  430. "error": {
  431. "code": 429,
  432. "status": "RESOURCE_EXHAUSTED",
  433. "details": [
  434. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED"},
  435. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "3s"}
  436. ]
  437. }
  438. }`,
  439. expectedNil: true,
  440. },
  441. {
  442. name: "invalid JSON",
  443. body: `not json`,
  444. expectedNil: true,
  445. },
  446. }
  447. for _, tt := range tests {
  448. t.Run(tt.name, func(t *testing.T) {
  449. result := parseAntigravitySmartRetryInfo([]byte(tt.body))
  450. if tt.expectedNil {
  451. if result != nil {
  452. t.Errorf("expected nil, got %+v", result)
  453. }
  454. return
  455. }
  456. if result == nil {
  457. t.Errorf("expected non-nil result")
  458. return
  459. }
  460. if result.RetryDelay != tt.expectedDelay {
  461. t.Errorf("RetryDelay = %v, want %v", result.RetryDelay, tt.expectedDelay)
  462. }
  463. if result.ModelName != tt.expectedModel {
  464. t.Errorf("ModelName = %q, want %q", result.ModelName, tt.expectedModel)
  465. }
  466. if result.IsModelCapacityExhausted != tt.expectedIsModelCapacityExhausted {
  467. t.Errorf("IsModelCapacityExhausted = %v, want %v", result.IsModelCapacityExhausted, tt.expectedIsModelCapacityExhausted)
  468. }
  469. })
  470. }
  471. }
  472. func TestShouldTriggerAntigravitySmartRetry(t *testing.T) {
  473. oauthAccount := &Account{Type: AccountTypeOAuth, Platform: PlatformAntigravity}
  474. setupTokenAccount := &Account{Type: AccountTypeSetupToken, Platform: PlatformAntigravity}
  475. upstreamAccount := &Account{Type: AccountTypeUpstream, Platform: PlatformAntigravity}
  476. apiKeyAccount := &Account{Type: AccountTypeAPIKey}
  477. tests := []struct {
  478. name string
  479. account *Account
  480. body string
  481. expectedShouldRetry bool
  482. expectedShouldRateLimit bool
  483. expectedIsModelCapacityExhausted bool
  484. minWait time.Duration
  485. modelName string
  486. }{
  487. {
  488. name: "OAuth account with short delay (< 7s) - smart retry",
  489. account: oauthAccount,
  490. body: `{
  491. "error": {
  492. "status": "RESOURCE_EXHAUSTED",
  493. "details": [
  494. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-opus-4"}, "reason": "RATE_LIMIT_EXCEEDED"},
  495. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"}
  496. ]
  497. }
  498. }`,
  499. expectedShouldRetry: true,
  500. expectedShouldRateLimit: false,
  501. minWait: 1 * time.Second, // 0.5s < 1s, 使用最小等待时间 1s
  502. modelName: "claude-opus-4",
  503. },
  504. {
  505. name: "SetupToken account with short delay - smart retry",
  506. account: setupTokenAccount,
  507. body: `{
  508. "error": {
  509. "status": "RESOURCE_EXHAUSTED",
  510. "details": [
  511. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
  512. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "3s"}
  513. ]
  514. }
  515. }`,
  516. expectedShouldRetry: true,
  517. expectedShouldRateLimit: false,
  518. minWait: 3 * time.Second,
  519. modelName: "gemini-3-flash",
  520. },
  521. {
  522. name: "OAuth account with long delay (>= 7s) - direct rate limit",
  523. account: oauthAccount,
  524. body: `{
  525. "error": {
  526. "status": "RESOURCE_EXHAUSTED",
  527. "details": [
  528. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
  529. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
  530. ]
  531. }
  532. }`,
  533. expectedShouldRetry: false,
  534. expectedShouldRateLimit: true,
  535. modelName: "claude-sonnet-4-5",
  536. },
  537. {
  538. name: "Upstream account with short delay - smart retry",
  539. account: upstreamAccount,
  540. body: `{
  541. "error": {
  542. "status": "RESOURCE_EXHAUSTED",
  543. "details": [
  544. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
  545. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "2s"}
  546. ]
  547. }
  548. }`,
  549. expectedShouldRetry: true,
  550. expectedShouldRateLimit: false,
  551. minWait: 2 * time.Second,
  552. modelName: "claude-sonnet-4-5",
  553. },
  554. {
  555. name: "API Key account - should not trigger",
  556. account: apiKeyAccount,
  557. body: `{
  558. "error": {
  559. "status": "RESOURCE_EXHAUSTED",
  560. "details": [
  561. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "test"}, "reason": "RATE_LIMIT_EXCEEDED"},
  562. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"}
  563. ]
  564. }
  565. }`,
  566. expectedShouldRetry: false,
  567. expectedShouldRateLimit: false,
  568. },
  569. {
  570. name: "OAuth account with exactly 7s delay - direct rate limit",
  571. account: oauthAccount,
  572. body: `{
  573. "error": {
  574. "status": "RESOURCE_EXHAUSTED",
  575. "details": [
  576. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-pro"}, "reason": "RATE_LIMIT_EXCEEDED"},
  577. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "7s"}
  578. ]
  579. }
  580. }`,
  581. expectedShouldRetry: false,
  582. expectedShouldRateLimit: true,
  583. minWait: 7 * time.Second,
  584. modelName: "gemini-pro",
  585. },
  586. {
  587. name: "503 UNAVAILABLE with MODEL_CAPACITY_EXHAUSTED - long delay",
  588. account: oauthAccount,
  589. body: `{
  590. "error": {
  591. "code": 503,
  592. "status": "UNAVAILABLE",
  593. "details": [
  594. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
  595. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"}
  596. ]
  597. }
  598. }`,
  599. expectedShouldRetry: true,
  600. expectedShouldRateLimit: false,
  601. expectedIsModelCapacityExhausted: true,
  602. minWait: 1 * time.Second,
  603. modelName: "gemini-3-pro-high",
  604. },
  605. {
  606. name: "503 UNAVAILABLE with MODEL_CAPACITY_EXHAUSTED - no retryDelay - use fixed wait",
  607. account: oauthAccount,
  608. body: `{
  609. "error": {
  610. "code": 503,
  611. "status": "UNAVAILABLE",
  612. "details": [
  613. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-2.5-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}
  614. ],
  615. "message": "No capacity available for model gemini-2.5-flash on the server"
  616. }
  617. }`,
  618. expectedShouldRetry: true,
  619. expectedShouldRateLimit: false,
  620. expectedIsModelCapacityExhausted: true,
  621. minWait: 1 * time.Second,
  622. modelName: "gemini-2.5-flash",
  623. },
  624. {
  625. name: "429 RESOURCE_EXHAUSTED with RATE_LIMIT_EXCEEDED - no retryDelay - use default rate limit",
  626. account: oauthAccount,
  627. body: `{
  628. "error": {
  629. "code": 429,
  630. "status": "RESOURCE_EXHAUSTED",
  631. "details": [
  632. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"}
  633. ],
  634. "message": "You have exhausted your capacity on this model."
  635. }
  636. }`,
  637. expectedShouldRetry: false,
  638. expectedShouldRateLimit: true,
  639. minWait: 30 * time.Second,
  640. modelName: "claude-sonnet-4-5",
  641. },
  642. }
  643. for _, tt := range tests {
  644. t.Run(tt.name, func(t *testing.T) {
  645. shouldRetry, shouldRateLimit, wait, model, isModelCapacityExhausted := shouldTriggerAntigravitySmartRetry(tt.account, []byte(tt.body))
  646. if shouldRetry != tt.expectedShouldRetry {
  647. t.Errorf("shouldRetry = %v, want %v", shouldRetry, tt.expectedShouldRetry)
  648. }
  649. if shouldRateLimit != tt.expectedShouldRateLimit {
  650. t.Errorf("shouldRateLimit = %v, want %v", shouldRateLimit, tt.expectedShouldRateLimit)
  651. }
  652. if isModelCapacityExhausted != tt.expectedIsModelCapacityExhausted {
  653. t.Errorf("isModelCapacityExhausted = %v, want %v", isModelCapacityExhausted, tt.expectedIsModelCapacityExhausted)
  654. }
  655. if shouldRetry {
  656. if wait < tt.minWait {
  657. t.Errorf("wait = %v, want >= %v", wait, tt.minWait)
  658. }
  659. }
  660. if shouldRateLimit && tt.minWait > 0 {
  661. if wait < tt.minWait {
  662. t.Errorf("rate limit wait = %v, want >= %v", wait, tt.minWait)
  663. }
  664. }
  665. if (shouldRetry || shouldRateLimit) && model != tt.modelName {
  666. t.Errorf("modelName = %q, want %q", model, tt.modelName)
  667. }
  668. })
  669. }
  670. }
  671. // TestSetModelRateLimitByModelName_UsesOfficialModelID 验证写入端使用官方模型 ID
  672. func TestSetModelRateLimitByModelName_UsesOfficialModelID(t *testing.T) {
  673. tests := []struct {
  674. name string
  675. modelName string
  676. expectedModelKey string
  677. expectedSuccess bool
  678. }{
  679. {
  680. name: "claude-sonnet-4-5 should be stored as-is",
  681. modelName: "claude-sonnet-4-5",
  682. expectedModelKey: "claude-sonnet-4-5",
  683. expectedSuccess: true,
  684. },
  685. {
  686. name: "gemini-3-pro-high should be stored as-is",
  687. modelName: "gemini-3-pro-high",
  688. expectedModelKey: "gemini-3-pro-high",
  689. expectedSuccess: true,
  690. },
  691. {
  692. name: "gemini-3-flash should be stored as-is",
  693. modelName: "gemini-3-flash",
  694. expectedModelKey: "gemini-3-flash",
  695. expectedSuccess: true,
  696. },
  697. {
  698. name: "empty model name should fail",
  699. modelName: "",
  700. expectedModelKey: "",
  701. expectedSuccess: false,
  702. },
  703. }
  704. for _, tt := range tests {
  705. t.Run(tt.name, func(t *testing.T) {
  706. repo := &stubAntigravityAccountRepo{}
  707. resetAt := time.Now().Add(30 * time.Second)
  708. success := setModelRateLimitByModelName(
  709. context.Background(),
  710. repo,
  711. 123, // accountID
  712. tt.modelName,
  713. "[test]",
  714. 429,
  715. resetAt,
  716. false, // afterSmartRetry
  717. )
  718. require.Equal(t, tt.expectedSuccess, success)
  719. if tt.expectedSuccess {
  720. require.Len(t, repo.modelRateLimitCalls, 1)
  721. call := repo.modelRateLimitCalls[0]
  722. require.Equal(t, int64(123), call.accountID)
  723. // 关键断言:存储的 key 应该是官方模型 ID,而不是 scope
  724. require.Equal(t, tt.expectedModelKey, call.modelKey, "should store official model ID, not scope")
  725. require.WithinDuration(t, resetAt, call.resetAt, time.Second)
  726. } else {
  727. require.Empty(t, repo.modelRateLimitCalls)
  728. }
  729. })
  730. }
  731. }
  732. // TestSetModelRateLimitByModelName_NotConvertToScope 验证不会将模型名转换为 scope
  733. func TestSetModelRateLimitByModelName_NotConvertToScope(t *testing.T) {
  734. repo := &stubAntigravityAccountRepo{}
  735. resetAt := time.Now().Add(30 * time.Second)
  736. // 调用 setModelRateLimitByModelName,传入官方模型 ID
  737. success := setModelRateLimitByModelName(
  738. context.Background(),
  739. repo,
  740. 456,
  741. "claude-sonnet-4-5", // 官方模型 ID
  742. "[test]",
  743. 429,
  744. resetAt,
  745. true, // afterSmartRetry
  746. )
  747. require.True(t, success)
  748. require.Len(t, repo.modelRateLimitCalls, 1)
  749. call := repo.modelRateLimitCalls[0]
  750. // 关键断言:存储的应该是 "claude-sonnet-4-5",而不是 "claude_sonnet"
  751. require.Equal(t, "claude-sonnet-4-5", call.modelKey, "should NOT convert to scope like claude_sonnet")
  752. require.NotEqual(t, "claude_sonnet", call.modelKey, "should NOT be scope")
  753. }
  754. func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) {
  755. repo := &stubAntigravityAccountRepo{}
  756. svc := &AntigravityGatewayService{}
  757. account := &Account{ID: 789, Platform: PlatformAntigravity}
  758. resetAt := time.Now().Add(30 * time.Second)
  759. success := svc.setAntigravityModelRateLimits(
  760. context.Background(),
  761. repo,
  762. account,
  763. "gemini-3-pro",
  764. "[test]",
  765. 429,
  766. resetAt,
  767. false,
  768. )
  769. require.True(t, success)
  770. require.Len(t, repo.modelRateLimitCalls, 2)
  771. require.Equal(t, "gemini-3-pro", repo.modelRateLimitCalls[0].modelKey)
  772. require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey)
  773. }
  774. func TestSetAntigravityModelRateLimits_DoesNotDoubleMapCustomChain(t *testing.T) {
  775. repo := &stubAntigravityAccountRepo{}
  776. svc := &AntigravityGatewayService{}
  777. account := &Account{
  778. ID: 790,
  779. Platform: PlatformAntigravity,
  780. Credentials: map[string]any{
  781. "model_mapping": map[string]any{
  782. "custom-sonnet": "claude-sonnet-4-5",
  783. "claude-sonnet-4-5": "claude-sonnet-4-6",
  784. },
  785. },
  786. }
  787. resetAt := time.Now().Add(30 * time.Second)
  788. canonicalModel := resolveFinalAntigravityModelKey(context.Background(), account, "custom-sonnet")
  789. require.Equal(t, "claude-sonnet-4-5", canonicalModel)
  790. success := svc.setAntigravityModelRateLimits(
  791. context.Background(),
  792. repo,
  793. account,
  794. canonicalModel,
  795. "[test]",
  796. 429,
  797. resetAt,
  798. false,
  799. )
  800. require.True(t, success)
  801. require.Len(t, repo.modelRateLimitCalls, 1)
  802. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
  803. }
  804. func TestSetModelRateLimitAndClearSession_UsesUpstreamReportedModelMetadata(t *testing.T) {
  805. repo := &stubAntigravityAccountRepo{}
  806. svc := &AntigravityGatewayService{accountRepo: repo}
  807. account := &Account{
  808. ID: 791,
  809. Platform: PlatformAntigravity,
  810. Credentials: map[string]any{
  811. "model_mapping": map[string]any{
  812. "claude-sonnet-4-5": "claude-sonnet-4-6",
  813. },
  814. },
  815. }
  816. svc.setModelRateLimitAndClearSession(&handleModelRateLimitParams{
  817. ctx: context.Background(),
  818. prefix: "[test]",
  819. account: account,
  820. statusCode: 429,
  821. }, &antigravitySmartRetryInfo{
  822. RetryDelay: 30 * time.Second,
  823. ModelName: "claude-sonnet-4-5",
  824. })
  825. require.Len(t, repo.modelRateLimitCalls, 1)
  826. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
  827. }
  828. func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) {
  829. upstream := &recordingOKUpstream{}
  830. account := &Account{
  831. ID: 1,
  832. Name: "acc-1",
  833. Platform: PlatformAntigravity,
  834. Schedulable: true,
  835. Status: StatusActive,
  836. Concurrency: 1,
  837. Extra: map[string]any{
  838. modelRateLimitsKey: map[string]any{
  839. "claude-sonnet-4-5": map[string]any{
  840. "rate_limit_reset_at": time.Now().Add(2 * time.Second).Format(time.RFC3339),
  841. },
  842. },
  843. },
  844. }
  845. svc := &AntigravityGatewayService{}
  846. result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
  847. ctx: context.Background(),
  848. prefix: "[test]",
  849. account: account,
  850. accessToken: "token",
  851. action: "generateContent",
  852. body: []byte(`{"input":"test"}`),
  853. requestedModel: "claude-sonnet-4-5",
  854. httpUpstream: upstream,
  855. isStickySession: true,
  856. handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
  857. return nil
  858. },
  859. })
  860. require.Nil(t, result)
  861. var switchErr *AntigravityAccountSwitchError
  862. require.ErrorAs(t, err, &switchErr)
  863. require.Equal(t, account.ID, switchErr.OriginalAccountID)
  864. require.Equal(t, "claude-sonnet-4-5", switchErr.RateLimitedModel)
  865. require.True(t, switchErr.IsStickySession)
  866. require.Equal(t, 0, upstream.calls, "should not call upstream when switching on pre-check")
  867. }
  868. func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRemainingLong(t *testing.T) {
  869. upstream := &recordingOKUpstream{}
  870. account := &Account{
  871. ID: 2,
  872. Name: "acc-2",
  873. Platform: PlatformAntigravity,
  874. Schedulable: true,
  875. Status: StatusActive,
  876. Concurrency: 1,
  877. Extra: map[string]any{
  878. modelRateLimitsKey: map[string]any{
  879. "claude-sonnet-4-5": map[string]any{
  880. "rate_limit_reset_at": time.Now().Add(11 * time.Second).Format(time.RFC3339),
  881. },
  882. },
  883. },
  884. }
  885. svc := &AntigravityGatewayService{}
  886. result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
  887. ctx: context.Background(),
  888. prefix: "[test]",
  889. account: account,
  890. accessToken: "token",
  891. action: "generateContent",
  892. body: []byte(`{"input":"test"}`),
  893. requestedModel: "claude-sonnet-4-5",
  894. httpUpstream: upstream,
  895. isStickySession: true,
  896. handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
  897. return nil
  898. },
  899. })
  900. require.Nil(t, result)
  901. var switchErr *AntigravityAccountSwitchError
  902. require.ErrorAs(t, err, &switchErr)
  903. require.Equal(t, account.ID, switchErr.OriginalAccountID)
  904. require.Equal(t, "claude-sonnet-4-5", switchErr.RateLimitedModel)
  905. require.True(t, switchErr.IsStickySession)
  906. require.Equal(t, 0, upstream.calls, "should not call upstream when switching on pre-check")
  907. }
  908. func TestIsAntigravityAccountSwitchError(t *testing.T) {
  909. tests := []struct {
  910. name string
  911. err error
  912. expectedOK bool
  913. expectedID int64
  914. expectedModel string
  915. }{
  916. {
  917. name: "nil error",
  918. err: nil,
  919. expectedOK: false,
  920. },
  921. {
  922. name: "generic error",
  923. err: fmt.Errorf("some error"),
  924. expectedOK: false,
  925. },
  926. {
  927. name: "account switch error",
  928. err: &AntigravityAccountSwitchError{
  929. OriginalAccountID: 123,
  930. RateLimitedModel: "claude-sonnet-4-5",
  931. IsStickySession: true,
  932. },
  933. expectedOK: true,
  934. expectedID: 123,
  935. expectedModel: "claude-sonnet-4-5",
  936. },
  937. {
  938. name: "wrapped account switch error",
  939. err: fmt.Errorf("wrapped: %w", &AntigravityAccountSwitchError{
  940. OriginalAccountID: 456,
  941. RateLimitedModel: "gemini-3-flash",
  942. IsStickySession: false,
  943. }),
  944. expectedOK: true,
  945. expectedID: 456,
  946. expectedModel: "gemini-3-flash",
  947. },
  948. }
  949. for _, tt := range tests {
  950. t.Run(tt.name, func(t *testing.T) {
  951. switchErr, ok := IsAntigravityAccountSwitchError(tt.err)
  952. require.Equal(t, tt.expectedOK, ok)
  953. if tt.expectedOK {
  954. require.NotNil(t, switchErr)
  955. require.Equal(t, tt.expectedID, switchErr.OriginalAccountID)
  956. require.Equal(t, tt.expectedModel, switchErr.RateLimitedModel)
  957. } else {
  958. require.Nil(t, switchErr)
  959. }
  960. })
  961. }
  962. }
  963. func TestResolveAntigravityForwardBaseURL(t *testing.T) {
  964. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  965. defer func() {
  966. antigravity.BaseURLs = oldBaseURLs
  967. }()
  968. prodURL := "https://prod.test"
  969. dailyURL := "https://daily.test"
  970. antigravity.BaseURLs = []string{prodURL, dailyURL}
  971. tests := []struct {
  972. name string
  973. env string
  974. account *Account
  975. want string
  976. }{
  977. {
  978. name: "pro defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": " Pro "}},
  979. want: dailyURL,
  980. },
  981. {
  982. name: "ultra defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "ULTRA"}},
  983. want: dailyURL,
  984. },
  985. {name: "free defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free"}}, want: prodURL},
  986. {name: "abnormal defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "Abnormal"}}, want: prodURL},
  987. {name: "unknown defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "enterprise"}}, want: prodURL},
  988. {name: "malformed defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": map[string]any{"name": "pro"}}}, want: prodURL},
  989. {name: "missing defaults to prod", account: &Account{Credentials: map[string]any{}}, want: prodURL},
  990. {name: "nil account defaults to prod", account: nil, want: prodURL},
  991. {
  992. name: "daily override wins for free tier", env: " daily ",
  993. account: &Account{Credentials: map[string]any{"plan_type": "free"}},
  994. want: dailyURL,
  995. },
  996. {
  997. name: "prod override wins for paid tier", env: " PROD ",
  998. account: &Account{Credentials: map[string]any{"plan_type": "pro"}},
  999. want: prodURL,
  1000. },
  1001. }
  1002. for _, tt := range tests {
  1003. t.Run(tt.name, func(t *testing.T) {
  1004. t.Setenv(antigravityForwardBaseURLEnv, tt.env)
  1005. require.Equal(t, tt.want, resolveAntigravityForwardBaseURL(tt.account))
  1006. })
  1007. }
  1008. }
  1009. func TestAntigravityAccountSwitchError_Error(t *testing.T) {
  1010. err := &AntigravityAccountSwitchError{
  1011. OriginalAccountID: 789,
  1012. RateLimitedModel: "claude-opus-4-5",
  1013. IsStickySession: true,
  1014. }
  1015. msg := err.Error()
  1016. require.Contains(t, msg, "789")
  1017. require.Contains(t, msg, "claude-opus-4-5")
  1018. }
  1019. // stubSchedulerCache 用于测试的 SchedulerCache 实现
  1020. type stubSchedulerCache struct {
  1021. SchedulerCache
  1022. setAccountCalls []*Account
  1023. setAccountErr error
  1024. }
  1025. func (s *stubSchedulerCache) SetAccount(ctx context.Context, account *Account) error {
  1026. s.setAccountCalls = append(s.setAccountCalls, account)
  1027. return s.setAccountErr
  1028. }
  1029. // TestUpdateAccountModelRateLimitInCache_UpdatesExtraAndCallsCache 测试模型限流后更新缓存
  1030. func TestUpdateAccountModelRateLimitInCache_UpdatesExtraAndCallsCache(t *testing.T) {
  1031. cache := &stubSchedulerCache{}
  1032. snapshotService := &SchedulerSnapshotService{cache: cache}
  1033. svc := &AntigravityGatewayService{
  1034. schedulerSnapshot: snapshotService,
  1035. }
  1036. account := &Account{
  1037. ID: 100,
  1038. Name: "test-account",
  1039. Platform: PlatformAntigravity,
  1040. }
  1041. modelKey := "claude-sonnet-4-5"
  1042. resetAt := time.Now().Add(30 * time.Second)
  1043. svc.updateAccountModelRateLimitInCache(context.Background(), account, modelKey, resetAt)
  1044. // 验证 Extra 字段被正确更新
  1045. require.NotNil(t, account.Extra)
  1046. limits, ok := account.Extra["model_rate_limits"].(map[string]any)
  1047. require.True(t, ok)
  1048. modelLimit, ok := limits[modelKey].(map[string]any)
  1049. require.True(t, ok)
  1050. require.NotEmpty(t, modelLimit["rate_limited_at"])
  1051. require.NotEmpty(t, modelLimit["rate_limit_reset_at"])
  1052. // 验证 cache.SetAccount 被调用
  1053. require.Len(t, cache.setAccountCalls, 1)
  1054. require.Equal(t, account.ID, cache.setAccountCalls[0].ID)
  1055. }
  1056. // TestUpdateAccountModelRateLimitInCache_NilSchedulerSnapshot 测试 schedulerSnapshot 为 nil 时不 panic
  1057. func TestUpdateAccountModelRateLimitInCache_NilSchedulerSnapshot(t *testing.T) {
  1058. svc := &AntigravityGatewayService{
  1059. schedulerSnapshot: nil,
  1060. }
  1061. account := &Account{ID: 1, Name: "test"}
  1062. // 不应 panic
  1063. svc.updateAccountModelRateLimitInCache(context.Background(), account, "claude-sonnet-4-5", time.Now().Add(30*time.Second))
  1064. // Extra 不应被更新(因为函数提前返回)
  1065. require.Nil(t, account.Extra)
  1066. }
  1067. // TestUpdateAccountModelRateLimitInCache_PreservesExistingExtra 测试保留已有的 Extra 数据
  1068. func TestUpdateAccountModelRateLimitInCache_PreservesExistingExtra(t *testing.T) {
  1069. cache := &stubSchedulerCache{}
  1070. snapshotService := &SchedulerSnapshotService{cache: cache}
  1071. svc := &AntigravityGatewayService{
  1072. schedulerSnapshot: snapshotService,
  1073. }
  1074. account := &Account{
  1075. ID: 200,
  1076. Name: "test-account",
  1077. Platform: PlatformAntigravity,
  1078. Extra: map[string]any{
  1079. "existing_key": "existing_value",
  1080. "model_rate_limits": map[string]any{
  1081. "gemini-3-flash": map[string]any{
  1082. "rate_limited_at": "2024-01-01T00:00:00Z",
  1083. "rate_limit_reset_at": "2024-01-01T00:05:00Z",
  1084. },
  1085. },
  1086. },
  1087. }
  1088. svc.updateAccountModelRateLimitInCache(context.Background(), account, "claude-sonnet-4-5", time.Now().Add(30*time.Second))
  1089. // 验证已有数据被保留
  1090. require.Equal(t, "existing_value", account.Extra["existing_key"])
  1091. limits := account.Extra["model_rate_limits"].(map[string]any)
  1092. require.NotNil(t, limits["gemini-3-flash"])
  1093. require.NotNil(t, limits["claude-sonnet-4-5"])
  1094. }
  1095. // TestSchedulerSnapshotService_UpdateAccountInCache 测试 UpdateAccountInCache 方法
  1096. func TestSchedulerSnapshotService_UpdateAccountInCache(t *testing.T) {
  1097. t.Run("calls cache.SetAccount", func(t *testing.T) {
  1098. cache := &stubSchedulerCache{}
  1099. svc := &SchedulerSnapshotService{cache: cache}
  1100. account := &Account{ID: 123, Name: "test"}
  1101. err := svc.UpdateAccountInCache(context.Background(), account)
  1102. require.NoError(t, err)
  1103. require.Len(t, cache.setAccountCalls, 1)
  1104. require.Equal(t, int64(123), cache.setAccountCalls[0].ID)
  1105. })
  1106. t.Run("returns nil when cache is nil", func(t *testing.T) {
  1107. svc := &SchedulerSnapshotService{cache: nil}
  1108. err := svc.UpdateAccountInCache(context.Background(), &Account{ID: 1})
  1109. require.NoError(t, err)
  1110. })
  1111. t.Run("returns nil when account is nil", func(t *testing.T) {
  1112. cache := &stubSchedulerCache{}
  1113. svc := &SchedulerSnapshotService{cache: cache}
  1114. err := svc.UpdateAccountInCache(context.Background(), nil)
  1115. require.NoError(t, err)
  1116. require.Empty(t, cache.setAccountCalls)
  1117. })
  1118. t.Run("propagates cache error", func(t *testing.T) {
  1119. expectedErr := fmt.Errorf("cache error")
  1120. cache := &stubSchedulerCache{setAccountErr: expectedErr}
  1121. svc := &SchedulerSnapshotService{cache: cache}
  1122. err := svc.UpdateAccountInCache(context.Background(), &Account{ID: 1})
  1123. require.ErrorIs(t, err, expectedErr)
  1124. })
  1125. }
  1126. func TestNormalizeAntigravityModelName(t *testing.T) {
  1127. tests := []struct {
  1128. name string
  1129. model string
  1130. expected string
  1131. }{
  1132. {
  1133. name: "plain model name",
  1134. model: "gemini-1.5-pro",
  1135. expected: "gemini-1.5-pro",
  1136. },
  1137. {
  1138. name: "models/ prefix",
  1139. model: "models/gemini-1.5-pro",
  1140. expected: "gemini-1.5-pro",
  1141. },
  1142. {
  1143. name: "publishers/google/models/ prefix",
  1144. model: "publishers/google/models/gemini-1.5-pro",
  1145. expected: "gemini-1.5-pro",
  1146. },
  1147. {
  1148. name: "projects/.../publishers/google/models/ path",
  1149. model: "projects/my-proj/locations/us-central1/publishers/google/models/gemini-2.5-flash",
  1150. expected: "gemini-2.5-flash",
  1151. },
  1152. {
  1153. name: "publishers/anthropic/models/ prefix",
  1154. model: "publishers/anthropic/models/claude-sonnet-4-5",
  1155. expected: "claude-sonnet-4-5",
  1156. },
  1157. {
  1158. name: "projects/.../publishers/anthropic/models/ path",
  1159. model: "projects/my-proj/locations/global/publishers/anthropic/models/claude-sonnet-4-5",
  1160. expected: "claude-sonnet-4-5",
  1161. },
  1162. {
  1163. name: "mixed case and spaces",
  1164. model: " Models/Gemini-1.5-Pro ",
  1165. expected: "gemini-1.5-pro",
  1166. },
  1167. }
  1168. for _, tt := range tests {
  1169. t.Run(tt.name, func(t *testing.T) {
  1170. actual := normalizeAntigravityModelName(tt.model)
  1171. require.Equal(t, tt.expected, actual)
  1172. })
  1173. }
  1174. }