mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
完善 Grok 限流冷却与用量兼容
This commit is contained in:
@@ -76,6 +76,12 @@ func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryC
|
||||
return delay
|
||||
}
|
||||
|
||||
// sameAccountRetryDeadlineAllows prevents a retry from starting after the
|
||||
// service-provided same-account retry window has elapsed.
|
||||
func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool {
|
||||
return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline)
|
||||
}
|
||||
|
||||
// FailoverState 跨循环迭代共享的 failover 状态
|
||||
type FailoverState struct {
|
||||
SwitchCount int
|
||||
|
||||
@@ -60,6 +60,16 @@ func TestSameAccountRetryDelayFor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSameAccountRetryDeadlineAllows(t *testing.T) {
|
||||
require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{}))
|
||||
require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{
|
||||
SameAccountRetryDeadline: time.Now().Add(time.Second),
|
||||
}))
|
||||
require.False(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{
|
||||
SameAccountRetryDeadline: time.Now().Add(-time.Second),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -362,18 +362,20 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
}
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("grok_media.pool_mode_same_account_retry",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("retry_limit", retryLimit),
|
||||
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
|
||||
zap.Duration("retry_delay", retryDelay),
|
||||
)
|
||||
select {
|
||||
case <-requestCtx.Done():
|
||||
return
|
||||
case <-time.After(sameAccountRetryDelay):
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
// Pool mode: retry on the same account
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry",
|
||||
|
||||
@@ -718,7 +718,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
// 池模式:同账号重试
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai.pool_mode_same_account_retry",
|
||||
@@ -1267,7 +1267,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
// 池模式:同账号重试
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai_messages.pool_mode_same_account_retry",
|
||||
|
||||
@@ -305,18 +305,20 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
}
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai.images.pool_mode_same_account_retry",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("retry_limit", retryLimit),
|
||||
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
|
||||
zap.Duration("retry_delay", retryDelay),
|
||||
)
|
||||
select {
|
||||
case <-requestCtx.Done():
|
||||
return
|
||||
case <-time.After(sameAccountRetryDelay):
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ const (
|
||||
upstreamProtocolModeOpenAIH1 = "openai_h1"
|
||||
upstreamProtocolModeOpenAIH2 = "openai_h2"
|
||||
upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback"
|
||||
upstreamProtocolModeGrok = "grok"
|
||||
)
|
||||
|
||||
var errUpstreamClientLimitReached = errors.New("upstream client cache limit reached")
|
||||
@@ -910,7 +911,7 @@ func (s *httpUpstreamService) applyProfilePoolSettings(settings poolSettings, pr
|
||||
// generic 600s gateway timeout from turning one request into a 10-minute
|
||||
// resource hold; streaming after headers is unaffected.
|
||||
settings.responseHeaderTimeout = 120 * time.Second
|
||||
if s != nil && s.cfg != nil && s.cfg.Gateway.GrokResponseHeaderTimeout > 0 {
|
||||
if s != nil && s.cfg != nil {
|
||||
settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.GrokResponseHeaderTimeout) * time.Second
|
||||
}
|
||||
}
|
||||
@@ -991,6 +992,9 @@ func (s *httpUpstreamService) resolveOpenAIHTTP2Settings() openAIHTTP2Settings {
|
||||
}
|
||||
|
||||
func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamProfile, proxyKey string, parsedProxy *url.URL) string {
|
||||
if profile == service.HTTPUpstreamProfileGrok {
|
||||
return upstreamProtocolModeGrok
|
||||
}
|
||||
if profile != service.HTTPUpstreamProfileOpenAI {
|
||||
return upstreamProtocolModeDefault
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ func grokStreamIdleFailoverError(account *Account, idle time.Duration) *Upstream
|
||||
StatusCode: 502,
|
||||
ResponseBody: []byte(`{"error":{"code":"empty_upstream","message":"` + strings.ReplaceAll(msg, `"`, `'`) + `"}}`),
|
||||
SafeToFailoverAfterWrite: true,
|
||||
// Allow pool-mode retries; normal OAuth switches account via handler.
|
||||
RetryableOnSameAccount: account != nil && account.IsPoolMode(),
|
||||
// An idle upstream stream is transient and should get the configured
|
||||
// same-account retry budget before switching credentials. This applies
|
||||
// to both pooled and dedicated Grok accounts; the handler still enforces
|
||||
// the request's retry limit.
|
||||
RetryableOnSameAccount: account != nil && account.Platform == PlatformGrok,
|
||||
RequestScopedTransient: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,14 @@ func TestGrokStreamIdleFailoverError(t *testing.T) {
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, 502, err.StatusCode)
|
||||
require.True(t, err.SafeToFailoverAfterWrite)
|
||||
require.True(t, err.RetryableOnSameAccount)
|
||||
require.True(t, err.RequestScopedTransient)
|
||||
require.Contains(t, string(err.ResponseBody), "empty_upstream")
|
||||
}
|
||||
|
||||
func TestGrokStreamIdleFailoverErrorRequiresGrokAccount(t *testing.T) {
|
||||
openAI := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
err := grokStreamIdleFailoverError(openAI, time.Second)
|
||||
require.False(t, err.RetryableOnSameAccount)
|
||||
require.True(t, err.RequestScopedTransient)
|
||||
}
|
||||
|
||||
@@ -417,6 +417,19 @@ func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody
|
||||
return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
// shouldMarkGrokTeamModelRateLimit controls the process-local sibling-account
|
||||
// overlay. Model-capacity responses are request pressure, not a team quota;
|
||||
// marking them would hide healthy sibling credentials while the bounded
|
||||
// same-account retry is still in progress. Ordinary 429s and free-usage
|
||||
// exhaustion retain the existing quota/team isolation behavior.
|
||||
func shouldMarkGrokTeamModelRateLimit(statusCode int, responseBody []byte) bool {
|
||||
decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
|
||||
if decision.Class == GrokFailureModelCapacity {
|
||||
return false
|
||||
}
|
||||
return statusCode == http.StatusTooManyRequests || decision.Class == GrokFailureFreeUsage
|
||||
}
|
||||
|
||||
func isGrokCompatibilityError(low, code string) bool {
|
||||
combined := strings.ToLower(strings.TrimSpace(low + " " + code))
|
||||
// Compaction blobs are account/session-bound and frequently fail with 400
|
||||
@@ -427,11 +440,6 @@ func isGrokCompatibilityError(low, code string) bool {
|
||||
"decode the compaction blob",
|
||||
"ensure it is unmodified from the compact response",
|
||||
"compaction blob",
|
||||
"failed to deserialize the json body",
|
||||
"data did not match any variant",
|
||||
"untagged enum content",
|
||||
"invalid content shape",
|
||||
"invalid response history",
|
||||
} {
|
||||
if strings.Contains(combined, phrase) {
|
||||
return true
|
||||
@@ -440,8 +448,6 @@ func isGrokCompatibilityError(low, code string) bool {
|
||||
for _, marker := range []string{
|
||||
"invalid_compaction",
|
||||
"compaction_decode_error",
|
||||
"response_history_incompatible",
|
||||
"invalid_content_shape",
|
||||
} {
|
||||
if strings.Contains(combined, marker) {
|
||||
return true
|
||||
|
||||
@@ -77,6 +77,11 @@ func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) {
|
||||
[]byte(`{"error":{"message":"rate limit exceeded"}}`)))
|
||||
require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired,
|
||||
[]byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`)))
|
||||
poolAccount := &Account{ID: 9108, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"pool_mode": true}}
|
||||
require.False(t, grokRetryableOnSameAccount(poolAccount, http.StatusTooManyRequests,
|
||||
[]byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`)),
|
||||
"pool free-usage must fail over instead of retrying the exhausted account")
|
||||
require.False(t, grokRetryableOnSameAccount(account, http.StatusBadRequest,
|
||||
[]byte(`{"error":{"message":"capacity field is invalid"}}`)))
|
||||
nonGrok := &Account{ID: 9106, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
@@ -84,6 +89,17 @@ func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) {
|
||||
[]byte(`{"error":{"message":"model at capacity"}}`)))
|
||||
}
|
||||
|
||||
func TestShouldMarkGrokTeamModelRateLimit_ExcludesCapacity(t *testing.T) {
|
||||
require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests,
|
||||
[]byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`)))
|
||||
require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests,
|
||||
[]byte(`{"error":{"message":"rate limit exceeded"}}`)))
|
||||
require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest,
|
||||
[]byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`)))
|
||||
require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest,
|
||||
[]byte(`{"error":{"message":"invalid request"}}`)))
|
||||
}
|
||||
|
||||
func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) {
|
||||
account := &Account{ID: 9107, Platform: PlatformGrok, Type: AccountTypeOAuth}
|
||||
retryable, delay, deadline := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests,
|
||||
@@ -116,7 +132,6 @@ func TestClassifyGrokUpstreamFailure_FreeUsageWinsOver5xx(t *testing.T) {
|
||||
func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"error":{"message":"Could not decode the compaction blob. Ensure it is unmodified from the compact response"}}`,
|
||||
`{"error":{"message":"data did not match any variant of the untagged enum content"}}`,
|
||||
`{"code":"compaction_decode_error","message":"invalid response history"}`,
|
||||
}
|
||||
for _, body := range cases {
|
||||
@@ -128,6 +143,13 @@ func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyGrokUpstreamFailure_GenericShapeErrorDoesNotFailover(t *testing.T) {
|
||||
d := classifyGrokUpstreamFailure(http.StatusBadRequest,
|
||||
[]byte(`{"error":{"message":"data did not match any variant of the untagged enum content"}}`), "grok-4.6")
|
||||
require.NotEqual(t, GrokFailureCompatibility, d.Class)
|
||||
require.False(t, d.ShouldFailover)
|
||||
}
|
||||
|
||||
func TestShouldFailoverGrokUpstreamError_FreeUsageBody(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`)
|
||||
|
||||
@@ -167,9 +167,9 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
})
|
||||
errCtx := withGrokTeamRateLimitModel(ctx, upstreamModel)
|
||||
s.handleGrokAccountUpstreamError(errCtx, account, resp.StatusCode, resp.Header, respBody)
|
||||
// 429 / free-usage: stamp team+model cool so sibling accounts skip this model.
|
||||
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||
classifyGrokUpstreamFailure(resp.StatusCode, respBody, upstreamModel).Class == GrokFailureFreeUsage {
|
||||
// Quota/rate-limit responses stamp the team+model overlay. Capacity is
|
||||
// request pressure and must not hide sibling accounts.
|
||||
if shouldMarkGrokTeamModelRateLimit(resp.StatusCode, respBody) {
|
||||
markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now()))
|
||||
}
|
||||
if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
|
||||
@@ -179,6 +179,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
ResponseBody: respBody,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
RetryableOnSameAccount: retryable,
|
||||
RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests,
|
||||
SameAccountRetryDelay: retryDelay,
|
||||
SameAccountRetryDeadline: retryDeadline,
|
||||
}
|
||||
@@ -649,7 +650,7 @@ func grokSupportsXHighReasoningEffort(model string) bool {
|
||||
func grokSupportsReasoningEffort(model string) bool {
|
||||
model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model)))
|
||||
switch model {
|
||||
case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.6", "grok-4.6-latest",
|
||||
case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.6-latest",
|
||||
"grok-4.3", "grok-4.3-latest",
|
||||
"grok-3-mini", "grok-3-mini-fast", "grok-4.20-0309-reasoning",
|
||||
"grok-4.20-reasoning", "grok-4.20-multi-agent-0309":
|
||||
@@ -1189,6 +1190,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage(
|
||||
ResponseBody: respBody,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
RetryableOnSameAccount: retryable,
|
||||
RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests,
|
||||
SameAccountRetryDelay: retryDelay,
|
||||
SameAccountRetryDeadline: retryDeadline,
|
||||
}
|
||||
@@ -1744,7 +1746,7 @@ func persistGrokTransientModelCooldown(account *Account, decision GrokUpstreamFa
|
||||
return false
|
||||
}
|
||||
model := strings.TrimSpace(decision.Model)
|
||||
if model == "" || !isGrokHeavyTransientModel(model) {
|
||||
if model == "" {
|
||||
return false
|
||||
}
|
||||
cooldown := decision.Cooldown
|
||||
|
||||
@@ -1182,8 +1182,26 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
|
||||
inputTokens = value.Get("prompt_tokens").Int()
|
||||
}
|
||||
outputTokens := value.Get("output_tokens").Int()
|
||||
outputTokensFromResponses := outputTokens != 0
|
||||
if outputTokens == 0 {
|
||||
outputTokens = value.Get("completion_tokens").Int()
|
||||
outputTokensFromResponses = false
|
||||
}
|
||||
// xAI Chat Completions reports visible completion tokens separately from
|
||||
// reasoning_tokens. Billing must include both. Responses usage generally
|
||||
// already folds reasoning into output_tokens; use total_tokens when present
|
||||
// to avoid double-counting that canonical shape.
|
||||
reasoningTokens := max(int(firstPositiveGJSONInt(
|
||||
value.Get("completion_tokens_details.reasoning_tokens"),
|
||||
value.Get("output_tokens_details.reasoning_tokens"),
|
||||
)), 0)
|
||||
if reasoningTokens > 0 {
|
||||
totalTokens := value.Get("total_tokens").Int()
|
||||
if !outputTokensFromResponses {
|
||||
outputTokens += int64(reasoningTokens)
|
||||
} else if totalTokens > inputTokens && totalTokens >= outputTokens+int64(reasoningTokens) {
|
||||
outputTokens += int64(reasoningTokens)
|
||||
}
|
||||
}
|
||||
cacheReadTokens := openAICacheReadTokensFromUsage(value)
|
||||
cacheCreationTokens := openAICacheCreationTokensFromUsage(value)
|
||||
|
||||
@@ -3453,6 +3453,20 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te
|
||||
require.Zero(t, usage.CacheReadInputTokens, "官方嵌套缓存读取字段显式为零时仍应优先于兼容顶层别名")
|
||||
}
|
||||
|
||||
func TestExtractOpenAIUsageFromJSONBytes_IncludesGrokReasoningTokens(t *testing.T) {
|
||||
usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"prompt_tokens":32,"completion_tokens":9,"total_tokens":135,"completion_tokens_details":{"reasoning_tokens":94}}}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 103, usage.OutputTokens, "Grok Chat usage bills visible completion plus reasoning tokens")
|
||||
|
||||
usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":32,"output_tokens":103,"total_tokens":135,"output_tokens_details":{"reasoning_tokens":94}}}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 103, usage.OutputTokens, "Responses output_tokens already includes reasoning when total confirms it")
|
||||
|
||||
usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":32,"output_tokens":9,"total_tokens":135,"output_tokens_details":{"reasoning_tokens":94}}}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 103, usage.OutputTokens, "Responses detail-only shape is normalized when total exposes the full output")
|
||||
}
|
||||
|
||||
func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`event: message`,
|
||||
|
||||
@@ -214,6 +214,9 @@ gateway:
|
||||
# OpenAI/Codex upstream response header timeout (seconds, 0=disabled)
|
||||
# OpenAI/Codex 等待上游响应头超时时间(秒,0=禁用本地响应头超时)
|
||||
openai_response_header_timeout: 0
|
||||
# Grok text Responses first-byte timeout (seconds, 0=disabled; default 120)
|
||||
# Grok 文本 Responses 首字节超时时间(秒,0=禁用;默认 120)
|
||||
grok_response_header_timeout: 120
|
||||
# Native OpenAI HTTP Responses first semantic output timeout (seconds, 0=disabled)
|
||||
# Includes response-header wait; does not apply to passthrough or WebSocket transports.
|
||||
# A timed-out request may already have incurred upstream usage; account failover can therefore duplicate upstream billing.
|
||||
|
||||
Reference in New Issue
Block a user