openai_gateway_grok_search_billing_test.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "sync"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. )
  17. func TestForwardGrokResponses_PropagatesSearchCountFromJSON(t *testing.T) {
  18. gin.SetMode(gin.TestMode)
  19. body := []byte(`{"model":"grok","input":"search something","tools":[{"type":"web_search"}],"stream":false}`)
  20. recorder := httptest.NewRecorder()
  21. c, _ := gin.CreateTestContext(recorder)
  22. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  23. account := healthyGrokOAuthGatewayTestAccount(9901, "access-token")
  24. repo := &mockAccountRepoForPlatform{accountsByID: map[int64]*Account{account.ID: account}}
  25. upstreamBody := `{
  26. "id":"resp_search_bill",
  27. "object":"response",
  28. "model":"grok-4.5",
  29. "status":"completed",
  30. "output":[
  31. {"type":"web_search_call","id":"ws1","call_id":"c1","status":"completed"},
  32. {"type":"x_search_call","id":"xs1","call_id":"c2"},
  33. {"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}
  34. ],
  35. "usage":{"input_tokens":10,"output_tokens":5}
  36. }`
  37. upstream := &httpUpstreamRecorder{resp: &http.Response{
  38. StatusCode: http.StatusOK,
  39. Header: http.Header{"Content-Type": []string{"application/json"}},
  40. Body: io.NopCloser(bytes.NewReader([]byte(upstreamBody))),
  41. }}
  42. svc := &OpenAIGatewayService{
  43. httpUpstream: upstream,
  44. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  45. accountRepo: repo,
  46. }
  47. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
  48. require.NoError(t, err)
  49. require.NotNil(t, result)
  50. require.Equal(t, 2, result.SearchCount, "Grok Responses must surface search tool calls for surcharge billing")
  51. require.Equal(t, 10, result.Usage.InputTokens)
  52. require.Equal(t, 5, result.Usage.OutputTokens)
  53. }
  54. func TestForwardGrokResponses_PropagatesSearchCountFromSSE(t *testing.T) {
  55. gin.SetMode(gin.TestMode)
  56. body := []byte(`{"model":"grok","input":"search","tools":[{"type":"web_search"}],"stream":true}`)
  57. recorder := httptest.NewRecorder()
  58. c, _ := gin.CreateTestContext(recorder)
  59. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  60. account := healthyGrokOAuthGatewayTestAccount(9902, "access-token")
  61. repo := &mockAccountRepoForPlatform{accountsByID: map[int64]*Account{account.ID: account}}
  62. // item.done + response.completed for same call_id must count once after wire-up.
  63. sse := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"web_search_call\",\"id\":\"ws1\",\"call_id\":\"c1\"}}\n\n" +
  64. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws1\",\"call_id\":\"c1\"}],\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\n"
  65. upstream := &httpUpstreamRecorder{resp: &http.Response{
  66. StatusCode: http.StatusOK,
  67. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  68. Body: io.NopCloser(bytes.NewReader([]byte(sse))),
  69. }}
  70. svc := &OpenAIGatewayService{
  71. httpUpstream: upstream,
  72. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  73. accountRepo: repo,
  74. }
  75. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", true, time.Now())
  76. require.NoError(t, err)
  77. require.NotNil(t, result)
  78. require.Equal(t, 1, result.SearchCount, "stream SearchCount must be wired and deduped")
  79. }
  80. func TestGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) {
  81. // Sticky/non-list path must not return over-gate free OAuth accounts once cache is warm.
  82. // First sticky hit fail-opens and schedules async refresh; subsequent hits use the cache.
  83. cfg := &config.Config{}
  84. cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true
  85. cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000
  86. cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95
  87. cfg.Gateway.Grok.FreeQuotaWindowHours = 24
  88. cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 60
  89. account := healthyGrokOAuthGatewayTestAccount(8801, "tok")
  90. account.Credentials["subscription_tier"] = "free"
  91. account.Status = StatusActive
  92. account.Schedulable = true
  93. repo := &mockAccountRepoForPlatform{
  94. accountsByID: map[int64]*Account{account.ID: account},
  95. }
  96. usageRepo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  97. account.ID: {Tokens: 480_000}, // above 95% of 500k
  98. }}
  99. // Clear shared gateway free-gate cache so this test is deterministic.
  100. gatewayGrokFreeQuotaGateCache.Range(func(key, _ any) bool {
  101. gatewayGrokFreeQuotaGateCache.Delete(key)
  102. return true
  103. })
  104. if root, ok := freeQuotaRefreshInFlight.Load(&gatewayGrokFreeQuotaGateCache); ok {
  105. if m, ok := root.(*sync.Map); ok {
  106. m.Delete(account.ID)
  107. }
  108. }
  109. svc := &GatewayService{
  110. cfg: cfg,
  111. accountRepo: repo,
  112. usageLogRepo: usageRepo,
  113. }
  114. // Miss: fail open + schedule refresh.
  115. got, err := svc.getSchedulableAccount(context.Background(), account.ID)
  116. require.NoError(t, err)
  117. require.NotNil(t, got, "first sticky hit fail-opens while free-gate stats refresh")
  118. require.Eventually(t, func() bool {
  119. got, err := svc.getSchedulableAccount(context.Background(), account.ID)
  120. return err == nil && got == nil
  121. }, 2*time.Second, 10*time.Millisecond, "over free soft-gate sticky hit must miss after cache warm")
  122. }
  123. func TestOpenAIGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) {
  124. // Legacy OpenAI-compatible sticky (advanced scheduler off) must free-gate Grok.
  125. cfg := &config.Config{}
  126. cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true
  127. cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000
  128. cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95
  129. cfg.Gateway.Grok.FreeQuotaWindowHours = 24
  130. cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 60
  131. account := healthyGrokOAuthGatewayTestAccount(8802, "tok")
  132. account.Credentials["subscription_tier"] = "free"
  133. account.Status = StatusActive
  134. account.Schedulable = true
  135. repo := &mockAccountRepoForPlatform{
  136. accountsByID: map[int64]*Account{account.ID: account},
  137. }
  138. usageRepo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  139. account.ID: {Tokens: 480_000},
  140. }}
  141. openaiGrokFreeQuotaGateCache.Range(func(key, _ any) bool {
  142. openaiGrokFreeQuotaGateCache.Delete(key)
  143. return true
  144. })
  145. if root, ok := freeQuotaRefreshInFlight.Load(&openaiGrokFreeQuotaGateCache); ok {
  146. if m, ok := root.(*sync.Map); ok {
  147. m.Delete(account.ID)
  148. }
  149. }
  150. svc := &OpenAIGatewayService{
  151. cfg: cfg,
  152. accountRepo: repo,
  153. usageLogRepo: usageRepo,
  154. }
  155. got, err := svc.getSchedulableAccount(context.Background(), account.ID)
  156. require.NoError(t, err)
  157. require.NotNil(t, got, "first sticky hit fail-opens while free-gate stats refresh")
  158. require.Eventually(t, func() bool {
  159. got, err := svc.getSchedulableAccount(context.Background(), account.ID)
  160. return err == nil && got == nil
  161. }, 2*time.Second, 10*time.Millisecond, "OpenAI legacy sticky must apply free soft-gate after cache warm")
  162. }
  163. func TestCountGrokNativeSearchCallsFromJSON_MessagesStyleBody(t *testing.T) {
  164. // Proves the same counter used by Anthropic-buffered Grok /v1/messages path.
  165. body := []byte(`{"id":"r1","output":[{"type":"web_search_call","id":"ws1"},{"type":"message","role":"assistant"}]}`)
  166. require.Equal(t, 1, countGrokNativeSearchCallsFromJSONBytes(body))
  167. }