Files
sub2api/backend/internal/service/openai_gateway_grok.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1850 lines
60 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
grokComposerImageBridgeVisionModel = "grok-build-0.1"
grokComposerImageBridgeMaxOutputTokens = 512
// grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers).
grokCLIVersion = xai.CLIClientVersion
grokDefaultResponsesModel = "grok-4.5"
grokRateLimitFallbackCooldown = 2 * time.Minute
grokRateLimitRepeatCooldown = 10 * time.Minute
grokRateLimitSustainedCooldown = 30 * time.Minute
grokRateLimitMaxAdaptiveCooldown = time.Hour
grokRateLimitBackoffQuietPeriod = time.Hour
)
func (s *OpenAIGatewayService) forwardGrokResponses(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
originalModel string,
reqStream bool,
startTime time.Time,
) (*OpenAIForwardResult, error) {
if account.Type != AccountTypeOAuth && account.Type != AccountTypeAPIKey {
return nil, fmt.Errorf("grok account type %s is not supported by Responses forwarding", account.Type)
}
upstreamModel := account.GetMappedModel(originalModel)
if strings.TrimSpace(upstreamModel) == "" {
upstreamModel = grokDefaultResponsesModel
}
// Account mappings are optional. Canonicalize client aliases even when the
// account has no model_mapping, matching the Chat Completions path and xAI's
// actual Responses model IDs.
upstreamModel = xai.ResolveGrokTextResponsesModelID(upstreamModel, grokDefaultResponsesModel)
if isGrokImageGenerationModel(upstreamModel) {
return nil, fmt.Errorf("model %s is an image model and is not available on the Responses endpoint; use /v1/images/generations instead", upstreamModel)
}
patchedBody, clientToolMapping, err := patchGrokResponsesBodyWithClientTools(body, upstreamModel)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": err.Error(), "param": "tools",
}})
return nil, err
}
setGrokResponsesClientToolMapping(c, clientToolMapping)
// OpenAI /responses/compact is not a native xAI endpoint. Convert it into a
// normal Grok Responses turn that asks for a structured summary, then map the
// reply back to an OpenAI compaction item on the way out.
if isOpenAIResponsesCompactPath(c) {
patchedBody, err = buildGrokCompactRequestBody(patchedBody)
if err != nil {
return nil, err
}
}
// Derive the identity from the request xAI will actually see. This makes
// Codex Responses Lite additional_tools part of the stable tool prefix.
cacheIdentity := resolveGrokCacheIdentity(c, patchedBody, "", upstreamModel)
mixedCacheIntentBody := append([]byte(nil), patchedBody...)
patchedBody, err = applyGrokResponsesCacheIdentity(patchedBody, body, cacheIdentity, account.IsGrokOAuth())
if err != nil {
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
}
// Free OAuth + client function tools: reuse Messages mixed-tools cache route
// (append web_search/x_search so xAI does not force non-cacheable build-free).
patchedBody, err = applyGrokFreeRequestToolCacheRoute(c, patchedBody, mixedCacheIntentBody, account, cacheIdentity)
if err != nil {
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
}
token, _, err := s.getRequestCredential(ctx, c, account)
if err != nil {
return nil, err
}
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
defer releaseUpstreamCtx()
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
upstreamStart := time.Now()
var resp *http.Response
for attempt := 0; ; attempt++ {
upstreamReq, buildErr := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity, s.cfg, s.settingService)
if buildErr != nil {
return nil, buildErr
}
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
if err != nil {
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
}
// xAI can reject encrypted reasoning copied from a response produced under
// another account or cache identity. Retry once with the same routing and
// credential after removing only the rejected encrypted reasoning payload.
if attempt > 0 || resp.StatusCode != http.StatusBadRequest {
break
}
respBody := s.readUpstreamErrorBody(resp)
if resp.Body != nil {
_ = resp.Body.Close()
}
if !isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody) {
resp.Body = io.NopCloser(bytes.NewReader(respBody))
break
}
retryBody, changed, trimErr := trimGrokInvalidEncryptedContentRetryBody(patchedBody)
if trimErr != nil {
return nil, fmt.Errorf("prepare Grok invalid encrypted_content retry: %w", trimErr)
}
if !changed {
resp.Body = io.NopCloser(bytes.NewReader(respBody))
break
}
patchedBody = retryBody
slog.Info("grok_invalid_encrypted_content_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
respBody := s.readUpstreamErrorBody(resp)
resp.Body = io.NopCloser(bytes.NewReader(respBody))
upstreamMsg := sanitizeUpstreamErrorMessage(extractUpstreamErrorMessage(respBody))
if upstreamMsg == "" {
upstreamMsg = fmt.Sprintf("xAI upstream returned status %d", resp.StatusCode)
}
kind := "http_error"
if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")),
Kind: kind,
Message: upstreamMsg,
})
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 {
markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now()))
}
if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
ResponseHeaders: resp.Header.Clone(),
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel)
}
// Attach model so rate-limit snapshots can fan out a team+model cool.
stateCtx := withGrokTeamRateLimitModel(ctx, upstreamModel)
s.updateGrokUsageFromResponse(stateCtx, account, resp.Header, resp.StatusCode)
var usage *OpenAIUsage
var firstTokenMs *int
responseID := ""
searchCount := 0
imageCount := 0
var imageOutputSizes []string
if reqStream {
maxLineSize := defaultMaxLineSize
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
maxLineSize = s.cfg.Gateway.MaxLineSize
}
resp.Body = newGrokResponsesBillingPingFilterBody(resp.Body, account, maxLineSize)
if hasGrokResponsesClientToolMapping(clientToolMapping) {
resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, clientToolMapping, maxLineSize)
}
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
if err != nil {
return nil, err
}
usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs
responseID = strings.TrimSpace(streamResult.responseID)
searchCount = streamResult.searchCount
imageCount = streamResult.imageCount
imageOutputSizes = streamResult.imageOutputSizes
} else {
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
if err != nil {
return nil, err
}
usage = nonStreamResult.usage
responseID = strings.TrimSpace(nonStreamResult.responseID)
searchCount = nonStreamResult.searchCount
imageCount = nonStreamResult.imageCount
imageOutputSizes = nonStreamResult.imageOutputSizes
}
if usage == nil {
usage = &OpenAIUsage{}
}
reasoningEffort := extractOpenAIReasoningEffortFromBody(patchedBody, originalModel)
result := &OpenAIForwardResult{
RequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")),
ResponseID: responseID,
Usage: *usage,
Model: originalModel,
UpstreamModel: upstreamModel,
ReasoningEffort: reasoningEffort,
Stream: reqStream,
OpenAIWSMode: false,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}
// Propagate search/image counters from the shared Responses handler — without
// this, stream/JSON counting runs but search_price_per_1k / image bills never apply.
if searchCount > 0 {
result.SearchCount = searchCount
}
if imageCount > 0 {
result.ImageCount = imageCount
result.ImageOutputSizes = imageOutputSizes
}
return result, nil
}
func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool {
if statusCode != http.StatusBadRequest {
return false
}
// xAI has used both flat and nested error envelopes:
// {"code":"invalid-argument","error":"Could not decrypt the provided encrypted_content."}
// {"error":{"message":"Could not decrypt the provided encrypted_content."}}
code := strings.TrimSpace(gjson.GetBytes(body, "code").String())
message := ""
errNode := gjson.GetBytes(body, "error")
switch {
case errNode.Type == gjson.String:
message = errNode.String()
case errNode.IsObject():
message = firstNonEmpty(errNode.Get("message").String(), errNode.Get("error").String())
if code == "" {
code = strings.TrimSpace(errNode.Get("code").String())
}
default:
message = gjson.GetBytes(body, "message").String()
}
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
if normalizedMessage == "" {
return false
}
if strings.EqualFold(code, "invalid_encrypted_content") {
return true
}
// Keep the official xAI flat-code gate so unrelated 400s are not retried.
if !strings.EqualFold(code, "invalid-argument") && code != "" {
return false
}
// Nested OpenAI-style envelopes may omit top-level code; require decrypt text.
if code == "" && !strings.Contains(normalizedMessage, "decrypt") {
return false
}
return strings.Contains(normalizedMessage, "encrypted_content") &&
(strings.Contains(normalizedMessage, "decrypt") ||
strings.Contains(normalizedMessage, "unmodified"))
}
// requestHasGrokEncryptedReasoning reports whether the outbound Responses body
// still carries reasoning.encrypted_content that can be stripped for retry.
func requestHasGrokEncryptedReasoning(body []byte) bool {
input := gjson.GetBytes(body, "input")
if !input.Exists() {
return false
}
items := input.Array()
if input.IsObject() {
items = []gjson.Result{input}
}
for _, item := range items {
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
continue
}
enc := item.Get("encrypted_content")
if enc.Exists() && enc.Type != gjson.Null && strings.TrimSpace(enc.String()) != "" {
return true
}
}
return false
}
type grokEncryptedContentStripRetriedKey struct{}
func markGrokEncryptedContentStripRetried(ctx context.Context) context.Context {
return context.WithValue(ctx, grokEncryptedContentStripRetriedKey{}, true)
}
func grokEncryptedContentStripRetried(ctx context.Context) bool {
v, _ := ctx.Value(grokEncryptedContentStripRetriedKey{}).(bool)
return v
}
// stripAnthropicThinkingSignatures removes thinking.signature from Claude
// history so a different Grok OAuth account can accept multi-turn tool
// continuations after decrypt failures. Returns ok=false when nothing changed.
func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) {
if len(body) == 0 || !bytes.Contains(body, []byte(`"signature"`)) {
return body, false
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return body, false
}
messages, ok := req["messages"].([]any)
if !ok || len(messages) == 0 {
return body, false
}
changed := false
for _, rawMsg := range messages {
msg, ok := rawMsg.(map[string]any)
if !ok {
continue
}
content, ok := msg["content"].([]any)
if !ok {
continue
}
for _, rawBlock := range content {
block, ok := rawBlock.(map[string]any)
if !ok {
continue
}
if typ, _ := block["type"].(string); typ != "thinking" {
continue
}
if _, has := block["signature"]; has {
delete(block, "signature")
changed = true
}
}
}
if !changed {
return body, false
}
out, err := json.Marshal(req)
if err != nil {
return body, false
}
return out, true
}
func trimGrokInvalidEncryptedContentRetryBody(body []byte) ([]byte, bool, error) {
input := gjson.GetBytes(body, "input")
items := input.Array()
if input.IsObject() {
items = []gjson.Result{input}
}
hasEncryptedReasoning := false
for _, item := range items {
if strings.TrimSpace(item.Get("type").String()) == "reasoning" && item.Get("encrypted_content").Exists() {
hasEncryptedReasoning = true
break
}
}
if !hasEncryptedReasoning {
return body, false, nil
}
var requestBody map[string]any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&requestBody); err != nil {
return nil, false, err
}
if !trimOpenAIEncryptedReasoningItems(requestBody) {
return body, false, nil
}
retryBody, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return nil, false, err
}
return retryBody, true, nil
}
func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
return patchGrokResponsesBodyBase(body, upstreamModel)
}
func patchGrokResponsesBodyWithClientTools(body []byte, upstreamModel string) ([]byte, apicompat.ResponsesClientToolMapping, error) {
if !json.Valid(body) {
return nil, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("invalid json request body")
}
promoted, err := sanitizeGrokResponsesInput(body)
if err != nil {
return nil, apicompat.ResponsesClientToolMapping{}, err
}
adapted, mapping, err := adaptGrokResponsesClientTools(promoted)
if err != nil {
return nil, apicompat.ResponsesClientToolMapping{}, err
}
patched, err := patchGrokResponsesBodyBase(adapted, upstreamModel)
if err != nil {
return nil, apicompat.ResponsesClientToolMapping{}, err
}
return patched, mapping, nil
}
func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, error) {
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json request body")
}
// sjson may reuse the input backing array; keep the caller's request bytes
// unchanged because the same body can be inspected for billing/retry paths.
out, err := sjson.SetBytes(append([]byte(nil), body...), "model", upstreamModel)
if err != nil {
return nil, err
}
out, err = normalizeGrokResponsesReasoningEffort(out, upstreamModel)
if err != nil {
return nil, err
}
out, err = sanitizeGrokResponsesModelCapabilities(out, upstreamModel)
if err != nil {
return nil, err
}
for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} {
if gjson.GetBytes(out, unsupportedField).Exists() {
out, err = sjson.DeleteBytes(out, unsupportedField)
if err != nil {
return nil, err
}
}
}
if strings.EqualFold(upstreamModel, "grok-4.5") {
for _, unsupportedField := range []string{"presence_penalty", "presencePenalty", "frequency_penalty", "frequencyPenalty", "stop"} {
if gjson.GetBytes(out, unsupportedField).Exists() {
out, err = sjson.DeleteBytes(out, unsupportedField)
if err != nil {
return nil, err
}
}
}
}
if grokModelRejectsLogprobs(upstreamModel) {
for _, unsupportedField := range []string{"logprobs", "top_logprobs"} {
if gjson.GetBytes(out, unsupportedField).Exists() {
out, err = sjson.DeleteBytes(out, unsupportedField)
if err != nil {
return nil, err
}
}
}
}
out, err = sanitizeGrokResponsesUnsupportedFields(out)
if err != nil {
return nil, err
}
out, err = convertOpenAICompactInputsForGrok(out)
if err != nil {
return nil, err
}
out, err = sanitizeGrokResponsesInput(out)
if err != nil {
return nil, err
}
out, err = stripRedundantGrokViewImageTool(out)
if err != nil {
return nil, err
}
out, err = sanitizeGrokReasoningNullContent(out)
if err != nil {
return nil, err
}
out, err = sanitizeGrokResponsesTools(out)
if err != nil {
return nil, err
}
return out, nil
}
// xAI's Grok 4.20 family and newer models do not support OpenAI's logprobs
// fields. Remove them before egress instead of forwarding a request the
// upstream rejects. Older Grok models retain the fields for compatibility.
func grokModelRejectsLogprobs(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
if slash := strings.LastIndex(model, "/"); slash >= 0 {
model = strings.TrimSpace(model[slash+1:])
}
return strings.HasPrefix(model, "grok-4.20")
}
func sanitizeGrokResponsesModelCapabilities(body []byte, upstreamModel string) ([]byte, error) {
if !grokModelRejectsReasoningEffort(upstreamModel) {
return body, nil
}
out := body
for _, field := range []string{"reasoning", "reasoning_effort", "reasoningEffort"} {
if !gjson.GetBytes(out, field).Exists() {
continue
}
var err error
out, err = sjson.DeleteBytes(out, field)
if err != nil {
return nil, fmt.Errorf("remove unsupported Grok Composer %s: %w", field, err)
}
}
return out, nil
}
func grokModelRejectsReasoningEffort(model string) bool {
model = strings.TrimSpace(strings.ToLower(model))
if slash := strings.LastIndex(model, "/"); slash >= 0 {
model = strings.TrimSpace(model[slash+1:])
}
switch model {
case "grok-composer", "grok-composer-2.5-fast", "composer-2.5":
return true
default:
return false
}
}
func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([]byte, error) {
supportsEffort := grokSupportsReasoningEffort(upstreamModel)
out := body
var err error
for _, field := range []string{"reasoning.effort", "reasoning_effort"} {
value := gjson.GetBytes(out, field)
if !value.Exists() {
continue
}
normalized, keep := normalizeGrokReasoningEffortValue(value.String(), upstreamModel)
if !supportsEffort || !keep {
out, err = sjson.DeleteBytes(out, field)
} else {
out, err = sjson.SetBytes(out, field, normalized)
}
if err != nil {
return nil, fmt.Errorf("normalize Grok reasoning field %s: %w", field, err)
}
}
if camel := gjson.GetBytes(out, "reasoningEffort"); camel.Exists() {
normalized, keep := normalizeGrokReasoningEffortValue(camel.String(), upstreamModel)
out, err = sjson.DeleteBytes(out, "reasoningEffort")
if err != nil {
return nil, fmt.Errorf("remove Grok reasoningEffort: %w", err)
}
if supportsEffort && keep && !gjson.GetBytes(out, "reasoning_effort").Exists() {
out, err = sjson.SetBytes(out, "reasoning_effort", normalized)
if err != nil {
return nil, fmt.Errorf("set Grok reasoning_effort: %w", err)
}
}
}
if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 {
out, err = sjson.DeleteBytes(out, "reasoning")
if err != nil {
return nil, fmt.Errorf("remove empty Grok reasoning: %w", err)
}
}
return out, nil
}
func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte, error) {
raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
if raw == "" {
raw = strings.TrimSpace(gjson.GetBytes(body, "reasoningEffort").String())
}
normalized, keep := normalizeGrokReasoningEffortValue(raw, upstreamModel)
keep = keep && grokSupportsReasoningEffort(upstreamModel)
out := body
var err error
if gjson.GetBytes(out, "reasoningEffort").Exists() {
out, err = sjson.DeleteBytes(out, "reasoningEffort")
if err != nil {
return nil, err
}
}
if !keep {
if gjson.GetBytes(out, "reasoning_effort").Exists() {
out, err = sjson.DeleteBytes(out, "reasoning_effort")
}
return out, err
}
out, err = sjson.SetBytes(out, "reasoning_effort", normalized)
return out, err
}
func normalizeGrokReasoningEffortValue(raw, model string) (string, bool) {
value := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(raw)))
switch value {
case "none", "low", "medium", "high":
return value, true
case "minimal":
return "low", true
case "xhigh", "extrahigh":
if grokSupportsXHighReasoningEffort(model) {
return "xhigh", true
}
return "high", true
case "max", "ultra":
return "high", true
default:
return "", false
}
}
func grokSupportsXHighReasoningEffort(model string) bool {
model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model)))
return model == "grok-4.6" || model == "grok-4.6-latest"
}
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",
"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":
return true
default:
return false
}
}
var grokResponsesUnsupportedRecursiveFields = map[string]struct{}{
"external_web_access": {},
}
func sanitizeGrokResponsesUnsupportedFields(body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"external_web_access"`)) {
return body, nil
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
if !deleteJSONFields(payload, grokResponsesUnsupportedRecursiveFields) {
return body, nil
}
return json.Marshal(payload)
}
func deleteJSONFields(value any, fields map[string]struct{}) bool {
switch typed := value.(type) {
case map[string]any:
changed := false
for field := range fields {
if _, ok := typed[field]; ok {
delete(typed, field)
changed = true
}
}
for _, child := range typed {
if deleteJSONFields(child, fields) {
changed = true
}
}
return changed
case []any:
changed := false
for _, child := range typed {
if deleteJSONFields(child, fields) {
changed = true
}
}
return changed
default:
return false
}
}
// additional_tools is a Codex/Responses Lite private input carrier. xAI's
// Responses schema rejects the carrier itself, but accepts supported tools at
// the top level. Preserve top-level order, append newly discovered tools in
// carrier order, then let sanitizeGrokResponsesTools filter unsupported types.
func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"additional_tools"`)) {
return body, nil
}
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body, nil
}
rawItems := input.Array()
filtered := make([]json.RawMessage, 0, len(rawItems))
topLevelTools := gjson.GetBytes(body, "tools")
mergedTools := make([]json.RawMessage, 0)
seenTools := make(map[string]struct{})
appendTool := func(tool gjson.Result) bool {
key := grokResponsesToolDedupKey(tool)
if _, exists := seenTools[key]; exists {
return false
}
seenTools[key] = struct{}{}
mergedTools = append(mergedTools, json.RawMessage(tool.Raw))
return true
}
if topLevelTools.IsArray() {
for _, tool := range topLevelTools.Array() {
seenTools[grokResponsesToolDedupKey(tool)] = struct{}{}
mergedTools = append(mergedTools, json.RawMessage(tool.Raw))
}
}
promoted := false
for _, item := range rawItems {
if strings.TrimSpace(item.Get("type").String()) == "additional_tools" {
tools := item.Get("tools")
if tools.IsArray() {
for _, tool := range tools.Array() {
if appendTool(tool) {
promoted = true
}
}
}
continue
}
filtered = append(filtered, json.RawMessage(item.Raw))
}
if len(filtered) == len(rawItems) {
return body, nil
}
encoded, err := json.Marshal(filtered)
if err != nil {
return nil, err
}
body, err = sjson.SetRawBytes(body, "input", encoded)
if err != nil || !promoted {
return body, err
}
encodedTools, err := json.Marshal(mergedTools)
if err != nil {
return nil, err
}
return sjson.SetRawBytes(body, "tools", encodedTools)
}
// An inline input_image is already visible to Grok. Keeping Codex's local
// view_image tool in the same turn can make Grok announce a tool call without
// actually calling it, so remove only that redundant automatic choice.
func stripRedundantGrokViewImageTool(body []byte) ([]byte, error) {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, nil
}
items := input.Array()
if len(items) == 0 {
return body, nil
}
current := items[len(items)-1]
if strings.TrimSpace(current.Get("role").String()) != "user" ||
!openAIJSONValueMayContainImageInput(current) {
return body, nil
}
toolChoice := gjson.GetBytes(body, "tool_choice")
if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" {
choiceName := strings.TrimSpace(toolChoice.Get("name").String())
if choiceName == "" {
choiceName = strings.TrimSpace(toolChoice.Get("function.name").String())
}
if choiceName == "view_image" {
return body, nil
}
}
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return body, nil
}
filtered := make([]json.RawMessage, 0, len(tools.Array()))
changed := false
for _, tool := range tools.Array() {
if strings.TrimSpace(tool.Get("type").String()) == "function" &&
strings.TrimSpace(tool.Get("name").String()) == "view_image" {
changed = true
continue
}
filtered = append(filtered, json.RawMessage(tool.Raw))
}
if !changed {
return body, nil
}
if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" {
return body, nil
}
if len(filtered) == 0 {
out, err := sjson.DeleteBytes(body, "tools")
if err != nil {
return nil, err
}
return sjson.DeleteBytes(out, "parallel_tool_calls")
}
encoded, err := json.Marshal(filtered)
if err != nil {
return nil, err
}
return sjson.SetRawBytes(body, "tools", encoded)
}
func grokResponsesToolDedupKey(tool gjson.Result) string {
toolType := strings.TrimSpace(tool.Get("type").String())
if toolType != "" {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return "type:" + toolType + "\x00name:" + name
}
if toolType == "mcp" {
if label := strings.TrimSpace(tool.Get("server_label").String()); label != "" {
return "type:mcp\x00server_label:" + label
}
}
}
return "json:" + normalizeCompatSeedJSON(json.RawMessage(tool.Raw))
}
// sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。
// xAI 的 untagged enum 反序列化器拒收该字段,返回 422。
func sanitizeGrokReasoningNullContent(body []byte) ([]byte, error) {
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body, nil
}
items := input.Array()
changed := false
for i := len(items) - 1; i >= 0; i-- {
item := items[i]
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
continue
}
contentResult := item.Get("content")
if contentResult.Exists() && contentResult.Type == gjson.Null {
var err error
body, err = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.content", i))
if err != nil {
return nil, err
}
changed = true
}
}
_ = changed
return body, nil
}
var grokResponsesSupportedToolTypes = map[string]struct{}{
"code_execution": {},
"code_interpreter": {},
"collections_search": {},
"file_search": {},
"function": {},
"mcp": {},
"shell": {},
"web_search": {},
"x_search": {},
}
func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
tools := gjson.GetBytes(body, "tools")
if !tools.Exists() {
if gjson.GetBytes(body, "tool_choice").Exists() {
return sjson.DeleteBytes(body, "tool_choice")
}
return body, nil
}
if !tools.IsArray() {
return body, nil
}
rawTools := tools.Array()
filteredTools := make([]json.RawMessage, 0, len(rawTools))
toolsChanged := false
for _, tool := range rawTools {
toolType := strings.TrimSpace(tool.Get("type").String())
if _, ok := grokResponsesSupportedToolTypes[toolType]; ok {
raw := json.RawMessage(tool.Raw)
if toolType == "function" && (!tool.Get("parameters").Exists() || tool.Get("parameters").Type == gjson.Null) {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
payload["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
raw = encoded
toolsChanged = true
}
filteredTools = append(filteredTools, raw)
}
}
var err error
if len(filteredTools) != len(rawTools) || toolsChanged {
if len(filteredTools) == 0 {
body, err = sjson.DeleteBytes(body, "tools")
} else {
var encoded []byte
encoded, err = json.Marshal(filteredTools)
if err != nil {
return nil, err
}
body, err = sjson.SetRawBytes(body, "tools", encoded)
}
if err != nil {
return nil, err
}
}
toolChoice := gjson.GetBytes(body, "tool_choice")
if !toolChoice.Exists() {
return body, nil
}
if shouldDropGrokToolChoice(toolChoice, filteredTools) {
body, err = sjson.DeleteBytes(body, "tool_choice")
if err != nil {
return nil, err
}
}
return body, nil
}
func shouldDropGrokToolChoice(toolChoice gjson.Result, tools []json.RawMessage) bool {
if len(tools) == 0 {
return true
}
if !toolChoice.IsObject() {
return false
}
choiceType := strings.TrimSpace(toolChoice.Get("type").String())
if choiceType == "" {
return false
}
if _, ok := grokResponsesSupportedToolTypes[choiceType]; !ok {
return true
}
if choiceType == "function" {
choiceName := strings.TrimSpace(toolChoice.Get("name").String())
if choiceName == "" {
choiceName = strings.TrimSpace(toolChoice.Get("function.name").String())
}
if choiceName == "" {
return false
}
for _, tool := range tools {
var item struct {
Type string `json:"type"`
Name string `json:"name"`
Function struct {
Name string `json:"name"`
} `json:"function"`
}
if err := json.Unmarshal(tool, &item); err != nil {
continue
}
name := strings.TrimSpace(item.Name)
if name == "" {
name = strings.TrimSpace(item.Function.Name)
}
if strings.TrimSpace(item.Type) == "function" && name == choiceName {
return false
}
}
return true
}
return false
}
func (s *OpenAIGatewayService) bridgeGrokComposerImageInputs(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
token string,
) ([]byte, OpenAIUsage, bool, error) {
if !shouldBridgeGrokComposerImageInputs(body) {
return body, OpenAIUsage{}, false, nil
}
var reqBody map[string]any
if err := json.Unmarshal(body, &reqBody); err != nil {
return body, OpenAIUsage{}, false, fmt.Errorf("parse grok composer image bridge request: %w", err)
}
imageURLs := collectGrokComposerImageURLs(reqBody)
if len(imageURLs) == 0 {
return body, OpenAIUsage{}, false, nil
}
descriptions := make([]string, 0, len(imageURLs))
var bridgeUsage OpenAIUsage
for index, imageURL := range imageURLs {
description, usage, err := s.describeGrokComposerImage(ctx, c, account, token, imageURL, index+1)
if err != nil {
return body, bridgeUsage, false, err
}
descriptions = append(descriptions, description)
addOpenAIUsage(&bridgeUsage, usage)
}
if !rewriteGrokComposerImagesAsText(reqBody, descriptions) {
return body, bridgeUsage, false, nil
}
bridgedBody, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, bridgeUsage, false, fmt.Errorf("serialize grok composer image bridge request: %w", err)
}
return bridgedBody, bridgeUsage, true, nil
}
func shouldBridgeGrokComposerImageInputs(body []byte) bool {
if len(body) == 0 || !isGrokComposerModel(gjson.GetBytes(body, "model").String()) {
return false
}
messages := gjson.GetBytes(body, "messages")
if !messages.Exists() {
return false
}
return openAIJSONValueMayContainImageInput(messages)
}
func isGrokComposerModel(model string) bool {
model = strings.TrimSpace(strings.ToLower(model))
if model == "" {
return false
}
if strings.Contains(model, "/") {
parts := strings.Split(model, "/")
model = strings.TrimSpace(parts[len(parts)-1])
}
return strings.Contains(model, "composer")
}
func collectGrokComposerImageURLs(reqBody map[string]any) []string {
messages, ok := reqBody["messages"].([]any)
if !ok {
return nil
}
var imageURLs []string
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
parts, ok := msgMap["content"].([]any)
if !ok {
continue
}
for _, part := range parts {
if imageURL := grokComposerImageURLFromPart(part); imageURL != "" {
imageURLs = append(imageURLs, imageURL)
}
}
}
return imageURLs
}
func grokComposerImageURLFromPart(part any) string {
partMap, ok := part.(map[string]any)
if !ok {
return ""
}
if strings.TrimSpace(strings.ToLower(fmt.Sprint(partMap["type"]))) != "image_url" {
return ""
}
switch imageURL := partMap["image_url"].(type) {
case string:
return normalizeGrokComposerImageURL(imageURL)
case map[string]any:
raw, _ := imageURL["url"].(string)
return normalizeGrokComposerImageURL(raw)
default:
return ""
}
}
func normalizeGrokComposerImageURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || isEmptyBase64DataURI(trimmed) {
return ""
}
return trimmed
}
func (s *OpenAIGatewayService) describeGrokComposerImage(
ctx context.Context,
c *gin.Context,
account *Account,
token string,
imageURL string,
index int,
) (string, OpenAIUsage, error) {
body, err := buildGrokComposerImageDescriptionBody(imageURL, index)
if err != nil {
return "", OpenAIUsage{}, err
}
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
// Image-description probes are auxiliary requests, not conversation turns.
// Do not bind them to the caller's Grok prompt-cache identity.
upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, body, token, "", s.cfg, s.settingService)
releaseUpstreamCtx()
if err != nil {
return "", OpenAIUsage{}, fmt.Errorf("build grok composer image bridge request: %w", err)
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
if err != nil {
return "", OpenAIUsage{}, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
respBody := s.readUpstreamErrorBody(resp)
upstreamMsg := sanitizeUpstreamErrorMessage(extractUpstreamErrorMessage(respBody))
if upstreamMsg == "" {
upstreamMsg = fmt.Sprintf("xAI image bridge upstream returned status %d", resp.StatusCode)
}
kind := "http_error"
if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")),
Kind: kind,
Message: upstreamMsg,
})
s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, grokComposerImageBridgeVisionModel), account, resp.StatusCode, resp.Header, respBody)
if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
return "", OpenAIUsage{}, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
ResponseHeaders: resp.Header.Clone(),
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return "", OpenAIUsage{}, fmt.Errorf("grok composer image bridge upstream error: %s", upstreamMsg)
}
s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, grokComposerImageBridgeVisionModel), account, resp.Header, resp.StatusCode)
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, nil)
if err != nil {
return "", OpenAIUsage{}, fmt.Errorf("read grok composer image bridge response: %w", err)
}
var parsed apicompat.ResponsesResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", OpenAIUsage{}, fmt.Errorf("parse grok composer image bridge response: %w", err)
}
description := strings.TrimSpace(grokResponsesOutputText(&parsed))
if description == "" {
return "", copyOpenAIUsageFromResponsesUsage(parsed.Usage), fmt.Errorf("grok composer image bridge returned empty description")
}
return description, copyOpenAIUsageFromResponsesUsage(parsed.Usage), nil
}
func buildGrokComposerImageDescriptionBody(imageURL string, index int) ([]byte, error) {
prompt := fmt.Sprintf("Describe image %d in concise, factual text for a downstream coding/composer model. Include visible text, UI elements, diagrams, errors, and spatial relationships. Do not mention that you are an image analysis bridge.", index)
req := map[string]any{
"model": grokComposerImageBridgeVisionModel,
"stream": false,
"store": false,
"max_output_tokens": grokComposerImageBridgeMaxOutputTokens,
"input": []any{
map[string]any{
"type": "message",
"role": "user",
"content": []any{
map[string]any{"type": "input_text", "text": prompt},
map[string]any{"type": "input_image", "image_url": imageURL},
},
},
},
}
return marshalOpenAIUpstreamJSON(req)
}
func grokResponsesOutputText(resp *apicompat.ResponsesResponse) string {
if resp == nil {
return ""
}
var parts []string
for _, output := range resp.Output {
for _, content := range output.Content {
if content.Type == "output_text" || content.Type == "text" || content.Type == "input_text" {
if text := strings.TrimSpace(content.Text); text != "" {
parts = append(parts, text)
}
}
}
}
return strings.Join(parts, "\n\n")
}
func rewriteGrokComposerImagesAsText(reqBody map[string]any, descriptions []string) bool {
messages, ok := reqBody["messages"].([]any)
if !ok {
return false
}
imageIndex := 0
changed := false
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
parts, ok := msgMap["content"].([]any)
if !ok {
continue
}
var textParts []string
messageChanged := false
for _, part := range parts {
if imageURL := grokComposerImageURLFromPart(part); imageURL != "" {
if imageIndex < len(descriptions) {
textParts = append(textParts, fmt.Sprintf("Image %d description: %s", imageIndex+1, strings.TrimSpace(descriptions[imageIndex])))
}
imageIndex++
messageChanged = true
continue
}
if text := grokComposerTextFromPart(part); text != "" {
textParts = append(textParts, text)
}
}
if messageChanged {
msgMap["content"] = strings.Join(textParts, "\n\n")
changed = true
}
}
return changed
}
func grokComposerTextFromPart(part any) string {
partMap, ok := part.(map[string]any)
if !ok {
return ""
}
partType := strings.TrimSpace(strings.ToLower(fmt.Sprint(partMap["type"])))
switch partType {
case "text", "input_text":
text, _ := partMap["text"].(string)
return strings.TrimSpace(text)
default:
return ""
}
}
func addOpenAIUsage(dst *OpenAIUsage, usage OpenAIUsage) {
if dst == nil {
return
}
dst.InputTokens += usage.InputTokens
dst.ImageInputTokens += usage.ImageInputTokens
dst.OutputTokens += usage.OutputTokens
dst.CacheCreationInputTokens += usage.CacheCreationInputTokens
dst.CacheReadInputTokens += usage.CacheReadInputTokens
dst.ImageOutputTokens += usage.ImageOutputTokens
}
func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, cacheIdentity string, cfg *config.Config, settings ...*SettingService) (*http.Request, error) {
targetURL, err := buildGrokResponsesURL(account, cfg, settings...)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if account.IsGrokOAuth() {
applyGrokCLIHeaders(req.Header)
}
applyGrokCacheHeaders(req.Header, cacheIdentity)
if c != nil {
if v := c.GetHeader("OpenAI-Beta"); strings.TrimSpace(v) != "" {
req.Header.Set("OpenAI-Beta", v)
}
}
// 账号级请求头覆写最后应用,使配置值优先于上面的内置默认头;
// 打到官方 CLI 网关时身份头仍由共享传输层最终强制。
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
// applyGrokCLIHeaders identifies subscription traffic as a supported Grok CLI
// version. The CLI gateway rejects otherwise valid OAuth requests without it.
// Identity pins come from package xai so service-layer headers match the final
// transport rewrite on cli-chat-proxy.grok.com.
func applyGrokCLIHeaders(headers http.Header) {
if headers == nil {
return
}
version := xai.ResolveCLIVersion()
headers.Set("User-Agent", xai.CLIUserAgent(version))
headers.Set("X-Grok-Client-Version", version)
headers.Set("x-grok-client-version", version)
headers.Set("x-grok-client-identifier", xai.CLIClientIdentifier)
// Historical mode value expected by some unit tests / older CLI probes.
headers.Set("X-Grok-Client-Mode", "interactive")
}
func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot) {
if s == nil || account == nil || account.ID <= 0 || snapshot == nil {
return
}
accountID := account.ID
now := time.Now()
resetAt, hasActiveLimit := grokRateLimitResetAtForAccount(account, snapshot, now)
if hasActiveLimit {
normalizeGrokExhaustedWindowResets(snapshot, resetAt, now)
}
recovery := isSuccessfulGrokRateLimitRecovery(account, snapshot)
critical := snapshot.StatusCode == http.StatusTooManyRequests || hasActiveLimit || recovery
if s.codexSnapshotThrottle != nil {
allowed := s.codexSnapshotThrottle.Allow(accountID, now)
if !critical && !allowed {
return
}
}
updates := map[string]any{
grokQuotaSnapshotExtraKey: snapshot,
}
// Also derive the scheduling-threshold extras (grok_sched_*) the evaluator
// reads in grokThresholdCandidates. Without this writer the admin-configured
// Grok auto-pause threshold could never fire (the read side was dead config).
for k, v := range buildGrokSchedulerExtraUpdates(snapshot) {
updates[k] = v
}
stateCtx := ctx
if hasActiveLimit {
var cancel context.CancelFunc
stateCtx, cancel = openAIAccountStateContext(ctx)
defer cancel()
}
// Account pointers on the request path are per-request copies (Redis/DB decode),
// not a shared in-process cache. Mutating Extra here matches token refresh /
// rate-limit writers; do not reuse the same *Account across goroutines.
if account.Extra == nil {
account.Extra = map[string]any{}
}
account.Extra[grokQuotaSnapshotExtraKey] = snapshot
if s.accountRepo != nil {
_ = s.accountRepo.UpdateExtra(stateCtx, accountID, updates)
}
// Error responses are reconciled by handleGrokAccountUpstreamError. Pool-mode
// API keys retain the snapshot for observability but leave account health to
// the upstream pool. Other accounts install the immediate runtime and durable
// rate-limit state when the observed window is exhausted.
if hasActiveLimit && !account.IsPoolMode() {
s.rateLimitGrok(stateCtx, account, resetAt)
} else if recovery {
clearGrokRateLimitAfterRecovery(stateCtx, s.accountRepo, account)
}
}
func (s *OpenAIGatewayService) updateGrokUsageFromResponse(ctx context.Context, account *Account, headers http.Header, statusCode int) {
snapshot := parseGrokQuotaSnapshot(headers, statusCode, time.Now())
if snapshot != nil {
stampGrokQuotaSnapshotForPlan(account, snapshot, grokRequestedModelFromCtx(ctx))
s.updateGrokUsageSnapshot(ctx, account, snapshot)
return
}
// Successful responses are recovery evidence even when the upstream omits
// optional quota headers. Do not replace an informative stored snapshot with
// an empty one; only clear the exact observed cooldown generation.
recoverySnapshot := &xai.QuotaSnapshot{StatusCode: statusCode}
if isSuccessfulGrokRateLimitRecovery(account, recoverySnapshot) {
clearGrokRateLimitAfterRecovery(ctx, s.accountRepo, account)
}
}
func parseGrokQuotaSnapshot(headers http.Header, statusCode int, now time.Time) *xai.QuotaSnapshot {
snapshot := xai.ParseQuotaHeaders(headers, statusCode)
if snapshot == nil && statusCode == http.StatusTooManyRequests {
return &xai.QuotaSnapshot{
StatusCode: statusCode,
UpdatedAt: now.UTC().Format(time.RFC3339),
}
}
return snapshot
}
func normalizeGrokExhaustedWindowResets(snapshot *xai.QuotaSnapshot, resetAt, now time.Time) {
if snapshot == nil || !resetAt.After(now) {
return
}
for _, window := range []*xai.QuotaWindow{snapshot.Requests, snapshot.Tokens} {
if window == nil || window.Remaining == nil || *window.Remaining > 0 {
continue
}
candidate := time.Time{}
if window.ResetUnix != nil && *window.ResetUnix > 0 {
candidate = time.Unix(*window.ResetUnix, 0)
} else if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(window.ResetAt)); err == nil {
candidate = parsed
}
if !candidate.After(now) {
candidate = resetAt
}
resetUnix := candidate.Unix()
window.ResetUnix = &resetUnix
window.ResetAt = candidate.UTC().Format(time.RFC3339)
}
}
func grokRateLimitResetAt(snapshot *xai.QuotaSnapshot, now time.Time) (time.Time, bool) {
if snapshot == nil {
return time.Time{}, false
}
// Retry-After is xAI's explicit retry boundary. Use the observation time so
// a persisted snapshot does not start a fresh cooldown every time it is read.
retryAfterExpired := false
var resetAt time.Time
if snapshot.RetryAfterSeconds != nil && *snapshot.RetryAfterSeconds > 0 {
observedAt := now
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(snapshot.UpdatedAt)); err == nil {
observedAt = parsed
}
retryAfterResetAt := observedAt.Add(time.Duration(*snapshot.RetryAfterSeconds) * time.Second)
if retryAfterResetAt.After(now) {
resetAt = retryAfterResetAt
} else {
retryAfterExpired = true
}
}
exhausted := false
for _, window := range []*xai.QuotaWindow{snapshot.Requests, snapshot.Tokens} {
if window == nil || window.Remaining == nil || *window.Remaining > 0 {
continue
}
exhausted = true
candidate := time.Time{}
if window.ResetUnix != nil && *window.ResetUnix > 0 {
candidate = time.Unix(*window.ResetUnix, 0)
} else if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(window.ResetAt)); err == nil {
candidate = parsed
}
if candidate.After(now) && candidate.After(resetAt) {
resetAt = candidate
}
}
if !resetAt.IsZero() {
return resetAt, true
}
// An observed Retry-After is an absolute boundary once combined with the
// snapshot timestamp. Do not turn an expired persisted snapshot into a new
// rolling fallback cooldown, but still allow a later explicit window reset.
if retryAfterExpired {
return time.Time{}, false
}
if exhausted || snapshot.StatusCode == http.StatusTooManyRequests {
return now.Add(grokRateLimitFallbackCooldown), true
}
return time.Time{}, false
}
func grokRateLimitResetAtForAccount(account *Account, snapshot *xai.QuotaSnapshot, now time.Time) (time.Time, bool) {
resetAt, limited := grokRateLimitResetAt(snapshot, now)
if !limited || !isGrokOAuthAccount(account) || snapshot == nil || snapshot.StatusCode != http.StatusTooManyRequests {
return resetAt, limited
}
if account.RateLimitedAt == nil || account.RateLimitResetAt == nil {
return resetAt, true
}
previousResetAt := *account.RateLimitResetAt
if previousResetAt.After(now) || now.Sub(previousResetAt) > grokRateLimitBackoffQuietPeriod {
return resetAt, true
}
previousCooldown := previousResetAt.Sub(*account.RateLimitedAt)
if previousCooldown <= 0 {
return resetAt, true
}
adaptiveCooldown := grokRateLimitRepeatCooldown
switch {
case previousCooldown >= grokRateLimitSustainedCooldown:
adaptiveCooldown = grokRateLimitMaxAdaptiveCooldown
case previousCooldown >= grokRateLimitRepeatCooldown:
adaptiveCooldown = grokRateLimitSustainedCooldown
}
adaptiveResetAt := now.Add(adaptiveCooldown)
if adaptiveResetAt.After(resetAt) {
resetAt = adaptiveResetAt
}
return resetAt, true
}
func normalizeGrokRateLimitResetAt(account *Account, resetAt, now time.Time) time.Time {
if !resetAt.After(now) {
resetAt = now.Add(grokRateLimitFallbackCooldown)
}
if account != nil && account.RateLimitResetAt != nil && account.RateLimitResetAt.After(resetAt) {
resetAt = *account.RateLimitResetAt
}
return resetAt
}
type grokRateLimitExtendingRepository interface {
SetRateLimitedIfLater(ctx context.Context, id int64, resetAt time.Time) error
}
type grokRateLimitRecoveryRepository interface {
ClearRateLimitIfObserved(ctx context.Context, id int64, observedLimitedAt, observedResetAt time.Time) (bool, error)
}
func isSuccessfulGrokRateLimitRecovery(account *Account, snapshot *xai.QuotaSnapshot) bool {
return isGrokOAuthAccount(account) &&
account.RateLimitedAt != nil &&
account.RateLimitResetAt != nil &&
snapshot != nil &&
snapshot.StatusCode >= http.StatusOK &&
snapshot.StatusCode < http.StatusMultipleChoices
}
func clearGrokRateLimitAfterRecovery(ctx context.Context, repo AccountRepository, account *Account) {
if repo == nil || account == nil || account.RateLimitedAt == nil || account.RateLimitResetAt == nil || ctx.Err() != nil {
return
}
recoveryRepo, ok := repo.(grokRateLimitRecoveryRepository)
if !ok {
return
}
_, err := recoveryRepo.ClearRateLimitIfObserved(ctx, account.ID, *account.RateLimitedAt, *account.RateLimitResetAt)
if err != nil {
slog.Warn("grok_rate_limit_recovery_clear_failed", "account_id", account.ID, "error", err)
}
}
func persistGrokRateLimit(ctx context.Context, repo AccountRepository, account *Account, resetAt time.Time) {
if repo == nil || account == nil || account.ID <= 0 {
return
}
resetAt = normalizeGrokRateLimitResetAt(account, resetAt, time.Now())
stateCtx, cancel := openAIAccountStateContext(ctx)
defer cancel()
var err error
if extendingRepo, ok := repo.(grokRateLimitExtendingRepository); ok {
err = extendingRepo.SetRateLimitedIfLater(stateCtx, account.ID, resetAt)
} else {
err = repo.SetRateLimited(stateCtx, account.ID, resetAt)
}
if err != nil {
slog.Warn("persist_grok_rate_limit_failed", "account_id", account.ID, "reset_at", resetAt.UTC(), "error", err)
}
}
func (s *OpenAIGatewayService) rateLimitGrok(ctx context.Context, account *Account, resetAt time.Time) {
if s == nil || account == nil {
return
}
now := time.Now()
resetAt = normalizeGrokRateLimitResetAt(account, resetAt, now)
runtimeUntil := resetAt
if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(runtimeUntil) {
runtimeUntil = *account.TempUnschedulableUntil
}
s.BlockAccountScheduling(account, runtimeUntil, "429")
persistGrokRateLimit(ctx, s.accountRepo, account, resetAt)
// Propagate a short team+model cool so sibling OAuth accounts on the same
// xAI team skip the hot model without waiting for each to hit 429 alone.
// Model is taken from the latest request context when available; empty is a
// no-op inside markGrokTeamModelRateLimit.
if model, _ := ctx.Value(grokTeamRateLimitModelContextKey{}).(string); model != "" {
markGrokTeamModelRateLimit(account, model, resolveGrokTeamRateLimitUntil(resetAt, now))
}
}
// buildGrokSchedulerExtraUpdates derives the grok_sched_* scheduling snapshot
// (utilization percent + reset time) consumed by EvaluateAccountSchedulingThreshold.
// Utilization is the most-constrained of the requests/tokens windows.
func buildGrokSchedulerExtraUpdates(snapshot *xai.QuotaSnapshot) map[string]any {
if snapshot == nil {
return nil
}
util, reset, ok := grokSnapshotUtilization(snapshot)
if !ok {
return nil
}
updates := map[string]any{
"grok_sched_utilization": util,
"grok_sched_usage_updated_at": time.Now().UTC().Format(time.RFC3339),
}
if reset != nil {
// 防御:调度阈值暂停时长由 grok_sched_reset_at 决定。若上游返回脏的
// reset 头(例如把相对毫秒 "6000" 误当相对秒解析出 ~33h 的未来时刻),
// 不设上限会把耗尽账号长时间锁死。xAI 配额窗口不会超过一天,因此对
// 未来时刻做 grokMaxSchedulingResetHorizon 钳制;过去/无效值直接不写。
now := time.Now()
if reset.After(now) {
capped := *reset
if horizon := now.Add(grokMaxSchedulingResetHorizon); capped.After(horizon) {
capped = horizon
}
updates["grok_sched_reset_at"] = capped.UTC().Format(time.RFC3339)
}
}
return updates
}
// grokSnapshotUtilization returns the highest window utilization (0-100) across
// the requests/tokens quota windows and the reset time of that window.
func grokSnapshotUtilization(snapshot *xai.QuotaSnapshot) (float64, *time.Time, bool) {
if snapshot == nil {
return 0, nil, false
}
best := -1.0
var bestReset *time.Time
consider := func(window *xai.QuotaWindow) {
if window == nil || window.Limit == nil || *window.Limit <= 0 || window.Remaining == nil {
return
}
remaining := *window.Remaining
if remaining < 0 {
remaining = 0
}
util := (1 - float64(remaining)/float64(*window.Limit)) * 100
if util < 0 {
util = 0
}
if util > 100 {
util = 100
}
if util > best {
best = util
if window.ResetUnix != nil {
t := time.Unix(*window.ResetUnix, 0).UTC()
bestReset = &t
} else {
bestReset = nil
}
}
}
consider(snapshot.Requests)
consider(snapshot.Tokens)
if best < 0 {
return 0, nil, false
}
return best, bestReset, true
}
// grokMaxSchedulingResetHorizon bounds how far into the future a Grok
// scheduling-threshold pause (grok_sched_reset_at) may be set, so a malformed
// upstream reset header can't park an over-threshold account for days. xAI quota
// windows do not exceed ~a day.
const grokMaxSchedulingResetHorizon = 25 * time.Hour
// grokTeamRateLimitModelContextKey carries the upstream model for team cools.
type grokTeamRateLimitModelContextKey struct{}
// withGrokTeamRateLimitModel attaches the upstream model name for rate-limit
// side effects (team+model cool). Safe when model is empty.
func withGrokTeamRateLimitModel(ctx context.Context, model string) context.Context {
model = strings.TrimSpace(model)
if model == "" || ctx == nil {
return ctx
}
return context.WithValue(ctx, grokTeamRateLimitModelContextKey{}, model)
}
func grokRequestedModelFromCtx(ctx context.Context) string {
if ctx == nil {
return ""
}
model, _ := ctx.Value(grokTeamRateLimitModelContextKey{}).(string)
return strings.TrimSpace(model)
}
func isGrokHeavyTransientModel(requestedModel string) bool {
model := strings.ToLower(strings.TrimSpace(xai.ResolveGrokTextResponsesModelID(requestedModel)))
return strings.Contains(model, "multi-agent")
}
func persistGrokTransientModelCooldown(account *Account, decision GrokUpstreamFailureDecision) bool {
if account == nil {
return false
}
model := strings.TrimSpace(decision.Model)
if model == "" || !isGrokHeavyTransientModel(model) {
return false
}
cooldown := decision.Cooldown
if cooldown <= 0 {
cooldown = 3 * time.Minute
}
markGrokModelTransientBlock(account.ID, model, time.Now().Add(cooldown))
return true
}
func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) {
if s == nil || account == nil {
return
}
if isGrokContentPolicyRejection(statusCode, responseBody) {
return
}
now := time.Now()
snapshot := parseGrokQuotaSnapshot(headers, statusCode, now)
stampGrokQuotaSnapshotForPlan(account, snapshot, grokRequestedModelFromCtx(ctx))
s.updateGrokUsageSnapshot(ctx, account, snapshot)
// Body-first free-usage / empty / billing / capacity must run before the
// status switch so non-429 free-usage bodies still cool the account.
// Pool-mode still skips durable mutation unless an explicit temp rule matches.
decision := classifyGrokUpstreamFailure(statusCode, responseBody, grokRequestedModelFromCtx(ctx))
if decision.ShouldCooldown && decision.Class != GrokFailureNone && decision.Class != GrokFailureRateLimit {
if account.IsPoolMode() {
// Allow configured temp rules (403) below; skip default body cools.
} else {
// A free-tier exhaustion message describes a rolling usage window. Use
// an upstream absolute reset (or Retry-After) when available; otherwise
// apply only a short probe cooldown. Never start a fabricated 24h window
// at the instant this error was received.
if decision.Class == GrokFailureFreeUsage {
if resetAt, limited := grokRateLimitResetAtForAccount(account, parseGrokQuotaSnapshot(headers, statusCode, now), now); limited && resetAt.After(now) {
if decision.Model != "" && isGrokModelSpecificFreeUsage(strings.ToLower(decision.Reason), decision.Model) {
markGrokModelQuotaBlock(account.ID, decision.Model, resetAt)
return
}
s.rateLimitGrok(ctx, account, resetAt)
return
}
}
if s.applyGrokUpstreamFailureDecision(ctx, account, decision) {
return
}
}
}
if statusCode == http.StatusForbidden && s.applyGrokForbiddenPolicy(ctx, account, responseBody) {
return
}
if account.IsPoolMode() {
slog.Info("grok_pool_mode_error_state_skipped", "account_id", account.ID, "status_code", statusCode)
return
}
switch statusCode {
case http.StatusUnauthorized:
s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok credentials unauthorized")
case http.StatusPaymentRequired:
// 402 without a body-classified billing decision: keep the legacy 30m cool.
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok payment required")
case http.StatusForbidden:
// Spending-limit already handled by body classifier when phrasing matches.
if isGrokSpendingLimitError(responseBody) {
s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now()))
return
}
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied")
case http.StatusTooManyRequests:
// updateGrokUsageSnapshot installs rate-limit state for non-pool accounts.
// Free-usage 429 was already cooled above via body classification.
default:
if statusCode >= 500 {
s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error")
}
}
}
// isGrokSpendingLimitError detects xAI billing exhaustion bodies (often 403, sometimes 402).
func isGrokSpendingLimitError(responseBody []byte) bool {
if len(responseBody) == 0 {
return false
}
code := strings.ToLower(strings.TrimSpace(firstNonEmpty(
gjson.GetBytes(responseBody, "code").String(),
gjson.GetBytes(responseBody, "error.code").String(),
)))
if code == "personal-team-blocked:spending-limit" {
return true
}
message := strings.ToLower(strings.TrimSpace(firstNonEmpty(
gjson.GetBytes(responseBody, "error").String(),
gjson.GetBytes(responseBody, "error.message").String(),
gjson.GetBytes(responseBody, "message").String(),
)))
return strings.Contains(message, "spending limit") ||
strings.Contains(message, "run out of credits")
}
func (s *OpenAIGatewayService) tempUnscheduleGrok(ctx context.Context, account *Account, cooldown time.Duration, reason string) {
if s == nil || account == nil {
return
}
until := time.Now().Add(cooldown)
if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(until) {
until = *account.TempUnschedulableUntil
}
s.BlockAccountScheduling(account, until, reason)
if s.accountRepo != nil {
stateCtx, cancel := openAIAccountStateContext(ctx)
defer cancel()
_ = s.accountRepo.SetTempUnschedulable(stateCtx, account.ID, until, reason)
}
}