antigravity_credits_overages_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestClassifyAntigravity429(t *testing.T) {
  15. t.Run("明确配额耗尽", func(t *testing.T) {
  16. body := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)
  17. require.Equal(t, antigravity429QuotaExhausted, classifyAntigravity429(body))
  18. })
  19. t.Run("结构化限流", func(t *testing.T) {
  20. body := []byte(`{
  21. "error": {
  22. "status": "RESOURCE_EXHAUSTED",
  23. "details": [
  24. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
  25. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"}
  26. ]
  27. }
  28. }`)
  29. require.Equal(t, antigravity429RateLimited, classifyAntigravity429(body))
  30. })
  31. t.Run("未知429", func(t *testing.T) {
  32. body := []byte(`{"error":{"message":"too many requests"}}`)
  33. require.Equal(t, antigravity429Unknown, classifyAntigravity429(body))
  34. })
  35. }
  36. func TestIsCreditsExhausted_UsesAICreditsKey(t *testing.T) {
  37. t.Run("无 AICredits key 则积分可用", func(t *testing.T) {
  38. account := &Account{
  39. ID: 1,
  40. Platform: PlatformAntigravity,
  41. Extra: map[string]any{
  42. "allow_overages": true,
  43. },
  44. }
  45. require.False(t, account.isCreditsExhausted())
  46. })
  47. t.Run("AICredits key 生效则积分耗尽", func(t *testing.T) {
  48. account := &Account{
  49. ID: 2,
  50. Platform: PlatformAntigravity,
  51. Extra: map[string]any{
  52. "allow_overages": true,
  53. modelRateLimitsKey: map[string]any{
  54. creditsExhaustedKey: map[string]any{
  55. "rate_limited_at": time.Now().UTC().Format(time.RFC3339),
  56. "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
  57. },
  58. },
  59. },
  60. }
  61. require.True(t, account.isCreditsExhausted())
  62. })
  63. t.Run("AICredits key 过期则积分可用", func(t *testing.T) {
  64. account := &Account{
  65. ID: 3,
  66. Platform: PlatformAntigravity,
  67. Extra: map[string]any{
  68. "allow_overages": true,
  69. modelRateLimitsKey: map[string]any{
  70. creditsExhaustedKey: map[string]any{
  71. "rate_limited_at": time.Now().Add(-6 * time.Hour).UTC().Format(time.RFC3339),
  72. "rate_limit_reset_at": time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339),
  73. },
  74. },
  75. },
  76. }
  77. require.False(t, account.isCreditsExhausted())
  78. })
  79. }
  80. func TestHandleSmartRetry_QuotaExhausted_UsesCreditsAndStoresIndependentState(t *testing.T) {
  81. successResp := &http.Response{
  82. StatusCode: http.StatusOK,
  83. Header: http.Header{},
  84. Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
  85. }
  86. upstream := &mockSmartRetryUpstream{
  87. responses: []*http.Response{successResp},
  88. errors: []error{nil},
  89. }
  90. repo := &stubAntigravityAccountRepo{}
  91. account := &Account{
  92. ID: 101,
  93. Name: "acc-101",
  94. Type: AccountTypeOAuth,
  95. Platform: PlatformAntigravity,
  96. Extra: map[string]any{
  97. "allow_overages": true,
  98. },
  99. Credentials: map[string]any{
  100. "model_mapping": map[string]any{
  101. "claude-opus-4-6": "claude-sonnet-4-5",
  102. },
  103. },
  104. }
  105. respBody := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)
  106. resp := &http.Response{
  107. StatusCode: http.StatusTooManyRequests,
  108. Header: http.Header{},
  109. Body: io.NopCloser(bytes.NewReader(respBody)),
  110. }
  111. params := antigravityRetryLoopParams{
  112. ctx: context.Background(),
  113. prefix: "[test]",
  114. account: account,
  115. accessToken: "token",
  116. action: "generateContent",
  117. body: []byte(`{"model":"claude-opus-4-6","request":{}}`),
  118. httpUpstream: upstream,
  119. accountRepo: repo,
  120. requestedModel: "claude-opus-4-6",
  121. 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 {
  122. return nil
  123. },
  124. }
  125. svc := &AntigravityGatewayService{}
  126. result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, []string{"https://ag-1.test"})
  127. require.NotNil(t, result)
  128. require.Equal(t, smartRetryActionBreakWithResp, result.action)
  129. require.NotNil(t, result.resp)
  130. require.Nil(t, result.switchError)
  131. require.Len(t, upstream.requestBodies, 1)
  132. require.Contains(t, string(upstream.requestBodies[0]), "enabledCreditTypes")
  133. require.Empty(t, repo.modelRateLimitCalls, "overages 成功后不应写入普通 model_rate_limits")
  134. }
  135. func TestHandleSmartRetry_RateLimited_DoesNotUseCredits(t *testing.T) {
  136. successResp := &http.Response{
  137. StatusCode: http.StatusOK,
  138. Header: http.Header{},
  139. Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
  140. }
  141. upstream := &mockSmartRetryUpstream{
  142. responses: []*http.Response{successResp},
  143. errors: []error{nil},
  144. }
  145. repo := &stubAntigravityAccountRepo{}
  146. account := &Account{
  147. ID: 102,
  148. Name: "acc-102",
  149. Type: AccountTypeOAuth,
  150. Platform: PlatformAntigravity,
  151. Extra: map[string]any{
  152. "allow_overages": true,
  153. },
  154. }
  155. respBody := []byte(`{
  156. "error": {
  157. "status": "RESOURCE_EXHAUSTED",
  158. "details": [
  159. {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
  160. {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
  161. ]
  162. }
  163. }`)
  164. resp := &http.Response{
  165. StatusCode: http.StatusTooManyRequests,
  166. Header: http.Header{},
  167. Body: io.NopCloser(bytes.NewReader(respBody)),
  168. }
  169. params := antigravityRetryLoopParams{
  170. ctx: context.Background(),
  171. prefix: "[test]",
  172. account: account,
  173. accessToken: "token",
  174. action: "generateContent",
  175. body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
  176. httpUpstream: upstream,
  177. accountRepo: repo,
  178. 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 {
  179. return nil
  180. },
  181. }
  182. svc := &AntigravityGatewayService{}
  183. result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, []string{"https://ag-1.test"})
  184. require.NotNil(t, result)
  185. require.Equal(t, smartRetryActionBreakWithResp, result.action)
  186. require.NotNil(t, result.resp)
  187. require.Len(t, upstream.requestBodies, 1)
  188. require.NotContains(t, string(upstream.requestBodies[0]), "enabledCreditTypes")
  189. require.Empty(t, repo.extraUpdateCalls)
  190. require.Empty(t, repo.modelRateLimitCalls)
  191. }
  192. func TestAntigravityRetryLoop_ModelRateLimited_InjectsCredits(t *testing.T) {
  193. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  194. oldAvailability := antigravity.DefaultURLAvailability
  195. defer func() {
  196. antigravity.BaseURLs = oldBaseURLs
  197. antigravity.DefaultURLAvailability = oldAvailability
  198. }()
  199. antigravity.BaseURLs = []string{"https://ag-1.test"}
  200. antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
  201. upstream := &queuedHTTPUpstreamStub{
  202. responses: []*http.Response{
  203. {
  204. StatusCode: http.StatusOK,
  205. Header: http.Header{},
  206. Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
  207. },
  208. },
  209. errors: []error{nil},
  210. }
  211. // 模型已限流 + overages 启用 + 无 AICredits key → 应直接注入积分
  212. account := &Account{
  213. ID: 103,
  214. Name: "acc-103",
  215. Type: AccountTypeOAuth,
  216. Platform: PlatformAntigravity,
  217. Status: StatusActive,
  218. Schedulable: true,
  219. Extra: map[string]any{
  220. "allow_overages": true,
  221. modelRateLimitsKey: map[string]any{
  222. "claude-sonnet-4-5": map[string]any{
  223. "rate_limited_at": time.Now().UTC().Format(time.RFC3339),
  224. "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339),
  225. },
  226. },
  227. },
  228. }
  229. svc := &AntigravityGatewayService{}
  230. result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
  231. ctx: context.Background(),
  232. prefix: "[test]",
  233. account: account,
  234. accessToken: "token",
  235. action: "generateContent",
  236. body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
  237. httpUpstream: upstream,
  238. requestedModel: "claude-sonnet-4-5",
  239. 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 {
  240. return nil
  241. },
  242. })
  243. require.NoError(t, err)
  244. require.NotNil(t, result)
  245. require.Len(t, upstream.requestBodies, 1)
  246. require.Contains(t, string(upstream.requestBodies[0]), "enabledCreditTypes")
  247. }
  248. func TestAntigravityRetryLoop_CreditsExhausted_DoesNotInject(t *testing.T) {
  249. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  250. oldAvailability := antigravity.DefaultURLAvailability
  251. defer func() {
  252. antigravity.BaseURLs = oldBaseURLs
  253. antigravity.DefaultURLAvailability = oldAvailability
  254. }()
  255. antigravity.BaseURLs = []string{"https://ag-1.test"}
  256. antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
  257. // 模型限流 + overages 启用 + AICredits key 生效 → 不应注入积分,应切号
  258. account := &Account{
  259. ID: 104,
  260. Name: "acc-104",
  261. Type: AccountTypeOAuth,
  262. Platform: PlatformAntigravity,
  263. Status: StatusActive,
  264. Schedulable: true,
  265. Extra: map[string]any{
  266. "allow_overages": true,
  267. modelRateLimitsKey: map[string]any{
  268. "claude-sonnet-4-5": map[string]any{
  269. "rate_limited_at": time.Now().UTC().Format(time.RFC3339),
  270. "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339),
  271. },
  272. creditsExhaustedKey: map[string]any{
  273. "rate_limited_at": time.Now().UTC().Format(time.RFC3339),
  274. "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
  275. },
  276. },
  277. },
  278. }
  279. svc := &AntigravityGatewayService{}
  280. _, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
  281. ctx: context.Background(),
  282. prefix: "[test]",
  283. account: account,
  284. accessToken: "token",
  285. action: "generateContent",
  286. body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
  287. requestedModel: "claude-sonnet-4-5",
  288. 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 {
  289. return nil
  290. },
  291. })
  292. // 模型限流 + 积分耗尽 → 应触发切号错误
  293. require.Error(t, err)
  294. var switchErr *AntigravityAccountSwitchError
  295. require.ErrorAs(t, err, &switchErr)
  296. }
  297. func TestAntigravityRetryLoop_CreditErrorMarksExhausted(t *testing.T) {
  298. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  299. oldAvailability := antigravity.DefaultURLAvailability
  300. defer func() {
  301. antigravity.BaseURLs = oldBaseURLs
  302. antigravity.DefaultURLAvailability = oldAvailability
  303. }()
  304. antigravity.BaseURLs = []string{"https://ag-1.test"}
  305. antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
  306. repo := &stubAntigravityAccountRepo{}
  307. upstream := &queuedHTTPUpstreamStub{
  308. responses: []*http.Response{
  309. {
  310. StatusCode: http.StatusForbidden,
  311. Header: http.Header{},
  312. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`)),
  313. },
  314. },
  315. errors: []error{nil},
  316. }
  317. // 模型限流 + overages 启用 + 积分可用 → 注入积分但上游返回积分不足
  318. account := &Account{
  319. ID: 105,
  320. Name: "acc-105",
  321. Type: AccountTypeOAuth,
  322. Platform: PlatformAntigravity,
  323. Status: StatusActive,
  324. Schedulable: true,
  325. Extra: map[string]any{
  326. "allow_overages": true,
  327. modelRateLimitsKey: map[string]any{
  328. "claude-sonnet-4-5": map[string]any{
  329. "rate_limited_at": time.Now().UTC().Format(time.RFC3339),
  330. "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339),
  331. },
  332. },
  333. },
  334. }
  335. svc := &AntigravityGatewayService{accountRepo: repo}
  336. result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
  337. ctx: context.Background(),
  338. prefix: "[test]",
  339. account: account,
  340. accessToken: "token",
  341. action: "generateContent",
  342. body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
  343. httpUpstream: upstream,
  344. accountRepo: repo,
  345. requestedModel: "claude-sonnet-4-5",
  346. 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 {
  347. return nil
  348. },
  349. })
  350. require.NoError(t, err)
  351. require.NotNil(t, result)
  352. // 验证 AICredits key 已通过 SetModelRateLimit 写入数据库
  353. require.Len(t, repo.modelRateLimitCalls, 1, "应通过 SetModelRateLimit 写入 AICredits key")
  354. require.Equal(t, creditsExhaustedKey, repo.modelRateLimitCalls[0].modelKey)
  355. }
  356. func TestShouldMarkCreditsExhausted(t *testing.T) {
  357. t.Run("reqErr 不为 nil 时不标记", func(t *testing.T) {
  358. resp := &http.Response{StatusCode: http.StatusForbidden}
  359. require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), io.ErrUnexpectedEOF))
  360. })
  361. t.Run("resp 为 nil 时不标记", func(t *testing.T) {
  362. require.False(t, shouldMarkCreditsExhausted(nil, []byte(`{"error":"Insufficient credits"}`), nil))
  363. })
  364. t.Run("5xx 响应不标记", func(t *testing.T) {
  365. resp := &http.Response{StatusCode: http.StatusInternalServerError}
  366. require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), nil))
  367. })
  368. t.Run("408 RequestTimeout 不标记", func(t *testing.T) {
  369. resp := &http.Response{StatusCode: http.StatusRequestTimeout}
  370. require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), nil))
  371. })
  372. t.Run("Resource has been exhausted 应标记为积分耗尽", func(t *testing.T) {
  373. resp := &http.Response{StatusCode: http.StatusTooManyRequests}
  374. body := []byte(`{"error":{"message":"Resource has been exhausted"}}`)
  375. require.True(t, shouldMarkCreditsExhausted(resp, body, nil))
  376. })
  377. t.Run("Resource has been exhausted (check quota) 完整格式应标记", func(t *testing.T) {
  378. resp := &http.Response{StatusCode: http.StatusTooManyRequests}
  379. body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`)
  380. require.True(t, shouldMarkCreditsExhausted(resp, body, nil))
  381. })
  382. t.Run("结构化限流不标记", func(t *testing.T) {
  383. resp := &http.Response{StatusCode: http.StatusTooManyRequests}
  384. body := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"RATE_LIMIT_EXCEEDED"},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"0.5s"}]}}`)
  385. require.False(t, shouldMarkCreditsExhausted(resp, body, nil))
  386. })
  387. t.Run("含 credits 关键词时标记", func(t *testing.T) {
  388. resp := &http.Response{StatusCode: http.StatusForbidden}
  389. for _, keyword := range []string{
  390. "Insufficient GOOGLE_ONE_AI credits",
  391. "insufficient credit balance",
  392. "not enough credits for this request",
  393. "Credits exhausted",
  394. "minimumCreditAmountForUsage requirement not met",
  395. } {
  396. body := []byte(`{"error":{"message":"` + keyword + `"}}`)
  397. require.True(t, shouldMarkCreditsExhausted(resp, body, nil), "should mark for keyword: %s", keyword)
  398. }
  399. })
  400. t.Run("无 credits 关键词时不标记", func(t *testing.T) {
  401. resp := &http.Response{StatusCode: http.StatusForbidden}
  402. body := []byte(`{"error":{"message":"permission denied"}}`)
  403. require.False(t, shouldMarkCreditsExhausted(resp, body, nil))
  404. })
  405. }
  406. func TestInjectEnabledCreditTypes(t *testing.T) {
  407. t.Run("正常 JSON 注入成功", func(t *testing.T) {
  408. body := []byte(`{"model":"claude-sonnet-4-5","request":{}}`)
  409. result := injectEnabledCreditTypes(body)
  410. require.NotNil(t, result)
  411. require.Contains(t, string(result), `"enabledCreditTypes"`)
  412. require.Contains(t, string(result), `GOOGLE_ONE_AI`)
  413. })
  414. t.Run("非法 JSON 返回 nil", func(t *testing.T) {
  415. require.Nil(t, injectEnabledCreditTypes([]byte(`not json`)))
  416. })
  417. t.Run("空 body 返回 nil", func(t *testing.T) {
  418. require.Nil(t, injectEnabledCreditTypes([]byte{}))
  419. })
  420. t.Run("已有 enabledCreditTypes 会被覆盖", func(t *testing.T) {
  421. body := []byte(`{"enabledCreditTypes":["OLD"],"model":"test"}`)
  422. result := injectEnabledCreditTypes(body)
  423. require.NotNil(t, result)
  424. require.Contains(t, string(result), `GOOGLE_ONE_AI`)
  425. require.NotContains(t, string(result), `OLD`)
  426. })
  427. }
  428. func TestClearCreditsExhausted(t *testing.T) {
  429. t.Run("account 为 nil 不操作", func(t *testing.T) {
  430. repo := &stubAntigravityAccountRepo{}
  431. svc := &AntigravityGatewayService{accountRepo: repo}
  432. svc.clearCreditsExhausted(context.Background(), nil)
  433. require.Empty(t, repo.extraUpdateCalls)
  434. })
  435. t.Run("Extra 为 nil 不操作", func(t *testing.T) {
  436. repo := &stubAntigravityAccountRepo{}
  437. svc := &AntigravityGatewayService{accountRepo: repo}
  438. svc.clearCreditsExhausted(context.Background(), &Account{ID: 1})
  439. require.Empty(t, repo.extraUpdateCalls)
  440. })
  441. t.Run("无 modelRateLimitsKey 不操作", func(t *testing.T) {
  442. repo := &stubAntigravityAccountRepo{}
  443. svc := &AntigravityGatewayService{accountRepo: repo}
  444. svc.clearCreditsExhausted(context.Background(), &Account{
  445. ID: 1,
  446. Extra: map[string]any{"some_key": "value"},
  447. })
  448. require.Empty(t, repo.extraUpdateCalls)
  449. })
  450. t.Run("无 AICredits key 不操作", func(t *testing.T) {
  451. repo := &stubAntigravityAccountRepo{}
  452. svc := &AntigravityGatewayService{accountRepo: repo}
  453. svc.clearCreditsExhausted(context.Background(), &Account{
  454. ID: 1,
  455. Extra: map[string]any{
  456. modelRateLimitsKey: map[string]any{
  457. "claude-sonnet-4-5": map[string]any{
  458. "rate_limited_at": "2026-03-15T00:00:00Z",
  459. "rate_limit_reset_at": "2099-03-15T00:00:00Z",
  460. },
  461. },
  462. },
  463. })
  464. require.Empty(t, repo.extraUpdateCalls)
  465. })
  466. t.Run("有 AICredits key 时删除并调用 UpdateExtra", func(t *testing.T) {
  467. repo := &stubAntigravityAccountRepo{}
  468. svc := &AntigravityGatewayService{accountRepo: repo}
  469. account := &Account{
  470. ID: 1,
  471. Extra: map[string]any{
  472. modelRateLimitsKey: map[string]any{
  473. "claude-sonnet-4-5": map[string]any{
  474. "rate_limited_at": "2026-03-15T00:00:00Z",
  475. "rate_limit_reset_at": "2099-03-15T00:00:00Z",
  476. },
  477. creditsExhaustedKey: map[string]any{
  478. "rate_limited_at": "2026-03-15T00:00:00Z",
  479. "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
  480. },
  481. },
  482. },
  483. }
  484. svc.clearCreditsExhausted(context.Background(), account)
  485. require.Len(t, repo.extraUpdateCalls, 1)
  486. // AICredits key 应被删除
  487. rawLimits := account.Extra[modelRateLimitsKey].(map[string]any)
  488. _, exists := rawLimits[creditsExhaustedKey]
  489. require.False(t, exists, "AICredits key 应被删除")
  490. // 普通模型限流应保留
  491. _, exists = rawLimits["claude-sonnet-4-5"]
  492. require.True(t, exists, "普通模型限流应保留")
  493. })
  494. }