antigravity_quota_fetcher_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. "github.com/stretchr/testify/require"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  13. )
  14. // ---------------------------------------------------------------------------
  15. // normalizeTier
  16. // ---------------------------------------------------------------------------
  17. func TestNormalizeTier(t *testing.T) {
  18. tests := []struct {
  19. name string
  20. raw string
  21. expected string
  22. }{
  23. {name: "empty string", raw: "", expected: ""},
  24. {name: "free-tier", raw: "free-tier", expected: "FREE"},
  25. {name: "g1-pro-tier", raw: "g1-pro-tier", expected: "PRO"},
  26. {name: "g1-ultra-tier", raw: "g1-ultra-tier", expected: "ULTRA"},
  27. {name: "unknown-something", raw: "unknown-something", expected: "UNKNOWN"},
  28. {name: "Google AI Pro contains pro keyword", raw: "Google AI Pro", expected: "PRO"},
  29. {name: "case insensitive FREE", raw: "FREE-TIER", expected: "FREE"},
  30. {name: "case insensitive Ultra", raw: "Ultra Plan", expected: "ULTRA"},
  31. {name: "arbitrary unrecognized string", raw: "enterprise-custom", expected: "UNKNOWN"},
  32. }
  33. for _, tt := range tests {
  34. t.Run(tt.name, func(t *testing.T) {
  35. got := normalizeTier(tt.raw)
  36. require.Equal(t, tt.expected, got, "normalizeTier(%q)", tt.raw)
  37. })
  38. }
  39. }
  40. // ---------------------------------------------------------------------------
  41. // buildUsageInfo
  42. // ---------------------------------------------------------------------------
  43. func aqfBoolPtr(v bool) *bool { return &v }
  44. func aqfIntPtr(v int) *int { return &v }
  45. func TestBuildUsageInfo_BasicModels(t *testing.T) {
  46. fetcher := &AntigravityQuotaFetcher{}
  47. modelsResp := &antigravity.FetchAvailableModelsResponse{
  48. Models: map[string]antigravity.ModelInfo{
  49. "claude-sonnet-4-20250514": {
  50. QuotaInfo: &antigravity.ModelQuotaInfo{
  51. RemainingFraction: 0.75,
  52. ResetTime: "2026-03-08T12:00:00Z",
  53. },
  54. DisplayName: "Claude Sonnet 4",
  55. SupportsImages: aqfBoolPtr(true),
  56. SupportsThinking: aqfBoolPtr(false),
  57. ThinkingBudget: aqfIntPtr(0),
  58. Recommended: aqfBoolPtr(true),
  59. MaxTokens: aqfIntPtr(200000),
  60. MaxOutputTokens: aqfIntPtr(16384),
  61. SupportedMimeTypes: map[string]bool{
  62. "image/png": true,
  63. "image/jpeg": true,
  64. },
  65. },
  66. "gemini-2.5-pro": {
  67. QuotaInfo: &antigravity.ModelQuotaInfo{
  68. RemainingFraction: 0.50,
  69. ResetTime: "2026-03-08T15:00:00Z",
  70. },
  71. DisplayName: "Gemini 2.5 Pro",
  72. MaxTokens: aqfIntPtr(1000000),
  73. MaxOutputTokens: aqfIntPtr(65536),
  74. },
  75. },
  76. }
  77. info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", nil)
  78. // 基本字段
  79. require.NotNil(t, info.UpdatedAt, "UpdatedAt should be set")
  80. require.Equal(t, "PRO", info.SubscriptionTier)
  81. require.Equal(t, "g1-pro-tier", info.SubscriptionTierRaw)
  82. // AntigravityQuota
  83. require.Len(t, info.AntigravityQuota, 2)
  84. sonnetQuota := info.AntigravityQuota["claude-sonnet-4-20250514"]
  85. require.NotNil(t, sonnetQuota)
  86. require.Equal(t, 25, sonnetQuota.Utilization) // (1 - 0.75) * 100 = 25
  87. require.Equal(t, "2026-03-08T12:00:00Z", sonnetQuota.ResetTime)
  88. geminiQuota := info.AntigravityQuota["gemini-2.5-pro"]
  89. require.NotNil(t, geminiQuota)
  90. require.Equal(t, 50, geminiQuota.Utilization) // (1 - 0.50) * 100 = 50
  91. require.Equal(t, "2026-03-08T15:00:00Z", geminiQuota.ResetTime)
  92. // AntigravityQuotaDetails
  93. require.Len(t, info.AntigravityQuotaDetails, 2)
  94. sonnetDetail := info.AntigravityQuotaDetails["claude-sonnet-4-20250514"]
  95. require.NotNil(t, sonnetDetail)
  96. require.Equal(t, "Claude Sonnet 4", sonnetDetail.DisplayName)
  97. require.Equal(t, aqfBoolPtr(true), sonnetDetail.SupportsImages)
  98. require.Equal(t, aqfBoolPtr(false), sonnetDetail.SupportsThinking)
  99. require.Equal(t, aqfIntPtr(0), sonnetDetail.ThinkingBudget)
  100. require.Equal(t, aqfBoolPtr(true), sonnetDetail.Recommended)
  101. require.Equal(t, aqfIntPtr(200000), sonnetDetail.MaxTokens)
  102. require.Equal(t, aqfIntPtr(16384), sonnetDetail.MaxOutputTokens)
  103. require.Equal(t, map[string]bool{"image/png": true, "image/jpeg": true}, sonnetDetail.SupportedMimeTypes)
  104. geminiDetail := info.AntigravityQuotaDetails["gemini-2.5-pro"]
  105. require.NotNil(t, geminiDetail)
  106. require.Equal(t, "Gemini 2.5 Pro", geminiDetail.DisplayName)
  107. require.Nil(t, geminiDetail.SupportsImages)
  108. require.Nil(t, geminiDetail.SupportsThinking)
  109. require.Equal(t, aqfIntPtr(1000000), geminiDetail.MaxTokens)
  110. require.Equal(t, aqfIntPtr(65536), geminiDetail.MaxOutputTokens)
  111. }
  112. func TestBuildUsageInfo_DeprecatedModels(t *testing.T) {
  113. fetcher := &AntigravityQuotaFetcher{}
  114. modelsResp := &antigravity.FetchAvailableModelsResponse{
  115. Models: map[string]antigravity.ModelInfo{
  116. "claude-sonnet-4-20250514": {
  117. QuotaInfo: &antigravity.ModelQuotaInfo{
  118. RemainingFraction: 1.0,
  119. },
  120. },
  121. },
  122. DeprecatedModelIDs: map[string]antigravity.DeprecatedModelInfo{
  123. "claude-3-sonnet-20240229": {NewModelID: "claude-sonnet-4-20250514"},
  124. "claude-3-haiku-20240307": {NewModelID: "claude-haiku-3.5-latest"},
  125. },
  126. }
  127. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  128. require.Len(t, info.ModelForwardingRules, 2)
  129. require.Equal(t, "claude-sonnet-4-20250514", info.ModelForwardingRules["claude-3-sonnet-20240229"])
  130. require.Equal(t, "claude-haiku-3.5-latest", info.ModelForwardingRules["claude-3-haiku-20240307"])
  131. }
  132. func TestBuildUsageInfo_NoDeprecatedModels(t *testing.T) {
  133. fetcher := &AntigravityQuotaFetcher{}
  134. modelsResp := &antigravity.FetchAvailableModelsResponse{
  135. Models: map[string]antigravity.ModelInfo{
  136. "some-model": {
  137. QuotaInfo: &antigravity.ModelQuotaInfo{RemainingFraction: 0.9},
  138. },
  139. },
  140. }
  141. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  142. require.Nil(t, info.ModelForwardingRules, "ModelForwardingRules should be nil when no deprecated models")
  143. }
  144. func TestBuildUsageInfo_EmptyModels(t *testing.T) {
  145. fetcher := &AntigravityQuotaFetcher{}
  146. modelsResp := &antigravity.FetchAvailableModelsResponse{
  147. Models: map[string]antigravity.ModelInfo{},
  148. }
  149. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  150. require.NotNil(t, info)
  151. require.NotNil(t, info.AntigravityQuota)
  152. require.Empty(t, info.AntigravityQuota)
  153. require.NotNil(t, info.AntigravityQuotaDetails)
  154. require.Empty(t, info.AntigravityQuotaDetails)
  155. require.Nil(t, info.FiveHour, "FiveHour should be nil when no priority model exists")
  156. }
  157. func TestBuildUsageInfo_ModelWithNilQuotaInfo(t *testing.T) {
  158. fetcher := &AntigravityQuotaFetcher{}
  159. modelsResp := &antigravity.FetchAvailableModelsResponse{
  160. Models: map[string]antigravity.ModelInfo{
  161. "model-without-quota": {
  162. DisplayName: "No Quota Model",
  163. // QuotaInfo is nil
  164. },
  165. },
  166. }
  167. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  168. require.NotNil(t, info)
  169. require.Empty(t, info.AntigravityQuota, "models with nil QuotaInfo should be skipped")
  170. require.Empty(t, info.AntigravityQuotaDetails, "models with nil QuotaInfo should be skipped from details too")
  171. }
  172. func TestBuildUsageInfo_FiveHourPriorityOrder(t *testing.T) {
  173. fetcher := &AntigravityQuotaFetcher{}
  174. // priorityModels = ["claude-sonnet-4-20250514", "claude-sonnet-4", "gemini-2.5-pro"]
  175. // When the first priority model exists, it should be used for FiveHour
  176. modelsResp := &antigravity.FetchAvailableModelsResponse{
  177. Models: map[string]antigravity.ModelInfo{
  178. "gemini-2.5-pro": {
  179. QuotaInfo: &antigravity.ModelQuotaInfo{
  180. RemainingFraction: 0.40,
  181. ResetTime: "2026-03-08T18:00:00Z",
  182. },
  183. },
  184. "claude-sonnet-4-20250514": {
  185. QuotaInfo: &antigravity.ModelQuotaInfo{
  186. RemainingFraction: 0.80,
  187. ResetTime: "2026-03-08T12:00:00Z",
  188. },
  189. },
  190. },
  191. }
  192. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  193. require.NotNil(t, info.FiveHour, "FiveHour should be set when a priority model exists")
  194. // claude-sonnet-4-20250514 is first in priority list, so it should be used
  195. expectedUtilization := (1.0 - 0.80) * 100 // 20
  196. require.InDelta(t, expectedUtilization, info.FiveHour.Utilization, 0.01)
  197. require.NotNil(t, info.FiveHour.ResetsAt, "ResetsAt should be parsed from ResetTime")
  198. }
  199. func TestBuildUsageInfo_FiveHourFallbackToClaude4(t *testing.T) {
  200. fetcher := &AntigravityQuotaFetcher{}
  201. // Only claude-sonnet-4 exists (second in priority list), not claude-sonnet-4-20250514
  202. modelsResp := &antigravity.FetchAvailableModelsResponse{
  203. Models: map[string]antigravity.ModelInfo{
  204. "claude-sonnet-4": {
  205. QuotaInfo: &antigravity.ModelQuotaInfo{
  206. RemainingFraction: 0.60,
  207. ResetTime: "2026-03-08T14:00:00Z",
  208. },
  209. },
  210. "gemini-2.5-pro": {
  211. QuotaInfo: &antigravity.ModelQuotaInfo{
  212. RemainingFraction: 0.30,
  213. },
  214. },
  215. },
  216. }
  217. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  218. require.NotNil(t, info.FiveHour)
  219. expectedUtilization := (1.0 - 0.60) * 100 // 40
  220. require.InDelta(t, expectedUtilization, info.FiveHour.Utilization, 0.01)
  221. }
  222. func TestBuildUsageInfo_FiveHourFallbackToGemini(t *testing.T) {
  223. fetcher := &AntigravityQuotaFetcher{}
  224. // Only gemini-2.5-pro exists (third in priority list)
  225. modelsResp := &antigravity.FetchAvailableModelsResponse{
  226. Models: map[string]antigravity.ModelInfo{
  227. "gemini-2.5-pro": {
  228. QuotaInfo: &antigravity.ModelQuotaInfo{
  229. RemainingFraction: 0.30,
  230. },
  231. },
  232. "other-model": {
  233. QuotaInfo: &antigravity.ModelQuotaInfo{
  234. RemainingFraction: 0.90,
  235. },
  236. },
  237. },
  238. }
  239. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  240. require.NotNil(t, info.FiveHour)
  241. expectedUtilization := (1.0 - 0.30) * 100 // 70
  242. require.InDelta(t, expectedUtilization, info.FiveHour.Utilization, 0.01)
  243. }
  244. func TestBuildUsageInfo_FiveHourNoPriorityModel(t *testing.T) {
  245. fetcher := &AntigravityQuotaFetcher{}
  246. // None of the priority models exist
  247. modelsResp := &antigravity.FetchAvailableModelsResponse{
  248. Models: map[string]antigravity.ModelInfo{
  249. "some-other-model": {
  250. QuotaInfo: &antigravity.ModelQuotaInfo{
  251. RemainingFraction: 0.50,
  252. },
  253. },
  254. },
  255. }
  256. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  257. require.Nil(t, info.FiveHour, "FiveHour should be nil when no priority model exists")
  258. }
  259. func TestBuildUsageInfo_FiveHourWithEmptyResetTime(t *testing.T) {
  260. fetcher := &AntigravityQuotaFetcher{}
  261. modelsResp := &antigravity.FetchAvailableModelsResponse{
  262. Models: map[string]antigravity.ModelInfo{
  263. "claude-sonnet-4-20250514": {
  264. QuotaInfo: &antigravity.ModelQuotaInfo{
  265. RemainingFraction: 0.50,
  266. ResetTime: "", // empty reset time
  267. },
  268. },
  269. },
  270. }
  271. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  272. require.NotNil(t, info.FiveHour)
  273. require.Nil(t, info.FiveHour.ResetsAt, "ResetsAt should be nil when ResetTime is empty")
  274. require.Equal(t, 0, info.FiveHour.RemainingSeconds)
  275. }
  276. func TestBuildUsageInfo_FullUtilization(t *testing.T) {
  277. fetcher := &AntigravityQuotaFetcher{}
  278. modelsResp := &antigravity.FetchAvailableModelsResponse{
  279. Models: map[string]antigravity.ModelInfo{
  280. "claude-sonnet-4-20250514": {
  281. QuotaInfo: &antigravity.ModelQuotaInfo{
  282. RemainingFraction: 0.0, // fully used
  283. ResetTime: "2026-03-08T12:00:00Z",
  284. },
  285. },
  286. },
  287. }
  288. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  289. quota := info.AntigravityQuota["claude-sonnet-4-20250514"]
  290. require.NotNil(t, quota)
  291. require.Equal(t, 100, quota.Utilization)
  292. }
  293. func TestBuildUsageInfo_ZeroUtilization(t *testing.T) {
  294. fetcher := &AntigravityQuotaFetcher{}
  295. modelsResp := &antigravity.FetchAvailableModelsResponse{
  296. Models: map[string]antigravity.ModelInfo{
  297. "claude-sonnet-4-20250514": {
  298. QuotaInfo: &antigravity.ModelQuotaInfo{
  299. RemainingFraction: 1.0, // fully available
  300. },
  301. },
  302. },
  303. }
  304. info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
  305. quota := info.AntigravityQuota["claude-sonnet-4-20250514"]
  306. require.NotNil(t, quota)
  307. require.Equal(t, 0, quota.Utilization)
  308. }
  309. func TestBuildUsageInfo_AICredits(t *testing.T) {
  310. fetcher := &AntigravityQuotaFetcher{}
  311. modelsResp := &antigravity.FetchAvailableModelsResponse{
  312. Models: map[string]antigravity.ModelInfo{},
  313. }
  314. loadResp := &antigravity.LoadCodeAssistResponse{
  315. PaidTier: &antigravity.PaidTierInfo{
  316. ID: "g1-pro-tier",
  317. AvailableCredits: []antigravity.AvailableCredit{
  318. {
  319. CreditType: "GOOGLE_ONE_AI",
  320. CreditAmount: "25",
  321. MinimumCreditAmountForUsage: "5",
  322. },
  323. },
  324. },
  325. }
  326. info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", loadResp)
  327. require.Len(t, info.AICredits, 1)
  328. require.Equal(t, "GOOGLE_ONE_AI", info.AICredits[0].CreditType)
  329. require.Equal(t, 25.0, info.AICredits[0].Amount)
  330. require.Equal(t, 5.0, info.AICredits[0].MinimumBalance)
  331. }
  332. func TestFetchQuotaUsesConfiguredModelsListBodyLimit(t *testing.T) {
  333. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  334. w.Header().Set("Content-Type", "application/json")
  335. _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`))
  336. }))
  337. defer server.Close()
  338. oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
  339. oldAvailability := antigravity.DefaultURLAvailability
  340. t.Cleanup(func() {
  341. antigravity.BaseURLs = oldBaseURLs
  342. antigravity.DefaultURLAvailability = oldAvailability
  343. })
  344. antigravity.BaseURLs = []string{server.URL}
  345. antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
  346. cfg := &config.Config{}
  347. cfg.Gateway.ModelsListReadMaxBytes = 8
  348. fetcher := NewAntigravityQuotaFetcher(nil, cfg)
  349. _, err := fetcher.FetchQuota(context.Background(), &Account{
  350. Platform: PlatformAntigravity,
  351. Type: AccountTypeOAuth,
  352. Credentials: map[string]any{
  353. "access_token": "token",
  354. "project_id": "project",
  355. },
  356. }, "")
  357. require.ErrorContains(t, err, "响应超过 8 字节")
  358. }
  359. func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) {
  360. // 模拟 FetchQuota 遇到 403 时的行为:
  361. // FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true
  362. forbiddenErr := &antigravity.ForbiddenError{
  363. StatusCode: 403,
  364. Body: "Access denied",
  365. }
  366. // 验证 ForbiddenError 满足 errors.As
  367. var target *antigravity.ForbiddenError
  368. require.True(t, errors.As(forbiddenErr, &target))
  369. require.Equal(t, 403, target.StatusCode)
  370. require.Equal(t, "Access denied", target.Body)
  371. require.Contains(t, forbiddenErr.Error(), "403")
  372. }
  373. // ---------------------------------------------------------------------------
  374. // classifyForbiddenType
  375. // ---------------------------------------------------------------------------
  376. func TestClassifyForbiddenType(t *testing.T) {
  377. tests := []struct {
  378. name string
  379. body string
  380. expected string
  381. }{
  382. {
  383. name: "VALIDATION_REQUIRED keyword",
  384. body: `{"error":{"message":"VALIDATION_REQUIRED"}}`,
  385. expected: "validation",
  386. },
  387. {
  388. name: "verify your account",
  389. body: `Please verify your account to continue`,
  390. expected: "validation",
  391. },
  392. {
  393. name: "contains validation_url field",
  394. body: `{"error":{"details":[{"metadata":{"validation_url":"https://..."}}]}}`,
  395. expected: "validation",
  396. },
  397. {
  398. name: "terms of service violation",
  399. body: `Your account has been suspended for Terms of Service violation`,
  400. expected: "violation",
  401. },
  402. {
  403. name: "violation keyword",
  404. body: `Account suspended due to policy violation`,
  405. expected: "violation",
  406. },
  407. {
  408. name: "generic 403",
  409. body: `Access denied`,
  410. expected: "forbidden",
  411. },
  412. {
  413. name: "empty body",
  414. body: "",
  415. expected: "forbidden",
  416. },
  417. }
  418. for _, tt := range tests {
  419. t.Run(tt.name, func(t *testing.T) {
  420. got := classifyForbiddenType(tt.body)
  421. require.Equal(t, tt.expected, got)
  422. })
  423. }
  424. }
  425. // ---------------------------------------------------------------------------
  426. // extractValidationURL
  427. // ---------------------------------------------------------------------------
  428. func TestExtractValidationURL(t *testing.T) {
  429. tests := []struct {
  430. name string
  431. body string
  432. expected string
  433. }{
  434. {
  435. name: "structured validation_url",
  436. body: `{"error":{"details":[{"metadata":{"validation_url":"https://accounts.google.com/verify?token=abc"}}]}}`,
  437. expected: "https://accounts.google.com/verify?token=abc",
  438. },
  439. {
  440. name: "structured appeal_url",
  441. body: `{"error":{"details":[{"metadata":{"appeal_url":"https://support.google.com/appeal/123"}}]}}`,
  442. expected: "https://support.google.com/appeal/123",
  443. },
  444. {
  445. name: "validation_url takes priority over appeal_url",
  446. body: `{"error":{"details":[{"metadata":{"validation_url":"https://v.com","appeal_url":"https://a.com"}}]}}`,
  447. expected: "https://v.com",
  448. },
  449. {
  450. name: "fallback regex with verify keyword",
  451. body: `Please verify your account at https://accounts.google.com/verify`,
  452. expected: "https://accounts.google.com/verify",
  453. },
  454. {
  455. name: "no URL in generic forbidden",
  456. body: `Access denied`,
  457. expected: "",
  458. },
  459. {
  460. name: "empty body",
  461. body: "",
  462. expected: "",
  463. },
  464. {
  465. name: "URL present but no validation keywords",
  466. body: `Error at https://example.com/something`,
  467. expected: "",
  468. },
  469. {
  470. name: "unicode escaped ampersand",
  471. body: `validation required: https://accounts.google.com/verify?a=1\u0026b=2`,
  472. expected: "https://accounts.google.com/verify?a=1&b=2",
  473. },
  474. }
  475. for _, tt := range tests {
  476. t.Run(tt.name, func(t *testing.T) {
  477. got := extractValidationURL(tt.body)
  478. require.Equal(t, tt.expected, got)
  479. })
  480. }
  481. }