grok_audio.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. coderws "github.com/coder/websocket"
  16. "github.com/gin-gonic/gin"
  17. "go.uber.org/zap"
  18. )
  19. // GrokRealtime exposes xAI's native Voice Realtime WebSocket.
  20. // Only Grok-platform API keys may use this endpoint.
  21. func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
  22. if c == nil || c.Request == nil || !isOpenAIWSUpgradeRequest(c.Request) {
  23. h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)")
  24. return
  25. }
  26. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  27. if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
  28. h.errorResponse(c, http.StatusNotFound, "not_found_error", "Realtime API is not supported for this platform")
  29. return
  30. }
  31. if !h.ensureResponsesDependencies(c, nil) {
  32. return
  33. }
  34. subscription, _ := middleware2.GetSubscriptionFromContext(c)
  35. if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
  36. status, code, message, retryAfter := billingErrorDetails(err)
  37. if retryAfter > 0 {
  38. c.Header("Retry-After", strconv.Itoa(retryAfter))
  39. }
  40. h.errorResponse(c, status, code, message)
  41. return
  42. }
  43. reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
  44. model := c.Query("model")
  45. if strings.TrimSpace(model) == "" {
  46. model = "grok-voice-latest"
  47. }
  48. // Keep the HTTP response uncommitted while selecting and probing an account.
  49. // Realtime is not an HTTP streaming response; using reqStream=true here would
  50. // let the wait queue flush an SSE ping before the WebSocket handshake succeeds.
  51. failed := map[int64]struct{}{}
  52. var selection *service.AccountSelectionResult
  53. var release func()
  54. var token string
  55. var upstream *service.GrokRealtimeUpstream
  56. var candidateSeen bool
  57. for attempts := 0; attempts < 4; attempts++ {
  58. // Realtime's voice model is not a text-model capability. Passing a
  59. // concrete text model here would reject accounts mapped only to an
  60. // older/default text model before the upstream handshake can decide.
  61. // An empty requested model keeps account selection capability-based;
  62. // the actual voice model remains in the upstream WS query below.
  63. candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
  64. c.Request.Context(), apiKey.GroupID, "", "", "", failed,
  65. service.OpenAIUpstreamTransportHTTPSSE,
  66. service.OpenAIEndpointCapabilityChatCompletions,
  67. false, false, false, service.PlatformGrok,
  68. )
  69. if selectErr != nil || candidate == nil || candidate.Account == nil {
  70. break
  71. }
  72. candidateSeen = true
  73. account := candidate.Account
  74. var streamStarted bool
  75. var slotStatus openAISlotAcquireResult
  76. release, slotStatus = h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", candidate, false, &streamStarted, reqLog)
  77. if slotStatus != openAISlotAcquireOK {
  78. if slotStatus == openAISlotAcquireFailed {
  79. return
  80. }
  81. failed[account.ID] = struct{}{}
  82. continue
  83. }
  84. var credErr error
  85. token, _, credErr = h.gatewayService.GetRequestCredential(c.Request.Context(), c, account)
  86. if credErr != nil {
  87. release()
  88. release = nil
  89. failed[account.ID] = struct{}{}
  90. continue
  91. }
  92. candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(c.Request.Context(), account, token, model)
  93. if openErr != nil {
  94. var local *service.UpstreamFailoverError
  95. if errors.As(openErr, &local) && local.Reason == "account_traffic_limit" {
  96. release()
  97. release = nil
  98. h.handleFailoverExhausted(c, local, false)
  99. return
  100. }
  101. reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr))
  102. statusCode := http.StatusBadGateway
  103. var dialErr *service.GrokRealtimeDialError
  104. if errors.As(openErr, &dialErr) && dialErr.StatusCode > 0 {
  105. statusCode = dialErr.StatusCode
  106. }
  107. h.gatewayService.HandleGrokRealtimeUpstreamError(c.Request.Context(), account, statusCode, []byte(openErr.Error()))
  108. release()
  109. release = nil
  110. failed[account.ID] = struct{}{}
  111. continue
  112. }
  113. selection, upstream = candidate, candidateUpstream
  114. break
  115. }
  116. if selection == nil || selection.Account == nil || release == nil || upstream == nil {
  117. if !candidateSeen {
  118. h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
  119. } else {
  120. h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable")
  121. }
  122. return
  123. }
  124. defer release()
  125. defer func() { _ = upstream.Close() }()
  126. conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
  127. if err != nil {
  128. return
  129. }
  130. defer func() { _ = conn.CloseNow() }()
  131. started := time.Now()
  132. audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtimeConn(c.Request.Context(), c, conn, upstream)
  133. elapsed := time.Since(started)
  134. if proxyErr != nil {
  135. reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
  136. if !isExpectedGrokRealtimeClose(proxyErr) {
  137. _ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed")
  138. return
  139. }
  140. }
  141. if result := grokRealtimeBillingResult(model, elapsed, audioObserved); result != nil {
  142. h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
  143. }
  144. }
  145. func grokRealtimeBillingResult(model string, elapsed time.Duration, audioObserved bool) *service.OpenAIForwardResult {
  146. if !audioObserved || elapsed <= 0 {
  147. return nil
  148. }
  149. return &service.OpenAIForwardResult{
  150. RequestID: service.StableGrokRealtimeBillingRequestID(""),
  151. Model: model,
  152. Duration: elapsed,
  153. AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
  154. }
  155. }
  156. func isExpectedGrokRealtimeClose(err error) bool {
  157. if err == nil {
  158. return true
  159. }
  160. switch coderws.CloseStatus(err) {
  161. case coderws.StatusNormalClosure, coderws.StatusGoingAway,
  162. coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure:
  163. return true
  164. default:
  165. return false
  166. }
  167. }
  168. // GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices".
  169. func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
  170. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  171. if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
  172. h.errorResponse(c, http.StatusNotFound, "not_found_error", "Voice API is not supported for this platform")
  173. return
  174. }
  175. if !h.ensureResponsesDependencies(c, nil) {
  176. return
  177. }
  178. subscription, _ := middleware2.GetSubscriptionFromContext(c)
  179. if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
  180. status, code, message, retryAfter := billingErrorDetails(err)
  181. if retryAfter > 0 {
  182. c.Header("Retry-After", strconv.Itoa(retryAfter))
  183. }
  184. h.errorResponse(c, status, code, message)
  185. return
  186. }
  187. body, err := readGrokVoiceGatewayBody(c)
  188. if err != nil {
  189. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  190. return
  191. }
  192. if endpoint == "tts" {
  193. subject, _ := middleware2.GetAuthSubjectFromContext(c)
  194. reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
  195. // TTS bodies use {"input":"..."} (and variants). Normalize to chat messages so
  196. // content moderation extractors see the spoken text.
  197. auditBody := body
  198. if input := extractGrokTTSInputText(body); input != "" {
  199. if b, err := json.Marshal(map[string]any{
  200. "messages": []map[string]any{{"role": "user", "content": input}},
  201. }); err == nil {
  202. auditBody = b
  203. }
  204. }
  205. if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, "grok-4.5", auditBody); decision != nil && !decision.AllowNextStage {
  206. h.openAISecurityAuditError(c, decision)
  207. return
  208. }
  209. }
  210. contentType := c.GetHeader("Content-Type")
  211. if strings.TrimSpace(contentType) == "" {
  212. contentType = "application/json"
  213. }
  214. failed := map[int64]struct{}{}
  215. var last *service.UpstreamFailoverError
  216. reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
  217. selectionModel := "grok-4.5"
  218. for attempts := 0; attempts < 4; attempts++ {
  219. selection, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
  220. c.Request.Context(),
  221. apiKey.GroupID,
  222. "",
  223. "",
  224. selectionModel,
  225. failed,
  226. service.OpenAIUpstreamTransportHTTPSSE,
  227. service.OpenAIEndpointCapabilityChatCompletions,
  228. false,
  229. false,
  230. false,
  231. service.PlatformGrok,
  232. )
  233. if selectErr != nil || selection == nil || selection.Account == nil {
  234. if last != nil {
  235. h.handleFailoverExhausted(c, last, false)
  236. } else {
  237. h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
  238. }
  239. return
  240. }
  241. account := selection.Account
  242. var started bool
  243. release, status := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &started, reqLog)
  244. if status == openAISlotAcquireProfitVetoed {
  245. failed[account.ID] = struct{}{}
  246. continue
  247. }
  248. if status != openAISlotAcquireOK {
  249. // Failed already wrote error response (or transient reject).
  250. if status == openAISlotAcquireFailed && len(failed) == 0 {
  251. // Slot path wrote the response; stop.
  252. return
  253. }
  254. failed[account.ID] = struct{}{}
  255. continue
  256. }
  257. result, forwardErr := func() (*service.OpenAIForwardResult, error) {
  258. defer release()
  259. return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType)
  260. }()
  261. if forwardErr == nil {
  262. h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result)
  263. return
  264. }
  265. var failoverErr *service.UpstreamFailoverError
  266. if errors.As(forwardErr, &failoverErr) && failoverErr.ShouldRetryNextAccount() {
  267. failed[account.ID] = struct{}{}
  268. last = failoverErr
  269. continue
  270. }
  271. // Non-failover errors: handleGrokMediaErrorResponse / transport already wrote response.
  272. return
  273. }
  274. if last != nil {
  275. h.handleFailoverExhausted(c, last, false)
  276. }
  277. }
  278. // recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set.
  279. func (h *OpenAIGatewayHandler) recordGrokVoiceUsage(
  280. c *gin.Context,
  281. apiKey *service.APIKey,
  282. account *service.Account,
  283. subscription *service.UserSubscription,
  284. endpoint string,
  285. body []byte,
  286. result *service.OpenAIForwardResult,
  287. ) {
  288. if h == nil || c == nil || apiKey == nil || account == nil || result == nil {
  289. return
  290. }
  291. if result.AudioUsage == nil {
  292. return
  293. }
  294. // Ensure forced durable request ids even if callers forget (realtime/tts/stt money path).
  295. if mode := strings.TrimSpace(result.AudioUsage.Mode); mode == "realtime" {
  296. result.RequestID = service.StableGrokRealtimeBillingRequestID(result.RequestID)
  297. } else {
  298. result.RequestID = service.StableGrokAudioBillingRequestID(result.RequestID)
  299. }
  300. userAgent := c.GetHeader("User-Agent")
  301. clientIP := ip.GetClientIP(c)
  302. sessionID := service.ExtractClientSessionID(c)
  303. requestPayloadHash := service.HashUsageRequestPayload(body)
  304. if requestPayloadHash == "" {
  305. requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint))
  306. }
  307. inboundEndpoint := GetInboundEndpoint(c)
  308. upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
  309. quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
  310. model := strings.TrimSpace(result.Model)
  311. if model == "" {
  312. model = endpoint
  313. }
  314. h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
  315. if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
  316. Result: result,
  317. APIKey: apiKey,
  318. User: apiKey.User,
  319. Account: account,
  320. Subscription: subscription,
  321. InboundEndpoint: inboundEndpoint,
  322. UpstreamEndpoint: upstreamEndpoint,
  323. UserAgent: userAgent,
  324. IPAddress: clientIP,
  325. RequestPayloadHash: requestPayloadHash,
  326. APIKeyService: h.apiKeyService,
  327. QuotaPlatform: quotaPlatform,
  328. SessionID: sessionID,
  329. ChannelUsageFields: clientRequestedUsageFields(c, service.ChannelMappingResult{}, model, result.UpstreamModel),
  330. }); err != nil {
  331. logger.L().With(
  332. zap.String("component", "handler.openai_gateway.grok_voice"),
  333. zap.Int64("user_id", apiKey.User.ID),
  334. zap.Int64("api_key_id", apiKey.ID),
  335. zap.Any("group_id", apiKey.GroupID),
  336. zap.String("endpoint", endpoint),
  337. zap.Int64("account_id", account.ID),
  338. ).Error("grok_voice.record_usage_failed", zap.Error(err))
  339. }
  340. })
  341. }
  342. func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) {
  343. if c == nil || c.Request == nil {
  344. return nil, errors.New("request body is required")
  345. }
  346. if c.Request.Body == nil {
  347. if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodDelete {
  348. return nil, nil
  349. }
  350. return nil, errors.New("request body is required")
  351. }
  352. return io.ReadAll(c.Request.Body)
  353. }
  354. // extractGrokTTSInputText pulls the primary spoken text from a TTS JSON body.
  355. func extractGrokTTSInputText(body []byte) string {
  356. if len(body) == 0 {
  357. return ""
  358. }
  359. var payload map[string]any
  360. if err := json.Unmarshal(body, &payload); err != nil {
  361. return ""
  362. }
  363. for _, key := range []string{"input", "text", "prompt"} {
  364. if v, ok := payload[key]; ok {
  365. if s, ok := v.(string); ok {
  366. return strings.TrimSpace(s)
  367. }
  368. }
  369. }
  370. return ""
  371. }