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 = "sub2api-grok/1.0" grokCLIVersion = "0.2.93" 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 } 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) } cacheIdentity := resolveGrokCacheIdentity(c, body, "", upstreamModel) patchedBody, err := patchGrokResponsesBody(body, upstreamModel) if err != nil { return nil, err } 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 = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, 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) 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) } 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: "failover", Message: upstreamMsg, }) s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverUpstreamError(resp.StatusCode) { 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) } s.updateGrokUsageFromResponse(ctx, account, resp.Header, resp.StatusCode) var usage *OpenAIUsage var firstTokenMs *int responseID := "" if reqStream { 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) } 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) } if usage == nil { usage = &OpenAIUsage{} } reasoningEffort := extractOpenAIReasoningEffortFromBody(patchedBody, originalModel) return &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, }, nil } func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { if statusCode != http.StatusBadRequest { return false } code := gjson.GetBytes(body, "code") message := gjson.GetBytes(body, "error") if code.Type != gjson.String || message.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(code.String()), "invalid-argument") { return false } normalizedMessage := strings.ToLower(message.String()) return strings.Contains(normalizedMessage, "decrypt") && strings.Contains(normalizedMessage, "encrypted_content") } 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) { if !json.Valid(body) { return nil, fmt.Errorf("invalid json request body") } out, err := sjson.SetBytes(body, "model", 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 } } } } out, err = sanitizeGrokResponsesUnsupportedFields(out) if err != nil { return nil, err } out, err = sanitizeGrokResponsesInput(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 } 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 } } 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 accepts ordinary message/function-call input items but // rejects this carrier before inference with a ModelInput deserialization // error. Top-level supported tools remain available through the separate // sanitizeGrokResponsesTools path. 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)) for _, item := range rawItems { if strings.TrimSpace(item.Get("type").String()) == "additional_tools" { 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 } return sjson.SetRawBytes(body, "input", encoded) } // 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() || !tools.IsArray() { return body, nil } rawTools := tools.Array() filteredTools := make([]json.RawMessage, 0, len(rawTools)) for _, tool := range rawTools { toolType := strings.TrimSpace(tool.Get("type").String()) if _, ok := grokResponsesSupportedToolTypes[toolType]; ok { filteredTools = append(filteredTools, json.RawMessage(tool.Raw)) } } var err error if len(filteredTools) != len(rawTools) { 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) 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) } 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: "failover", Message: upstreamMsg, }) s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverUpstreamError(resp.StatusCode) { 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(ctx, 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) (*http.Request, error) { targetURL, err := buildGrokResponsesURL(account, cfg) 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. func applyGrokCLIHeaders(headers http.Header) { if headers == nil { return } headers.Set("User-Agent", grokUpstreamUserAgent) headers.Set("X-Grok-Client-Version", grokCLIVersion) } 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 } } stateCtx := ctx if hasActiveLimit { var cancel context.CancelFunc stateCtx, cancel = openAIAccountStateContext(ctx) defer cancel() } if s.accountRepo != nil { _ = s.accountRepo.UpdateExtra(stateCtx, accountID, map[string]any{ grokQuotaSnapshotExtraKey: snapshot, }) } // Error responses are reconciled by handleGrokAccountUpstreamError, which // also installs the immediate in-memory scheduling block. Successful // responses can still consume the last available request/token, so persist // that exhausted window here as a real rate limit rather than relying only // on the passive snapshot scheduler check. if hasActiveLimit { 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 { 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 } resetAt = normalizeGrokRateLimitResetAt(account, resetAt, time.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) } func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) { if s == nil || account == nil { return } now := time.Now() s.updateGrokUsageSnapshot(ctx, account, parseGrokQuotaSnapshot(headers, statusCode, now)) switch statusCode { case http.StatusUnauthorized: s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok credentials unauthorized") case http.StatusForbidden: s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied") case http.StatusTooManyRequests: // updateGrokUsageSnapshot installs both runtime and durable rate-limit state. default: if statusCode >= 500 { s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error") } } _ = responseBody } 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) } }