mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-11 14:32:36 +00:00
693 lines
20 KiB
Go
693 lines
20 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
)
|
|
|
|
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 {
|
|
return nil, fmt.Errorf("grok account type %s is not supported by subscription forwarding", account.Type)
|
|
}
|
|
|
|
upstreamModel := account.GetMappedModel(originalModel)
|
|
if strings.TrimSpace(upstreamModel) == "" {
|
|
upstreamModel = "grok-4.3"
|
|
}
|
|
patchedBody, err := patchGrokResponsesBody(body, upstreamModel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
token, _, err := s.GetAccessToken(ctx, account)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
|
defer releaseUpstreamCtx()
|
|
upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
proxyURL := ""
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
proxyURL = account.Proxy.URL()
|
|
}
|
|
|
|
upstreamStart := time.Now()
|
|
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)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respBody := s.readUpstreamErrorBody(resp)
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode))
|
|
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,
|
|
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
|
}
|
|
}
|
|
return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel)
|
|
}
|
|
|
|
s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(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{}
|
|
}
|
|
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: ptrStringOrNil(normalizeOpenAIReasoningEffort(gjson.GetBytes(patchedBody, "reasoning.effort").String())),
|
|
Stream: reqStream,
|
|
OpenAIWSMode: false,
|
|
ResponseHeaders: resp.Header.Clone(),
|
|
Duration: time.Since(startTime),
|
|
FirstTokenMs: firstTokenMs,
|
|
}, 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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
out, err = sanitizeGrokResponsesUnsupportedFields(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err = sanitizeGrokResponsesTools(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, body, token)
|
|
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)
|
|
s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode))
|
|
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,
|
|
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
|
}
|
|
}
|
|
return "", OpenAIUsage{}, fmt.Errorf("grok composer image bridge upstream error: %s", upstreamMsg)
|
|
}
|
|
|
|
s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(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 string) (*http.Request, error) {
|
|
targetURL, err := xai.BuildResponsesURL(account.GetGrokBaseURL())
|
|
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")
|
|
req.Header.Set("User-Agent", "sub2api-grok/1.0")
|
|
if c != nil {
|
|
if v := c.GetHeader("OpenAI-Beta"); strings.TrimSpace(v) != "" {
|
|
req.Header.Set("OpenAI-Beta", v)
|
|
}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, accountID int64, snapshot *xai.QuotaSnapshot) {
|
|
if s == nil || s.accountRepo == nil || accountID <= 0 || snapshot == nil {
|
|
return
|
|
}
|
|
if s.codexSnapshotThrottle != nil && !s.codexSnapshotThrottle.Allow(accountID, time.Now()) {
|
|
return
|
|
}
|
|
_ = s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
|
|
grokQuotaSnapshotExtraKey: snapshot,
|
|
})
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) {
|
|
if s == nil || account == nil {
|
|
return
|
|
}
|
|
switch statusCode {
|
|
case http.StatusUnauthorized:
|
|
s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok oauth token unauthorized")
|
|
case http.StatusForbidden:
|
|
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok entitlement or subscription tier denied")
|
|
case http.StatusTooManyRequests:
|
|
cooldown := 2 * time.Minute
|
|
if snapshot := xai.ParseQuotaHeaders(headers, statusCode); snapshot != nil && snapshot.RetryAfterSeconds != nil && *snapshot.RetryAfterSeconds > 0 {
|
|
cooldown = time.Duration(*snapshot.RetryAfterSeconds) * time.Second
|
|
}
|
|
s.tempUnscheduleGrok(ctx, account, cooldown, "grok rate limited")
|
|
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)
|
|
}
|
|
}
|
|
|
|
func ptrStringOrNil(value string) *string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|