openai_ws_forwarder_payload.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  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. "unsafe"
  12. "github.com/gin-gonic/gin"
  13. "github.com/tidwall/gjson"
  14. "github.com/tidwall/sjson"
  15. )
  16. func validateOpenAIWSBearerToken(account *Account, token string) error {
  17. if account == nil {
  18. return errors.New("account is nil")
  19. }
  20. if strings.TrimSpace(token) == "" && !account.IsOpenAIAgentIdentity() {
  21. return errors.New("token is empty")
  22. }
  23. return nil
  24. }
  25. func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (string, error) {
  26. if account == nil {
  27. return "", errors.New("account is nil")
  28. }
  29. var targetURL string
  30. switch account.Type {
  31. case AccountTypeOAuth:
  32. targetURL = chatgptCodexURL
  33. case AccountTypeSetupToken:
  34. if account.IsOpenAIOAuthLike() {
  35. targetURL = chatgptCodexURL
  36. } else {
  37. targetURL = openaiPlatformAPIURL
  38. }
  39. case AccountTypeAPIKey:
  40. baseURL := account.GetOpenAIBaseURL()
  41. if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
  42. baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
  43. }
  44. if baseURL == "" {
  45. targetURL = openaiPlatformAPIURL
  46. } else {
  47. validatedURL, err := s.validateUpstreamBaseURL(baseURL)
  48. if err != nil {
  49. return "", err
  50. }
  51. targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
  52. }
  53. default:
  54. targetURL = openaiPlatformAPIURL
  55. }
  56. parsed, err := url.Parse(strings.TrimSpace(targetURL))
  57. if err != nil {
  58. return "", fmt.Errorf("invalid target url: %w", err)
  59. }
  60. switch strings.ToLower(parsed.Scheme) {
  61. case "https":
  62. parsed.Scheme = "wss"
  63. case "http":
  64. parsed.Scheme = "ws"
  65. case "wss", "ws":
  66. // 保持不变
  67. default:
  68. return "", fmt.Errorf("unsupported scheme for ws: %s", parsed.Scheme)
  69. }
  70. return parsed.String(), nil
  71. }
  72. func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
  73. ctx context.Context,
  74. c *gin.Context,
  75. account *Account,
  76. token string,
  77. decision OpenAIWSProtocolDecision,
  78. isCodexCLI bool,
  79. turnState string,
  80. turnMetadata string,
  81. promptCacheKey string,
  82. routingModel string,
  83. routingServiceTier string,
  84. ) (http.Header, openAIWSSessionHeaderResolution, error) {
  85. headers := make(http.Header)
  86. if account == nil || !account.IsOpenAIAgentIdentity() {
  87. headers.Set("authorization", "Bearer "+token)
  88. }
  89. sessionResolution := resolveOpenAIWSSessionHeaders(c, promptCacheKey)
  90. if c != nil && c.Request != nil {
  91. if v := strings.TrimSpace(c.Request.Header.Get("accept-language")); v != "" {
  92. headers.Set("accept-language", v)
  93. }
  94. for _, value := range c.Request.Header.Values("x-codex-beta-features") {
  95. if value = strings.TrimSpace(value); value != "" {
  96. headers.Add("x-codex-beta-features", value)
  97. }
  98. }
  99. for _, name := range [...]string{
  100. "x-codex-window-id",
  101. "x-codex-installation-id",
  102. "session-id",
  103. "thread-id",
  104. "x-client-request-id",
  105. } {
  106. if value := c.Request.Header.Get(name); strings.TrimSpace(value) != "" {
  107. headers.Set(name, value)
  108. }
  109. }
  110. }
  111. // 真实 Codex 的 WS 握手同样携带会话级 x-codex-beta-features
  112. // (client.rs build_websocket_headers 复用 build_responses_headers),
  113. // 客户端未声明时补成默认形态,与 HTTP 出站保持一致。放在客户端头拷贝
  114. // 之外:该头是账号/会话级属性,不依赖入站请求是否存在,也避免预热与
  115. // 实际请求因头差异落进不同的连接池兼容分桶。
  116. applyOpenAICodexBetaFeatures(c, account, headers)
  117. // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。
  118. if account != nil && account.UsesOpenAICodexProtocol() {
  119. apiKeyID := getAPIKeyIDFromContext(c)
  120. if sessionResolution.SessionID != "" {
  121. headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID))
  122. }
  123. if sessionResolution.ConversationID != "" {
  124. headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID))
  125. }
  126. } else {
  127. if sessionResolution.SessionID != "" {
  128. headers.Set("session_id", sessionResolution.SessionID)
  129. }
  130. if sessionResolution.ConversationID != "" {
  131. headers.Set("conversation_id", sessionResolution.ConversationID)
  132. }
  133. }
  134. if state := strings.TrimSpace(turnState); state != "" {
  135. headers.Set(openAIWSTurnStateHeader, state)
  136. }
  137. if metadata := strings.TrimSpace(turnMetadata); metadata != "" {
  138. headers.Set(openAIWSTurnMetadataHeader, metadata)
  139. }
  140. applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
  141. applyStagedCodexFingerprintHeaders(c, account, headers)
  142. if account != nil && account.UsesOpenAICodexProtocol() {
  143. if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil {
  144. return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err)
  145. }
  146. headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
  147. }
  148. betaValue := openAIWSBetaV2Value
  149. if decision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
  150. betaValue = openAIWSBetaV1Value
  151. }
  152. headers.Set("OpenAI-Beta", betaValue)
  153. customUA := ""
  154. if account != nil {
  155. customUA = account.GetOpenAIUserAgent()
  156. }
  157. if strings.TrimSpace(customUA) != "" {
  158. headers.Set("user-agent", customUA)
  159. } else if c != nil {
  160. if ua := strings.TrimSpace(c.GetHeader("User-Agent")); ua != "" {
  161. headers.Set("user-agent", ua)
  162. }
  163. }
  164. if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
  165. headers.Set("user-agent", CodexCanonicalUserAgent())
  166. }
  167. // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为
  168. // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。
  169. if account != nil && account.UsesOpenAICodexProtocol() {
  170. enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account))
  171. }
  172. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
  173. // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
  174. account.ApplyHeaderOverrides(headers)
  175. setOpenAICodexRoutingHint(headers, account, routingModel, routingServiceTier)
  176. logOpenAIRoutingDiagnostics(
  177. ctx,
  178. account,
  179. string(decision.Transport),
  180. routingModel,
  181. routingServiceTier,
  182. strings.TrimSpace(headers.Get(openAICodexRoutingHintHeader)) != "",
  183. "soft_routing_hint",
  184. )
  185. return headers, sessionResolution, nil
  186. }
  187. func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any, account *Account) map[string]any {
  188. // OpenAI WS Mode 协议:response.create 字段与 HTTP /responses 基本一致。
  189. // 保留 stream 字段(与 Codex CLI 一致),仅移除 background。
  190. payload := make(map[string]any, len(reqBody)+1)
  191. for k, v := range reqBody {
  192. payload[k] = v
  193. }
  194. delete(payload, "background")
  195. if _, exists := payload["stream"]; !exists {
  196. payload["stream"] = true
  197. }
  198. payload["type"] = "response.create"
  199. // OAuth 默认保持 store=false,避免误依赖服务端历史。
  200. if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) {
  201. payload["store"] = false
  202. }
  203. return payload
  204. }
  205. func setOpenAIWSTurnMetadata(payload map[string]any, turnMetadata string) {
  206. if len(payload) == 0 {
  207. return
  208. }
  209. metadata := strings.TrimSpace(turnMetadata)
  210. if metadata == "" {
  211. return
  212. }
  213. switch existing := payload["client_metadata"].(type) {
  214. case map[string]any:
  215. existing[openAIWSTurnMetadataHeader] = metadata
  216. payload["client_metadata"] = existing
  217. case map[string]string:
  218. next := make(map[string]any, len(existing)+1)
  219. for k, v := range existing {
  220. next[k] = v
  221. }
  222. next[openAIWSTurnMetadataHeader] = metadata
  223. payload["client_metadata"] = next
  224. default:
  225. payload["client_metadata"] = map[string]any{
  226. openAIWSTurnMetadataHeader: metadata,
  227. }
  228. }
  229. }
  230. func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) bool {
  231. if account != nil && account.IsOpenAIWSAllowStoreRecoveryEnabled() {
  232. return true
  233. }
  234. if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.AllowStoreRecovery {
  235. return true
  236. }
  237. return false
  238. }
  239. func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool {
  240. if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) {
  241. return true
  242. }
  243. if len(reqBody) == 0 {
  244. return false
  245. }
  246. rawStore, ok := reqBody["store"]
  247. if !ok {
  248. return false
  249. }
  250. storeEnabled, ok := rawStore.(bool)
  251. if !ok {
  252. return false
  253. }
  254. return !storeEnabled
  255. }
  256. func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool {
  257. if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) {
  258. return true
  259. }
  260. if len(reqBody) == 0 {
  261. return false
  262. }
  263. storeValue := gjson.GetBytes(reqBody, "store")
  264. if !storeValue.Exists() {
  265. return false
  266. }
  267. if storeValue.Type != gjson.True && storeValue.Type != gjson.False {
  268. return false
  269. }
  270. return !storeValue.Bool()
  271. }
  272. func (s *OpenAIGatewayService) openAIWSStoreDisabledConnMode() string {
  273. if s == nil || s.cfg == nil {
  274. return openAIWSStoreDisabledConnModeStrict
  275. }
  276. mode := strings.ToLower(strings.TrimSpace(s.cfg.Gateway.OpenAIWS.StoreDisabledConnMode))
  277. switch mode {
  278. case openAIWSStoreDisabledConnModeStrict, openAIWSStoreDisabledConnModeAdaptive, openAIWSStoreDisabledConnModeOff:
  279. return mode
  280. case "":
  281. // 兼容旧配置:仅配置了布尔开关时按旧语义推导。
  282. if s.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
  283. return openAIWSStoreDisabledConnModeStrict
  284. }
  285. return openAIWSStoreDisabledConnModeOff
  286. default:
  287. return openAIWSStoreDisabledConnModeStrict
  288. }
  289. }
  290. func shouldForceNewConnOnStoreDisabled(mode, lastFailureReason string) bool {
  291. switch mode {
  292. case openAIWSStoreDisabledConnModeOff:
  293. return false
  294. case openAIWSStoreDisabledConnModeAdaptive:
  295. reason := strings.TrimPrefix(strings.TrimSpace(lastFailureReason), "prewarm_")
  296. switch reason {
  297. case "policy_violation", "message_too_big", "auth_failed", "write_request", "write":
  298. return true
  299. default:
  300. return false
  301. }
  302. default:
  303. return true
  304. }
  305. }
  306. func dropPreviousResponseIDFromRawPayload(payload []byte) ([]byte, bool, error) {
  307. return dropPreviousResponseIDFromRawPayloadWithDeleteFn(payload, sjson.DeleteBytes)
  308. }
  309. func dropPreviousResponseIDFromRawPayloadWithDeleteFn(
  310. payload []byte,
  311. deleteFn func([]byte, string) ([]byte, error),
  312. ) ([]byte, bool, error) {
  313. if len(payload) == 0 {
  314. return payload, false, nil
  315. }
  316. if !gjson.GetBytes(payload, "previous_response_id").Exists() {
  317. return payload, false, nil
  318. }
  319. if deleteFn == nil {
  320. deleteFn = sjson.DeleteBytes
  321. }
  322. updated := payload
  323. for i := 0; i < openAIWSMaxPrevResponseIDDeletePasses &&
  324. gjson.GetBytes(updated, "previous_response_id").Exists(); i++ {
  325. next, err := deleteFn(updated, "previous_response_id")
  326. if err != nil {
  327. return payload, false, err
  328. }
  329. updated = next
  330. }
  331. return updated, !gjson.GetBytes(updated, "previous_response_id").Exists(), nil
  332. }
  333. func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string) ([]byte, error) {
  334. normalizedPrevID := strings.TrimSpace(previousResponseID)
  335. if len(payload) == 0 || normalizedPrevID == "" {
  336. return payload, nil
  337. }
  338. updated, err := sjson.SetBytes(payload, "previous_response_id", normalizedPrevID)
  339. if err == nil {
  340. return updated, nil
  341. }
  342. var reqBody map[string]any
  343. if unmarshalErr := decodeOpenAIJSONUseNumber(payload, &reqBody); unmarshalErr != nil {
  344. return nil, err
  345. }
  346. reqBody["previous_response_id"] = normalizedPrevID
  347. rebuilt, marshalErr := json.Marshal(reqBody)
  348. if marshalErr != nil {
  349. return nil, marshalErr
  350. }
  351. return rebuilt, nil
  352. }
  353. func shouldInferIngressFunctionCallOutputPreviousResponseID(
  354. storeDisabled bool,
  355. turn int,
  356. signals ToolContinuationSignals,
  357. currentPreviousResponseID string,
  358. expectedPreviousResponseID string,
  359. ) bool {
  360. if !storeDisabled || turn <= 1 || !signals.HasFunctionCallOutput {
  361. return false
  362. }
  363. if strings.TrimSpace(currentPreviousResponseID) != "" {
  364. return false
  365. }
  366. if signals.HasFunctionCallOutputMissingCallID {
  367. return false
  368. }
  369. // If the client already sent the actual tool-call context, treat this as
  370. // a full replay / self-contained continuation payload rather than
  371. // downgrading it into an inferred delta continuation. item_reference alone
  372. // is not enough on the store=false WS path: it still needs a valid prior
  373. // response anchor so upstream can resolve the referenced function_call.
  374. if signals.HasToolCallContext {
  375. return false
  376. }
  377. return strings.TrimSpace(expectedPreviousResponseID) != ""
  378. }
  379. func alignStoreDisabledPreviousResponseID(
  380. payload []byte,
  381. expectedPreviousResponseID string,
  382. ) ([]byte, bool, error) {
  383. if len(payload) == 0 {
  384. return payload, false, nil
  385. }
  386. expected := strings.TrimSpace(expectedPreviousResponseID)
  387. if expected == "" {
  388. return payload, false, nil
  389. }
  390. current := openAIWSPayloadStringFromRaw(payload, "previous_response_id")
  391. if current == "" || current == expected {
  392. return payload, false, nil
  393. }
  394. withoutPrev, removed, dropErr := dropPreviousResponseIDFromRawPayload(payload)
  395. if dropErr != nil {
  396. return payload, false, dropErr
  397. }
  398. if !removed {
  399. return payload, false, nil
  400. }
  401. updated, setErr := setPreviousResponseIDToRawPayload(withoutPrev, expected)
  402. if setErr != nil {
  403. return payload, false, setErr
  404. }
  405. return updated, true, nil
  406. }
  407. // Replay 状态所有权不变式:replay 序列中的 json.RawMessage 正文一经放入即视为
  408. // 不可变,所有持有者共享同一份字节,任何修改都必须整体替换元素或重建 payload。
  409. // 序列头数组在跨持有者保存时必须新建(combineOpenAIWSReplayItems),禁止通过
  410. // 共享头 append,否则会写入其他持有者可见的底层数组。
  411. // combineOpenAIWSReplayItems 合并历史与增量为新头数组,正文共享不复制。
  412. func combineOpenAIWSReplayItems(history, delta []json.RawMessage) []json.RawMessage {
  413. if len(delta) == 0 {
  414. return history
  415. }
  416. combined := make([]json.RawMessage, 0, len(history)+len(delta))
  417. combined = append(combined, history...)
  418. return append(combined, delta...)
  419. }
  420. // openAIWSPayloadStringView 返回与 payload 共享底层数组的零拷贝 string 视图,
  421. // 供 gjson.Get 使用(gjson.GetBytes 会整段复制结果 Raw,对 input 这类占
  422. // payload 主体的字段是每次 O(payload) 分配)。调用方必须保证 payload 在结果
  423. // 存活期间不可变(replay 所有权不变式)。
  424. func openAIWSPayloadStringView(payload []byte) string {
  425. return unsafe.String(unsafe.SliceData(payload), len(payload))
  426. }
  427. // openAIWSRawMessageFromResult 优先返回 parent 的子切片(gjson 值零拷贝共享),
  428. // Index 不可用时回退为复制。共享要求 parent 遵守上面的不可变约定。
  429. func openAIWSRawMessageFromResult(parent []byte, value gjson.Result) json.RawMessage {
  430. idx := value.Index
  431. if idx > 0 && idx+len(value.Raw) <= len(parent) && string(parent[idx:idx+len(value.Raw)]) == value.Raw {
  432. return json.RawMessage(parent[idx : idx+len(value.Raw)])
  433. }
  434. return json.RawMessage(value.Raw)
  435. }
  436. func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) {
  437. trimmed := bytes.TrimSpace(raw)
  438. if len(trimmed) == 0 {
  439. return nil, errors.New("json is empty")
  440. }
  441. var decoded any
  442. if err := decodeOpenAIJSONUseNumber(trimmed, &decoded); err != nil {
  443. return nil, err
  444. }
  445. return json.Marshal(decoded)
  446. }
  447. func normalizeOpenAIWSJSONForCompareOrRaw(raw []byte) []byte {
  448. normalized, err := normalizeOpenAIWSJSONForCompare(raw)
  449. if err != nil {
  450. return bytes.TrimSpace(raw)
  451. }
  452. return normalized
  453. }
  454. func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ([]byte, error) {
  455. if len(payload) == 0 {
  456. return nil, errors.New("payload is empty")
  457. }
  458. var decoded map[string]any
  459. if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil {
  460. return nil, err
  461. }
  462. delete(decoded, "input")
  463. delete(decoded, "previous_response_id")
  464. // Codex changes transport-only metadata for every response.create. These fields
  465. // do not alter the context referenced by previous_response_id and are excluded
  466. // from Codex's own websocket reuse comparison.
  467. delete(decoded, "client_metadata")
  468. delete(decoded, "stream_options")
  469. // Official Codex prewarms a connection with generate=false, then omits the
  470. // field on the business request that continues from the prewarm response.
  471. // Only normalize false so a meaningful generate=true change remains visible.
  472. if generate, ok := decoded["generate"].(bool); ok && !generate {
  473. delete(decoded, "generate")
  474. }
  475. return json.Marshal(decoded)
  476. }
  477. // openAIWSExtractNormalizedInputSequence 拆出 input 序列。返回的正文尽可能与
  478. // payload 共享底层数组(零拷贝),受 replay 所有权不变式保护。
  479. func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) {
  480. if len(payload) == 0 {
  481. return nil, false, nil
  482. }
  483. inputValue := gjson.Get(openAIWSPayloadStringView(payload), "input")
  484. if !inputValue.Exists() {
  485. return nil, false, nil
  486. }
  487. if inputValue.Type == gjson.JSON {
  488. if inputValue.IsArray() {
  489. // gjson 宽容解析;数组整体先做零分配合法性校验,避免把断裂
  490. // JSON 塞进 replay 历史。
  491. arrayRaw := openAIWSRawMessageFromResult(payload, inputValue)
  492. if !json.Valid(arrayRaw) {
  493. return nil, true, errors.New("input array json is invalid")
  494. }
  495. elems := inputValue.Array()
  496. items := make([]json.RawMessage, 0, len(elems))
  497. for _, elem := range elems {
  498. items = append(items, openAIWSRawMessageFromResult(payload, elem))
  499. }
  500. return items, true, nil
  501. }
  502. return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil
  503. }
  504. if inputValue.Type == gjson.String {
  505. encoded, _ := json.Marshal(inputValue.String())
  506. return []json.RawMessage{encoded}, true, nil
  507. }
  508. return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil
  509. }
  510. func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) {
  511. previousItems, previousExists, prevErr := openAIWSExtractNormalizedInputSequence(previousPayload)
  512. if prevErr != nil {
  513. return false, prevErr
  514. }
  515. currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload)
  516. if currentErr != nil {
  517. return false, currentErr
  518. }
  519. if !previousExists && !currentExists {
  520. return true, nil
  521. }
  522. if !previousExists {
  523. return len(currentItems) == 0, nil
  524. }
  525. if !currentExists {
  526. return len(previousItems) == 0, nil
  527. }
  528. if len(currentItems) < len(previousItems) {
  529. return false, nil
  530. }
  531. for idx := range previousItems {
  532. previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(previousItems[idx])
  533. currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(currentItems[idx])
  534. if !bytes.Equal(previousNormalized, currentNormalized) {
  535. return false, nil
  536. }
  537. }
  538. return true, nil
  539. }
  540. func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage) bool {
  541. if len(prefix) == 0 {
  542. return true
  543. }
  544. if len(items) < len(prefix) {
  545. return false
  546. }
  547. for idx := range prefix {
  548. // 快路径:客户端逐字节重发历史时直接比较,避免整轮历史的解码/再编码。
  549. if bytes.Equal(bytes.TrimSpace(prefix[idx]), bytes.TrimSpace(items[idx])) {
  550. continue
  551. }
  552. previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx])
  553. currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx])
  554. if !bytes.Equal(previousNormalized, currentNormalized) {
  555. return false
  556. }
  557. }
  558. return true
  559. }
  560. func openAIWSRawItemsHasFunctionCallOutput(items []json.RawMessage) bool {
  561. for _, item := range items {
  562. if isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) {
  563. return true
  564. }
  565. }
  566. return false
  567. }
  568. func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool {
  569. if len(items) == 0 {
  570. return false
  571. }
  572. contextCallIDs := make(map[string]struct{})
  573. outputCallIDs := make(map[string]struct{})
  574. for _, item := range items {
  575. itemType := gjson.GetBytes(item, "type").String()
  576. callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
  577. switch {
  578. case isCodexToolCallContextItemType(itemType):
  579. if callID != "" {
  580. contextCallIDs[callID] = struct{}{}
  581. }
  582. case isCodexToolCallOutputItemType(itemType):
  583. if callID == "" {
  584. return false
  585. }
  586. outputCallIDs[callID] = struct{}{}
  587. }
  588. }
  589. if len(outputCallIDs) == 0 || len(contextCallIDs) == 0 {
  590. return false
  591. }
  592. for callID := range outputCallIDs {
  593. if _, ok := contextCallIDs[callID]; !ok {
  594. return false
  595. }
  596. }
  597. return true
  598. }
  599. // sanitizeOpenAIWSHistoricalReplayToolCalls 返回的新头数组与 previousItems 共享正文。
  600. func sanitizeOpenAIWSHistoricalReplayToolCalls(
  601. previousItems []json.RawMessage,
  602. currentItems []json.RawMessage,
  603. ) []json.RawMessage {
  604. if len(previousItems) == 0 {
  605. return previousItems
  606. }
  607. outputCallIDs := make(map[string]struct{})
  608. collectOutputCallIDs := func(items []json.RawMessage) {
  609. for _, item := range items {
  610. if !isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) {
  611. continue
  612. }
  613. if callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()); callID != "" {
  614. outputCallIDs[callID] = struct{}{}
  615. }
  616. }
  617. }
  618. collectOutputCallIDs(previousItems)
  619. collectOutputCallIDs(currentItems)
  620. sanitized := make([]json.RawMessage, 0, len(previousItems))
  621. for _, item := range previousItems {
  622. if isCodexToolCallContextItemType(gjson.GetBytes(item, "type").String()) {
  623. callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
  624. if _, paired := outputCallIDs[callID]; !paired {
  625. continue
  626. }
  627. }
  628. sanitized = append(sanitized, item)
  629. }
  630. return sanitized
  631. }
  632. func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool {
  633. if len(payload) == 0 {
  634. return false
  635. }
  636. input := gjson.Get(openAIWSPayloadStringView(payload), "input")
  637. if !input.Exists() {
  638. return false
  639. }
  640. if input.IsArray() {
  641. for _, item := range input.Array() {
  642. if isCodexToolCallOutputItemType(item.Get("type").String()) {
  643. return true
  644. }
  645. }
  646. return false
  647. }
  648. if input.Type == gjson.JSON {
  649. return isCodexToolCallOutputItemType(input.Get("type").String())
  650. }
  651. return false
  652. }
  653. // buildOpenAIWSReplayInputSequenceFromItems 基于已解析的当前 turn input 构建
  654. // replay 序列。返回序列的正文与 previousFullInput/currentItems 共享所有权
  655. // (见 combineOpenAIWSReplayItems 上方的所有权不变式),头数组可能直接转移自
  656. // currentItems。
  657. func buildOpenAIWSReplayInputSequenceFromItems(
  658. previousFullInput []json.RawMessage,
  659. previousFullInputExists bool,
  660. currentItems []json.RawMessage,
  661. currentExists bool,
  662. hasPreviousResponseID bool,
  663. ) ([]json.RawMessage, bool) {
  664. if !hasPreviousResponseID || !previousFullInputExists {
  665. return currentItems, currentExists
  666. }
  667. previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems)
  668. if !currentExists || len(currentItems) == 0 {
  669. return previousFullInput, true
  670. }
  671. if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) {
  672. return currentItems, true
  673. }
  674. merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems))
  675. merged = append(merged, previousFullInput...)
  676. merged = append(merged, currentItems...)
  677. return merged, true
  678. }
  679. func buildOpenAIWSReplayInputSequence(
  680. previousFullInput []json.RawMessage,
  681. previousFullInputExists bool,
  682. currentPayload []byte,
  683. hasPreviousResponseID bool,
  684. ) ([]json.RawMessage, bool, error) {
  685. currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload)
  686. if currentErr != nil {
  687. return nil, false, currentErr
  688. }
  689. items, exists := buildOpenAIWSReplayInputSequenceFromItems(
  690. previousFullInput,
  691. previousFullInputExists,
  692. currentItems,
  693. currentExists,
  694. hasPreviousResponseID,
  695. )
  696. return items, exists, nil
  697. }
  698. func setOpenAIWSPayloadInputSequence(
  699. payload []byte,
  700. fullInput []json.RawMessage,
  701. fullInputExists bool,
  702. ) ([]byte, error) {
  703. if !fullInputExists {
  704. return payload, nil
  705. }
  706. // Preserve [] vs null semantics when input exists but is empty.
  707. inputForMarshal := fullInput
  708. if inputForMarshal == nil {
  709. inputForMarshal = []json.RawMessage{}
  710. }
  711. inputRaw, marshalErr := json.Marshal(inputForMarshal)
  712. if marshalErr != nil {
  713. return nil, marshalErr
  714. }
  715. return sjson.SetRawBytes(payload, "input", inputRaw)
  716. }
  717. func buildOpenAIWSCurrentTurnRetryPayload(
  718. payload []byte,
  719. fullInput []json.RawMessage,
  720. fullInputExists bool,
  721. originalModel string,
  722. ) ([]byte, bool, error) {
  723. if !fullInputExists {
  724. return nil, false, nil
  725. }
  726. retryPayload, err := setOpenAIWSPayloadInputSequence(payload, fullInput, true)
  727. if err != nil {
  728. return nil, false, err
  729. }
  730. retryPayload = RemovePreviousResponseIDFromBody(retryPayload)
  731. if model := strings.TrimSpace(originalModel); model != "" {
  732. retryPayload, err = sjson.SetBytes(retryPayload, "model", model)
  733. if err != nil {
  734. return nil, false, err
  735. }
  736. }
  737. coverage := AnalyzeToolCallOutputContextCoverageBytes(retryPayload)
  738. if coverage.HasFunctionCallOutput && !coverage.ContextCoversAllCallIDs {
  739. return nil, false, nil
  740. }
  741. return retryPayload, true, nil
  742. }
  743. func shouldKeepIngressPreviousResponseID(
  744. previousPayload []byte,
  745. currentPayload []byte,
  746. lastTurnResponseID string,
  747. hasFunctionCallOutput bool,
  748. ) (bool, string, error) {
  749. if hasFunctionCallOutput {
  750. return true, "has_function_call_output", nil
  751. }
  752. currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id"))
  753. if currentPreviousResponseID == "" {
  754. return false, "missing_previous_response_id", nil
  755. }
  756. expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID)
  757. if expectedPreviousResponseID == "" {
  758. return false, "missing_last_turn_response_id", nil
  759. }
  760. if currentPreviousResponseID != expectedPreviousResponseID {
  761. return false, "previous_response_id_mismatch", nil
  762. }
  763. if len(previousPayload) == 0 {
  764. return false, "missing_previous_turn_payload", nil
  765. }
  766. previousComparable, previousComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(previousPayload)
  767. if previousComparableErr != nil {
  768. return false, "non_input_compare_error", previousComparableErr
  769. }
  770. currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload)
  771. if currentComparableErr != nil {
  772. return false, "non_input_compare_error", currentComparableErr
  773. }
  774. if !bytes.Equal(previousComparable, currentComparable) {
  775. return false, "non_input_changed", nil
  776. }
  777. return true, "strict_incremental_ok", nil
  778. }
  779. type openAIWSIngressPreviousTurnStrictState struct {
  780. nonInputComparable []byte
  781. }
  782. func buildOpenAIWSIngressPreviousTurnStrictState(payload []byte) (*openAIWSIngressPreviousTurnStrictState, error) {
  783. if len(payload) == 0 {
  784. return nil, nil
  785. }
  786. nonInputComparable, nonInputErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload)
  787. if nonInputErr != nil {
  788. return nil, nonInputErr
  789. }
  790. return &openAIWSIngressPreviousTurnStrictState{
  791. nonInputComparable: nonInputComparable,
  792. }, nil
  793. }
  794. func shouldKeepIngressPreviousResponseIDWithStrictState(
  795. previousState *openAIWSIngressPreviousTurnStrictState,
  796. currentPayload []byte,
  797. lastTurnResponseID string,
  798. hasFunctionCallOutput bool,
  799. ) (bool, string, error) {
  800. if hasFunctionCallOutput {
  801. return true, "has_function_call_output", nil
  802. }
  803. currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id"))
  804. if currentPreviousResponseID == "" {
  805. return false, "missing_previous_response_id", nil
  806. }
  807. expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID)
  808. if expectedPreviousResponseID == "" {
  809. return false, "missing_last_turn_response_id", nil
  810. }
  811. if currentPreviousResponseID != expectedPreviousResponseID {
  812. return false, "previous_response_id_mismatch", nil
  813. }
  814. if previousState == nil {
  815. return false, "missing_previous_turn_payload", nil
  816. }
  817. currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload)
  818. if currentComparableErr != nil {
  819. return false, "non_input_compare_error", currentComparableErr
  820. }
  821. if !bytes.Equal(previousState.nonInputComparable, currentComparable) {
  822. return false, "non_input_changed", nil
  823. }
  824. return true, "strict_incremental_ok", nil
  825. }