openai_ws_forwarder_v2.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "net/url"
  10. "strings"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  14. "github.com/gin-gonic/gin"
  15. "github.com/tidwall/gjson"
  16. )
  17. func (s *OpenAIGatewayService) forwardOpenAIWSV2(
  18. ctx context.Context,
  19. c *gin.Context,
  20. account *Account,
  21. reqBody map[string]any,
  22. clientPromptCacheKey string,
  23. token string,
  24. decision OpenAIWSProtocolDecision,
  25. isCodexCLI bool,
  26. reqStream bool,
  27. originalModel string,
  28. mappedModel string,
  29. startTime time.Time,
  30. attempt int,
  31. lastFailureReason string,
  32. agentTaskRecoveryTried *bool,
  33. ) (trafficResult *OpenAIForwardResult, trafficErr error) {
  34. if s == nil || account == nil {
  35. return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil"))
  36. }
  37. ctx, permit, admissionErr := beginAccountTrafficTurn(ctx, s.httpUpstream, account)
  38. if admissionErr != nil {
  39. return nil, admissionErr
  40. }
  41. defer func() { finishAccountTrafficTurn(permit, trafficErr) }()
  42. responseModelObserver := &upstreamResponseModelObserver{}
  43. wsURL, err := s.buildOpenAIResponsesWSURL(account)
  44. if err != nil {
  45. return nil, wrapOpenAIWSFallback("build_ws_url", err)
  46. }
  47. wsHost := "-"
  48. wsPath := "-"
  49. if parsed, parseErr := url.Parse(wsURL); parseErr == nil && parsed != nil {
  50. if h := strings.TrimSpace(parsed.Host); h != "" {
  51. wsHost = normalizeOpenAIWSLogValue(h)
  52. }
  53. if p := strings.TrimSpace(parsed.Path); p != "" {
  54. wsPath = normalizeOpenAIWSLogValue(p)
  55. }
  56. }
  57. logOpenAIWSModeDebug(
  58. "dial_target account_id=%d account_type=%s ws_host=%s ws_path=%s",
  59. account.ID,
  60. account.Type,
  61. wsHost,
  62. wsPath,
  63. )
  64. payload := s.buildOpenAIWSCreatePayload(reqBody, account)
  65. payloadStrategy := "mode1_preserve"
  66. var removedKeys []string
  67. if !isMode1ProtectionEnabled(account) {
  68. payloadStrategy, removedKeys = applyOpenAIWSRetryPayloadStrategy(payload, attempt)
  69. }
  70. turnState := ""
  71. turnMetadata := ""
  72. if c != nil && c.Request != nil {
  73. turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader))
  74. turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader))
  75. }
  76. setOpenAIWSTurnMetadata(payload, turnMetadata)
  77. applyStagedCodexFingerprintClientMetadata(c, account, payload)
  78. if err := validateMode1StagedRequest(c, account, payloadAsJSONBytes(payload)); err != nil {
  79. return nil, err
  80. }
  81. previousResponseID := openAIWSPayloadString(payload, "previous_response_id")
  82. previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID)
  83. promptCacheKey := strings.TrimSpace(clientPromptCacheKey)
  84. if promptCacheKey == "" {
  85. // Fingerprint convergence may inject a default key when the client did
  86. // not send one; retain that fallback without replacing an explicit raw key.
  87. promptCacheKey = openAIWSPayloadString(payload, "prompt_cache_key")
  88. }
  89. _, hasTools := payload["tools"]
  90. debugEnabled := isOpenAIWSModeDebugEnabled()
  91. payloadBytes := -1
  92. resolvePayloadBytes := func() int {
  93. if payloadBytes >= 0 {
  94. return payloadBytes
  95. }
  96. payloadBytes = len(payloadAsJSONBytes(payload))
  97. return payloadBytes
  98. }
  99. streamValue := "-"
  100. if raw, ok := payload["stream"]; ok {
  101. streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw)))
  102. }
  103. payloadEventType := openAIWSPayloadString(payload, "type")
  104. if payloadEventType == "" {
  105. payloadEventType = "response.create"
  106. }
  107. if s.shouldEmitOpenAIWSPayloadSchema(attempt) {
  108. logOpenAIWSModeInfo(
  109. "[debug] payload_schema account_id=%d attempt=%d event=%s payload_keys=%s payload_bytes=%d payload_key_sizes=%s input_summary=%s stream=%s payload_strategy=%s removed_keys=%s has_previous_response_id=%v has_prompt_cache_key=%v has_tools=%v",
  110. account.ID,
  111. attempt,
  112. payloadEventType,
  113. normalizeOpenAIWSLogValue(strings.Join(sortedKeys(payload), ",")),
  114. resolvePayloadBytes(),
  115. normalizeOpenAIWSLogValue(summarizeOpenAIWSPayloadKeySizes(payload, openAIWSPayloadKeySizeTopN)),
  116. normalizeOpenAIWSLogValue(summarizeOpenAIWSInput(payload["input"])),
  117. streamValue,
  118. normalizeOpenAIWSLogValue(payloadStrategy),
  119. normalizeOpenAIWSLogValue(strings.Join(removedKeys, ",")),
  120. previousResponseID != "",
  121. promptCacheKey != "",
  122. hasTools,
  123. )
  124. }
  125. stateStore := s.getOpenAIWSStateStore()
  126. groupID := getOpenAIGroupIDFromContext(c)
  127. sessionHash := s.GenerateSessionHash(c, nil)
  128. if sessionHash == "" {
  129. var legacySessionHash string
  130. sessionHash, legacySessionHash = openAIWSSessionHashesFromID(promptCacheKey)
  131. attachOpenAILegacySessionHashToGin(c, legacySessionHash)
  132. }
  133. if turnState == "" && stateStore != nil && sessionHash != "" {
  134. if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok {
  135. turnState = savedTurnState
  136. }
  137. }
  138. preferredConnID := ""
  139. if stateStore != nil && previousResponseID != "" {
  140. if connID, ok := stateStore.GetResponseConn(previousResponseID); ok {
  141. preferredConnID = connID
  142. }
  143. }
  144. storeDisabled := s.isOpenAIWSStoreDisabledInRequest(reqBody, account)
  145. if stateStore != nil && storeDisabled && previousResponseID == "" && sessionHash != "" {
  146. if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok {
  147. preferredConnID = connID
  148. }
  149. }
  150. storeDisabledConnMode := s.openAIWSStoreDisabledConnMode()
  151. forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason)
  152. forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == ""
  153. wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(
  154. ctx,
  155. c,
  156. account,
  157. token,
  158. decision,
  159. isCodexCLI,
  160. turnState,
  161. turnMetadata,
  162. promptCacheKey,
  163. openAIWSPayloadString(payload, "model"),
  164. openAIWSPayloadString(payload, "service_tier"),
  165. )
  166. if buildHdrErr != nil {
  167. return nil, fmt.Errorf("build ws headers: %w", buildHdrErr)
  168. }
  169. logOpenAIWSModeDebug(
  170. "acquire_start account_id=%d account_type=%s transport=%s preferred_conn_id=%s has_previous_response_id=%v session_hash=%s has_turn_state=%v turn_state_len=%d has_turn_metadata=%v turn_metadata_len=%d store_disabled=%v store_disabled_conn_mode=%s retry_last_reason=%s force_new_conn=%v header_user_agent=%s header_openai_beta=%s header_originator=%s header_accept_language=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_prompt_cache_key=%v has_chatgpt_account_id=%v has_authorization=%v has_session_id=%v has_conversation_id=%v proxy_enabled=%v",
  171. account.ID,
  172. account.Type,
  173. normalizeOpenAIWSLogValue(string(decision.Transport)),
  174. truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen),
  175. previousResponseID != "",
  176. truncateOpenAIWSLogValue(sessionHash, 12),
  177. turnState != "",
  178. len(turnState),
  179. turnMetadata != "",
  180. len(turnMetadata),
  181. storeDisabled,
  182. normalizeOpenAIWSLogValue(storeDisabledConnMode),
  183. truncateOpenAIWSLogValue(lastFailureReason, openAIWSLogValueMaxLen),
  184. forceNewConn,
  185. openAIWSHeaderValueForLog(wsHeaders, "user-agent"),
  186. openAIWSHeaderValueForLog(wsHeaders, "openai-beta"),
  187. openAIWSHeaderValueForLog(wsHeaders, "originator"),
  188. openAIWSHeaderValueForLog(wsHeaders, "accept-language"),
  189. openAIWSHeaderValueForLog(wsHeaders, "session_id"),
  190. openAIWSHeaderValueForLog(wsHeaders, "conversation_id"),
  191. normalizeOpenAIWSLogValue(sessionResolution.SessionSource),
  192. normalizeOpenAIWSLogValue(sessionResolution.ConversationSource),
  193. promptCacheKey != "",
  194. hasOpenAIWSHeader(wsHeaders, "chatgpt-account-id"),
  195. hasOpenAIWSHeader(wsHeaders, "authorization"),
  196. hasOpenAIWSHeader(wsHeaders, "session_id"),
  197. hasOpenAIWSHeader(wsHeaders, "conversation_id"),
  198. account.ProxyID != nil && account.Proxy != nil,
  199. )
  200. acquireCtx, acquireCancel := context.WithTimeout(ctx, s.openAIWSAcquireTimeout())
  201. defer acquireCancel()
  202. lease, err := s.getOpenAIWSConnPool().Acquire(acquireCtx, openAIWSAcquireRequest{
  203. Account: account,
  204. WSURL: wsURL,
  205. Headers: wsHeaders,
  206. HeadersFactory: func(factoryCtx context.Context, headers http.Header) (http.Header, error) {
  207. return s.refreshOpenAIAgentIdentityHeaders(factoryCtx, account, headers)
  208. },
  209. PreferredConnID: preferredConnID,
  210. ForceNewConn: forceNewConn,
  211. ProxyURL: func() string {
  212. if account.ProxyID != nil && account.Proxy != nil {
  213. return account.Proxy.URL()
  214. }
  215. return ""
  216. }(),
  217. })
  218. if err != nil {
  219. var agentDialErr *openAIWSDialError
  220. if s.isAgentIdentityAccount(ctx, account) && errors.As(err, &agentDialErr) && isAgentIdentityTaskInvalidWSDialError(agentDialErr) && agentTaskRecoveryTried != nil && !*agentTaskRecoveryTried {
  221. *agentTaskRecoveryTried = true
  222. if recoveryErr := s.recoverAgentIdentityTask(ctx, account, account.GetCredential("task_id")); recoveryErr != nil {
  223. return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
  224. }
  225. return nil, &agentIdentityTaskRecoveredError{}
  226. }
  227. s.handleOpenAIWSDialTransientFailure(ctx, account, mappedModel, err)
  228. dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err)
  229. logOpenAIWSModeInfo(
  230. "acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v",
  231. account.ID,
  232. account.Type,
  233. normalizeOpenAIWSLogValue(string(decision.Transport)),
  234. normalizeOpenAIWSLogValue(classifyOpenAIWSAcquireError(err)),
  235. dialStatus,
  236. dialClass,
  237. dialCloseStatus,
  238. truncateOpenAIWSLogValue(dialCloseReason, openAIWSHeaderValueMaxLen),
  239. dialRespServer,
  240. dialRespVia,
  241. dialRespCFRay,
  242. dialRespReqID,
  243. truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen),
  244. truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen),
  245. forceNewConn,
  246. wsHost,
  247. wsPath,
  248. account.ProxyID != nil && account.Proxy != nil,
  249. )
  250. var dialErr *openAIWSDialError
  251. if errors.As(err, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests {
  252. s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error()), mappedModel)
  253. }
  254. return nil, wrapOpenAIWSFallback(classifyOpenAIWSAcquireError(err), err)
  255. }
  256. // cleanExit 标记正常终端事件退出,此时上游不会再发送帧,连接可安全归还复用。
  257. // 所有异常路径(读写错误、error 事件等)已在各自分支中提前调用 MarkBroken,
  258. // 因此 defer 中只需处理正常退出时不 MarkBroken 即可。
  259. cleanExit := false
  260. defer func() {
  261. if !cleanExit {
  262. lease.MarkBroken()
  263. }
  264. lease.Release()
  265. }()
  266. connID := strings.TrimSpace(lease.ConnID())
  267. logOpenAIWSModeDebug(
  268. "connected account_id=%d account_type=%s transport=%s conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d has_previous_response_id=%v",
  269. account.ID,
  270. account.Type,
  271. normalizeOpenAIWSLogValue(string(decision.Transport)),
  272. connID,
  273. lease.Reused(),
  274. lease.ConnPickDuration().Milliseconds(),
  275. lease.QueueWaitDuration().Milliseconds(),
  276. previousResponseID != "",
  277. )
  278. if previousResponseID != "" {
  279. logOpenAIWSModeInfo(
  280. "continuation_probe account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s preferred_conn_id=%s conn_reused=%v store_disabled=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v",
  281. account.ID,
  282. account.Type,
  283. truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen),
  284. truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
  285. normalizeOpenAIWSLogValue(previousResponseIDKind),
  286. truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen),
  287. lease.Reused(),
  288. storeDisabled,
  289. truncateOpenAIWSLogValue(sessionHash, 12),
  290. openAIWSHeaderValueForLog(wsHeaders, "session_id"),
  291. openAIWSHeaderValueForLog(wsHeaders, "conversation_id"),
  292. normalizeOpenAIWSLogValue(sessionResolution.SessionSource),
  293. normalizeOpenAIWSLogValue(sessionResolution.ConversationSource),
  294. turnState != "",
  295. len(turnState),
  296. promptCacheKey != "",
  297. )
  298. }
  299. if c != nil {
  300. SetOpsLatencyMs(c, OpsOpenAIWSConnPickMsKey, lease.ConnPickDuration().Milliseconds())
  301. SetOpsLatencyMs(c, OpsOpenAIWSQueueWaitMsKey, lease.QueueWaitDuration().Milliseconds())
  302. c.Set(OpsOpenAIWSConnReusedKey, lease.Reused())
  303. if connID != "" {
  304. c.Set(OpsOpenAIWSConnIDKey, connID)
  305. }
  306. }
  307. handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader))
  308. logOpenAIWSModeDebug(
  309. "handshake account_id=%d conn_id=%s has_turn_state=%v turn_state_len=%d",
  310. account.ID,
  311. connID,
  312. handshakeTurnState != "",
  313. len(handshakeTurnState),
  314. )
  315. if handshakeTurnState != "" {
  316. if stateStore != nil && sessionHash != "" {
  317. stateStore.BindSessionTurnState(groupID, sessionHash, handshakeTurnState, s.openAIWSSessionStickyTTL())
  318. }
  319. if c != nil {
  320. c.Header(http.CanonicalHeaderKey(openAIWSTurnStateHeader), handshakeTurnState)
  321. }
  322. }
  323. if err := s.performOpenAIWSGeneratePrewarm(
  324. ctx,
  325. lease,
  326. decision,
  327. payload,
  328. previousResponseID,
  329. reqBody,
  330. account,
  331. stateStore,
  332. groupID,
  333. ); err != nil {
  334. return nil, err
  335. }
  336. if err := lease.WriteJSONWithContextTimeout(ctx, payload, s.openAIWSWriteTimeout()); err != nil {
  337. lease.MarkBroken()
  338. logOpenAIWSModeInfo(
  339. "write_request_fail account_id=%d conn_id=%s cause=%s payload_bytes=%d",
  340. account.ID,
  341. connID,
  342. truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen),
  343. resolvePayloadBytes(),
  344. )
  345. return nil, wrapOpenAIWSFallback("write_request", err)
  346. }
  347. if debugEnabled {
  348. logOpenAIWSModeDebug(
  349. "write_request_sent account_id=%d conn_id=%s stream=%v payload_bytes=%d previous_response_id=%s",
  350. account.ID,
  351. connID,
  352. reqStream,
  353. resolvePayloadBytes(),
  354. truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
  355. )
  356. }
  357. usage := &OpenAIUsage{}
  358. imageCounter := newOpenAIImageOutputCounter()
  359. var firstTokenMs *int
  360. responseID := ""
  361. var finalResponse []byte
  362. wroteDownstream := false
  363. needModelReplace := originalModel != mappedModel
  364. var mappedModelBytes []byte
  365. if needModelReplace && mappedModel != "" {
  366. mappedModelBytes = []byte(mappedModel)
  367. }
  368. bufferedStreamEvents := make([][]byte, 0, 4)
  369. eventCount := 0
  370. tokenEventCount := 0
  371. terminalEventCount := 0
  372. bufferedEventCount := 0
  373. flushedBufferedEventCount := 0
  374. firstEventType := ""
  375. lastEventType := ""
  376. upstreamTerminalEvent := ""
  377. clientDisconnected := false
  378. clientDisconnectDrainStartedAt := time.Time{}
  379. readTimeout := s.openAIWSReadTimeout()
  380. upstreamReadCtx := ctx
  381. upstreamReadDetached := false
  382. clientRequestCanceled := func() bool {
  383. return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
  384. }
  385. markClientDisconnected := func(cause string) {
  386. if clientDisconnected {
  387. return
  388. }
  389. clientDisconnected = true
  390. clientDisconnectDrainStartedAt = time.Now()
  391. if !upstreamReadDetached {
  392. upstreamReadCtx = context.WithoutCancel(ctx)
  393. upstreamReadDetached = true
  394. }
  395. logOpenAIWSModeInfo(
  396. "client_disconnected account_id=%d conn_id=%s cause=%s events=%d token_events=%d",
  397. account.ID,
  398. connID,
  399. cause,
  400. eventCount,
  401. tokenEventCount,
  402. )
  403. }
  404. markClientRequestCanceled := func() {
  405. if clientRequestCanceled() {
  406. markClientDisconnected("request_context_canceled")
  407. }
  408. }
  409. resultWithUsage := func() *OpenAIForwardResult {
  410. return &OpenAIForwardResult{
  411. RequestID: responseID,
  412. ResponseID: responseID,
  413. Usage: *usage,
  414. Model: originalModel,
  415. UpstreamModel: mappedModel,
  416. UpstreamResponseModel: responseModelObserver.Model(),
  417. UpstreamResponseModelConflict: responseModelObserver.Conflict(),
  418. UpstreamResponseServiceTier: responseModelObserver.ServiceTier(),
  419. ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)),
  420. ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
  421. Stream: reqStream,
  422. OpenAIWSMode: true,
  423. UpstreamTerminalEvent: upstreamTerminalEvent,
  424. ResponseHeaders: lease.HandshakeHeaders(),
  425. Duration: time.Since(startTime),
  426. FirstTokenMs: firstTokenMs,
  427. ClientDisconnect: clientDisconnected,
  428. }
  429. }
  430. var flusher http.Flusher
  431. if reqStream {
  432. if s.responseHeaderFilter != nil {
  433. responseheaders.WriteFilteredHeaders(c.Writer.Header(), http.Header{}, s.responseHeaderFilter)
  434. }
  435. c.Header("Content-Type", "text/event-stream")
  436. c.Header("Cache-Control", "no-cache")
  437. c.Header("Connection", "keep-alive")
  438. c.Header("X-Accel-Buffering", "no")
  439. f, ok := c.Writer.(http.Flusher)
  440. if !ok {
  441. lease.MarkBroken()
  442. return nil, wrapOpenAIWSFallback("streaming_not_supported", errors.New("streaming not supported"))
  443. }
  444. flusher = f
  445. }
  446. flushBatchSize := s.openAIWSEventFlushBatchSize()
  447. flushInterval := s.openAIWSEventFlushInterval()
  448. pendingFlushEvents := 0
  449. lastFlushAt := time.Now()
  450. flushStreamWriter := func(force bool) {
  451. if clientDisconnected || flusher == nil || pendingFlushEvents <= 0 {
  452. return
  453. }
  454. if !force && flushBatchSize > 1 && pendingFlushEvents < flushBatchSize {
  455. if flushInterval <= 0 || time.Since(lastFlushAt) < flushInterval {
  456. return
  457. }
  458. }
  459. flusher.Flush()
  460. pendingFlushEvents = 0
  461. lastFlushAt = time.Now()
  462. }
  463. emitStreamMessage := func(message []byte, forceFlush bool) {
  464. if clientDisconnected {
  465. return
  466. }
  467. frame := make([]byte, 0, len(message)+8)
  468. frame = append(frame, "data: "...)
  469. frame = append(frame, message...)
  470. frame = append(frame, '\n', '\n')
  471. _, wErr := c.Writer.Write(frame)
  472. if wErr == nil {
  473. wroteDownstream = true
  474. pendingFlushEvents++
  475. flushStreamWriter(forceFlush)
  476. return
  477. }
  478. markClientDisconnected("downstream_write_error")
  479. logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID)
  480. }
  481. flushBufferedStreamEvents := func(reason string) {
  482. if len(bufferedStreamEvents) == 0 {
  483. return
  484. }
  485. flushed := len(bufferedStreamEvents)
  486. for _, buffered := range bufferedStreamEvents {
  487. emitStreamMessage(buffered, false)
  488. }
  489. bufferedStreamEvents = bufferedStreamEvents[:0]
  490. flushStreamWriter(true)
  491. flushedBufferedEventCount += flushed
  492. if debugEnabled {
  493. logOpenAIWSModeDebug(
  494. "buffer_flush account_id=%d conn_id=%s reason=%s flushed=%d total_flushed=%d client_disconnected=%v",
  495. account.ID,
  496. connID,
  497. truncateOpenAIWSLogValue(reason, openAIWSLogValueMaxLen),
  498. flushed,
  499. flushedBufferedEventCount,
  500. clientDisconnected,
  501. )
  502. }
  503. }
  504. // Keep per-read timeouts unchanged for connected clients. Once a client
  505. // disconnects, use the same timeout as a bounded total drain budget.
  506. var pendingJSONDocuments [][]byte
  507. readLoop:
  508. for {
  509. markClientRequestCanceled()
  510. var message []byte
  511. var readErr error
  512. readUsedDetachedContext := upstreamReadDetached
  513. if len(pendingJSONDocuments) > 0 {
  514. message = pendingJSONDocuments[0]
  515. pendingJSONDocuments = pendingJSONDocuments[1:]
  516. } else {
  517. currentReadTimeout := readTimeout
  518. if clientDisconnected && !clientDisconnectDrainStartedAt.IsZero() {
  519. remaining := readTimeout - time.Since(clientDisconnectDrainStartedAt)
  520. if remaining <= 0 {
  521. lease.MarkBroken()
  522. break readLoop
  523. }
  524. if remaining < currentReadTimeout {
  525. currentReadTimeout = remaining
  526. }
  527. }
  528. message, readErr = lease.ReadMessageWithContextTimeout(upstreamReadCtx, currentReadTimeout)
  529. if readErr == nil {
  530. if documents, repaired := splitOpenAIConcatenatedJSONDocuments(message); repaired {
  531. logOpenAIWSModeInfo(
  532. "concatenated_json_repaired account_id=%d conn_id=%s documents=%d bytes=%d",
  533. account.ID,
  534. truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen),
  535. len(documents),
  536. len(message),
  537. )
  538. message = documents[0]
  539. pendingJSONDocuments = append(pendingJSONDocuments, documents[1:]...)
  540. }
  541. }
  542. }
  543. markClientRequestCanceled()
  544. if readErr == nil && !json.Valid(message) {
  545. eventType, _, _ := parseOpenAIWSEventEnvelope(message)
  546. if eventType == "" {
  547. eventType = "unknown"
  548. }
  549. lease.MarkBroken()
  550. logOpenAIWSModeInfo(
  551. "invalid_event_json account_id=%d conn_id=%s event_type=%s bytes=%d wrote_downstream=%v",
  552. account.ID,
  553. truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen),
  554. truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
  555. len(message),
  556. wroteDownstream,
  557. )
  558. if !wroteDownstream {
  559. return nil, wrapOpenAIWSFallback("invalid_event_json", errors.New("upstream websocket returned malformed Responses event JSON"))
  560. }
  561. return nil, errors.New("upstream websocket returned malformed Responses event JSON after downstream output")
  562. }
  563. if readErr != nil {
  564. lease.MarkBroken()
  565. closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
  566. logOpenAIWSModeInfo(
  567. "read_fail account_id=%d conn_id=%s wrote_downstream=%v close_status=%s close_reason=%s cause=%s events=%d token_events=%d terminal_events=%d buffered_pending=%d buffered_flushed=%d first_event=%s last_event=%s",
  568. account.ID,
  569. connID,
  570. wroteDownstream,
  571. closeStatus,
  572. closeReason,
  573. truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen),
  574. eventCount,
  575. tokenEventCount,
  576. terminalEventCount,
  577. len(bufferedStreamEvents),
  578. flushedBufferedEventCount,
  579. truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen),
  580. truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen),
  581. )
  582. if clientDisconnected {
  583. if !readUsedDetachedContext && errors.Is(readErr, context.Canceled) && clientRequestCanceled() {
  584. continue
  585. }
  586. break
  587. }
  588. if !wroteDownstream {
  589. return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr)
  590. }
  591. setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "")
  592. return nil, fmt.Errorf("openai ws read event: %w", readErr)
  593. }
  594. if normalized, changed := normalizeCompletedImageGenerationStatus(message); changed {
  595. message = normalized
  596. }
  597. eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message)
  598. if eventType == "" {
  599. continue
  600. }
  601. responseModelObserver.ObserveOpenAI(message, eventType)
  602. eventCount++
  603. if firstEventType == "" {
  604. firstEventType = eventType
  605. }
  606. lastEventType = eventType
  607. if responseID == "" && eventResponseID != "" {
  608. responseID = eventResponseID
  609. }
  610. isTokenEvent := isOpenAIWSTokenEvent(eventType)
  611. if isTokenEvent {
  612. tokenEventCount++
  613. }
  614. isTerminalEvent := isOpenAIWSTerminalEvent(eventType)
  615. if isTerminalEvent {
  616. terminalEventCount++
  617. }
  618. if firstTokenMs == nil && isTokenEvent {
  619. ms := int(time.Since(startTime).Milliseconds())
  620. firstTokenMs = &ms
  621. }
  622. if debugEnabled && shouldLogOpenAIWSEvent(eventCount, eventType) {
  623. logOpenAIWSModeDebug(
  624. "event_received account_id=%d conn_id=%s idx=%d type=%s bytes=%d token=%v terminal=%v buffered_pending=%d",
  625. account.ID,
  626. connID,
  627. eventCount,
  628. truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
  629. len(message),
  630. isTokenEvent,
  631. isTerminalEvent,
  632. len(bufferedStreamEvents),
  633. )
  634. }
  635. if !clientDisconnected {
  636. if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(message, mappedModelBytes) {
  637. message = replaceOpenAIWSMessageModel(message, mappedModel, originalModel)
  638. }
  639. if openAIWSEventMayContainToolCalls(eventType) && openAIWSMessageLikelyContainsToolCalls(message) {
  640. if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(message); changed {
  641. message = corrected
  642. }
  643. }
  644. message = restoreCodexToolNamesFromContext(c, message)
  645. }
  646. if openAIWSMessageShouldParseUsage(eventType, message) {
  647. parseOpenAIWSResponseUsageFromCompletedEvent(message, usage)
  648. }
  649. imageCounter.AddSSEData(message)
  650. if eventType == "error" || eventType == "response.failed" {
  651. markOpenAICyberPolicyEvent(c, message, http.StatusOK, usage)
  652. }
  653. if eventType == "error" {
  654. s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message)
  655. errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message)
  656. s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw, mappedModel)
  657. errMsg := strings.TrimSpace(errMsgRaw)
  658. if errMsg == "" {
  659. errMsg = "Upstream websocket error"
  660. }
  661. fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
  662. errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
  663. logOpenAIWSModeInfo(
  664. "error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s",
  665. account.ID,
  666. connID,
  667. eventCount,
  668. truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen),
  669. canFallback,
  670. errCode,
  671. errType,
  672. errMessage,
  673. )
  674. if fallbackReason == "previous_response_not_found" {
  675. logOpenAIWSModeInfo(
  676. "previous_response_not_found_diag account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s event_idx=%d req_stream=%v store_disabled=%v conn_reused=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v err_code=%s err_type=%s err_message=%s",
  677. account.ID,
  678. account.Type,
  679. connID,
  680. truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
  681. normalizeOpenAIWSLogValue(previousResponseIDKind),
  682. truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen),
  683. eventCount,
  684. reqStream,
  685. storeDisabled,
  686. lease.Reused(),
  687. truncateOpenAIWSLogValue(sessionHash, 12),
  688. openAIWSHeaderValueForLog(wsHeaders, "session_id"),
  689. openAIWSHeaderValueForLog(wsHeaders, "conversation_id"),
  690. normalizeOpenAIWSLogValue(sessionResolution.SessionSource),
  691. normalizeOpenAIWSLogValue(sessionResolution.ConversationSource),
  692. turnState != "",
  693. len(turnState),
  694. promptCacheKey != "",
  695. errCode,
  696. errType,
  697. errMessage,
  698. )
  699. }
  700. // error 事件后连接不再可复用,避免回池后污染下一请求。
  701. lease.MarkBroken()
  702. if !wroteDownstream && canFallback {
  703. return nil, wrapOpenAIWSFallback(fallbackReason, errors.New(errMsg))
  704. }
  705. statusCode := openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw)
  706. setOpsUpstreamError(c, statusCode, errMsg, "")
  707. if reqStream && !clientDisconnected {
  708. flushBufferedStreamEvents("error_event")
  709. emitStreamMessage(message, true)
  710. }
  711. if !reqStream {
  712. c.JSON(statusCode, gin.H{
  713. "error": gin.H{
  714. "type": "upstream_error",
  715. "message": errMsg,
  716. },
  717. })
  718. }
  719. return nil, fmt.Errorf("openai ws error event: %s", errMsg)
  720. }
  721. if reqStream {
  722. // 在首个 token 前先缓冲事件(如 response.created),
  723. // 以便上游早期断连时仍可安全回退到 HTTP,不给下游发送半截流。
  724. shouldBuffer := firstTokenMs == nil && !isTokenEvent && !isTerminalEvent
  725. if shouldBuffer {
  726. buffered := make([]byte, len(message))
  727. copy(buffered, message)
  728. bufferedStreamEvents = append(bufferedStreamEvents, buffered)
  729. bufferedEventCount++
  730. if debugEnabled && shouldLogOpenAIWSBufferedEvent(bufferedEventCount) {
  731. logOpenAIWSModeDebug(
  732. "buffer_enqueue account_id=%d conn_id=%s idx=%d event_idx=%d event_type=%s buffer_size=%d",
  733. account.ID,
  734. connID,
  735. bufferedEventCount,
  736. eventCount,
  737. truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
  738. len(bufferedStreamEvents),
  739. )
  740. }
  741. } else {
  742. flushBufferedStreamEvents(eventType)
  743. emitStreamMessage(message, isTerminalEvent)
  744. }
  745. } else {
  746. if responseField.Exists() && responseField.Type == gjson.JSON {
  747. finalResponse = []byte(responseField.Raw)
  748. }
  749. }
  750. if isTerminalEvent {
  751. if !clientDisconnected {
  752. markOpenAIWSClientVisibleFailure(c, eventType, message)
  753. }
  754. upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message)
  755. // A terminal event must be the final JSON document in its WS message.
  756. // Ignore any tail for the completed client turn, but never reuse the
  757. // ambiguous upstream connection for another request.
  758. cleanExit = len(pendingJSONDocuments) == 0
  759. break
  760. }
  761. }
  762. if clientDisconnected && terminalEventCount == 0 {
  763. return resultWithUsage(), fmt.Errorf("openai ws stream incomplete after client disconnect: %w", context.Canceled)
  764. }
  765. if !reqStream {
  766. if clientDisconnected {
  767. return resultWithUsage(), nil
  768. }
  769. if len(finalResponse) == 0 {
  770. logOpenAIWSModeInfo(
  771. "missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v",
  772. account.ID,
  773. connID,
  774. eventCount,
  775. tokenEventCount,
  776. terminalEventCount,
  777. wroteDownstream,
  778. )
  779. if !wroteDownstream {
  780. return nil, wrapOpenAIWSFallback("missing_final_response", errors.New("no terminal response payload"))
  781. }
  782. return nil, errors.New("ws finished without final response")
  783. }
  784. if needModelReplace {
  785. finalResponse = s.replaceModelInResponseBody(finalResponse, mappedModel, originalModel)
  786. }
  787. finalResponse = s.correctToolCallsInResponseBody(finalResponse)
  788. populateOpenAIUsageFromResponseJSON(finalResponse, usage)
  789. if responseID == "" {
  790. responseID = strings.TrimSpace(gjson.GetBytes(finalResponse, "id").String())
  791. }
  792. c.Data(http.StatusOK, "application/json", finalResponse)
  793. } else {
  794. flushStreamWriter(true)
  795. }
  796. if responseID != "" && stateStore != nil {
  797. ttl := s.openAIWSResponseStickyTTL()
  798. logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
  799. stateStore.BindResponseConn(responseID, lease.ConnID(), ttl)
  800. }
  801. if stateStore != nil && storeDisabled && sessionHash != "" {
  802. stateStore.BindSessionConn(groupID, sessionHash, lease.ConnID(), s.openAIWSSessionStickyTTL())
  803. }
  804. firstTokenMsValue := -1
  805. if firstTokenMs != nil {
  806. firstTokenMsValue = *firstTokenMs
  807. }
  808. logOpenAIWSModeDebug(
  809. "completed account_id=%d conn_id=%s response_id=%s stream=%v duration_ms=%d events=%d token_events=%d terminal_events=%d buffered_events=%d buffered_flushed=%d first_event=%s last_event=%s first_token_ms=%d wrote_downstream=%v client_disconnected=%v",
  810. account.ID,
  811. connID,
  812. truncateOpenAIWSLogValue(strings.TrimSpace(responseID), openAIWSIDValueMaxLen),
  813. reqStream,
  814. time.Since(startTime).Milliseconds(),
  815. eventCount,
  816. tokenEventCount,
  817. terminalEventCount,
  818. bufferedEventCount,
  819. flushedBufferedEventCount,
  820. truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen),
  821. truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen),
  822. firstTokenMsValue,
  823. wroteDownstream,
  824. clientDisconnected,
  825. )
  826. result := resultWithUsage()
  827. result.ImageCount = imageCounter.Count()
  828. result.ImageOutputSizes = imageCounter.Sizes()
  829. return result, nil
  830. }
  831. // ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。
  832. // 当前实现按“单请求 -> 终止事件 -> 下一请求”的顺序代理,适配 Codex CLI 的 turn 模式。
  833. // stripCodexSparkImageGenerationToolFromRawPayload removes the image_generation
  834. // tool from a raw /responses payload when the upstream model is gpt-5.3-codex-spark.
  835. // Spark rejects that tool upstream with HTTP 400 (invalid_request_error, param=tools);
  836. // Codex clients advertise it by default. Returns the (possibly unchanged) payload,
  837. // whether it changed, and any JSON decode error.
  838. func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model string) ([]byte, bool, error) {
  839. if !isCodexSparkModel(model) {
  840. return payload, false, nil
  841. }
  842. return stripOpenAIImageGenerationToolsFromRawPayload(payload)
  843. }