| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- package handler
- import (
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- coderws "github.com/coder/websocket"
- "github.com/gin-gonic/gin"
- "go.uber.org/zap"
- )
- // GrokRealtime exposes xAI's native Voice Realtime WebSocket.
- // Only Grok-platform API keys may use this endpoint.
- func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
- if c == nil || c.Request == nil || !isOpenAIWSUpgradeRequest(c.Request) {
- h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)")
- return
- }
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
- h.errorResponse(c, http.StatusNotFound, "not_found_error", "Realtime API is not supported for this platform")
- return
- }
- if !h.ensureResponsesDependencies(c, nil) {
- return
- }
- subscription, _ := middleware2.GetSubscriptionFromContext(c)
- if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
- status, code, message, retryAfter := billingErrorDetails(err)
- if retryAfter > 0 {
- c.Header("Retry-After", strconv.Itoa(retryAfter))
- }
- h.errorResponse(c, status, code, message)
- return
- }
- reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
- model := c.Query("model")
- if strings.TrimSpace(model) == "" {
- model = "grok-voice-latest"
- }
- // Keep the HTTP response uncommitted while selecting and probing an account.
- // Realtime is not an HTTP streaming response; using reqStream=true here would
- // let the wait queue flush an SSE ping before the WebSocket handshake succeeds.
- failed := map[int64]struct{}{}
- var selection *service.AccountSelectionResult
- var release func()
- var token string
- var upstream *service.GrokRealtimeUpstream
- var candidateSeen bool
- for attempts := 0; attempts < 4; attempts++ {
- // Realtime's voice model is not a text-model capability. Passing a
- // concrete text model here would reject accounts mapped only to an
- // older/default text model before the upstream handshake can decide.
- // An empty requested model keeps account selection capability-based;
- // the actual voice model remains in the upstream WS query below.
- candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
- c.Request.Context(), apiKey.GroupID, "", "", "", failed,
- service.OpenAIUpstreamTransportHTTPSSE,
- service.OpenAIEndpointCapabilityChatCompletions,
- false, false, false, service.PlatformGrok,
- )
- if selectErr != nil || candidate == nil || candidate.Account == nil {
- break
- }
- candidateSeen = true
- account := candidate.Account
- var streamStarted bool
- var slotStatus openAISlotAcquireResult
- release, slotStatus = h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", candidate, false, &streamStarted, reqLog)
- if slotStatus != openAISlotAcquireOK {
- if slotStatus == openAISlotAcquireFailed {
- return
- }
- failed[account.ID] = struct{}{}
- continue
- }
- var credErr error
- token, _, credErr = h.gatewayService.GetRequestCredential(c.Request.Context(), c, account)
- if credErr != nil {
- release()
- release = nil
- failed[account.ID] = struct{}{}
- continue
- }
- candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(c.Request.Context(), account, token, model)
- if openErr != nil {
- var local *service.UpstreamFailoverError
- if errors.As(openErr, &local) && local.Reason == "account_traffic_limit" {
- release()
- release = nil
- h.handleFailoverExhausted(c, local, false)
- return
- }
- reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr))
- statusCode := http.StatusBadGateway
- var dialErr *service.GrokRealtimeDialError
- if errors.As(openErr, &dialErr) && dialErr.StatusCode > 0 {
- statusCode = dialErr.StatusCode
- }
- h.gatewayService.HandleGrokRealtimeUpstreamError(c.Request.Context(), account, statusCode, []byte(openErr.Error()))
- release()
- release = nil
- failed[account.ID] = struct{}{}
- continue
- }
- selection, upstream = candidate, candidateUpstream
- break
- }
- if selection == nil || selection.Account == nil || release == nil || upstream == nil {
- if !candidateSeen {
- h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
- } else {
- h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable")
- }
- return
- }
- defer release()
- defer func() { _ = upstream.Close() }()
- conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
- if err != nil {
- return
- }
- defer func() { _ = conn.CloseNow() }()
- started := time.Now()
- audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtimeConn(c.Request.Context(), c, conn, upstream)
- elapsed := time.Since(started)
- if proxyErr != nil {
- reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
- if !isExpectedGrokRealtimeClose(proxyErr) {
- _ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed")
- return
- }
- }
- if result := grokRealtimeBillingResult(model, elapsed, audioObserved); result != nil {
- h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
- }
- }
- func grokRealtimeBillingResult(model string, elapsed time.Duration, audioObserved bool) *service.OpenAIForwardResult {
- if !audioObserved || elapsed <= 0 {
- return nil
- }
- return &service.OpenAIForwardResult{
- RequestID: service.StableGrokRealtimeBillingRequestID(""),
- Model: model,
- Duration: elapsed,
- AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
- }
- }
- func isExpectedGrokRealtimeClose(err error) bool {
- if err == nil {
- return true
- }
- switch coderws.CloseStatus(err) {
- case coderws.StatusNormalClosure, coderws.StatusGoingAway,
- coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure:
- return true
- default:
- return false
- }
- }
- // GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices".
- func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
- h.errorResponse(c, http.StatusNotFound, "not_found_error", "Voice API is not supported for this platform")
- return
- }
- if !h.ensureResponsesDependencies(c, nil) {
- return
- }
- subscription, _ := middleware2.GetSubscriptionFromContext(c)
- if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
- status, code, message, retryAfter := billingErrorDetails(err)
- if retryAfter > 0 {
- c.Header("Retry-After", strconv.Itoa(retryAfter))
- }
- h.errorResponse(c, status, code, message)
- return
- }
- body, err := readGrokVoiceGatewayBody(c)
- if err != nil {
- h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
- return
- }
- if endpoint == "tts" {
- subject, _ := middleware2.GetAuthSubjectFromContext(c)
- reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
- // TTS bodies use {"input":"..."} (and variants). Normalize to chat messages so
- // content moderation extractors see the spoken text.
- auditBody := body
- if input := extractGrokTTSInputText(body); input != "" {
- if b, err := json.Marshal(map[string]any{
- "messages": []map[string]any{{"role": "user", "content": input}},
- }); err == nil {
- auditBody = b
- }
- }
- if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, "grok-4.5", auditBody); decision != nil && !decision.AllowNextStage {
- h.openAISecurityAuditError(c, decision)
- return
- }
- }
- contentType := c.GetHeader("Content-Type")
- if strings.TrimSpace(contentType) == "" {
- contentType = "application/json"
- }
- failed := map[int64]struct{}{}
- var last *service.UpstreamFailoverError
- reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
- selectionModel := "grok-4.5"
- for attempts := 0; attempts < 4; attempts++ {
- selection, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
- c.Request.Context(),
- apiKey.GroupID,
- "",
- "",
- selectionModel,
- failed,
- service.OpenAIUpstreamTransportHTTPSSE,
- service.OpenAIEndpointCapabilityChatCompletions,
- false,
- false,
- false,
- service.PlatformGrok,
- )
- if selectErr != nil || selection == nil || selection.Account == nil {
- if last != nil {
- h.handleFailoverExhausted(c, last, false)
- } else {
- h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
- }
- return
- }
- account := selection.Account
- var started bool
- release, status := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &started, reqLog)
- if status == openAISlotAcquireProfitVetoed {
- failed[account.ID] = struct{}{}
- continue
- }
- if status != openAISlotAcquireOK {
- // Failed already wrote error response (or transient reject).
- if status == openAISlotAcquireFailed && len(failed) == 0 {
- // Slot path wrote the response; stop.
- return
- }
- failed[account.ID] = struct{}{}
- continue
- }
- result, forwardErr := func() (*service.OpenAIForwardResult, error) {
- defer release()
- return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType)
- }()
- if forwardErr == nil {
- h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result)
- return
- }
- var failoverErr *service.UpstreamFailoverError
- if errors.As(forwardErr, &failoverErr) && failoverErr.ShouldRetryNextAccount() {
- failed[account.ID] = struct{}{}
- last = failoverErr
- continue
- }
- // Non-failover errors: handleGrokMediaErrorResponse / transport already wrote response.
- return
- }
- if last != nil {
- h.handleFailoverExhausted(c, last, false)
- }
- }
- // recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set.
- func (h *OpenAIGatewayHandler) recordGrokVoiceUsage(
- c *gin.Context,
- apiKey *service.APIKey,
- account *service.Account,
- subscription *service.UserSubscription,
- endpoint string,
- body []byte,
- result *service.OpenAIForwardResult,
- ) {
- if h == nil || c == nil || apiKey == nil || account == nil || result == nil {
- return
- }
- if result.AudioUsage == nil {
- return
- }
- // Ensure forced durable request ids even if callers forget (realtime/tts/stt money path).
- if mode := strings.TrimSpace(result.AudioUsage.Mode); mode == "realtime" {
- result.RequestID = service.StableGrokRealtimeBillingRequestID(result.RequestID)
- } else {
- result.RequestID = service.StableGrokAudioBillingRequestID(result.RequestID)
- }
- userAgent := c.GetHeader("User-Agent")
- clientIP := ip.GetClientIP(c)
- sessionID := service.ExtractClientSessionID(c)
- requestPayloadHash := service.HashUsageRequestPayload(body)
- if requestPayloadHash == "" {
- requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint))
- }
- inboundEndpoint := GetInboundEndpoint(c)
- upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
- model := strings.TrimSpace(result.Model)
- if model == "" {
- model = endpoint
- }
- h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
- if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
- Result: result,
- APIKey: apiKey,
- User: apiKey.User,
- Account: account,
- Subscription: subscription,
- InboundEndpoint: inboundEndpoint,
- UpstreamEndpoint: upstreamEndpoint,
- UserAgent: userAgent,
- IPAddress: clientIP,
- RequestPayloadHash: requestPayloadHash,
- APIKeyService: h.apiKeyService,
- QuotaPlatform: quotaPlatform,
- SessionID: sessionID,
- ChannelUsageFields: clientRequestedUsageFields(c, service.ChannelMappingResult{}, model, result.UpstreamModel),
- }); err != nil {
- logger.L().With(
- zap.String("component", "handler.openai_gateway.grok_voice"),
- zap.Int64("user_id", apiKey.User.ID),
- zap.Int64("api_key_id", apiKey.ID),
- zap.Any("group_id", apiKey.GroupID),
- zap.String("endpoint", endpoint),
- zap.Int64("account_id", account.ID),
- ).Error("grok_voice.record_usage_failed", zap.Error(err))
- }
- })
- }
- func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) {
- if c == nil || c.Request == nil {
- return nil, errors.New("request body is required")
- }
- if c.Request.Body == nil {
- if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodDelete {
- return nil, nil
- }
- return nil, errors.New("request body is required")
- }
- return io.ReadAll(c.Request.Body)
- }
- // extractGrokTTSInputText pulls the primary spoken text from a TTS JSON body.
- func extractGrokTTSInputText(body []byte) string {
- if len(body) == 0 {
- return ""
- }
- var payload map[string]any
- if err := json.Unmarshal(body, &payload); err != nil {
- return ""
- }
- for _, key := range []string{"input", "text", "prompt"} {
- if v, ok := payload[key]; ok {
- if s, ok := v.(string); ok {
- return strings.TrimSpace(s)
- }
- }
- }
- return ""
- }
|