Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,791 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user