grok_audio.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "net/url"
  9. "strings"
  10. "sync/atomic"
  11. "time"
  12. coderws "github.com/coder/websocket"
  13. "github.com/gin-gonic/gin"
  14. "github.com/tidwall/gjson"
  15. )
  16. // DefaultGrokRealtimeDialTimeout bounds the pre-accept upstream handshake.
  17. // The timeout only covers dialing; an established session is not interrupted.
  18. const DefaultGrokRealtimeDialTimeout = 12 * time.Second
  19. // supportedGrokVoiceHTTPEndpoints are xAI Voice HTTP paths we forward as-is.
  20. var supportedGrokVoiceHTTPEndpoints = map[string]struct{}{
  21. "tts": {},
  22. "stt": {},
  23. "custom-voices": {},
  24. }
  25. // ForwardGrokVoice forwards the official xAI Voice HTTP APIs (/tts, /stt, and
  26. // the custom-voices CRUD/audio subresources).
  27. // The response is intentionally passed through because TTS returns audio bytes
  28. // while STT returns JSON and xAI may add format-specific headers.
  29. func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Context, account *Account, endpoint string, body []byte, contentType string) (*OpenAIForwardResult, error) {
  30. if s == nil || account == nil {
  31. return nil, fmt.Errorf("grok voice service/account is required")
  32. }
  33. if account.Platform != PlatformGrok {
  34. return nil, fmt.Errorf("account platform %s is not supported for grok voice", account.Platform)
  35. }
  36. endpoint = strings.Trim(strings.TrimSpace(endpoint), "/")
  37. parts := strings.Split(endpoint, "/")
  38. baseEndpoint := parts[0]
  39. if _, ok := supportedGrokVoiceHTTPEndpoints[baseEndpoint]; !ok {
  40. return nil, fmt.Errorf("unsupported grok voice endpoint: %s", endpoint)
  41. }
  42. if len(parts) > 1 && baseEndpoint != "custom-voices" {
  43. return nil, fmt.Errorf("unsupported grok voice endpoint: %s", endpoint)
  44. }
  45. if baseEndpoint == "custom-voices" {
  46. if len(parts) > 3 || (len(parts) == 3 && parts[2] != "audio") {
  47. return nil, fmt.Errorf("unsupported grok voice endpoint: %s", endpoint)
  48. }
  49. }
  50. for _, part := range parts[1:] {
  51. if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "?#\\") {
  52. return nil, fmt.Errorf("invalid grok voice endpoint path")
  53. }
  54. }
  55. token, _, err := s.getRequestCredential(ctx, c, account)
  56. if err != nil {
  57. return nil, err
  58. }
  59. targetURL, err := buildGrokVoiceURL(account, s.cfg, endpoint)
  60. if err != nil {
  61. return nil, err
  62. }
  63. upstreamCtx, release := detachUpstreamContext(ctx)
  64. defer release()
  65. method := http.MethodPost
  66. if c != nil && c.Request != nil && strings.TrimSpace(c.Request.Method) != "" {
  67. method = c.Request.Method
  68. }
  69. req, err := http.NewRequestWithContext(upstreamCtx, method, targetURL, bytes.NewReader(body))
  70. if err != nil {
  71. return nil, err
  72. }
  73. req.Header.Set("Authorization", "Bearer "+token)
  74. req.Header.Set("Accept", "application/json, audio/*")
  75. if strings.TrimSpace(contentType) == "" {
  76. contentType = "application/json"
  77. }
  78. req.Header.Set("Content-Type", contentType)
  79. // Match media path: CLI identity headers only on the CLI chat proxy.
  80. // Official api.x.ai voice rejects or mistreats OAuth when CLI headers are stamped.
  81. if account.IsGrokOAuth() && isGrokCLIProxyTarget(targetURL) {
  82. applyGrokCLIHeaders(req.Header)
  83. }
  84. account.ApplyHeaderOverrides(req.Header)
  85. proxyURL := ""
  86. if account.ProxyID != nil && account.Proxy != nil {
  87. proxyURL = account.Proxy.URL()
  88. }
  89. started := time.Now()
  90. resp, err := s.httpUpstream.Do(WithAccountTrafficRequest(req, account), proxyURL, account.ID, account.Concurrency)
  91. SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds())
  92. if err != nil {
  93. return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
  94. }
  95. defer func() { _ = resp.Body.Close() }()
  96. if resp.StatusCode >= 400 {
  97. return s.handleGrokMediaErrorResponse(ctx, resp, c, account, resp.Header.Get("x-request-id"), endpoint)
  98. }
  99. data, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
  100. if err != nil {
  101. return nil, err
  102. }
  103. writeGrokMediaResponse(c, resp, data, s.responseHeaderFilter)
  104. audioUsage := estimateGrokVoiceAudioUsage(baseEndpoint, body, contentType, data, time.Since(started))
  105. upstreamID := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id"))
  106. return &OpenAIForwardResult{
  107. // Forced durable money-event id so usage_billing_dedup cannot collapse under a reused client id.
  108. RequestID: StableGrokAudioBillingRequestID(upstreamID),
  109. UpstreamHeaders: resp.Header,
  110. Model: baseEndpoint,
  111. UpstreamModel: baseEndpoint,
  112. Duration: time.Since(started),
  113. AudioUsage: audioUsage,
  114. }, nil
  115. }
  116. // ProxyGrokRealtime relays JSON Realtime events to xAI's native Voice WS.
  117. // Audio is carried as base64 inside JSON events, so preserving the JSON bytes
  118. // is sufficient and avoids translating protocol event types.
  119. func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Context, client *coderws.Conn, account *Account, token, model string) (bool, error) {
  120. if s == nil || client == nil || account == nil {
  121. return false, fmt.Errorf("realtime service, client, and account are required")
  122. }
  123. if account.Platform != PlatformGrok {
  124. return false, fmt.Errorf("account platform %s is not supported for grok realtime", account.Platform)
  125. }
  126. upstream, err := s.OpenGrokRealtime(ctx, account, token, model)
  127. if err != nil {
  128. return false, err
  129. }
  130. defer func() { _ = upstream.Close() }()
  131. return s.ProxyGrokRealtimeConn(ctx, c, client, upstream)
  132. }
  133. type GrokRealtimeUpstream struct {
  134. conn openAIWSClientConn
  135. traffic *AccountTrafficPermit
  136. trafficStatus atomic.Int32
  137. }
  138. // GrokRealtimeDialError preserves an HTTP status returned before WebSocket
  139. // upgrade so handlers can apply the normal Grok account policy.
  140. type GrokRealtimeDialError struct {
  141. StatusCode int
  142. Err error
  143. }
  144. func (e *GrokRealtimeDialError) Error() string { return e.Err.Error() }
  145. func (e *GrokRealtimeDialError) Unwrap() error { return e.Err }
  146. func (u *GrokRealtimeUpstream) Close() error {
  147. if u == nil || u.conn == nil {
  148. return nil
  149. }
  150. if u.traffic != nil {
  151. u.traffic.Finish(int(u.trafficStatus.Load()))
  152. }
  153. return u.conn.Close()
  154. }
  155. func (s *OpenAIGatewayService) OpenGrokRealtime(ctx context.Context, account *Account, token, model string) (*GrokRealtimeUpstream, error) {
  156. if s == nil || account == nil || account.Platform != PlatformGrok {
  157. return nil, fmt.Errorf("grok realtime account is required")
  158. }
  159. if err := validateGrokRealtimeTrafficPolicy(account); err != nil {
  160. return nil, err
  161. }
  162. base, err := buildGrokVoiceURL(account, s.cfg, "realtime")
  163. if err != nil {
  164. return nil, err
  165. }
  166. u, err := url.Parse(base)
  167. if err != nil {
  168. return nil, err
  169. }
  170. u.Scheme = "wss"
  171. q := u.Query()
  172. q.Set("model", firstNonEmpty(model, "grok-voice-latest"))
  173. u.RawQuery = q.Encode()
  174. headers := http.Header{"Authorization": []string{"Bearer " + token}}
  175. if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) {
  176. applyGrokCLIHeaders(headers)
  177. }
  178. account.ApplyHeaderOverrides(headers)
  179. proxyURL := ""
  180. if account.ProxyID != nil && account.Proxy != nil {
  181. proxyURL = account.Proxy.URL()
  182. }
  183. ctx, permit, admissionErr := beginAccountTrafficTurn(ctx, s.httpUpstream, account)
  184. if admissionErr != nil {
  185. return nil, admissionErr
  186. }
  187. dialCtx, cancelDial := context.WithTimeout(ctx, DefaultGrokRealtimeDialTimeout)
  188. conn, status, _, err := s.getOpenAIWSPassthroughDialer().Dial(dialCtx, u.String(), headers, proxyURL)
  189. cancelDial()
  190. if err != nil {
  191. permit.Finish(status)
  192. return nil, &GrokRealtimeDialError{StatusCode: status, Err: err}
  193. }
  194. if permit != nil {
  195. go func() { <-ctx.Done(); _ = conn.Close() }()
  196. }
  197. return &GrokRealtimeUpstream{conn: conn, traffic: permit}, nil
  198. }
  199. // HandleGrokRealtimeUpstreamError applies the shared Grok account policy to a
  200. // failed pre-accept WebSocket handshake.
  201. func (s *OpenAIGatewayService) HandleGrokRealtimeUpstreamError(ctx context.Context, account *Account, statusCode int, body []byte) {
  202. if statusCode <= 0 {
  203. statusCode = http.StatusBadGateway
  204. }
  205. s.handleGrokAccountUpstreamError(ctx, account, statusCode, nil, body)
  206. }
  207. func (s *OpenAIGatewayService) ProxyGrokRealtimeConn(ctx context.Context, c *gin.Context, client *coderws.Conn, upstream *GrokRealtimeUpstream) (bool, error) {
  208. if s == nil || client == nil || upstream == nil || upstream.conn == nil {
  209. return false, fmt.Errorf("realtime connection is required")
  210. }
  211. conn := upstream.conn
  212. ctx, cancel := context.WithCancel(ctx)
  213. defer cancel()
  214. errCh := make(chan error, 2)
  215. var audioObserved atomic.Bool
  216. // Upstream → client
  217. go func() {
  218. for {
  219. msg, readErr := conn.ReadMessage(ctx)
  220. if readErr != nil {
  221. errCh <- readErr
  222. return
  223. }
  224. if grokRealtimeEventHasAudio(msg) {
  225. audioObserved.Store(true)
  226. }
  227. if writeErr := client.Write(ctx, coderws.MessageText, msg); writeErr != nil {
  228. errCh <- writeErr
  229. return
  230. }
  231. }
  232. }()
  233. // Client → upstream (JSON events only)
  234. go func() {
  235. for {
  236. kind, msg, readErr := client.Read(ctx)
  237. if readErr != nil {
  238. errCh <- readErr
  239. return
  240. }
  241. if kind != coderws.MessageText && kind != coderws.MessageBinary {
  242. continue
  243. }
  244. if grokRealtimeEventHasAudio(msg) {
  245. audioObserved.Store(true)
  246. }
  247. var raw json.RawMessage
  248. if unmarshalErr := json.Unmarshal(msg, &raw); unmarshalErr != nil {
  249. errCh <- fmt.Errorf("invalid realtime event: %w", unmarshalErr)
  250. return
  251. }
  252. if writeErr := conn.WriteJSON(ctx, raw); writeErr != nil {
  253. errCh <- writeErr
  254. return
  255. }
  256. }
  257. }()
  258. observed, err := awaitGrokRealtimeAudioObserved(errCh, &audioObserved)
  259. if observed {
  260. upstream.trafficStatus.Store(200)
  261. }
  262. return observed, err
  263. }
  264. // ProbeGrokRealtime performs the upstream WebSocket handshake without sending
  265. // any client-visible events. Handlers use it before accepting the downstream
  266. // upgrade so authentication and endpoint failures remain ordinary HTTP errors.
  267. func (s *OpenAIGatewayService) ProbeGrokRealtime(ctx context.Context, account *Account, token, model string) (trafficErr error) {
  268. if s == nil || account == nil {
  269. return fmt.Errorf("realtime service and account are required")
  270. }
  271. if account.Platform != PlatformGrok {
  272. return fmt.Errorf("account platform %s is not supported for grok realtime", account.Platform)
  273. }
  274. if err := validateGrokRealtimeTrafficPolicy(account); err != nil {
  275. return err
  276. }
  277. ctx, permit, admissionErr := beginAccountTrafficTurn(ctx, s.httpUpstream, account)
  278. if admissionErr != nil {
  279. return admissionErr
  280. }
  281. defer func() { finishAccountTrafficTurn(permit, trafficErr) }()
  282. base, err := buildGrokVoiceURL(account, s.cfg, "realtime")
  283. if err != nil {
  284. return err
  285. }
  286. u, err := url.Parse(base)
  287. if err != nil {
  288. return err
  289. }
  290. u.Scheme = "wss"
  291. q := u.Query()
  292. q.Set("model", firstNonEmpty(model, "grok-voice-latest"))
  293. u.RawQuery = q.Encode()
  294. headers := http.Header{"Authorization": []string{"Bearer " + token}}
  295. if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) {
  296. applyGrokCLIHeaders(headers)
  297. }
  298. account.ApplyHeaderOverrides(headers)
  299. proxyURL := ""
  300. if account.ProxyID != nil && account.Proxy != nil {
  301. proxyURL = account.Proxy.URL()
  302. }
  303. dialer := s.getOpenAIWSPassthroughDialer()
  304. conn, _, _, err := dialer.Dial(ctx, u.String(), headers, proxyURL)
  305. if err != nil {
  306. return err
  307. }
  308. return conn.Close()
  309. }
  310. func awaitGrokRealtimeAudioObserved(errCh <-chan error, audioObserved *atomic.Bool) (bool, error) {
  311. err := <-errCh
  312. if audioObserved == nil {
  313. return false, err
  314. }
  315. return audioObserved.Load(), err
  316. }
  317. func validateGrokRealtimeTrafficPolicy(account *Account) error {
  318. plan, err := AccountTrafficPlanFor(account)
  319. if err != nil {
  320. return err
  321. }
  322. if plan.Policy.Enforces() {
  323. return (&AccountTrafficLimitError{Status: http.StatusBadRequest, Reason: "Grok 实时语音的自动生成暂不支持逐轮硬限制。请使用 HTTP 语音接口,或将此账号流量控制设为仅观察(关闭严格 RPM 和自动并发)。"}).FailoverError()
  324. }
  325. return nil
  326. }
  327. func grokRealtimeEventHasAudio(msg []byte) bool {
  328. if !gjson.ValidBytes(msg) {
  329. return false
  330. }
  331. eventType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(msg, "type").String()))
  332. if !strings.Contains(eventType, "audio") || strings.Contains(eventType, "transcript") {
  333. return false
  334. }
  335. for _, path := range []string{"audio", "delta", "data"} {
  336. value := gjson.GetBytes(msg, path)
  337. if value.Type == gjson.String && strings.TrimSpace(value.String()) != "" {
  338. return true
  339. }
  340. }
  341. return false
  342. }
  343. // estimateGrokVoiceAudioUsage derives billing units from the request/response.
  344. // TTS: million characters of input text; STT: hours approximated from request body size
  345. // when duration is unknown; custom-voices: no units (nil).
  346. func estimateGrokVoiceAudioUsage(endpoint string, reqBody []byte, contentType string, respBody []byte, elapsed time.Duration) *AudioUsage {
  347. switch strings.TrimSpace(endpoint) {
  348. case "tts":
  349. // Prefer JSON "input" / "text" fields; fallback to raw body length.
  350. chars := 0
  351. if gjson.ValidBytes(reqBody) {
  352. for _, key := range []string{"input", "text", "prompt"} {
  353. if s := strings.TrimSpace(gjson.GetBytes(reqBody, key).String()); s != "" {
  354. chars = len([]rune(s))
  355. break
  356. }
  357. }
  358. }
  359. if chars <= 0 {
  360. chars = len(reqBody)
  361. }
  362. if chars <= 0 {
  363. return nil
  364. }
  365. return &AudioUsage{Mode: "tts", DurationOrUnits: float64(chars) / 1_000_000.0}
  366. case "stt":
  367. // Prefer response duration when present; do not trust client duration_seconds alone
  368. // (under-report would underbill). Floor against body-size heuristic and elapsed.
  369. secs := 0.0
  370. if gjson.ValidBytes(respBody) {
  371. for _, path := range []string{"duration", "duration_seconds", "audio_duration", "usage.seconds"} {
  372. if v := gjson.GetBytes(respBody, path); v.Exists() && v.Type == gjson.Number && v.Float() > 0 {
  373. secs = v.Float()
  374. break
  375. }
  376. }
  377. }
  378. // Multipart / body size heuristic: ~16KB/s for compressed speech (lower bound).
  379. sizeFloor := 0.0
  380. if len(reqBody) > 0 {
  381. sizeFloor = float64(len(reqBody)) / 16000.0
  382. }
  383. clientSecs := 0.0
  384. if gjson.ValidBytes(reqBody) {
  385. if v := gjson.GetBytes(reqBody, "duration_seconds"); v.Exists() && v.Type == gjson.Number {
  386. clientSecs = v.Float()
  387. }
  388. }
  389. if secs <= 0 {
  390. secs = elapsed.Seconds()
  391. }
  392. if secs <= 0 {
  393. secs = clientSecs
  394. }
  395. if secs <= 0 {
  396. secs = sizeFloor
  397. }
  398. // Cap untrusted client under-report: if client duration is much smaller than
  399. // size/elapsed floors, bill the larger of floors (anti underbill).
  400. if clientSecs > 0 && secs == clientSecs {
  401. floor := sizeFloor
  402. if elapsed.Seconds() > floor {
  403. floor = elapsed.Seconds()
  404. }
  405. if floor > 0 && clientSecs < floor*0.5 {
  406. secs = floor
  407. }
  408. }
  409. if secs <= 0 {
  410. return nil
  411. }
  412. return &AudioUsage{Mode: "stt", DurationOrUnits: secs / 3600.0}
  413. case "realtime":
  414. mins := elapsed.Minutes()
  415. if mins <= 0 {
  416. return nil
  417. }
  418. return &AudioUsage{Mode: "realtime", DurationOrUnits: mins}
  419. default:
  420. return nil
  421. }
  422. }