Merge pull request #4585 from superman2003/fix/issues-4561-4562-4566-4582

fix(grok): improve token counting, quota probes, and proxy checks
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-07-20 11:37:02 +08:00
commit dabca7ca79
22 files changed
+508 -83

No files matched your search

@@ -13,6 +13,48 @@ import (
"go.uber.org/zap"
)
// GrokCountTokens handles Anthropic-compatible count_tokens requests locally.
// The route middleware already authenticates the API key and resolves the
// group; this handler intentionally does not select an account or check billing.
func (h *OpenAIGatewayHandler) GrokCountTokens(c *gin.Context) {
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
if len(body) == 0 {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
bodyRef := service.NewRequestBodyRef(body)
parsedReq, err := service.ParseGatewayRequest(bodyRef, domain.PlatformAnthropic)
if err != nil {
logRequestBodyParseFailure(requestLogger(c, "handler.openai_gateway.grok_count_tokens"), body, err)
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
if parsedReq.Model == "" {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
estimated, err := service.EstimateGrokCountTokens(parsedReq.Body.Bytes())
if err != nil {
requestLogger(c, "handler.openai_gateway.grok_count_tokens").Warn("grok_count_tokens.local_estimate_failed", zap.Error(err))
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
setOpsRequestContext(c, parsedReq.Model, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
c.JSON(http.StatusOK, gin.H{"input_tokens": estimated})
}
// CountTokens handles Anthropic-compatible POST /v1/messages/count_tokens for OpenAI groups.
// It validates billing and routes to an OpenAI token-count bridge without taking concurrency slots
// or recording usage.
+12
View File
@@ -7,6 +7,18 @@ import (
"time"
)
const GrokFreeRolling24hTokenLimit int64 = 1_000_000
var grokFreeRolling24hTokenLimits = map[int64]struct{}{
GrokFreeRolling24hTokenLimit: {},
2_000_000: {}, // Legacy Free limit observed before July 2026.
}
func IsGrokFreeRolling24hTokenLimit(limit int64) bool {
_, ok := grokFreeRolling24hTokenLimits[limit]
return ok
}
type QuotaWindow struct {
Limit *int64 `json:"limit,omitempty"`
Remaining *int64 `json:"remaining,omitempty"`
+8
View File
@@ -61,3 +61,11 @@ func TestObserveQuotaHeadersRecordsNoHeaderProbe(t *testing.T) {
require.Nil(t, snapshot.Requests)
require.Nil(t, snapshot.Tokens)
}
func TestIsGrokFreeRolling24hTokenLimit(t *testing.T) {
t.Parallel()
require.True(t, IsGrokFreeRolling24hTokenLimit(GrokFreeRolling24hTokenLimit))
require.True(t, IsGrokFreeRolling24hTokenLimit(2_000_000), "legacy snapshots remain classifiable")
require.False(t, IsGrokFreeRolling24hTokenLimit(3_000_000))
}
+14 -20
View File
@@ -43,6 +43,16 @@ func RegisterGatewayRoutes(
isOpenAIGatewayPlatform := func(c *gin.Context) bool {
return getGroupPlatform(c) == service.PlatformOpenAI
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
default:
h.Gateway.CountTokens(c)
}
}
modelsHandler := func(c *gin.Context) {
if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" {
h.OpenAIGateway.CodexModels(c)
@@ -139,26 +149,9 @@ func RegisterGatewayRoutes(
}
h.Gateway.Messages(c)
})
// /v1/messages/count_tokens: OpenAI uses Anthropic-compat bridge; other
// OpenAI-compatible platforms keep the prior unsupported response.
gateway.POST("/messages/count_tokens", func(c *gin.Context) {
if isOpenAIGatewayPlatform(c) {
h.OpenAIGateway.CountTokens(c)
return
}
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"type": "error",
"error": gin.H{
"type": "not_found_error",
"message": "Token counting is not supported for this platform",
},
})
return
}
h.Gateway.CountTokens(c)
})
// /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates
// locally, and Anthropic-compatible platforms retain their existing path.
gateway.POST("/messages/count_tokens", countTokensHandler)
// Codex CLI / Codex app refresh their model picker from the provider's
// /models endpoint with a client_version query and expect the ChatGPT
// Codex manifest format; other clients keep the OpenAI-style list.
@@ -256,6 +249,7 @@ func RegisterGatewayRoutes(
h.OpenAIGateway.ResponsesWebSocket(c)
})
r.GET("/models", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, modelsHandler)
r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, countTokensHandler)
codexDirect := r.Group("/backend-api/codex")
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic)
{
+21 -7
View File
@@ -1,6 +1,7 @@
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -15,6 +16,10 @@ import (
)
func newGatewayRoutesTestRouter(platform ...string) *gin.Engine {
return newGatewayRoutesTestRouterWithConfig(&config.Config{}, platform...)
}
func newGatewayRoutesTestRouterWithConfig(cfg *config.Config, platform ...string) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -42,7 +47,7 @@ func newGatewayRoutesTestRouter(platform ...string) *gin.Engine {
nil,
nil,
nil,
&config.Config{},
cfg,
)
return router
@@ -224,13 +229,22 @@ func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) {
require.NotContains(t, w.Body.String(), "not supported for Grok groups")
}
req := httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", strings.NewReader(`{"model":"grok","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
countTokensRouter := newGatewayRoutesTestRouterWithConfig(&config.Config{
Gateway: config.GatewayConfig{MaxBodySize: 1024 * 1024},
}, service.PlatformGrok)
for _, path := range []string{"/v1/messages/count_tokens", "/messages/count_tokens"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), "Token counting is not supported for this platform")
countTokensRouter.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "path=%s", path)
var response struct {
InputTokens int `json:"input_tokens"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response), "path=%s", path)
require.Positive(t, response.InputTokens, "path=%s", path)
}
for _, path := range []string{
"/v1/responses",
@@ -207,6 +207,7 @@ type UsageInfo struct {
GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"`
GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"`
GrokLastStatusCode int `json:"grok_last_status_code,omitempty"`
GrokFreeTokenLimit int64 `json:"grok_free_token_limit,omitempty"`
GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"`
GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"`
GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"`
@@ -576,6 +576,14 @@ var proxyQualityTargets = []proxyQualityTarget{
http.StatusOK: {},
},
},
{
Target: "grok",
URL: "https://api.x.ai/v1/models",
Method: http.MethodGet,
AllowedStatuses: map[int]struct{}{
http.StatusUnauthorized: {},
},
},
}
const (
@@ -93,3 +93,44 @@ func TestRunProxyQualityTarget_AllowedStatusPassForUnauthorized(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, item.HTTPStatus)
require.Contains(t, item.Message, "目标可达")
}
func TestProxyQualityTargets_IncludesGrok(t *testing.T) {
var grokTarget *proxyQualityTarget
for i := range proxyQualityTargets {
if proxyQualityTargets[i].Target == "grok" {
grokTarget = &proxyQualityTargets[i]
break
}
}
require.NotNil(t, grokTarget)
require.Equal(t, "https://api.x.ai/v1/models", grokTarget.URL)
require.Equal(t, http.MethodGet, grokTarget.Method)
require.Contains(t, grokTarget.AllowedStatuses, http.StatusUnauthorized)
}
func TestRunProxyQualityTarget_GrokUnauthorizedPasses(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("unexpected method: %s", r.Method)
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}))
defer server.Close()
target := proxyQualityTarget{
Target: "grok",
URL: server.URL,
Method: http.MethodGet,
AllowedStatuses: map[int]struct{}{
http.StatusUnauthorized: {},
},
}
item := runProxyQualityTarget(context.Background(), server.Client(), target)
require.Equal(t, "grok", item.Target)
require.Equal(t, "pass", item.Status)
require.Equal(t, http.StatusUnauthorized, item.HTTPStatus)
require.Contains(t, item.Message, "目标可达")
}
@@ -21,8 +21,9 @@ func NewGrokQuotaFetcher() *GrokQuotaFetcher {
func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo {
now := time.Now()
usage := &UsageInfo{
Source: "passive",
UpdatedAt: &now,
Source: "passive",
UpdatedAt: &now,
GrokFreeTokenLimit: xai.GrokFreeRolling24hTokenLimit,
}
if account == nil {
usage.ErrorCode = "quota_unknown"
@@ -19,6 +19,7 @@ func TestGrokQuotaFetcherBuildUsageInfoUnknownUntilFirstSnapshot(t *testing.T) {
usage := NewGrokQuotaFetcher().BuildUsageInfo(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth})
require.Equal(t, "passive", usage.Source)
require.Equal(t, xai.GrokFreeRolling24hTokenLimit, usage.GrokFreeTokenLimit)
require.Equal(t, "quota_unknown", usage.ErrorCode)
require.Contains(t, usage.Error, "unknown until billing is probed")
}
+56 -24
View File
@@ -23,6 +23,8 @@ const (
grokQuotaProbeInput = "hi"
grokQuotaDefaultModel = grokDefaultResponsesModel
grokBillingExtraKey = "grok_billing_snapshot"
grokBillingMaxAttempts = 2
grokBillingRetryDelay = 100 * time.Millisecond
)
type GrokQuotaProbeResult struct {
@@ -356,33 +358,63 @@ func (s *GrokQuotaService) fetchBilling(
if err != nil {
return nil, 0, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_BASE_URL_INVALID", "invalid Grok base_url: %v", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, billingURL, nil)
if err != nil {
return nil, 0, infraerrors.Newf(http.StatusInternalServerError, "GROK_QUOTA_PROBE_REQUEST_BUILD_FAILED", "failed to build billing request: %v", err)
}
xai.ApplyCLIBillingHeaders(req, token)
// billing 探测与真实转发保持同一套账号级请求头覆写。
account.ApplyHeaderOverrides(req.Header)
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 2))
if err != nil {
return nil, 0, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_PROBE_REQUEST_FAILED", "billing request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
for attempt := 0; attempt < grokBillingMaxAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, billingURL, nil)
if err != nil {
return nil, 0, infraerrors.Newf(http.StatusInternalServerError, "GROK_QUOTA_PROBE_REQUEST_BUILD_FAILED", "failed to build billing request: %v", err)
}
xai.ApplyCLIBillingHeaders(req, token)
// billing 探测与真实转发保持同一套账号级请求头覆写。
account.ApplyHeaderOverrides(req.Header)
resp, requestErr := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 2))
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusTooManyRequests {
return nil, resp.StatusCode, nil
statusCode := 0
var bodyBytes []byte
if requestErr == nil {
statusCode = resp.StatusCode
bodyBytes, _ = io.ReadAll(io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
}
shouldRetry := requestErr != nil || isRetryableGrokBillingStatus(statusCode)
if shouldRetry && attempt+1 < grokBillingMaxAttempts {
timer := time.NewTimer(grokBillingRetryDelay)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return nil, statusCode, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_PROBE_REQUEST_FAILED", "billing request failed: %v", ctx.Err())
}
continue
}
if requestErr != nil {
return nil, 0, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_PROBE_REQUEST_FAILED", "billing request failed: %v", requestErr)
}
if statusCode == http.StatusTooManyRequests {
return nil, statusCode, nil
}
if statusCode >= 400 {
bodyText := truncate(strings.TrimSpace(string(bodyBytes)), 240)
slog.Warn("grok_quota_billing_failed", "account_id", account.ID, "weekly", weekly, "status", statusCode, "body", bodyText)
return nil, statusCode, infraerrors.Newf(mapUpstreamStatus(statusCode), "GROK_QUOTA_PROBE_UPSTREAM_ERROR", "billing returned %d: %s", statusCode, bodyText)
}
payload, err := xai.ParseBillingPayload(bodyBytes)
if err != nil {
return nil, statusCode, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_BILLING_PARSE_ERROR", "failed to parse billing body: %v", err)
}
return xai.BuildBillingSummary(payload.Config), statusCode, nil
}
if resp.StatusCode >= 400 {
bodyText := truncate(strings.TrimSpace(string(bodyBytes)), 240)
slog.Warn("grok_quota_billing_failed", "account_id", account.ID, "weekly", weekly, "status", resp.StatusCode, "body", bodyText)
return nil, resp.StatusCode, infraerrors.Newf(mapUpstreamStatus(resp.StatusCode), "GROK_QUOTA_PROBE_UPSTREAM_ERROR", "billing returned %d: %s", resp.StatusCode, bodyText)
return nil, 0, infraerrors.New(http.StatusBadGateway, "GROK_QUOTA_PROBE_REQUEST_FAILED", "billing request failed")
}
func isRetryableGrokBillingStatus(statusCode int) bool {
switch statusCode {
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
payload, err := xai.ParseBillingPayload(bodyBytes)
if err != nil {
return nil, resp.StatusCode, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_BILLING_PARSE_ERROR", "failed to parse billing body: %v", err)
}
return xai.BuildBillingSummary(payload.Config), resp.StatusCode, nil
}
func mergeGrokBillingProbeErrors(weeklyStatus, monthlyStatus int, weeklyErr, monthlyErr error) error {
@@ -5,6 +5,7 @@ package service
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
@@ -127,6 +128,44 @@ type grokHybridUpstream struct {
billingHeaders http.Header
}
type grokQuotaUpstreamStep struct {
status int
body string
err error
}
type grokQuotaSequenceUpstream struct {
httpUpstreamRecorder
mu sync.Mutex
steps []grokQuotaUpstreamStep
requests []*http.Request
}
func (u *grokQuotaSequenceUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.mu.Lock()
defer u.mu.Unlock()
u.requests = append(u.requests, req)
index := len(u.requests) - 1
if index >= len(u.steps) {
return nil, errors.New("unexpected upstream request")
}
step := u.steps[index]
if step.err != nil {
return nil, step.err
}
return &http.Response{
StatusCode: step.status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(step.body)),
}, nil
}
func (u *grokQuotaSequenceUpstream) snapshotRequests() []*http.Request {
u.mu.Lock()
defer u.mu.Unlock()
return append([]*http.Request(nil), u.requests...)
}
func (u *grokHybridUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
var body []byte
if req != nil && req.Body != nil {
@@ -228,6 +267,139 @@ func healthyGrokQuotaOAuthAccount(id int64) *Account {
}
}
func TestGrokQuotaServiceFetchBillingRetries502ThenSucceeds(t *testing.T) {
account := healthyGrokQuotaOAuthAccount(401)
upstream := &grokQuotaSequenceUpstream{steps: []grokQuotaUpstreamStep{
{status: http.StatusBadGateway, body: `The origin web server returned an invalid or incomplete response to Cloudflare.`},
{status: http.StatusOK, body: `{"config":{"currentPeriod":{"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"},"creditUsagePercent":12}}`},
}}
svc := &GrokQuotaService{httpUpstream: upstream}
summary, status, err := svc.fetchBilling(context.Background(), account, "access-token", "", true)
require.NoError(t, err)
require.Equal(t, http.StatusOK, status)
require.NotNil(t, summary)
require.NotNil(t, summary.UsagePercent)
require.Equal(t, 12.0, *summary.UsagePercent)
requests := upstream.snapshotRequests()
require.Len(t, requests, 2)
require.Equal(t, http.MethodGet, requests[0].Method)
require.Equal(t, "/v1/billing", requests[0].URL.Path)
require.Equal(t, "format=credits", requests[0].URL.RawQuery)
}
func TestGrokQuotaServiceFetchBillingRetriesTransportErrorThenSucceeds(t *testing.T) {
account := healthyGrokQuotaOAuthAccount(402)
upstream := &grokQuotaSequenceUpstream{steps: []grokQuotaUpstreamStep{
{err: errors.New("temporary transport failure")},
{status: http.StatusOK, body: `{"config":{"currentPeriod":{"type":"WEEKLY"},"creditUsagePercent":8}}`},
}}
svc := &GrokQuotaService{httpUpstream: upstream}
summary, status, err := svc.fetchBilling(context.Background(), account, "access-token", "", true)
require.NoError(t, err)
require.Equal(t, http.StatusOK, status)
require.NotNil(t, summary)
require.Len(t, upstream.snapshotRequests(), 2)
}
func TestGrokQuotaServiceFetchBillingStopsAfterSingleTransientRetry(t *testing.T) {
account := healthyGrokQuotaOAuthAccount(403)
upstream := &grokQuotaSequenceUpstream{steps: []grokQuotaUpstreamStep{
{status: http.StatusBadGateway, body: `cloudflare failure`},
{status: http.StatusBadGateway, body: `cloudflare failure`},
{status: http.StatusOK, body: `{"config":{"currentPeriod":{"type":"WEEKLY"}}}`},
}}
svc := &GrokQuotaService{httpUpstream: upstream}
summary, status, err := svc.fetchBilling(context.Background(), account, "access-token", "", true)
require.Error(t, err)
require.Nil(t, summary)
require.Equal(t, http.StatusBadGateway, status)
require.Equal(t, "GROK_QUOTA_PROBE_UPSTREAM_ERROR", infraerrors.Reason(err))
require.Contains(t, infraerrors.Message(err), "billing returned 502: cloudflare failure")
require.Len(t, upstream.snapshotRequests(), 2)
}
func TestGrokQuotaServiceFetchBillingDoesNotRetryNonTransientStatuses(t *testing.T) {
tests := []struct {
name string
status int
wantErr bool
}{
{name: "unauthorized", status: http.StatusUnauthorized, wantErr: true},
{name: "forbidden", status: http.StatusForbidden, wantErr: true},
{name: "rate limited", status: http.StatusTooManyRequests, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := healthyGrokQuotaOAuthAccount(404)
upstream := &grokQuotaSequenceUpstream{steps: []grokQuotaUpstreamStep{
{status: tt.status, body: `{"error":{"message":"rejected"}}`},
{status: http.StatusOK, body: `{"config":{"currentPeriod":{"type":"WEEKLY"}}}`},
}}
svc := &GrokQuotaService{httpUpstream: upstream}
summary, status, err := svc.fetchBilling(context.Background(), account, "access-token", "", true)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Nil(t, summary)
require.Equal(t, tt.status, status)
require.Len(t, upstream.snapshotRequests(), 1)
})
}
}
func TestIsRetryableGrokBillingStatus(t *testing.T) {
tests := []struct {
status int
want bool
}{
{status: http.StatusBadGateway, want: true},
{status: http.StatusServiceUnavailable, want: true},
{status: http.StatusGatewayTimeout, want: true},
{status: http.StatusUnauthorized, want: false},
{status: http.StatusForbidden, want: false},
{status: http.StatusTooManyRequests, want: false},
}
for _, tt := range tests {
t.Run(strconv.Itoa(tt.status), func(t *testing.T) {
require.Equal(t, tt.want, isRetryableGrokBillingStatus(tt.status))
})
}
}
func TestGrokQuotaServiceProbeUsageDoesNotRetryResponsesPost(t *testing.T) {
account := healthyGrokQuotaOAuthAccount(405)
repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
accountsByID: map[int64]*Account{account.ID: account},
}}
upstream := &grokQuotaSequenceUpstream{steps: []grokQuotaUpstreamStep{
{status: http.StatusBadGateway, body: `cloudflare failure`},
{status: http.StatusOK, body: `{"id":"unexpected_retry"}`},
}}
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
result, err := svc.ProbeUsage(context.Background(), account.ID)
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "GROK_QUOTA_PROBE_UPSTREAM_ERROR", infraerrors.Reason(err))
requests := upstream.snapshotRequests()
require.Len(t, requests, 1)
require.Equal(t, http.MethodPost, requests[0].Method)
require.Equal(t, "/v1/responses", requests[0].URL.Path)
}
func TestGrokQuotaServiceProbeUsageStoresHeaders(t *testing.T) {
t.Parallel()
@@ -39,6 +39,39 @@ type openAIInputTokensCountPrepared struct {
UpstreamModel string
}
// EstimateGrokCountTokens estimates an Anthropic-compatible count_tokens request
// locally. Grok does not expose a compatible token-counting endpoint, so this
// path deliberately avoids account selection, credentials, and upstream calls.
func EstimateGrokCountTokens(body []byte) (int, error) {
var anthropicReq apicompat.AnthropicRequest
if err := json.Unmarshal(body, &anthropicReq); err != nil {
return 0, fmt.Errorf("parse anthropic count_tokens request: %w", err)
}
if strings.TrimSpace(anthropicReq.Model) == "" {
return 0, fmt.Errorf("parse anthropic count_tokens request: model is required")
}
responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq)
if err != nil {
return 0, fmt.Errorf("convert anthropic request to responses: %w", err)
}
estimated, err := estimateOpenAIInputTokens(openAIInputTokensCountRequest{
Model: anthropicReq.Model,
Instructions: responsesReq.Instructions,
Input: responsesReq.Input,
Tools: responsesReq.Tools,
ToolChoice: responsesReq.ToolChoice,
})
if err != nil {
return 0, fmt.Errorf("estimate grok input tokens: %w", err)
}
if estimated < openAIInputTokensFallbackMinimum {
estimated = openAIInputTokensFallbackMinimum
}
return estimated, nil
}
// ForwardCountTokensAsAnthropic bridges Anthropic /v1/messages/count_tokens to
// OpenAI POST /v1/responses/input_tokens and returns Anthropic-compatible output.
func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
@@ -236,6 +236,51 @@ func TestEstimateOpenAIInputTokens_RequestSamples(t *testing.T) {
}
}
func TestEstimateGrokCountTokens_AnthropicRequests(t *testing.T) {
cases := []struct {
name string
body string
}{
{
name: "simple message",
body: `{"model":"grok-4","messages":[{"role":"user","content":"hello world"}]}`,
},
{
name: "system blocks and tools",
body: `{
"model":"grok-4",
"system":[{"type":"text","text":"You are helpful."}],
"messages":[{"role":"user","content":[{"type":"text","text":"look up the weather"}]}],
"tools":[{"name":"lookup_weather","description":"Look up weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}}],
"tool_choice":{"type":"auto"}
}`,
},
{
name: "empty conversation uses positive minimum",
body: `{"model":"grok-4","messages":[]}`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got, err := EstimateGrokCountTokens([]byte(tt.body))
require.NoError(t, err)
require.Positive(t, got)
})
}
}
func TestEstimateGrokCountTokens_RejectsInvalidRequests(t *testing.T) {
for _, body := range []string{
`{`,
`{"messages":[{"role":"user","content":"hello"}]}`,
`{"model":"grok-4","messages":[{"role":"user","content":{"unexpected":true}}]}`,
} {
_, err := EstimateGrokCountTokens([]byte(body))
require.Error(t, err, "body=%s", body)
}
}
func TestOpenAIInputTokensEncodingForModel(t *testing.T) {
cases := []struct {
model string
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -17,7 +18,6 @@ const (
grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
grokFreeCacheDisabledToolChoice = "none"
grokClientToolCacheOptInExtraKey = "grok_client_tool_cache_enabled"
grokFreeRolling24hTokenLimit = int64(2_000_000)
)
// resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity
@@ -295,7 +295,7 @@ func isKnownGrokFreeAccount(account *Account) bool {
}
}
if snapshot.Tokens != nil && snapshot.Tokens.Limit != nil &&
*snapshot.Tokens.Limit == grokFreeRolling24hTokenLimit {
xai.IsGrokFreeRolling24hTokenLimit(*snapshot.Tokens.Limit) {
inferredFreeSignal = true
}
}
@@ -8,6 +8,7 @@ import (
"sync"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -677,7 +678,19 @@ func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing.
a := healthyGrokOAuthGatewayTestAccount(9112, "access-token")
a.Extra = map[string]any{grokQuotaSnapshotExtraKey: map[string]any{
"headers_observed": true,
"tokens": map[string]any{"limit": grokFreeRolling24hTokenLimit},
"tokens": map[string]any{"limit": xai.GrokFreeRolling24hTokenLimit},
}}
return a
}(),
wantMix: true,
},
{
name: "legacy free rolling token quota",
account: func() *Account {
a := healthyGrokOAuthGatewayTestAccount(9113, "access-token")
a.Extra = map[string]any{grokQuotaSnapshotExtraKey: map[string]any{
"headers_observed": true,
"tokens": map[string]any{"limit": int64(2_000_000)},
}}
return a
}(),
@@ -699,7 +712,7 @@ func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing.
grokBillingExtraKey: map[string]any{"plan": "SuperGrok", "status_code": http.StatusOK},
grokQuotaSnapshotExtraKey: map[string]any{
"headers_observed": true,
"tokens": map[string]any{"limit": grokFreeRolling24hTokenLimit},
"tokens": map[string]any{"limit": int64(2_000_000)},
},
}
return a
@@ -408,7 +408,7 @@
<UsageProgressBar
v-if="grokFreeTokenBar"
label="24h"
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint')"
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })"
:utilization="grokFreeTokenBar.utilization"
:show-now-when-idle="true"
color="emerald"
@@ -631,8 +631,6 @@ import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
// xAI Free billing exposes a window without usage_percent, so estimate it from local tokens.
const GROK_FREE_TOKEN_LIMIT = 2_000_000
const props = withDefaults(
defineProps<{
@@ -1112,8 +1110,10 @@ const grokLocalUsage = computed(() => {
})
const grokFreeTokenBar = computed(() => {
if (!grokIsFree.value || !grokFreeQuotaUsage.value) return null
const limit = usageInfo.value?.grok_free_token_limit
if (typeof limit !== 'number' || limit <= 0) return null
const used = Math.max(0, grokFreeQuotaUsage.value.tokens || 0)
return { utilization: Math.min(100, (used / GROK_FREE_TOKEN_LIMIT) * 100) }
return { utilization: Math.min(100, (used / limit) * 100), limit }
})
const grokQuotaUnknown = computed(() => {
if (props.account.platform !== 'grok') return false
@@ -705,11 +705,12 @@ describe('AccountUsageCell', () => {
it.each([
{ tokens: 0, expected: 0, compact: '0' },
{ tokens: 1_000_000, expected: 50, compact: '1.0M' },
{ tokens: 2_000_000, expected: 100, compact: '2.0M' },
{ tokens: 2_200_000, expected: 100, compact: '2.2M' }
])('Grok Free derives its 2M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => {
{ tokens: 500_000, expected: 50, compact: '500.0K' },
{ tokens: 1_000_000, expected: 100, compact: '1.0M' },
{ tokens: 1_100_000, expected: 100, compact: '1.1M' }
])('Grok Free derives its 1M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
grok_billing: {
period_type: 'weekly',
usage_percent: null,
@@ -723,7 +724,7 @@ describe('AccountUsageCell', () => {
user_cost: 0
},
grok_request_quota: { limit: 100, remaining: 100 },
grok_token_quota: { limit: 2_000_000, remaining: 2_000_000 }
grok_token_quota: { limit: 1_000_000, remaining: 1_000_000 }
})
const wrapper = mount(AccountUsageCell, {
@@ -753,6 +754,7 @@ describe('AccountUsageCell', () => {
it('Grok Free uses rolling 24h usage instead of today-only usage', async () => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' },
grok_local_usage: {
requests: 2,
@@ -762,7 +764,7 @@ describe('AccountUsageCell', () => {
},
grok_local_usage_24h: {
requests: 12,
tokens: 1_500_000,
tokens: 750_000,
cost: 0,
standard_cost: 0
}
@@ -793,7 +795,7 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(wrapper.text()).toContain('24h|75|admin.accounts.usageWindow.grokFreeQuota24hHint')
expect(wrapper.text()).toContain('1.5M')
expect(wrapper.text()).toContain('750.0K')
expect(wrapper.text()).not.toContain('7d|')
expect(wrapper.text()).not.toContain('200.0K')
expect(wrapper.text()).not.toContain('250.0K')
@@ -801,6 +803,7 @@ describe('AccountUsageCell', () => {
it('Grok Free does not substitute today stats when rolling 24h usage is unavailable', async () => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' },
grok_local_usage: {
requests: 1,
@@ -921,8 +924,9 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).not.toContain('2M|')
})
it('Grok credential Free tier keeps the 2M fallback when billing is unavailable', async () => {
it('Grok credential Free tier keeps the 1M fallback when billing is unavailable', async () => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
subscription_tier: 'FREE',
grok_local_usage_24h: {
requests: 3,
@@ -950,7 +954,7 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(wrapper.text()).toContain('24h|50')
expect(wrapper.text()).toContain('24h|100')
})
it('Grok paid manual probes keep the weekly/local summary when 24h usage is returned', async () => {
@@ -1165,6 +1169,7 @@ describe('AccountUsageCell', () => {
it('Grok Free manual probes merge rolling 24h usage', async () => {
getUsage.mockResolvedValue({
grok_free_token_limit: 1_000_000,
subscription_tier: 'FREE',
grok_quota_snapshot_state: 'no_headers'
})
@@ -1185,7 +1190,7 @@ describe('AccountUsageCell', () => {
template: `<button class="probe" @click="$emit('probed', {
source: 'hybrid_probe',
billing: { period_type: 'weekly', usage_percent: null, plan: '' },
local_usage_24h: { requests: 12, tokens: 1500000, cost: 0, standard_cost: 0 },
local_usage_24h: { requests: 12, tokens: 750000, cost: 0, standard_cost: 0 },
headers_observed: false,
reset_supported: false,
fetched_at: 1
@@ -1199,7 +1204,7 @@ describe('AccountUsageCell', () => {
await wrapper.get('.probe').trigger('click')
expect(wrapper.text()).toContain('24h|75')
expect(wrapper.text()).toContain('1.5M')
expect(wrapper.text()).toContain('750.0K')
expect(wrapper.text()).not.toContain('7d|')
})
@@ -1280,7 +1280,7 @@ export default {
claude: 'Claude',
grokRequests: 'Req',
grokTokens: 'Tok',
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window (2M limit)',
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)',
grokWeeklyUsage: 'Weekly {percent}%',
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
grokRetryAfter: 'Retry after {time}',
@@ -351,7 +351,7 @@ export default {
claude: 'Claude',
grokRequests: '请求',
grokTokens: 'Token',
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 2M)',
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})',
grokWeeklyUsage: '周额度已用 {percent}%',
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
grokRetryAfter: '{time} 后重试',
+1
View File
@@ -1120,6 +1120,7 @@ export interface AccountUsageInfo {
grok_last_quota_probe_at?: string
grok_last_headers_seen_at?: string
grok_last_status_code?: number
grok_free_token_limit?: number
grok_local_usage?: WindowStats | null
grok_local_usage_24h?: WindowStats | null
grok_local_usage_7d?: WindowStats | null
+11 -9
View File
@@ -881,21 +881,21 @@
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
<tr>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableTarget') }}</th>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableStatus') }}</th>
<th class="px-3 py-2 text-left">HTTP</th>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableLatency') }}</th>
<th class="whitespace-nowrap px-3 py-2 text-left">{{ t('admin.proxies.qualityTableTarget') }}</th>
<th class="whitespace-nowrap px-3 py-2 text-left">{{ t('admin.proxies.qualityTableStatus') }}</th>
<th class="whitespace-nowrap px-3 py-2 text-left">HTTP</th>
<th class="whitespace-nowrap px-3 py-2 text-left">{{ t('admin.proxies.qualityTableLatency') }}</th>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableMessage') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-for="item in qualityReport.items" :key="item.target">
<td class="px-3 py-2 text-gray-900 dark:text-white">{{ qualityTargetLabel(item.target) }}</td>
<td class="px-3 py-2">
<span class="badge" :class="qualityStatusClass(item.status)">{{ qualityStatusLabel(item.status) }}</span>
<td class="whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white">{{ qualityTargetLabel(item.target) }}</td>
<td class="whitespace-nowrap px-3 py-2">
<span class="badge whitespace-nowrap" :class="qualityStatusClass(item.status)">{{ qualityStatusLabel(item.status) }}</span>
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{{ item.http_status ?? '-' }}</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
<td class="whitespace-nowrap px-3 py-2 text-gray-600 dark:text-gray-300">{{ item.http_status ?? '-' }}</td>
<td class="whitespace-nowrap px-3 py-2 text-gray-600 dark:text-gray-300">
{{ typeof item.latency_ms === 'number' ? `${item.latency_ms}ms` : '-' }}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
@@ -1777,6 +1777,8 @@ const qualityTargetLabel = (target: string) => {
return 'Anthropic'
case 'gemini':
return 'Gemini'
case 'grok':
return 'Grok'
default:
return target
}