model_rate_limit_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. package service
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestIsModelRateLimited(t *testing.T) {
  10. now := time.Now()
  11. future := now.Add(10 * time.Minute).Format(time.RFC3339)
  12. past := now.Add(-10 * time.Minute).Format(time.RFC3339)
  13. tests := []struct {
  14. name string
  15. account *Account
  16. requestedModel string
  17. expected bool
  18. }{
  19. {
  20. name: "official model ID hit - claude-sonnet-4-5",
  21. account: &Account{
  22. Extra: map[string]any{
  23. modelRateLimitsKey: map[string]any{
  24. "claude-sonnet-4-5": map[string]any{
  25. "rate_limit_reset_at": future,
  26. },
  27. },
  28. },
  29. },
  30. requestedModel: "claude-sonnet-4-5",
  31. expected: true,
  32. },
  33. {
  34. name: "official model ID hit via mapping - request claude-3-5-sonnet, mapped to claude-sonnet-4-5",
  35. account: &Account{
  36. Credentials: map[string]any{
  37. "model_mapping": map[string]any{
  38. "claude-3-5-sonnet": "claude-sonnet-4-5",
  39. },
  40. },
  41. Extra: map[string]any{
  42. modelRateLimitsKey: map[string]any{
  43. "claude-sonnet-4-5": map[string]any{
  44. "rate_limit_reset_at": future,
  45. },
  46. },
  47. },
  48. },
  49. requestedModel: "claude-3-5-sonnet",
  50. expected: true,
  51. },
  52. {
  53. name: "no rate limit - expired",
  54. account: &Account{
  55. Extra: map[string]any{
  56. modelRateLimitsKey: map[string]any{
  57. "claude-sonnet-4-5": map[string]any{
  58. "rate_limit_reset_at": past,
  59. },
  60. },
  61. },
  62. },
  63. requestedModel: "claude-sonnet-4-5",
  64. expected: false,
  65. },
  66. {
  67. name: "no rate limit - no matching key",
  68. account: &Account{
  69. Extra: map[string]any{
  70. modelRateLimitsKey: map[string]any{
  71. "gemini-3-flash": map[string]any{
  72. "rate_limit_reset_at": future,
  73. },
  74. },
  75. },
  76. },
  77. requestedModel: "claude-sonnet-4-5",
  78. expected: false,
  79. },
  80. {
  81. name: "no rate limit - unsupported model",
  82. account: &Account{},
  83. requestedModel: "gpt-4",
  84. expected: false,
  85. },
  86. {
  87. name: "no rate limit - empty model",
  88. account: &Account{},
  89. requestedModel: "",
  90. expected: false,
  91. },
  92. {
  93. name: "gemini model hit",
  94. account: &Account{
  95. Extra: map[string]any{
  96. modelRateLimitsKey: map[string]any{
  97. "gemini-3-pro-high": map[string]any{
  98. "rate_limit_reset_at": future,
  99. },
  100. },
  101. },
  102. },
  103. requestedModel: "gemini-3-pro-high",
  104. expected: true,
  105. },
  106. {
  107. name: "antigravity platform - gemini-3-pro-preview mapped to gemini-3-pro-high",
  108. account: &Account{
  109. Platform: PlatformAntigravity,
  110. Extra: map[string]any{
  111. modelRateLimitsKey: map[string]any{
  112. "gemini-3-pro-high": map[string]any{
  113. "rate_limit_reset_at": future,
  114. },
  115. },
  116. },
  117. },
  118. requestedModel: "gemini-3-pro-preview",
  119. expected: true,
  120. },
  121. {
  122. name: "antigravity platform - gemini family rate limit blocks mapped preview",
  123. account: &Account{
  124. Platform: PlatformAntigravity,
  125. Extra: map[string]any{
  126. modelRateLimitsKey: map[string]any{
  127. antigravityGeminiModelRateLimitKey: map[string]any{
  128. "rate_limit_reset_at": future,
  129. },
  130. },
  131. },
  132. },
  133. requestedModel: "gemini-3-pro-preview",
  134. expected: true,
  135. },
  136. {
  137. name: "antigravity platform - gemini family rate limit does not block claude",
  138. account: &Account{
  139. Platform: PlatformAntigravity,
  140. Extra: map[string]any{
  141. modelRateLimitsKey: map[string]any{
  142. antigravityGeminiModelRateLimitKey: map[string]any{
  143. "rate_limit_reset_at": future,
  144. },
  145. },
  146. },
  147. },
  148. requestedModel: "claude-sonnet-4-5",
  149. expected: false,
  150. },
  151. {
  152. name: "non-antigravity platform - gemini-3-pro-preview NOT mapped",
  153. account: &Account{
  154. Platform: PlatformGemini,
  155. Extra: map[string]any{
  156. modelRateLimitsKey: map[string]any{
  157. "gemini-3-pro-high": map[string]any{
  158. "rate_limit_reset_at": future,
  159. },
  160. },
  161. },
  162. },
  163. requestedModel: "gemini-3-pro-preview",
  164. expected: false, // gemini 平台不走 antigravity 映射
  165. },
  166. {
  167. name: "antigravity platform - claude-opus-4-5-thinking mapped to opus-4-6-thinking",
  168. account: &Account{
  169. Platform: PlatformAntigravity,
  170. Extra: map[string]any{
  171. modelRateLimitsKey: map[string]any{
  172. "claude-opus-4-6-thinking": map[string]any{
  173. "rate_limit_reset_at": future,
  174. },
  175. },
  176. },
  177. },
  178. requestedModel: "claude-opus-4-5-thinking",
  179. expected: true,
  180. },
  181. {
  182. name: "no scope fallback - claude_sonnet should not match",
  183. account: &Account{
  184. Extra: map[string]any{
  185. modelRateLimitsKey: map[string]any{
  186. "claude_sonnet": map[string]any{
  187. "rate_limit_reset_at": future,
  188. },
  189. },
  190. },
  191. },
  192. requestedModel: "claude-3-5-sonnet-20241022",
  193. expected: false,
  194. },
  195. {
  196. name: "openai image generation family key blocks image model",
  197. account: &Account{
  198. Platform: PlatformOpenAI,
  199. Extra: map[string]any{
  200. modelRateLimitsKey: map[string]any{
  201. openAIImageGenerationRateLimitKey: map[string]any{
  202. "rate_limit_reset_at": future,
  203. },
  204. },
  205. },
  206. },
  207. requestedModel: "gpt-image-2",
  208. expected: true,
  209. },
  210. {
  211. name: "openai image generation family key does not block text model",
  212. account: &Account{
  213. Platform: PlatformOpenAI,
  214. Extra: map[string]any{
  215. modelRateLimitsKey: map[string]any{
  216. openAIImageGenerationRateLimitKey: map[string]any{
  217. "rate_limit_reset_at": future,
  218. },
  219. },
  220. },
  221. },
  222. requestedModel: "gpt-5.4",
  223. expected: false,
  224. },
  225. }
  226. for _, tt := range tests {
  227. t.Run(tt.name, func(t *testing.T) {
  228. result := tt.account.isModelRateLimitedWithContext(context.Background(), tt.requestedModel)
  229. if result != tt.expected {
  230. t.Errorf("isModelRateLimited(%q) = %v, want %v", tt.requestedModel, result, tt.expected)
  231. }
  232. })
  233. }
  234. }
  235. func TestIsModelRateLimited_OpenAIImageGenerationIntentBlocksTextModelImageTool(t *testing.T) {
  236. future := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
  237. account := &Account{
  238. Platform: PlatformOpenAI,
  239. Extra: map[string]any{
  240. modelRateLimitsKey: map[string]any{
  241. openAIImageGenerationRateLimitKey: map[string]any{
  242. "rate_limit_reset_at": future,
  243. },
  244. },
  245. },
  246. }
  247. require.False(t, account.isModelRateLimitedWithContext(context.Background(), "gpt-5.4"))
  248. require.True(t, account.isModelRateLimitedWithContext(WithOpenAIImageGenerationIntent(context.Background()), "gpt-5.4"))
  249. }
  250. func TestIsModelRateLimited_Antigravity_ThinkingAffectsModelKey(t *testing.T) {
  251. now := time.Now()
  252. future := now.Add(10 * time.Minute).Format(time.RFC3339)
  253. account := &Account{
  254. Platform: PlatformAntigravity,
  255. Extra: map[string]any{
  256. modelRateLimitsKey: map[string]any{
  257. "claude-sonnet-4-5-thinking": map[string]any{
  258. "rate_limit_reset_at": future,
  259. },
  260. },
  261. },
  262. }
  263. ctx := context.WithValue(context.Background(), ctxkey.ThinkingEnabled, true)
  264. if !account.isModelRateLimitedWithContext(ctx, "claude-sonnet-4-5") {
  265. t.Errorf("expected model to be rate limited")
  266. }
  267. }
  268. func TestGetModelRateLimitRemainingTime(t *testing.T) {
  269. now := time.Now()
  270. future10m := now.Add(10 * time.Minute).Format(time.RFC3339)
  271. future5m := now.Add(5 * time.Minute).Format(time.RFC3339)
  272. past := now.Add(-10 * time.Minute).Format(time.RFC3339)
  273. tests := []struct {
  274. name string
  275. account *Account
  276. requestedModel string
  277. minExpected time.Duration
  278. maxExpected time.Duration
  279. }{
  280. {
  281. name: "nil account",
  282. account: nil,
  283. requestedModel: "claude-sonnet-4-5",
  284. minExpected: 0,
  285. maxExpected: 0,
  286. },
  287. {
  288. name: "model rate limited - direct hit",
  289. account: &Account{
  290. Extra: map[string]any{
  291. modelRateLimitsKey: map[string]any{
  292. "claude-sonnet-4-5": map[string]any{
  293. "rate_limit_reset_at": future10m,
  294. },
  295. },
  296. },
  297. },
  298. requestedModel: "claude-sonnet-4-5",
  299. minExpected: 9 * time.Minute,
  300. maxExpected: 11 * time.Minute,
  301. },
  302. {
  303. name: "model rate limited - via mapping",
  304. account: &Account{
  305. Credentials: map[string]any{
  306. "model_mapping": map[string]any{
  307. "claude-3-5-sonnet": "claude-sonnet-4-5",
  308. },
  309. },
  310. Extra: map[string]any{
  311. modelRateLimitsKey: map[string]any{
  312. "claude-sonnet-4-5": map[string]any{
  313. "rate_limit_reset_at": future5m,
  314. },
  315. },
  316. },
  317. },
  318. requestedModel: "claude-3-5-sonnet",
  319. minExpected: 4 * time.Minute,
  320. maxExpected: 6 * time.Minute,
  321. },
  322. {
  323. name: "expired rate limit",
  324. account: &Account{
  325. Extra: map[string]any{
  326. modelRateLimitsKey: map[string]any{
  327. "claude-sonnet-4-5": map[string]any{
  328. "rate_limit_reset_at": past,
  329. },
  330. },
  331. },
  332. },
  333. requestedModel: "claude-sonnet-4-5",
  334. minExpected: 0,
  335. maxExpected: 0,
  336. },
  337. {
  338. name: "no rate limit data",
  339. account: &Account{},
  340. requestedModel: "claude-sonnet-4-5",
  341. minExpected: 0,
  342. maxExpected: 0,
  343. },
  344. {
  345. name: "no scope fallback",
  346. account: &Account{
  347. Extra: map[string]any{
  348. modelRateLimitsKey: map[string]any{
  349. "claude_sonnet": map[string]any{
  350. "rate_limit_reset_at": future5m,
  351. },
  352. },
  353. },
  354. },
  355. requestedModel: "claude-3-5-sonnet-20241022",
  356. minExpected: 0,
  357. maxExpected: 0,
  358. },
  359. {
  360. name: "antigravity platform - claude-opus-4-5-thinking mapped to opus-4-6-thinking",
  361. account: &Account{
  362. Platform: PlatformAntigravity,
  363. Extra: map[string]any{
  364. modelRateLimitsKey: map[string]any{
  365. "claude-opus-4-6-thinking": map[string]any{
  366. "rate_limit_reset_at": future5m,
  367. },
  368. },
  369. },
  370. },
  371. requestedModel: "claude-opus-4-5-thinking",
  372. minExpected: 4 * time.Minute,
  373. maxExpected: 6 * time.Minute,
  374. },
  375. {
  376. name: "antigravity platform - gemini family rate limit remaining",
  377. account: &Account{
  378. Platform: PlatformAntigravity,
  379. Extra: map[string]any{
  380. modelRateLimitsKey: map[string]any{
  381. antigravityGeminiModelRateLimitKey: map[string]any{
  382. "rate_limit_reset_at": future10m,
  383. },
  384. },
  385. },
  386. },
  387. requestedModel: "gemini-3-pro-preview",
  388. minExpected: 9 * time.Minute,
  389. maxExpected: 11 * time.Minute,
  390. },
  391. {
  392. name: "antigravity platform - gemini family remaining ignored for claude",
  393. account: &Account{
  394. Platform: PlatformAntigravity,
  395. Extra: map[string]any{
  396. modelRateLimitsKey: map[string]any{
  397. antigravityGeminiModelRateLimitKey: map[string]any{
  398. "rate_limit_reset_at": future10m,
  399. },
  400. },
  401. },
  402. },
  403. requestedModel: "claude-sonnet-4-5",
  404. minExpected: 0,
  405. maxExpected: 0,
  406. },
  407. }
  408. for _, tt := range tests {
  409. t.Run(tt.name, func(t *testing.T) {
  410. result := tt.account.GetModelRateLimitRemainingTimeWithContext(context.Background(), tt.requestedModel)
  411. if result < tt.minExpected || result > tt.maxExpected {
  412. t.Errorf("GetModelRateLimitRemainingTime() = %v, want between %v and %v", result, tt.minExpected, tt.maxExpected)
  413. }
  414. })
  415. }
  416. }
  417. func TestGetRateLimitRemainingTime(t *testing.T) {
  418. now := time.Now()
  419. future15m := now.Add(15 * time.Minute).Format(time.RFC3339)
  420. future5m := now.Add(5 * time.Minute).Format(time.RFC3339)
  421. tests := []struct {
  422. name string
  423. account *Account
  424. requestedModel string
  425. minExpected time.Duration
  426. maxExpected time.Duration
  427. }{
  428. {
  429. name: "nil account",
  430. account: nil,
  431. requestedModel: "claude-sonnet-4-5",
  432. minExpected: 0,
  433. maxExpected: 0,
  434. },
  435. {
  436. name: "model rate limited - 15 minutes",
  437. account: &Account{
  438. Platform: PlatformAntigravity,
  439. Extra: map[string]any{
  440. modelRateLimitsKey: map[string]any{
  441. "claude-sonnet-4-5": map[string]any{
  442. "rate_limit_reset_at": future15m,
  443. },
  444. },
  445. },
  446. },
  447. requestedModel: "claude-sonnet-4-5",
  448. minExpected: 14 * time.Minute,
  449. maxExpected: 16 * time.Minute,
  450. },
  451. {
  452. name: "only model rate limited",
  453. account: &Account{
  454. Platform: PlatformAntigravity,
  455. Extra: map[string]any{
  456. modelRateLimitsKey: map[string]any{
  457. "claude-sonnet-4-5": map[string]any{
  458. "rate_limit_reset_at": future5m,
  459. },
  460. },
  461. },
  462. },
  463. requestedModel: "claude-sonnet-4-5",
  464. minExpected: 4 * time.Minute,
  465. maxExpected: 6 * time.Minute,
  466. },
  467. {
  468. name: "neither rate limited",
  469. account: &Account{
  470. Platform: PlatformAntigravity,
  471. },
  472. requestedModel: "claude-sonnet-4-5",
  473. minExpected: 0,
  474. maxExpected: 0,
  475. },
  476. }
  477. for _, tt := range tests {
  478. t.Run(tt.name, func(t *testing.T) {
  479. result := tt.account.GetRateLimitRemainingTimeWithContext(context.Background(), tt.requestedModel)
  480. if result < tt.minExpected || result > tt.maxExpected {
  481. t.Errorf("GetRateLimitRemainingTime() = %v, want between %v and %v", result, tt.minExpected, tt.maxExpected)
  482. }
  483. })
  484. }
  485. }
  486. func TestIsModelRateLimited_AnthropicFableFamilyKey(t *testing.T) {
  487. now := time.Now()
  488. future := now.Add(48 * time.Hour).Format(time.RFC3339)
  489. account := &Account{
  490. Platform: PlatformAnthropic,
  491. Extra: map[string]any{
  492. modelRateLimitsKey: map[string]any{
  493. anthropicFableRateLimitKey: map[string]any{
  494. "rate_limit_reset_at": future,
  495. },
  496. },
  497. },
  498. }
  499. tests := []struct {
  500. requestedModel string
  501. expected bool
  502. }{
  503. {"claude-fable-5", true},
  504. {"claude-fable-5[1m]", true}, // 家族 key 覆盖变体
  505. {"Claude-Fable-5-20260601", true}, // 大小写不敏感
  506. {"claude-sonnet-4-6", false}, // 其他模型不受影响
  507. {"claude-opus-4-8", false},
  508. }
  509. for _, tc := range tests {
  510. t.Run(tc.requestedModel, func(t *testing.T) {
  511. got := account.isModelRateLimitedWithContext(context.Background(), tc.requestedModel)
  512. require.Equal(t, tc.expected, got)
  513. remaining := account.GetModelRateLimitRemainingTimeWithContext(context.Background(), tc.requestedModel)
  514. require.Equal(t, tc.expected, remaining > 0)
  515. })
  516. }
  517. }
  518. func TestIsAnthropicFableModel(t *testing.T) {
  519. require.True(t, isAnthropicFableModel("claude-fable-5"))
  520. require.True(t, isAnthropicFableModel("claude-fable-5[1m]"))
  521. require.True(t, isAnthropicFableModel("Claude-Fable-5"))
  522. require.False(t, isAnthropicFableModel("claude-sonnet-4-6"))
  523. require.False(t, isAnthropicFableModel(""))
  524. }