Files
sub2api/backend/internal/service/openai_ws_forwarder_payload.go
T

924 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"unsafe"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func validateOpenAIWSBearerToken(account *Account, token string) error {
if account == nil {
return errors.New("account is nil")
}
if strings.TrimSpace(token) == "" && !account.IsOpenAIAgentIdentity() {
return errors.New("token is empty")
}
return nil
}
func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (string, error) {
if account == nil {
return "", errors.New("account is nil")
}
var targetURL string
switch account.Type {
case AccountTypeOAuth:
targetURL = chatgptCodexURL
case AccountTypeSetupToken:
if account.IsOpenAIOAuthLike() {
targetURL = chatgptCodexURL
} else {
targetURL = openaiPlatformAPIURL
}
case AccountTypeAPIKey:
baseURL := account.GetOpenAIBaseURL()
if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
}
if baseURL == "" {
targetURL = openaiPlatformAPIURL
} else {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return "", err
}
targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
}
default:
targetURL = openaiPlatformAPIURL
}
parsed, err := url.Parse(strings.TrimSpace(targetURL))
if err != nil {
return "", fmt.Errorf("invalid target url: %w", err)
}
switch strings.ToLower(parsed.Scheme) {
case "https":
parsed.Scheme = "wss"
case "http":
parsed.Scheme = "ws"
case "wss", "ws":
// 保持不变
default:
return "", fmt.Errorf("unsupported scheme for ws: %s", parsed.Scheme)
}
return parsed.String(), nil
}
func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
ctx context.Context,
c *gin.Context,
account *Account,
token string,
decision OpenAIWSProtocolDecision,
isCodexCLI bool,
turnState string,
turnMetadata string,
promptCacheKey string,
routingModel string,
routingServiceTier string,
) (http.Header, openAIWSSessionHeaderResolution, error) {
headers := make(http.Header)
if account == nil || !account.IsOpenAIAgentIdentity() {
headers.Set("authorization", "Bearer "+token)
}
sessionResolution := resolveOpenAIWSSessionHeaders(c, promptCacheKey)
if c != nil && c.Request != nil {
if v := strings.TrimSpace(c.Request.Header.Get("accept-language")); v != "" {
headers.Set("accept-language", v)
}
for _, value := range c.Request.Header.Values("x-codex-beta-features") {
if value = strings.TrimSpace(value); value != "" {
headers.Add("x-codex-beta-features", value)
}
}
for _, name := range [...]string{
"x-codex-window-id",
"x-codex-installation-id",
"session-id",
"thread-id",
"x-client-request-id",
} {
if value := c.Request.Header.Get(name); strings.TrimSpace(value) != "" {
headers.Set(name, value)
}
}
}
// 真实 Codex 的 WS 握手同样携带会话级 x-codex-beta-features
// (client.rs build_websocket_headers 复用 build_responses_headers),
// 客户端未声明时补成默认形态,与 HTTP 出站保持一致。放在客户端头拷贝
// 之外:该头是账号/会话级属性,不依赖入站请求是否存在,也避免预热与
// 实际请求因头差异落进不同的连接池兼容分桶。
applyOpenAICodexBetaFeatures(c, account, headers)
// OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。
if account != nil && account.UsesOpenAICodexProtocol() {
apiKeyID := getAPIKeyIDFromContext(c)
if sessionResolution.SessionID != "" {
headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID))
}
if sessionResolution.ConversationID != "" {
headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID))
}
} else {
if sessionResolution.SessionID != "" {
headers.Set("session_id", sessionResolution.SessionID)
}
if sessionResolution.ConversationID != "" {
headers.Set("conversation_id", sessionResolution.ConversationID)
}
}
if state := strings.TrimSpace(turnState); state != "" {
headers.Set(openAIWSTurnStateHeader, state)
}
if metadata := strings.TrimSpace(turnMetadata); metadata != "" {
headers.Set(openAIWSTurnMetadataHeader, metadata)
}
applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
applyStagedCodexFingerprintHeaders(c, account, headers)
if account != nil && account.UsesOpenAICodexProtocol() {
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil {
return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err)
}
headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
}
betaValue := openAIWSBetaV2Value
if decision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
betaValue = openAIWSBetaV1Value
}
headers.Set("OpenAI-Beta", betaValue)
customUA := ""
if account != nil {
customUA = account.GetOpenAIUserAgent()
}
if strings.TrimSpace(customUA) != "" {
headers.Set("user-agent", customUA)
} else if c != nil {
if ua := strings.TrimSpace(c.GetHeader("User-Agent")); ua != "" {
headers.Set("user-agent", ua)
}
}
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
headers.Set("user-agent", CodexCanonicalUserAgent())
}
// 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为
// 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。
if account != nil && account.UsesOpenAICodexProtocol() {
enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account))
}
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
// 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
account.ApplyHeaderOverrides(headers)
setOpenAICodexRoutingHint(headers, account, routingModel, routingServiceTier)
logOpenAIRoutingDiagnostics(
ctx,
account,
string(decision.Transport),
routingModel,
routingServiceTier,
strings.TrimSpace(headers.Get(openAICodexRoutingHintHeader)) != "",
"soft_routing_hint",
)
return headers, sessionResolution, nil
}
func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any, account *Account) map[string]any {
// OpenAI WS Mode 协议:response.create 字段与 HTTP /responses 基本一致。
// 保留 stream 字段(与 Codex CLI 一致),仅移除 background。
payload := make(map[string]any, len(reqBody)+1)
for k, v := range reqBody {
payload[k] = v
}
delete(payload, "background")
if _, exists := payload["stream"]; !exists {
payload["stream"] = true
}
payload["type"] = "response.create"
// OAuth 默认保持 store=false,避免误依赖服务端历史。
if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) {
payload["store"] = false
}
return payload
}
func setOpenAIWSTurnMetadata(payload map[string]any, turnMetadata string) {
if len(payload) == 0 {
return
}
metadata := strings.TrimSpace(turnMetadata)
if metadata == "" {
return
}
switch existing := payload["client_metadata"].(type) {
case map[string]any:
existing[openAIWSTurnMetadataHeader] = metadata
payload["client_metadata"] = existing
case map[string]string:
next := make(map[string]any, len(existing)+1)
for k, v := range existing {
next[k] = v
}
next[openAIWSTurnMetadataHeader] = metadata
payload["client_metadata"] = next
default:
payload["client_metadata"] = map[string]any{
openAIWSTurnMetadataHeader: metadata,
}
}
}
func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) bool {
if account != nil && account.IsOpenAIWSAllowStoreRecoveryEnabled() {
return true
}
if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.AllowStoreRecovery {
return true
}
return false
}
func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool {
if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) {
return true
}
if len(reqBody) == 0 {
return false
}
rawStore, ok := reqBody["store"]
if !ok {
return false
}
storeEnabled, ok := rawStore.(bool)
if !ok {
return false
}
return !storeEnabled
}
func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool {
if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) {
return true
}
if len(reqBody) == 0 {
return false
}
storeValue := gjson.GetBytes(reqBody, "store")
if !storeValue.Exists() {
return false
}
if storeValue.Type != gjson.True && storeValue.Type != gjson.False {
return false
}
return !storeValue.Bool()
}
func (s *OpenAIGatewayService) openAIWSStoreDisabledConnMode() string {
if s == nil || s.cfg == nil {
return openAIWSStoreDisabledConnModeStrict
}
mode := strings.ToLower(strings.TrimSpace(s.cfg.Gateway.OpenAIWS.StoreDisabledConnMode))
switch mode {
case openAIWSStoreDisabledConnModeStrict, openAIWSStoreDisabledConnModeAdaptive, openAIWSStoreDisabledConnModeOff:
return mode
case "":
// 兼容旧配置:仅配置了布尔开关时按旧语义推导。
if s.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
return openAIWSStoreDisabledConnModeStrict
}
return openAIWSStoreDisabledConnModeOff
default:
return openAIWSStoreDisabledConnModeStrict
}
}
func shouldForceNewConnOnStoreDisabled(mode, lastFailureReason string) bool {
switch mode {
case openAIWSStoreDisabledConnModeOff:
return false
case openAIWSStoreDisabledConnModeAdaptive:
reason := strings.TrimPrefix(strings.TrimSpace(lastFailureReason), "prewarm_")
switch reason {
case "policy_violation", "message_too_big", "auth_failed", "write_request", "write":
return true
default:
return false
}
default:
return true
}
}
func dropPreviousResponseIDFromRawPayload(payload []byte) ([]byte, bool, error) {
return dropPreviousResponseIDFromRawPayloadWithDeleteFn(payload, sjson.DeleteBytes)
}
func dropPreviousResponseIDFromRawPayloadWithDeleteFn(
payload []byte,
deleteFn func([]byte, string) ([]byte, error),
) ([]byte, bool, error) {
if len(payload) == 0 {
return payload, false, nil
}
if !gjson.GetBytes(payload, "previous_response_id").Exists() {
return payload, false, nil
}
if deleteFn == nil {
deleteFn = sjson.DeleteBytes
}
updated := payload
for i := 0; i < openAIWSMaxPrevResponseIDDeletePasses &&
gjson.GetBytes(updated, "previous_response_id").Exists(); i++ {
next, err := deleteFn(updated, "previous_response_id")
if err != nil {
return payload, false, err
}
updated = next
}
return updated, !gjson.GetBytes(updated, "previous_response_id").Exists(), nil
}
func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string) ([]byte, error) {
normalizedPrevID := strings.TrimSpace(previousResponseID)
if len(payload) == 0 || normalizedPrevID == "" {
return payload, nil
}
updated, err := sjson.SetBytes(payload, "previous_response_id", normalizedPrevID)
if err == nil {
return updated, nil
}
var reqBody map[string]any
if unmarshalErr := decodeOpenAIJSONUseNumber(payload, &reqBody); unmarshalErr != nil {
return nil, err
}
reqBody["previous_response_id"] = normalizedPrevID
rebuilt, marshalErr := json.Marshal(reqBody)
if marshalErr != nil {
return nil, marshalErr
}
return rebuilt, nil
}
type openAIWSContextWindowBoundary struct {
WindowID string
Changed bool
PreviousResponseIDRemoved bool
}
func openAIWSPayloadCodexWindowID(payload []byte) string {
if len(payload) == 0 {
return ""
}
if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
return windowID
}
turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String())
if turnMetadata == "" {
return ""
}
return strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String())
}
// normalizeOpenAIWSContextWindowBoundary breaks a Responses continuation chain
// when Codex moves to a new local context window. WebSocket response.create can
// still carry the previous window's previous_response_id after new_context,
// while HTTP starts the new window without that continuation anchor.
func normalizeOpenAIWSContextWindowBoundary(
payload []byte,
previousWindowID string,
) ([]byte, openAIWSContextWindowBoundary, error) {
currentWindowID := openAIWSPayloadCodexWindowID(payload)
boundary := openAIWSContextWindowBoundary{WindowID: currentWindowID}
if previousWindowID == "" || currentWindowID == "" || currentWindowID == previousWindowID {
return payload, boundary, nil
}
boundary.Changed = true
updated, removed, err := dropPreviousResponseIDFromRawPayload(payload)
if err != nil {
return payload, boundary, err
}
boundary.PreviousResponseIDRemoved = removed
return updated, boundary, nil
}
func shouldInferIngressFunctionCallOutputPreviousResponseID(
storeDisabled bool,
turn int,
signals ToolContinuationSignals,
currentPreviousResponseID string,
expectedPreviousResponseID string,
) bool {
if !storeDisabled || turn <= 1 || !signals.HasFunctionCallOutput {
return false
}
if strings.TrimSpace(currentPreviousResponseID) != "" {
return false
}
if signals.HasFunctionCallOutputMissingCallID {
return false
}
// If the client already sent the actual tool-call context, treat this as
// a full replay / self-contained continuation payload rather than
// downgrading it into an inferred delta continuation. item_reference alone
// is not enough on the store=false WS path: it still needs a valid prior
// response anchor so upstream can resolve the referenced function_call.
if signals.HasToolCallContext {
return false
}
return strings.TrimSpace(expectedPreviousResponseID) != ""
}
func alignStoreDisabledPreviousResponseID(
payload []byte,
expectedPreviousResponseID string,
) ([]byte, bool, error) {
if len(payload) == 0 {
return payload, false, nil
}
expected := strings.TrimSpace(expectedPreviousResponseID)
if expected == "" {
return payload, false, nil
}
current := openAIWSPayloadStringFromRaw(payload, "previous_response_id")
if current == "" || current == expected {
return payload, false, nil
}
withoutPrev, removed, dropErr := dropPreviousResponseIDFromRawPayload(payload)
if dropErr != nil {
return payload, false, dropErr
}
if !removed {
return payload, false, nil
}
updated, setErr := setPreviousResponseIDToRawPayload(withoutPrev, expected)
if setErr != nil {
return payload, false, setErr
}
return updated, true, nil
}
// Replay 状态所有权不变式:replay 序列中的 json.RawMessage 正文一经放入即视为
// 不可变,所有持有者共享同一份字节,任何修改都必须整体替换元素或重建 payload。
// 序列头数组在跨持有者保存时必须新建(combineOpenAIWSReplayItems),禁止通过
// 共享头 append,否则会写入其他持有者可见的底层数组。
// combineOpenAIWSReplayItems 合并历史与增量为新头数组,正文共享不复制。
func combineOpenAIWSReplayItems(history, delta []json.RawMessage) []json.RawMessage {
if len(delta) == 0 {
return history
}
combined := make([]json.RawMessage, 0, len(history)+len(delta))
combined = append(combined, history...)
return append(combined, delta...)
}
// openAIWSPayloadStringView 返回与 payload 共享底层数组的零拷贝 string 视图,
// 供 gjson.Get 使用(gjson.GetBytes 会整段复制结果 Raw,对 input 这类占
// payload 主体的字段是每次 O(payload) 分配)。调用方必须保证 payload 在结果
// 存活期间不可变(replay 所有权不变式)。
func openAIWSPayloadStringView(payload []byte) string {
return unsafe.String(unsafe.SliceData(payload), len(payload))
}
// openAIWSRawMessageFromResult 优先返回 parent 的子切片(gjson 值零拷贝共享),
// Index 不可用时回退为复制。共享要求 parent 遵守上面的不可变约定。
func openAIWSRawMessageFromResult(parent []byte, value gjson.Result) json.RawMessage {
idx := value.Index
if idx > 0 && idx+len(value.Raw) <= len(parent) && string(parent[idx:idx+len(value.Raw)]) == value.Raw {
return json.RawMessage(parent[idx : idx+len(value.Raw)])
}
return json.RawMessage(value.Raw)
}
func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return nil, errors.New("json is empty")
}
var decoded any
if err := decodeOpenAIJSONUseNumber(trimmed, &decoded); err != nil {
return nil, err
}
return json.Marshal(decoded)
}
func normalizeOpenAIWSJSONForCompareOrRaw(raw []byte) []byte {
normalized, err := normalizeOpenAIWSJSONForCompare(raw)
if err != nil {
return bytes.TrimSpace(raw)
}
return normalized
}
func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("payload is empty")
}
var decoded map[string]any
if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil {
return nil, err
}
delete(decoded, "input")
delete(decoded, "previous_response_id")
// Codex changes transport-only metadata for every response.create. These fields
// do not alter the context referenced by previous_response_id and are excluded
// from Codex's own websocket reuse comparison.
delete(decoded, "client_metadata")
delete(decoded, "stream_options")
// Official Codex prewarms a connection with generate=false, then omits the
// field on the business request that continues from the prewarm response.
// Only normalize false so a meaningful generate=true change remains visible.
if generate, ok := decoded["generate"].(bool); ok && !generate {
delete(decoded, "generate")
}
return json.Marshal(decoded)
}
// openAIWSExtractNormalizedInputSequence 拆出 input 序列。返回的正文尽可能与
// payload 共享底层数组(零拷贝),受 replay 所有权不变式保护。
func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) {
if len(payload) == 0 {
return nil, false, nil
}
inputValue := gjson.Get(openAIWSPayloadStringView(payload), "input")
if !inputValue.Exists() {
return nil, false, nil
}
if inputValue.Type == gjson.JSON {
if inputValue.IsArray() {
// gjson 宽容解析;数组整体先做零分配合法性校验,避免把断裂
// JSON 塞进 replay 历史。
arrayRaw := openAIWSRawMessageFromResult(payload, inputValue)
if !json.Valid(arrayRaw) {
return nil, true, errors.New("input array json is invalid")
}
elems := inputValue.Array()
items := make([]json.RawMessage, 0, len(elems))
for _, elem := range elems {
items = append(items, openAIWSRawMessageFromResult(payload, elem))
}
return items, true, nil
}
return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil
}
if inputValue.Type == gjson.String {
encoded, _ := json.Marshal(inputValue.String())
return []json.RawMessage{encoded}, true, nil
}
return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil
}
func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) {
previousItems, previousExists, prevErr := openAIWSExtractNormalizedInputSequence(previousPayload)
if prevErr != nil {
return false, prevErr
}
currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload)
if currentErr != nil {
return false, currentErr
}
if !previousExists && !currentExists {
return true, nil
}
if !previousExists {
return len(currentItems) == 0, nil
}
if !currentExists {
return len(previousItems) == 0, nil
}
if len(currentItems) < len(previousItems) {
return false, nil
}
for idx := range previousItems {
previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(previousItems[idx])
currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(currentItems[idx])
if !bytes.Equal(previousNormalized, currentNormalized) {
return false, nil
}
}
return true, nil
}
func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage) bool {
if len(prefix) == 0 {
return true
}
if len(items) < len(prefix) {
return false
}
for idx := range prefix {
// 快路径:客户端逐字节重发历史时直接比较,避免整轮历史的解码/再编码。
if bytes.Equal(bytes.TrimSpace(prefix[idx]), bytes.TrimSpace(items[idx])) {
continue
}
previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx])
currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx])
if !bytes.Equal(previousNormalized, currentNormalized) {
return false
}
}
return true
}
func openAIWSRawItemsHasFunctionCallOutput(items []json.RawMessage) bool {
for _, item := range items {
if isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) {
return true
}
}
return false
}
func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool {
if len(items) == 0 {
return false
}
contextCallIDs := make(map[string]struct{})
outputCallIDs := make(map[string]struct{})
for _, item := range items {
itemType := gjson.GetBytes(item, "type").String()
callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
switch {
case isCodexToolCallContextItemType(itemType):
if callID != "" {
contextCallIDs[callID] = struct{}{}
}
case isCodexToolCallOutputItemType(itemType):
if callID == "" {
return false
}
outputCallIDs[callID] = struct{}{}
}
}
if len(outputCallIDs) == 0 || len(contextCallIDs) == 0 {
return false
}
for callID := range outputCallIDs {
if _, ok := contextCallIDs[callID]; !ok {
return false
}
}
return true
}
// sanitizeOpenAIWSHistoricalReplayToolCalls 返回的新头数组与 previousItems 共享正文。
func sanitizeOpenAIWSHistoricalReplayToolCalls(
previousItems []json.RawMessage,
currentItems []json.RawMessage,
) []json.RawMessage {
if len(previousItems) == 0 {
return previousItems
}
outputCallIDs := make(map[string]struct{})
collectOutputCallIDs := func(items []json.RawMessage) {
for _, item := range items {
if !isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) {
continue
}
if callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()); callID != "" {
outputCallIDs[callID] = struct{}{}
}
}
}
collectOutputCallIDs(previousItems)
collectOutputCallIDs(currentItems)
sanitized := make([]json.RawMessage, 0, len(previousItems))
for _, item := range previousItems {
if isCodexToolCallContextItemType(gjson.GetBytes(item, "type").String()) {
callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
if _, paired := outputCallIDs[callID]; !paired {
continue
}
}
sanitized = append(sanitized, item)
}
return sanitized
}
func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool {
if len(payload) == 0 {
return false
}
input := gjson.Get(openAIWSPayloadStringView(payload), "input")
if !input.Exists() {
return false
}
if input.IsArray() {
for _, item := range input.Array() {
if isCodexToolCallOutputItemType(item.Get("type").String()) {
return true
}
}
return false
}
if input.Type == gjson.JSON {
return isCodexToolCallOutputItemType(input.Get("type").String())
}
return false
}
// buildOpenAIWSReplayInputSequenceFromItems 基于已解析的当前 turn input 构建
// replay 序列。返回序列的正文与 previousFullInput/currentItems 共享所有权
// (见 combineOpenAIWSReplayItems 上方的所有权不变式),头数组可能直接转移自
// currentItems。
func buildOpenAIWSReplayInputSequenceFromItems(
previousFullInput []json.RawMessage,
previousFullInputExists bool,
currentItems []json.RawMessage,
currentExists bool,
hasPreviousResponseID bool,
) ([]json.RawMessage, bool) {
if !hasPreviousResponseID || !previousFullInputExists {
return currentItems, currentExists
}
previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems)
if !currentExists || len(currentItems) == 0 {
return previousFullInput, true
}
if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) {
return currentItems, true
}
merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems))
merged = append(merged, previousFullInput...)
merged = append(merged, currentItems...)
return merged, true
}
func buildOpenAIWSReplayInputSequence(
previousFullInput []json.RawMessage,
previousFullInputExists bool,
currentPayload []byte,
hasPreviousResponseID bool,
) ([]json.RawMessage, bool, error) {
currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload)
if currentErr != nil {
return nil, false, currentErr
}
items, exists := buildOpenAIWSReplayInputSequenceFromItems(
previousFullInput,
previousFullInputExists,
currentItems,
currentExists,
hasPreviousResponseID,
)
return items, exists, nil
}
func setOpenAIWSPayloadInputSequence(
payload []byte,
fullInput []json.RawMessage,
fullInputExists bool,
) ([]byte, error) {
if !fullInputExists {
return payload, nil
}
// Preserve [] vs null semantics when input exists but is empty.
inputForMarshal := fullInput
if inputForMarshal == nil {
inputForMarshal = []json.RawMessage{}
}
inputRaw, marshalErr := json.Marshal(inputForMarshal)
if marshalErr != nil {
return nil, marshalErr
}
return sjson.SetRawBytes(payload, "input", inputRaw)
}
func buildOpenAIWSCurrentTurnRetryPayload(
payload []byte,
fullInput []json.RawMessage,
fullInputExists bool,
originalModel string,
) ([]byte, bool, error) {
if !fullInputExists {
return nil, false, nil
}
retryPayload, err := setOpenAIWSPayloadInputSequence(payload, fullInput, true)
if err != nil {
return nil, false, err
}
retryPayload = RemovePreviousResponseIDFromBody(retryPayload)
if model := strings.TrimSpace(originalModel); model != "" {
retryPayload, err = sjson.SetBytes(retryPayload, "model", model)
if err != nil {
return nil, false, err
}
}
coverage := AnalyzeToolCallOutputContextCoverageBytes(retryPayload)
if coverage.HasFunctionCallOutput && !coverage.ContextCoversAllCallIDs {
return nil, false, nil
}
return retryPayload, true, nil
}
func shouldKeepIngressPreviousResponseID(
previousPayload []byte,
currentPayload []byte,
lastTurnResponseID string,
hasFunctionCallOutput bool,
) (bool, string, error) {
if hasFunctionCallOutput {
return true, "has_function_call_output", nil
}
currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id"))
if currentPreviousResponseID == "" {
return false, "missing_previous_response_id", nil
}
expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID)
if expectedPreviousResponseID == "" {
return false, "missing_last_turn_response_id", nil
}
if currentPreviousResponseID != expectedPreviousResponseID {
return false, "previous_response_id_mismatch", nil
}
if len(previousPayload) == 0 {
return false, "missing_previous_turn_payload", nil
}
previousComparable, previousComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(previousPayload)
if previousComparableErr != nil {
return false, "non_input_compare_error", previousComparableErr
}
currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload)
if currentComparableErr != nil {
return false, "non_input_compare_error", currentComparableErr
}
if !bytes.Equal(previousComparable, currentComparable) {
return false, "non_input_changed", nil
}
return true, "strict_incremental_ok", nil
}
type openAIWSIngressPreviousTurnStrictState struct {
nonInputComparable []byte
}
func buildOpenAIWSIngressPreviousTurnStrictState(payload []byte) (*openAIWSIngressPreviousTurnStrictState, error) {
if len(payload) == 0 {
return nil, nil
}
nonInputComparable, nonInputErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload)
if nonInputErr != nil {
return nil, nonInputErr
}
return &openAIWSIngressPreviousTurnStrictState{
nonInputComparable: nonInputComparable,
}, nil
}
func shouldKeepIngressPreviousResponseIDWithStrictState(
previousState *openAIWSIngressPreviousTurnStrictState,
currentPayload []byte,
lastTurnResponseID string,
hasFunctionCallOutput bool,
) (bool, string, error) {
if hasFunctionCallOutput {
return true, "has_function_call_output", nil
}
currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id"))
if currentPreviousResponseID == "" {
return false, "missing_previous_response_id", nil
}
expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID)
if expectedPreviousResponseID == "" {
return false, "missing_last_turn_response_id", nil
}
if currentPreviousResponseID != expectedPreviousResponseID {
return false, "previous_response_id_mismatch", nil
}
if previousState == nil {
return false, "missing_previous_turn_payload", nil
}
currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload)
if currentComparableErr != nil {
return false, "non_input_compare_error", currentComparableErr
}
if !bytes.Equal(previousState.nonInputComparable, currentComparable) {
return false, "non_input_changed", nil
}
return true, "strict_incremental_ok", nil
}