Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
792 lines
24 KiB
Go
792 lines
24 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
|
||
"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 AccountTypeAPIKey:
|
||
baseURL := account.GetOpenAIBaseURL()
|
||
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.Type == AccountTypeOAuth {
|
||
apiKeyID := getAPIKeyIDFromContext(c)
|
||
if sessionResolution.SessionID != "" {
|
||
headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID))
|
||
}
|
||
if sessionResolution.ConversationID != "" {
|
||
headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, 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)
|
||
}
|
||
applyStagedCodexFingerprintHeaders(c, account, headers)
|
||
|
||
if account != nil && account.Type == AccountTypeOAuth {
|
||
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.Type == AccountTypeOAuth {
|
||
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.Type == AccountTypeOAuth && !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.Type == AccountTypeOAuth && !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.Type == AccountTypeOAuth && !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 := json.Unmarshal(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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func cloneOpenAIWSPayloadBytes(payload []byte) []byte {
|
||
if len(payload) == 0 {
|
||
return nil
|
||
}
|
||
cloned := make([]byte, len(payload))
|
||
copy(cloned, payload)
|
||
return cloned
|
||
}
|
||
|
||
func cloneOpenAIWSRawMessages(items []json.RawMessage) []json.RawMessage {
|
||
if items == nil {
|
||
return nil
|
||
}
|
||
cloned := make([]json.RawMessage, 0, len(items))
|
||
for idx := range items {
|
||
cloned = append(cloned, json.RawMessage(cloneOpenAIWSPayloadBytes(items[idx])))
|
||
}
|
||
return cloned
|
||
}
|
||
|
||
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 := json.Unmarshal(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 := json.Unmarshal(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)
|
||
}
|
||
|
||
func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) {
|
||
if len(payload) == 0 {
|
||
return nil, false, nil
|
||
}
|
||
inputValue := gjson.GetBytes(payload, "input")
|
||
if !inputValue.Exists() {
|
||
return nil, false, nil
|
||
}
|
||
if inputValue.Type == gjson.JSON {
|
||
raw := strings.TrimSpace(inputValue.Raw)
|
||
if strings.HasPrefix(raw, "[") {
|
||
var items []json.RawMessage
|
||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||
return nil, true, err
|
||
}
|
||
return items, true, nil
|
||
}
|
||
return []json.RawMessage{json.RawMessage(raw)}, true, nil
|
||
}
|
||
if inputValue.Type == gjson.String {
|
||
encoded, _ := json.Marshal(inputValue.String())
|
||
return []json.RawMessage{encoded}, true, nil
|
||
}
|
||
return []json.RawMessage{json.RawMessage(inputValue.Raw)}, 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 {
|
||
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
|
||
}
|
||
|
||
func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool {
|
||
if len(payload) == 0 {
|
||
return false
|
||
}
|
||
input := gjson.GetBytes(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
|
||
}
|
||
|
||
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
|
||
}
|
||
if !hasPreviousResponseID {
|
||
return cloneOpenAIWSRawMessages(currentItems), currentExists, nil
|
||
}
|
||
if !previousFullInputExists {
|
||
return cloneOpenAIWSRawMessages(currentItems), currentExists, nil
|
||
}
|
||
if !currentExists || len(currentItems) == 0 {
|
||
return cloneOpenAIWSRawMessages(previousFullInput), true, nil
|
||
}
|
||
if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) {
|
||
return cloneOpenAIWSRawMessages(currentItems), true, nil
|
||
}
|
||
merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems))
|
||
merged = append(merged, cloneOpenAIWSRawMessages(previousFullInput)...)
|
||
merged = append(merged, cloneOpenAIWSRawMessages(currentItems)...)
|
||
return merged, true, 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
|
||
}
|