fix(openai): harden Responses compatibility

This commit is contained in:
IanShaw
2026-08-19 09:03:37 -07:00
parent 32a0d9ba2d
commit cf3577a3c5
41 changed files with 3124 additions and 403 deletions
@@ -55,6 +55,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
logRequestBodyReadFailure(reqLog, c.Request, err)
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
@@ -197,7 +197,7 @@ func TestOpsClassificationTreatsCredentialFailureAsAuthNotInference(t *testing.T
require.Equal(t, http.StatusForbidden, entry.UpstreamErrors[0].UpstreamStatusCode)
}
func TestOpsRecoveredCredentialFailoverUsesAccountAuthAttribution(t *testing.T) {
func TestOpsRecoveredCredentialFailoverDoesNotCreateRequestError(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
@@ -217,21 +217,72 @@ func TestOpsRecoveredCredentialFailoverUsesAccountAuthAttribution(t *testing.T)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil))
require.Equal(t, http.StatusOK, recorder.Code)
require.Zero(t, OpsErrorLogQueueLength())
select {
case job := <-opsErrorLogQueue:
t.Fatalf("successful failover must not create ops error row: %+v", job.entry)
default:
}
}
func TestOpsWebSocketCredentialFailoverSuccessDoesNotCreateRequestError(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.GET("/openai/v1/responses", func(c *gin.Context) {
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
}})
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "websocket")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Zero(t, OpsErrorLogQueueLength())
select {
case job := <-opsErrorLogQueue:
t.Fatalf("successful websocket failover must not create ops error row: %+v", job.entry)
default:
}
}
func TestOpsWebSocketCredentialFailoverExhaustedIsRecorded(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.GET("/openai/v1/responses", func(c *gin.Context) {
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
}})
closeOpenAIWSFailoverExhausted(c, nil, &service.UpstreamFailoverError{
Stage: service.GatewayFailureStageAccountAuth,
Scope: service.GatewayFailureScopeAccount,
Reason: service.GrokCredentialReasonRevoked,
NextAccountAction: service.NextAccountStop,
})
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "websocket")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, "account_auth", job.entry.ErrorPhase)
require.Equal(t, "provider", job.entry.ErrorOwner)
require.Equal(t, "gateway", job.entry.ErrorSource)
require.Contains(t, job.entry.ErrorMessage, "Recovered account authentication failure")
require.NotContains(t, job.entry.ErrorMessage, "403")
require.NotContains(t, job.entry.ErrorMessage, "earlier inference failure")
require.NotNil(t, job.entry.UpstreamStatusCode)
require.Zero(t, *job.entry.UpstreamStatusCode)
require.Nil(t, job.entry.UpstreamErrors)
require.NotNil(t, job.entry.UpstreamErrorsJSON)
events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
require.NoError(t, err)
require.Len(t, events, 2)
require.Equal(t, http.StatusForbidden, events[0].UpstreamStatusCode)
require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode)
require.Equal(t, service.GrokCredentialUnavailableClientMessage, job.entry.ErrorMessage)
}
@@ -320,6 +320,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
logRequestBodyReadFailure(reqLog, c.Request, err)
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
@@ -839,6 +840,11 @@ func isOpenAIRemoteCompactionV2Request(body []byte) bool {
func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Context, reqLog *zap.Logger, body []byte) ([]byte, bool) {
isCompactRequest := isOpenAILegacyCompactPath(c)
if !isCompactRequest && isBareOpenAIResponsesPath(c) && service.HasCompactionTriggerInInput(body) {
if normalized, changed, err := service.NormalizeCompactionTriggerInputOrder(body); err != nil {
reqLog.Warn("codex.remote_compact.trigger_order_normalization_failed", zap.Error(err))
} else if changed {
body = normalized
}
if isOpenAIRemoteCompactionV2Request(body) {
return body, true
}
@@ -1908,7 +1914,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
releaseAccountSlot()
if !failoverErr.ShouldRetryNextAccount() {
closeOpenAIWSFailoverExhausted(wsConn, failoverErr)
closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr)
return false
}
if ctx.Err() != nil {
@@ -1918,12 +1924,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
failedAccountIDs[account.ID] = struct{}{}
lastFailoverErr = failoverErr
if switchCount >= maxAccountSwitches {
closeOpenAIWSFailoverExhausted(wsConn, failoverErr)
closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr)
return false
}
switchCount++
if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) {
closeOpenAIWSFailoverExhausted(wsConn, failoverErr)
closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr)
return false
}
reqLog.Warn("openai.websocket_upstream_failover_switching",
@@ -1979,7 +1985,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if lastFailoverErr != nil {
closeOpenAIWSFailoverExhausted(wsConn, lastFailoverErr)
closeOpenAIWSFailoverExhausted(c, wsConn, lastFailoverErr)
} else {
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
}
@@ -1987,7 +1993,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
if selection == nil || selection.Account == nil {
if lastFailoverErr != nil {
closeOpenAIWSFailoverExhausted(wsConn, lastFailoverErr)
closeOpenAIWSFailoverExhausted(c, wsConn, lastFailoverErr)
} else {
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
}
@@ -2329,7 +2335,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
retryPayload, retryCurrentTurn := service.OpenAIWSCurrentTurnRetryPayload(err)
nextAttemptMessage, retrySafe := openAIWSNextAttemptMessage(wsAttemptMessage, retryPayload, retryCurrentTurn)
if !retrySafe {
closeOpenAIWSFailoverExhausted(wsConn, failoverErr)
closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr)
return
}
wsAttemptMessage = nextAttemptMessage
@@ -2993,25 +2999,44 @@ func openAIWSNextAttemptMessage(current, retryPayload []byte, retryCurrentTurn b
return append([]byte(nil), retryPayload...), true
}
func closeOpenAIWSFailoverExhausted(conn *coderws.Conn, failoverErr *service.UpstreamFailoverError) {
if failoverErr == nil {
closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed")
return
}
if failoverErr.Stage == service.GatewayFailureStageAccountAuth {
closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, service.GrokCredentialUnavailableClientMessage)
return
}
switch failoverErr.StatusCode {
case http.StatusTooManyRequests:
closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, "upstream rate limit exceeded, please retry later")
case 529, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, "upstream service temporarily unavailable")
case http.StatusUnauthorized, http.StatusForbidden:
closeOpenAIClientWS(conn, coderws.StatusPolicyViolation, "upstream websocket authentication failed")
default:
closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed")
func closeOpenAIWSFailoverExhausted(c *gin.Context, conn *coderws.Conn, failoverErr *service.UpstreamFailoverError) {
intendedStatus := http.StatusBadGateway
errorType := "upstream_error"
errorCode := "upstream_ws_failover_exhausted"
message := "upstream websocket proxy failed"
closeStatus := coderws.StatusInternalError
if failoverErr != nil {
if reason := strings.TrimSpace(string(failoverErr.Reason)); reason != "" {
errorCode = reason
}
if failoverErr.Stage == service.GatewayFailureStageAccountAuth {
intendedStatus = http.StatusServiceUnavailable
errorType = "api_error"
message = service.GrokCredentialUnavailableClientMessage
closeStatus = coderws.StatusTryAgainLater
} else {
switch failoverErr.StatusCode {
case http.StatusTooManyRequests:
intendedStatus = http.StatusTooManyRequests
errorType = "rate_limit_error"
message = "upstream rate limit exceeded, please retry later"
closeStatus = coderws.StatusTryAgainLater
case 529, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
intendedStatus = failoverErr.StatusCode
message = "upstream service temporarily unavailable"
closeStatus = coderws.StatusTryAgainLater
case http.StatusUnauthorized, http.StatusForbidden:
intendedStatus = failoverErr.StatusCode
errorType = "authentication_error"
message = "upstream websocket authentication failed"
closeStatus = coderws.StatusPolicyViolation
}
}
}
service.MarkOpsStreamFailure(c, errorType, errorCode, message, intendedStatus)
closeOpenAIClientWS(conn, closeStatus, message)
}
func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, decision *service.ContentModerationDecision) {
+281 -289
View File
@@ -518,9 +518,11 @@ func isOpsNoAvailableAccountError(err error) bool {
type opsCaptureWriter struct {
gin.ResponseWriter
limit int
buf bytes.Buffer
ctx *gin.Context
limit int
buf bytes.Buffer
probe []byte
sseCapturing bool
ctx *gin.Context
}
const opsCaptureWriterLimit = service.OpsErrorLogQueueBodyMaxBytes
@@ -541,6 +543,8 @@ func acquireOpsCaptureWriter(rw gin.ResponseWriter) *opsCaptureWriter {
w.ResponseWriter = rw
w.limit = opsCaptureWriterLimit
w.buf.Reset()
w.probe = w.probe[:0]
w.sseCapturing = false
return w
}
@@ -551,6 +555,8 @@ func releaseOpsCaptureWriter(w *opsCaptureWriter) {
w.ResponseWriter = nil
w.ctx = nil
w.limit = opsCaptureWriterLimit
w.probe = w.probe[:0]
w.sseCapturing = false
if !shouldPoolOpsCaptureWriter(w) {
return
}
@@ -638,13 +644,8 @@ func (w *opsCaptureWriter) Write(b []byte) (int, error) {
if w.ResponseWriter == nil {
return 0, nil
}
if w.shouldCapture() && w.Status() >= 400 && w.limit > 0 && w.buf.Len() < w.limit {
remaining := w.limit - w.buf.Len()
if len(b) > remaining {
_, _ = w.buf.Write(b[:remaining])
} else {
_, _ = w.buf.Write(b)
}
if w.shouldCapture() {
w.captureResponseChunk(b, w.Status())
}
return w.ResponseWriter.Write(b)
}
@@ -653,17 +654,94 @@ func (w *opsCaptureWriter) WriteString(s string) (int, error) {
if w.ResponseWriter == nil {
return 0, nil
}
if w.shouldCapture() && w.Status() >= 400 && w.limit > 0 && w.buf.Len() < w.limit {
remaining := w.limit - w.buf.Len()
if len(s) > remaining {
_, _ = w.buf.WriteString(s[:remaining])
} else {
_, _ = w.buf.WriteString(s)
}
if w.shouldCapture() {
w.captureResponseChunk([]byte(s), w.Status())
}
return w.ResponseWriter.WriteString(s)
}
var opsTerminalSSEMarkers = [][]byte{
[]byte("event: response.failed"),
[]byte("event: error"),
[]byte(`data: {"type":"response.failed"`),
[]byte(`data:{"type":"response.failed"`),
[]byte(`data: {"type":"error"`),
[]byte(`data:{"type":"error"`),
}
func (w *opsCaptureWriter) captureResponseChunk(chunk []byte, status int) {
if w == nil || w.limit <= 0 || w.buf.Len() >= w.limit || len(chunk) == 0 {
return
}
if status >= 400 || w.sseCapturing {
w.appendCapturedResponse(chunk)
return
}
combined := make([]byte, 0, len(w.probe)+len(chunk))
combined = append(combined, w.probe...)
combined = append(combined, chunk...)
if start := findOpsTerminalSSEStart(combined); start >= 0 {
w.sseCapturing = true
w.probe = w.probe[:0]
w.appendCapturedResponse(combined[start:])
return
}
// Retain one full marker width so a marker split across writes still has
// its preceding byte available for the SSE line-boundary check.
keep := opsTerminalSSEProbeSize
if keep > len(combined) {
keep = len(combined)
}
w.probe = append(w.probe[:0], combined[len(combined)-keep:]...)
}
func (w *opsCaptureWriter) appendCapturedResponse(chunk []byte) {
remaining := w.limit - w.buf.Len()
if remaining <= 0 {
return
}
if len(chunk) > remaining {
chunk = chunk[:remaining]
}
_, _ = w.buf.Write(chunk)
}
func findOpsTerminalSSEStart(data []byte) int {
earliest := -1
for _, marker := range opsTerminalSSEMarkers {
searchFrom := 0
for searchFrom < len(data) {
idx := bytes.Index(data[searchFrom:], marker)
if idx < 0 {
break
}
idx += searchFrom
if idx == 0 || data[idx-1] == '\n' {
if earliest < 0 || idx < earliest {
earliest = idx
}
break
}
searchFrom = idx + 1
}
}
return earliest
}
func maxOpsTerminalSSEMarkerSize() int {
maxSize := 0
for _, marker := range opsTerminalSSEMarkers {
if len(marker) > maxSize {
maxSize = len(marker)
}
}
return maxSize
}
var opsTerminalSSEProbeSize = maxOpsTerminalSSEMarkerSize()
func (w *opsCaptureWriter) shouldCapture() bool {
if w.ctx == nil {
return true
@@ -675,7 +753,7 @@ func (w *opsCaptureWriter) shouldCapture() bool {
// OpsErrorLoggerMiddleware records error responses (status >= 400) into ops_error_logs.
//
// Notes:
// - It buffers response bodies only when status >= 400 to avoid overhead for successful traffic.
// - It buffers response bodies only for status >= 400 or terminal SSE frames.
// - Streaming errors after the response has started (SSE) may still need explicit logging.
func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -709,268 +787,20 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
}
status := c.Writer.Status()
body := w.buf.Bytes()
parsed := parseOpsErrorResponse(body)
if status < 400 {
// Even when the client request succeeds, we still want to persist upstream error attempts
// (retries/failover) so ops can observe upstream instability that gets "covered" by retries.
var events []*service.OpsUpstreamErrorEvent
if v, ok := c.Get(service.OpsUpstreamErrorsKey); ok {
if arr, ok := v.([]*service.OpsUpstreamErrorEvent); ok && len(arr) > 0 {
events = arr
}
}
// Also accept single upstream fields set by gateway services (rare for successful requests).
hasUpstreamContext := len(events) > 0
if !hasUpstreamContext {
if v, ok := c.Get(service.OpsUpstreamStatusCodeKey); ok {
switch t := v.(type) {
case int:
hasUpstreamContext = t > 0
case int64:
hasUpstreamContext = t > 0
}
}
}
if !hasUpstreamContext {
if v, ok := c.Get(service.OpsUpstreamErrorMessageKey); ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
hasUpstreamContext = true
}
}
}
if !hasUpstreamContext {
if v, ok := c.Get(service.OpsUpstreamErrorDetailKey); ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
hasUpstreamContext = true
}
}
}
if !hasUpstreamContext {
// 没有上游错误上下文,但网关可能在已固化的 200 流上就地补发了 SSE 错误帧
// (如 ping 等待后并发超限、Wait 后二次计费校验失败)。这类失败若不在此补记,
// 会因 wire 状态码为 200 而在错误看板里彻底隐形。
if parsed.StreamFailure {
status = inferStreamFailureStatus(c, parsed)
} else {
// Locally generated in-band errors use an explicit context marker and
// may not have a capturable terminal frame. Preserve that fallback,
// but never turn recovered upstream attempts into request errors.
logOpsStreamError(c, ops, status)
return
}
apiKey := getOpsAPIKey(c)
clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string)
model, _ := c.Get(opsModelKey)
streamV, _ := c.Get(opsStreamKey)
accountIDV, _ := c.Get(opsAccountIDKey)
var modelName string
if s, ok := model.(string); ok {
modelName = s
}
stream := false
if b, ok := streamV.(bool); ok {
stream = b
}
// Prefer showing the account that experienced the upstream error (if we have events),
// otherwise fall back to the final selected account (best-effort).
var accountID *int64
if len(events) > 0 {
if last := events[len(events)-1]; last != nil && last.AccountID > 0 {
v := last.AccountID
accountID = &v
}
}
if accountID == nil {
if v, ok := accountIDV.(int64); ok && v > 0 {
accountID = &v
}
}
fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path)
platform := resolveOpsPlatform(c.Request.Context(), apiKey, fallbackPlatform)
requestID := c.Writer.Header().Get("X-Request-Id")
if requestID == "" {
requestID = c.Writer.Header().Get("x-request-id")
}
// Best-effort backfill single upstream fields from the last event (if present).
var upstreamStatusCode *int
var upstreamErrorMessage *string
var upstreamErrorDetail *string
finalAccountAuth := false
if len(events) > 0 {
last := events[len(events)-1]
if last != nil {
finalAccountAuth = last.Stage == string(service.GatewayFailureStageAccountAuth)
if finalAccountAuth {
code := 0
upstreamStatusCode = &code
} else if last.UpstreamStatusCode > 0 {
code := last.UpstreamStatusCode
upstreamStatusCode = &code
}
if msg := strings.TrimSpace(last.Message); msg != "" {
upstreamErrorMessage = &msg
}
if detail := strings.TrimSpace(last.Detail); detail != "" {
upstreamErrorDetail = &detail
}
}
}
if !finalAccountAuth && upstreamStatusCode == nil {
if v, ok := c.Get(service.OpsUpstreamStatusCodeKey); ok {
switch t := v.(type) {
case int:
if t > 0 {
code := t
upstreamStatusCode = &code
}
case int64:
if t > 0 {
code := int(t)
upstreamStatusCode = &code
}
}
}
}
if !finalAccountAuth && upstreamErrorMessage == nil {
if v, ok := c.Get(service.OpsUpstreamErrorMessageKey); ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
msg := strings.TrimSpace(s)
upstreamErrorMessage = &msg
}
}
}
if !finalAccountAuth && upstreamErrorDetail == nil {
if v, ok := c.Get(service.OpsUpstreamErrorDetailKey); ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
detail := strings.TrimSpace(s)
upstreamErrorDetail = &detail
}
}
}
// If we still have nothing meaningful, skip.
if upstreamStatusCode == nil && upstreamErrorMessage == nil && upstreamErrorDetail == nil && len(events) == 0 {
return
}
effectiveUpstreamStatus := 0
if upstreamStatusCode != nil {
effectiveUpstreamStatus = *upstreamStatusCode
}
recoveredMsg := "Recovered upstream error"
if finalAccountAuth {
recoveredMsg = "Recovered account authentication failure"
} else if effectiveUpstreamStatus > 0 {
recoveredMsg += " " + strconvItoa(effectiveUpstreamStatus)
}
if upstreamErrorMessage != nil && strings.TrimSpace(*upstreamErrorMessage) != "" {
recoveredMsg += ": " + strings.TrimSpace(*upstreamErrorMessage)
}
recoveredMsg = truncateString(recoveredMsg, 2048)
recoveredPhase, recoveredBusinessLimited, recoveredOwner, recoveredSource := classifyOpsErrorLog(
c, "upstream_error", recoveredMsg, "", effectiveUpstreamStatus,
)
entry := &service.OpsInsertErrorLogInput{
RequestID: requestID,
ClientRequestID: clientRequestID,
AccountID: accountID,
Platform: platform,
Model: modelName,
RequestPath: func() string {
if c.Request != nil && c.Request.URL != nil {
return c.Request.URL.Path
}
return ""
}(),
Stream: stream,
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, platform),
RequestedModel: modelName,
UpstreamModel: func() string {
if v, ok := c.Get(opsUpstreamModelKey); ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
return ""
}(),
RequestType: func() *int16 {
if v, ok := c.Get(opsRequestTypeKey); ok {
switch t := v.(type) {
case int16:
return &t
case int:
v16 := int16(t)
return &v16
}
}
return nil
}(),
UserAgent: c.GetHeader("User-Agent"),
ErrorPhase: recoveredPhase,
ErrorType: "upstream_error",
// Severity should reflect the upstream failure, not the final client status (200).
Severity: classifyOpsSeverity("upstream_error", effectiveUpstreamStatus),
StatusCode: status,
IsBusinessLimited: recoveredBusinessLimited,
IsCountTokens: isCountTokensRequest(c),
ErrorMessage: recoveredMsg,
ErrorBody: "",
ErrorSource: recoveredSource,
ErrorOwner: recoveredOwner,
UpstreamStatusCode: upstreamStatusCode,
UpstreamErrorMessage: upstreamErrorMessage,
UpstreamErrorDetail: upstreamErrorDetail,
UpstreamErrors: events,
CreatedAt: time.Now(),
}
applyOpsLatencyFieldsFromContext(c, entry)
applyOpsUpstreamFieldsFromContext(c, entry)
if apiKey != nil {
entry.APIKeyID = &apiKey.ID
entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
if apiKey.User != nil {
entry.UserID = &apiKey.User.ID
}
if apiKey.GroupID != nil {
entry.GroupID = apiKey.GroupID
}
// Prefer group platform if present (more stable than inferring from path).
if apiKey.Group != nil && apiKey.Group.Platform != "" {
entry.Platform = apiKey.Group.Platform
}
}
var clientIP string
if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" {
clientIP = ip
entry.ClientIP = &clientIP
}
// Skip logging if a passthrough rule with skip_monitoring=true matched.
if v, ok := c.Get(service.OpsSkipPassthroughKey); ok {
if skip, _ := v.(bool); skip {
return
}
}
enqueueOpsErrorLog(ops, entry)
return
}
body := w.buf.Bytes()
parsed := parseOpsErrorResponse(body)
// Skip logging if a passthrough rule with skip_monitoring=true matched.
if v, ok := c.Get(service.OpsSkipPassthroughKey); ok {
if skip, _ := v.(bool); skip {
@@ -1007,9 +837,13 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path)
platform := resolveOpsPlatform(c.Request.Context(), apiKey, fallbackPlatform)
requestID := c.Writer.Header().Get("X-Request-Id")
requestID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
requestID = strings.TrimSpace(requestID)
if requestID == "" {
requestID = c.Writer.Header().Get("x-request-id")
requestID = c.Writer.Header().Get("X-Request-Id")
if requestID == "" {
requestID = c.Writer.Header().Get("x-request-id")
}
}
normalizedType := normalizeOpsErrorType(parsed.ErrorType, parsed.Code)
@@ -1073,6 +907,15 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
}
applyOpsLatencyFieldsFromContext(c, entry)
applyOpsUpstreamFieldsFromContext(c, entry)
if parsed.StreamFailure {
if message := strings.TrimSpace(parsed.Message); message != "" {
entry.UpstreamErrorMessage = &message
}
if status >= 400 {
finalStatus := status
entry.UpstreamStatusCode = &finalStatus
}
}
if apiKey != nil {
entry.APIKeyID = &apiKey.ID
@@ -1162,9 +1005,13 @@ func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int)
fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path)
platform := resolveOpsPlatform(c.Request.Context(), apiKey, fallbackPlatform)
requestID := c.Writer.Header().Get("X-Request-Id")
requestID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
requestID = strings.TrimSpace(requestID)
if requestID == "" {
requestID = c.Writer.Header().Get("x-request-id")
requestID = c.Writer.Header().Get("X-Request-Id")
if requestID == "" {
requestID = c.Writer.Header().Get("x-request-id")
}
}
entry := &service.OpsInsertErrorLogInput{
@@ -1222,6 +1069,7 @@ func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int)
CreatedAt: time.Now(),
}
applyOpsLatencyFieldsFromContext(c, entry)
applyOpsUpstreamFieldsFromContext(c, entry)
if apiKey != nil {
entry.APIKeyID = &apiKey.ID
@@ -1366,15 +1214,19 @@ func getContextLatencyMs(c *gin.Context, key string) *int64 {
}
type parsedOpsError struct {
ErrorType string
Message string
Code string
ErrorType string
Message string
Code string
StreamFailure bool
}
func parseOpsErrorResponse(body []byte) parsedOpsError {
if len(body) == 0 {
return parsedOpsError{}
}
if parsed, ok := parseOpsSSEFailure(body); ok {
return parsed
}
// Fast path: attempt to decode into a generic map.
var m map[string]any
@@ -1386,17 +1238,9 @@ func parseOpsErrorResponse(body []byte) parsedOpsError {
if errObj, ok := m["error"].(map[string]any); ok {
t, _ := errObj["type"].(string)
msg, _ := errObj["message"].(string)
// Gemini googleError also uses "error": { code, message, status }
if msg == "" {
if v, ok := errObj["message"]; ok {
msg, _ = v.(string)
}
}
if t == "" {
// Gemini error does not have "type" field.
t = "api_error"
}
// For gemini error, capture numeric code as string for business-limited mapping if needed.
var code string
if v, ok := errObj["code"]; ok {
switch n := v.(type) {
@@ -1421,6 +1265,153 @@ func parseOpsErrorResponse(body []byte) parsedOpsError {
return parsedOpsError{Message: truncateString(string(body), 1024)}
}
func parseOpsSSEFailure(body []byte) (parsedOpsError, bool) {
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
if findOpsTerminalSSEStart([]byte(normalized)) < 0 {
return parsedOpsError{}, false
}
var errorCandidate *parsedOpsError
for _, frame := range strings.Split(normalized, "\n\n") {
frame = strings.TrimSpace(frame)
if frame == "" {
continue
}
var eventType string
dataLines := make([]string, 0, 1)
for _, line := range strings.Split(frame, "\n") {
switch {
case strings.HasPrefix(line, "event:"):
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if eventType != "response.failed" && eventType != "error" && len(dataLines) == 0 {
continue
}
payload := strings.Join(dataLines, "\n")
var event map[string]any
if err := json.Unmarshal([]byte(payload), &event); err == nil {
if eventType == "" {
eventType, _ = event["type"].(string)
}
}
if eventType != "response.failed" && eventType != "error" {
continue
}
parsed := parsedOpsError{ErrorType: "upstream_error", StreamFailure: true}
if eventType == "error" {
parsed.ErrorType = "api_error"
}
if errObj := opsSSEErrorObject(event); errObj != nil {
parsed.ErrorType, _ = errObj["type"].(string)
parsed.Message, _ = errObj["message"].(string)
parsed.Code, _ = errObj["code"].(string)
if parsed.ErrorType == "" {
parsed.ErrorType = inferResponsesFailedOpsErrorType(parsed.Code)
}
if parsed.ErrorType == "" {
if eventType == "error" {
parsed.ErrorType = "api_error"
} else {
parsed.ErrorType = "upstream_error"
}
}
}
if strings.TrimSpace(parsed.Message) == "" && payload != "" {
parsed.Message = truncateString(payload, 1024)
}
if eventType == "response.failed" {
return parsed, true
}
candidate := parsed
errorCandidate = &candidate
}
if errorCandidate != nil {
return *errorCandidate, true
}
return parsedOpsError{}, false
}
func opsSSEErrorObject(event map[string]any) map[string]any {
if event == nil {
return nil
}
if errObj, ok := event["error"].(map[string]any); ok {
return errObj
}
if response, ok := event["response"].(map[string]any); ok {
if errObj, ok := response["error"].(map[string]any); ok {
return errObj
}
}
return nil
}
func inferResponsesFailedOpsErrorType(code string) string {
switch strings.TrimSpace(code) {
case "rate_limit_exceeded":
return "rate_limit_error"
case "permission_denied", "cyber_policy", "content_policy":
return "permission_error"
case "invalid_request", "context_length_exceeded":
return "invalid_request_error"
case "server_is_overloaded":
return "overloaded_error"
case "authentication_failed":
return "authentication_error"
default:
return ""
}
}
func inferStreamFailureStatus(c *gin.Context, parsed parsedOpsError) int {
switch strings.TrimSpace(parsed.Code) {
case "rate_limit_exceeded":
return http.StatusTooManyRequests
case "permission_denied", "cyber_policy", "content_policy":
return http.StatusForbidden
case "invalid_request", "context_length_exceeded":
return http.StatusBadRequest
case "server_is_overloaded":
return http.StatusServiceUnavailable
case "authentication_failed":
return http.StatusUnauthorized
}
switch strings.TrimSpace(parsed.ErrorType) {
case "rate_limit_error":
return http.StatusTooManyRequests
case "permission_error", "forbidden_error":
return http.StatusForbidden
case "authentication_error":
return http.StatusUnauthorized
case "invalid_request_error":
return http.StatusBadRequest
case "overloaded_error", "service_unavailable_error":
return http.StatusServiceUnavailable
}
if c != nil {
if v, ok := c.Get(service.OpsUpstreamStatusCodeKey); ok {
switch code := v.(type) {
case int:
if code >= 400 {
return code
}
case int64:
if code >= 400 {
return int(code)
}
}
}
}
return http.StatusBadGateway
}
// getOpsAPIKey 返回用于 Ops 错误日志的 API Key:优先取已鉴权写入的正式 key;
// 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时,
// 正式 key 尚未写入,回退到 middleware 写入的 ops fallback key
@@ -1472,6 +1463,7 @@ func isKnownOpsErrorType(t string) bool {
"subscription_error",
"upstream_error",
"overloaded_error",
"service_unavailable_error",
"api_error",
"not_found_error",
"forbidden_error":
@@ -9,6 +9,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -279,6 +280,168 @@ func TestOpsErrorLoggerMiddleware_HardSkipsIngressRejection(t *testing.T) {
require.Zero(t, OpsErrorLogEnqueuedTotal(), "ingress rejection must not enter the error queue")
}
func TestOpsErrorLoggerMiddleware_SkipsRecoveredUpstreamErrorOnSuccessfulRequest(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/responses", func(c *gin.Context) {
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
UpstreamStatusCode: http.StatusTooManyRequests,
Message: "earlier attempt was rate limited",
}})
c.JSON(http.StatusOK, gin.H{"status": "completed"})
})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
require.Equal(t, http.StatusOK, recorder.Code)
require.Zero(t, OpsErrorLogQueueLength())
}
func TestOpsErrorLoggerMiddleware_CapturesSplitResponsesFailedSSE(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/responses", func(c *gin.Context) {
setOpsRequestContext(c, "gpt-5.5", true)
c.Status(http.StatusOK)
_, _ = c.Writer.Write([]byte("event: response."))
_, _ = c.Writer.Write([]byte("failed\n"))
_, _ = c.Writer.Write([]byte(`data: {"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Too many pending requests"}}}`))
_, _ = c.Writer.Write([]byte("\n\n"))
})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
require.Equal(t, "rate_limit_error", job.entry.ErrorType)
require.Contains(t, job.entry.ErrorMessage, "Too many pending requests")
}
func TestOpsCaptureWriter_CapturesSplitDataOnlyTerminalMarkers(t *testing.T) {
tests := []struct {
name string
prefix string
suffix string
wantType string
wantCode string
wantError string
}{
{
name: "response failed with space",
prefix: `data: {"type":"response.`,
suffix: `failed","response":{"error":{"code":"server_is_overloaded","message":"busy"}}}`,
wantType: "overloaded_error",
wantCode: "server_is_overloaded",
wantError: "busy",
},
{
name: "response failed without space",
prefix: `data:{"type":"response.`,
suffix: `failed","error":{"code":"rate_limit_exceeded","message":"slow down"}}`,
wantType: "rate_limit_error",
wantCode: "rate_limit_exceeded",
wantError: "slow down",
},
{
name: "error with space",
prefix: `data: {"type":"er`,
suffix: `ror","error":{"type":"invalid_request_error","code":"invalid_request","message":"bad input"}}`,
wantType: "invalid_request_error",
wantCode: "invalid_request",
wantError: "bad input",
},
{
name: "error without space",
prefix: `data:{"type":"er`,
suffix: `ror","error":{"type":"authentication_error","code":"authentication_failed","message":"sign in"}}`,
wantType: "authentication_error",
wantCode: "authentication_failed",
wantError: "sign in",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &opsCaptureWriter{limit: opsCaptureWriterLimit}
writer.captureResponseChunk([]byte(tt.prefix), http.StatusOK)
require.Empty(t, writer.buf.Bytes(), "partial marker must remain in the bounded probe")
writer.captureResponseChunk([]byte(tt.suffix+"\n\n"), http.StatusOK)
parsed := parseOpsErrorResponse(writer.buf.Bytes())
require.True(t, writer.sseCapturing)
require.True(t, parsed.StreamFailure)
require.Equal(t, tt.wantType, parsed.ErrorType)
require.Equal(t, tt.wantCode, parsed.Code)
require.Equal(t, tt.wantError, parsed.Message)
require.LessOrEqual(t, len(writer.probe), opsTerminalSSEProbeSize)
})
}
}
func TestOpsErrorLoggerMiddleware_StreamFailureUsesTerminalErrorOverAttemptContext(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/responses", func(c *gin.Context) {
service.SetOpsUpstreamError(c, http.StatusBadGateway, "Upstream transport error", "earlier attempt failed")
c.Status(http.StatusOK)
_, _ = c.Writer.WriteString("event: er")
_, _ = c.Writer.WriteString("ror\n")
_, _ = c.Writer.WriteString(`data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input exceeds the context window"}}`)
_, _ = c.Writer.WriteString("\n\n")
})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, http.StatusBadRequest, job.entry.StatusCode)
require.Equal(t, "invalid_request_error", job.entry.ErrorType)
require.NotNil(t, job.entry.UpstreamStatusCode)
require.Equal(t, http.StatusBadRequest, *job.entry.UpstreamStatusCode)
require.NotNil(t, job.entry.UpstreamErrorMessage)
require.Equal(t, "input exceeds the context window", *job.entry.UpstreamErrorMessage)
}
func TestOpsErrorLoggerMiddleware_PrefersContextRequestID(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/responses", func(c *gin.Context) {
c.Header("X-Request-Id", "response-header-id")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "bad input"}})
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
request = request.WithContext(context.WithValue(request.Context(), ctxkey.RequestID, "context-request-id"))
router.ServeHTTP(recorder, request)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, "context-request-id", job.entry.RequestID)
}
func TestNormalizeOpsPersistentUserAgentBoundsAndPreservesUTF8(t *testing.T) {
value := strings.Repeat("a", opsErrorLogMaxUserAgentBytes-1) + "你" + strings.Repeat("b", 32)
got := normalizeOpsPersistentUserAgent(" " + value + " ")
@@ -0,0 +1,79 @@
package handler
import (
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"syscall"
"go.uber.org/zap"
)
// logRequestBodyReadFailure records a bounded, payload-free reason for a body
// read failure. Clients continue to receive the stable generic error message;
// operators get enough information to distinguish compression failures from a
// disconnected/truncated upload without logging request content.
func logRequestBodyReadFailure(reqLog *zap.Logger, req *http.Request, err error) {
if reqLog == nil || err == nil {
return
}
contentLength := int64(-1)
contentEncoding := "identity"
if req != nil {
contentLength = req.ContentLength
contentEncoding = requestContentEncodingCategory(req.Header.Get("Content-Encoding"))
}
reqLog.Warn("read request body failed",
zap.String("error_kind", requestBodyReadErrorKind(err)),
zap.String("content_encoding", contentEncoding),
zap.Int64("content_length", contentLength),
)
}
func requestContentEncodingCategory(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "identity":
return "identity"
case "gzip", "x-gzip":
return "gzip"
case "zstd":
return "zstd"
case "deflate":
return "deflate"
default:
return "other"
}
}
func requestBodyReadErrorKind(err error) string {
if err == nil {
return "none"
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return "max_bytes"
}
lower := strings.ToLower(err.Error())
if strings.Contains(lower, "decode content-encoding") {
if strings.Contains(lower, "unsupported content-encoding") {
return "unsupported_content_encoding"
}
return "decode_content_encoding"
}
if errors.Is(err, context.Canceled) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) {
return "client_disconnect"
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return "truncated_body"
}
var netErr net.Error
if errors.As(err, &netErr) {
return "transport"
}
return "io_read"
}
@@ -0,0 +1,40 @@
//go:build unit
package handler
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestLogRequestBodyReadFailureClassifiesWithoutPayload(t *testing.T) {
log, logs := newObservedLogger(t)
req, err := http.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader("secret-payload-marker"))
require.NoError(t, err)
req.Header.Set("Content-Encoding", "gzip")
req.ContentLength = 21
logRequestBodyReadFailure(log, req, errors.New(`decode Content-Encoding "gzip": unexpected EOF secret-payload-marker`))
entries := logs.All()
require.Len(t, entries, 1)
require.Equal(t, "read request body failed", entries[0].Message)
fields := entries[0].ContextMap()
require.Equal(t, "decode_content_encoding", fields["error_kind"])
require.Equal(t, "gzip", fields["content_encoding"])
require.EqualValues(t, 21, fields["content_length"])
require.NotContains(t, entries[0].Message+fmt.Sprint(fields), "secret-payload-marker")
}
func TestRequestBodyReadErrorKind(t *testing.T) {
require.Equal(t, "unsupported_content_encoding", requestBodyReadErrorKind(errors.New(`decode Content-Encoding "br": unsupported Content-Encoding`)))
require.Equal(t, "truncated_body", requestBodyReadErrorKind(io.ErrUnexpectedEOF))
require.Equal(t, "max_bytes", requestBodyReadErrorKind(&http.MaxBytesError{Limit: 10}))
require.Equal(t, "other", requestContentEncodingCategory("private-payload-marker"))
}
@@ -293,11 +293,16 @@ func openAIRequestBodyImageGenerationToolNeedsNormalization(body []byte) bool {
if openAIJSONString(item.Get("type")) != "image_generation" {
return true
}
// 只有旧字段需要迁移时才进入 map 修改,纯计费读取保持 raw 路径
// 只有旧字段或明确的模型不兼容字段需要修正时才进入 map 修改
if item.Get("format").Exists() || item.Get("compression").Exists() {
needsNormalization = true
return false
}
imageModel := strings.ToLower(strings.TrimSpace(item.Get("model").String()))
if strings.HasPrefix(imageModel, "gpt-image-2") && item.Get("input_fidelity").Exists() {
needsNormalization = true
return false
}
return true
})
return needsNormalization
@@ -449,6 +449,7 @@ var openAIAlphaSearchUnsupportedBodyFields = [...]string{
// alpha/search 会对这些字段返回 Unknown parameter(例如 prompt_cache_key)。
"prompt_cache_key",
"prompt_cache_retention",
"store",
}
func sanitizeOpenAIAlphaSearchBody(body []byte) ([]byte, error) {
@@ -482,6 +482,16 @@ func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
}
func TestSanitizeOpenAIAlphaSearchBody_RemovesResponsesOnlyFields(t *testing.T) {
body := []byte(`{"id":"search-session","store":false,"prompt_cache_key":"cache","commands":{"search_query":[{"q":"news"}]}}`)
normalized, err := sanitizeOpenAIAlphaSearchBody(body)
require.NoError(t, err)
require.False(t, gjson.GetBytes(normalized, "store").Exists())
require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists())
require.Equal(t, "news", gjson.GetBytes(normalized, "commands.search_query.0.q").String())
}
func TestIsOpenAIAlphaSearchEndpointUnsupported(t *testing.T) {
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
@@ -66,6 +66,91 @@ func TestFilterCodexInput_KeepsFcID_WhenPreservingReferences(t *testing.T) {
require.Equal(t, "fc_validID123", fc["id"], "valid fc* id must be preserved")
}
func TestFilterCodexInput_PreservesNativeCustomAndToolSearchIDs(t *testing.T) {
input := []any{
map[string]any{"type": "custom_tool_call", "id": "ctc_valid", "call_id": "call_custom", "name": "apply_patch"},
map[string]any{"type": "tool_search_call", "id": "tsc_valid", "call_id": "call_search"},
}
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true})
require.Equal(t, "ctc_valid", filtered[0].(map[string]any)["id"])
require.Equal(t, "tsc_valid", filtered[1].(map[string]any)["id"])
}
func TestFilterCodexInput_StripsWrongCustomAndToolSearchIDs(t *testing.T) {
input := []any{
map[string]any{"type": "custom_tool_call", "id": "fc_wrong", "call_id": "call_custom", "name": "apply_patch"},
map[string]any{"type": "tool_search_call", "id": "fc_wrong", "call_id": "call_search"},
}
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true})
require.NotContains(t, filtered[0].(map[string]any), "id")
require.NotContains(t, filtered[1].(map[string]any), "id")
}
func TestFilterCodexInput_MapsItemReferencesToNativeToolCallPair(t *testing.T) {
input := []any{
map[string]any{"type": "custom_tool_call", "id": "fc_custom", "call_id": "call_custom", "name": "apply_patch"},
map[string]any{"type": "custom_tool_call_output", "call_id": "fc_custom", "output": "done"},
map[string]any{"type": "item_reference", "id": "call_custom"},
map[string]any{"type": "tool_search_call", "id": "fc_search", "call_id": "call_search"},
map[string]any{"type": "tool_search_output", "call_id": "fc_search", "output": "result"},
map[string]any{"type": "item_reference", "id": "call_search"},
}
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true})
require.Equal(t, "ctc_custom", filtered[0].(map[string]any)["call_id"])
require.Equal(t, "ctc_custom", filtered[1].(map[string]any)["call_id"])
require.Equal(t, "ctc_custom", filtered[2].(map[string]any)["id"])
require.Equal(t, "tsc_search", filtered[3].(map[string]any)["call_id"])
require.Equal(t, "tsc_search", filtered[4].(map[string]any)["call_id"])
require.Equal(t, "tsc_search", filtered[5].(map[string]any)["id"])
}
func TestFilterCodexInput_PreservesAmbiguousItemReference(t *testing.T) {
input := []any{
map[string]any{"type": "custom_tool_call", "call_id": "call_shared", "name": "apply_patch"},
map[string]any{"type": "tool_search_call", "call_id": "call_shared"},
map[string]any{"type": "item_reference", "id": "call_shared"},
}
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true})
require.Equal(t, "ctc_shared", filtered[0].(map[string]any)["call_id"])
require.Equal(t, "tsc_shared", filtered[1].(map[string]any)["call_id"])
require.Equal(t, "call_shared", filtered[2].(map[string]any)["id"])
}
func TestFilterCodexInput_PreservesNativeItemIDReferenceIndependentlyFromCallID(t *testing.T) {
input := []any{
map[string]any{"type": "custom_tool_call", "id": "ctc_item", "call_id": "call_custom", "name": "apply_patch"},
map[string]any{"type": "item_reference", "id": "ctc_item"},
}
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true})
require.Equal(t, "ctc_item", filtered[0].(map[string]any)["id"])
require.Equal(t, "ctc_custom", filtered[0].(map[string]any)["call_id"])
require.Equal(t, "ctc_item", filtered[1].(map[string]any)["id"])
}
func TestFilterCodexInput_ExistingItemIDWinsOverLegacyCallIDMapping(t *testing.T) {
input := []any{
map[string]any{"type": "custom_tool_call", "call_id": "call_shared", "name": "apply_patch"},
map[string]any{"type": "function_call_output", "id": "call_shared", "call_id": "call_other", "output": "done"},
map[string]any{"type": "item_reference", "id": "call_shared"},
}
filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true})
require.Equal(t, "ctc_shared", filtered[0].(map[string]any)["call_id"])
require.Equal(t, "call_shared", filtered[1].(map[string]any)["id"])
require.Equal(t, "call_shared", filtered[2].(map[string]any)["id"])
}
// TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes verifies that
// item_* ids are stripped from all call-input types (not output types).
func TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes(t *testing.T) {
@@ -0,0 +1,244 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
const (
codexReservedPythonToolName = "python"
codexPythonToolAlias = "python__sub2api"
codexToolNameReverseKey = "openai_codex_tool_name_reverse"
)
type codexToolNameField struct {
object map[string]any
key string
name string
}
// aliasOpenAIOAuthReservedToolNames avoids names reserved by the ChatGPT
// Codex backend. It validates every declaration/reference before mutating so
// collisions cannot leave a partially rewritten request.
func aliasOpenAIOAuthReservedToolNames(reqBody map[string]any) (map[string]string, bool, error) {
if reqBody == nil {
return nil, false, nil
}
fields := collectOpenAIResponsesToolNameFields(reqBody)
owners := make(map[string]string)
reverse := make(map[string]string)
for _, field := range fields {
normalized := aliasOpenAIOAuthReservedToolName(field.name)
original := field.name
if normalized != field.name {
original = strings.TrimSpace(field.name)
}
if previous, exists := owners[normalized]; exists && previous != original {
return nil, false, fmt.Errorf("tool names %q and %q both normalize to %q", previous, original, normalized)
}
owners[normalized] = original
if normalized != field.name {
reverse[normalized] = original
}
}
if len(reverse) == 0 {
return nil, false, nil
}
for _, field := range fields {
if aliased := aliasOpenAIOAuthReservedToolName(field.name); aliased != field.name {
field.object[field.key] = aliased
}
}
return reverse, true, nil
}
func aliasOpenAIOAuthReservedToolName(name string) string {
trimmed := strings.TrimSpace(name)
if strings.EqualFold(trimmed, codexReservedPythonToolName) {
return codexPythonToolAlias
}
return name
}
func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNameField {
fields := make([]codexToolNameField, 0, 8)
appendName := func(object map[string]any, key string) {
if object == nil {
return
}
name, ok := object[key].(string)
if !ok || strings.TrimSpace(name) == "" {
return
}
fields = append(fields, codexToolNameField{object: object, key: key, name: name})
}
var collectTools func(any)
collectTools = func(rawTools any) {
tools, ok := rawTools.([]any)
if !ok {
return
}
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
continue
}
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
if toolType != "namespace" {
appendName(tool, "name")
}
if function, ok := tool["function"].(map[string]any); ok {
appendName(function, "name")
}
collectTools(tool["tools"])
}
}
collectTools(reqBody["tools"])
collectTools(reqBody["functions"])
if choice, ok := reqBody["tool_choice"].(map[string]any); ok {
if !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "namespace") {
appendName(choice, "name")
}
if function, ok := choice["function"].(map[string]any); ok {
appendName(function, "name")
}
}
if input, ok := reqBody["input"].([]any); ok {
for _, raw := range input {
item, ok := raw.(map[string]any)
if !ok {
continue
}
typ := strings.ToLower(strings.TrimSpace(firstNonEmptyString(item["type"])))
if typ == "additional_tools" {
collectTools(item["tools"])
}
if strings.HasSuffix(typ, "_call") || typ == "tool_call" {
appendName(item, "name")
if function, ok := item["function"].(map[string]any); ok {
appendName(function, "name")
}
}
}
}
return fields
}
func aliasOpenAIOAuthReservedToolNamesBody(body []byte) ([]byte, map[string]string, bool, error) {
if len(body) == 0 || !containsASCIIFold(body, []byte(codexReservedPythonToolName)) {
return body, nil, false, nil
}
var reqBody map[string]any
if err := json.Unmarshal(body, &reqBody); err != nil {
return body, nil, false, fmt.Errorf("decode OAuth reserved tool names: %w", err)
}
reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
if err != nil || !changed {
return body, reverse, false, err
}
normalized, err := json.Marshal(reqBody)
if err != nil {
return body, nil, false, fmt.Errorf("encode OAuth reserved tool names: %w", err)
}
return normalized, reverse, true, nil
}
func containsASCIIFold(haystack, needle []byte) bool {
if len(needle) == 0 || len(haystack) < len(needle) {
return false
}
for i := 0; i <= len(haystack)-len(needle); i++ {
matched := true
for j := range needle {
a, b := haystack[i+j], needle[j]
if a >= 'A' && a <= 'Z' {
a += 'a' - 'A'
}
if b >= 'A' && b <= 'Z' {
b += 'a' - 'A'
}
if a != b {
matched = false
break
}
}
if matched {
return true
}
}
return false
}
func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
if c == nil {
return
}
copyMap := make(map[string]string, len(reverse))
for aliased, original := range reverse {
copyMap[aliased] = original
}
c.Set(codexToolNameReverseKey, copyMap)
}
func codexToolNameReverseFromContext(c *gin.Context) map[string]string {
if c == nil {
return nil
}
raw, ok := c.Get(codexToolNameReverseKey)
if !ok {
return nil
}
reverse, _ := raw.(map[string]string)
return reverse
}
func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte {
if len(data) == 0 || len(reverse) == 0 || !json.Valid(data) {
return data
}
var decoded any
if err := json.Unmarshal(data, &decoded); err != nil {
return data
}
if !restoreCodexToolNameFields(decoded, reverse) {
return data
}
restored, err := json.Marshal(decoded)
if err != nil {
return data
}
return restored
}
func restoreCodexToolNamesFromContext(c *gin.Context, data []byte) []byte {
return restoreCodexToolNamesInJSON(data, codexToolNameReverseFromContext(c))
}
func restoreCodexToolNameFields(value any, reverse map[string]string) bool {
changed := false
switch typed := value.(type) {
case map[string]any:
if name, ok := typed["name"].(string); ok {
if original, exists := reverse[name]; exists {
typed["name"] = original
changed = true
}
}
for _, child := range typed {
if restoreCodexToolNameFields(child, reverse) {
changed = true
}
}
case []any:
for _, child := range typed {
if restoreCodexToolNameFields(child, reverse) {
changed = true
}
}
}
return changed
}
@@ -0,0 +1,95 @@
package service
import (
"encoding/json"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAliasOpenAIOAuthReservedToolNames_RewritesDeclarationsAndReferences(t *testing.T) {
reqBody := map[string]any{
"tools": []any{
map[string]any{"type": "function", "name": "python"},
map[string]any{"type": "namespace", "name": "code", "tools": []any{
map[string]any{"type": "function", "name": "shell"},
}},
},
"tool_choice": map[string]any{"type": "function", "name": "python"},
"input": []any{
map[string]any{"type": "function_call", "name": "python", "call_id": "fc_1"},
map[string]any{"type": "additional_tools", "tools": []any{
map[string]any{"type": "function", "function": map[string]any{"name": "python"}},
}},
},
}
reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "python", reverse[codexPythonToolAlias])
require.Equal(t, codexPythonToolAlias, reqBody["tools"].([]any)[0].(map[string]any)["name"])
require.Equal(t, codexPythonToolAlias, reqBody["tool_choice"].(map[string]any)["name"])
require.Equal(t, codexPythonToolAlias, reqBody["input"].([]any)[0].(map[string]any)["name"])
nested := reqBody["input"].([]any)[1].(map[string]any)["tools"].([]any)[0].(map[string]any)["function"].(map[string]any)
require.Equal(t, codexPythonToolAlias, nested["name"])
}
func TestAliasOpenAIOAuthReservedToolNames_CollisionDoesNotMutate(t *testing.T) {
reqBody := map[string]any{"tools": []any{
map[string]any{"type": "function", "name": "python"},
map[string]any{"type": "function", "name": codexPythonToolAlias},
}}
before, err := json.Marshal(reqBody)
require.NoError(t, err)
reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
require.ErrorContains(t, err, `both normalize to "python__sub2api"`)
require.False(t, changed)
require.Nil(t, reverse)
after, marshalErr := json.Marshal(reqBody)
require.NoError(t, marshalErr)
require.JSONEq(t, string(before), string(after))
}
func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.5",
"tools": []any{map[string]any{"type": "function", "name": "PYTHON"}},
}
result := applyCodexOAuthTransform(reqBody, true, false)
require.NoError(t, result.Error)
require.Equal(t, "PYTHON", result.ToolNameReverse[codexPythonToolAlias])
require.Equal(t, codexPythonToolAlias, reqBody["tools"].([]any)[0].(map[string]any)["name"])
apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(apiKeyBody), string(normalized))
}
func TestRestoreCodexToolNamesFromContext_HTTPAndWSPayloadShapes(t *testing.T) {
c, _ := gin.CreateTestContext(nil)
setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: "python"})
streamEvent := restoreCodexToolNamesFromContext(c, []byte(
`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"},"note":"python__sub2api"}`,
))
require.Equal(t, "python", gjson.GetBytes(streamEvent, "item.name").String())
require.Equal(t, "python__sub2api", gjson.GetBytes(streamEvent, "note").String())
nonStreaming := restoreCodexToolNamesFromContext(c, []byte(
`{"id":"resp_1","output":[{"type":"function_call","name":"python__sub2api"}]}`,
))
require.Equal(t, "python", gjson.GetBytes(nonStreaming, "output.0.name").String())
setCodexToolNameReverse(c, nil)
require.JSONEq(t,
`{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`,
string(restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`))),
)
}
@@ -76,6 +76,8 @@ type codexTransformResult struct {
Modified bool
NormalizedModel string
PromptCacheKey string
ToolNameReverse map[string]string
Error error
}
type codexOAuthTransformOptions struct {
@@ -92,26 +94,45 @@ const (
)
func normalizeCodexCallID(id string) string {
return normalizeCodexCallIDForItemType("function_call", id)
}
func normalizeCodexCallIDForItemType(itemType, id string) string {
prefix := openAIResponsesToolCallIDPrefix(itemType) + "_"
candidate := id
switch {
case id == "":
return ""
case strings.HasPrefix(id, "fc"):
case strings.HasPrefix(id, strings.TrimSuffix(prefix, "_")):
case strings.HasPrefix(id, "call_"):
candidate = codexCallIDPrefix + strings.TrimPrefix(id, "call_")
candidate = prefix + strings.TrimPrefix(id, "call_")
default:
candidate = codexCallIDPrefix + id
candidate = prefix + trimOpenAIResponsesKnownCallIDPrefix(id)
}
if len(candidate) <= codexCallIDMaxLength {
return candidate
}
return compactCodexCallID(candidate)
return compactCodexCallIDForItemType(itemType, candidate)
}
func compactCodexCallID(id string) string {
return compactCodexCallIDForItemType("function_call", id)
}
func compactCodexCallIDForItemType(itemType, id string) string {
prefix := openAIResponsesToolCallIDPrefix(itemType) + "_"
digest := sha256.Sum256([]byte("sub2api:codex-call-id:v1:" + id))
encoded := hex.EncodeToString(digest[:])
return codexCallIDPrefix + encoded[:codexCallIDMaxLength-len(codexCallIDPrefix)]
return prefix + encoded[:codexCallIDMaxLength-len(prefix)]
}
func trimOpenAIResponsesKnownCallIDPrefix(id string) string {
for _, prefix := range []string{"fc_", "ctc_", "tsc_"} {
if strings.HasPrefix(id, prefix) {
return strings.TrimPrefix(id, prefix)
}
}
return id
}
const codexImageGenerationFunctionToolName = "image_gen.imagegen"
@@ -124,11 +145,14 @@ const (
)
var openAIChatGPTInternalUnsupportedFields = []string{
"chat_template_kwargs",
"user",
"metadata",
"prompt_cache_retention",
"safety_identifier",
"stream_options",
"truncation",
"stop_sequences",
}
var openAICodexOAuthUnsupportedFields = append([]string{
@@ -149,6 +173,18 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult {
result := codexTransformResult{}
toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
if err != nil {
result.Error = err
return result
}
result.ToolNameReverse = toolNameReverse
if toolNamesChanged {
result.Modified = true
}
if normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody) {
result.Modified = true
}
// 工具续链需求会影响存储策略与 input 过滤逻辑。
needsToolContinuation := NeedsToolContinuation(reqBody)
@@ -884,6 +920,124 @@ func normalizeOpenAIResponsesImageGenerationTools(reqBody map[string]any) bool {
delete(toolMap, "compression")
modified = true
}
imageModel := strings.ToLower(strings.TrimSpace(firstNonEmptyString(toolMap["model"])))
if strings.HasPrefix(imageModel, "gpt-image-2") {
if _, ok := toolMap["input_fidelity"]; ok {
delete(toolMap, "input_fidelity")
modified = true
}
}
}
return modified
}
func normalizeOpenAIResponseFormatSchemas(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
modified := false
normalizeFormat := func(format map[string]any) {
if format == nil || strings.TrimSpace(firstNonEmptyString(format["type"])) != "json_schema" {
return
}
if schema, ok := format["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) {
modified = true
}
if jsonSchema, ok := format["json_schema"].(map[string]any); ok {
if schema, ok := jsonSchema["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) {
modified = true
}
}
}
if text, ok := reqBody["text"].(map[string]any); ok {
if format, ok := text["format"].(map[string]any); ok {
normalizeFormat(format)
}
}
if responseFormat, ok := reqBody["response_format"].(map[string]any); ok {
normalizeFormat(responseFormat)
}
return modified
}
func normalizeOpenAIResponseJSONSchema(schema map[string]any) bool {
if schema == nil {
return false
}
modified := false
for _, key := range []string{"uniqueItems", "minProperties"} {
if _, exists := schema[key]; exists {
delete(schema, key)
modified = true
}
}
if rawType, exists := schema["type"]; !exists || rawType == nil {
switch {
case schema["properties"] != nil:
schema["type"] = "object"
modified = true
case schema["items"] != nil:
schema["type"] = "array"
modified = true
}
}
if properties, ok := schema["properties"].(map[string]any); ok {
for _, raw := range properties {
if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
modified = true
}
}
}
switch items := schema["items"].(type) {
case map[string]any:
if normalizeOpenAIResponseJSONSchema(items) {
modified = true
}
case []any:
for _, raw := range items {
if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
modified = true
}
}
}
for _, key := range []string{
"additionalProperties",
"additionalItems",
"contains",
"not",
"if",
"then",
"else",
"propertyNames",
"unevaluatedProperties",
"unevaluatedItems",
} {
if child, ok := schema[key].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
modified = true
}
}
for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
children, _ := schema[key].([]any)
for _, raw := range children {
if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
modified = true
}
}
}
for _, key := range []string{"$defs", "definitions", "patternProperties", "dependentSchemas"} {
children, _ := schema[key].(map[string]any)
for _, raw := range children {
if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
modified = true
}
}
}
if dependencies, ok := schema["dependencies"].(map[string]any); ok {
for _, raw := range dependencies {
if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
modified = true
}
}
}
return modified
}
@@ -1357,8 +1511,62 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
})
}
func normalizeCodexFilterCallID(itemType, id string, preserve bool) string {
if preserve && len(id) <= codexCallIDMaxLength {
return id
}
return normalizeCodexCallIDForItemType(itemType, id)
}
func codexItemReferenceIDMappings(input []any, preserveCallIDs bool) map[string]string {
mappings := make(map[string]string)
ambiguous := make(map[string]struct{})
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
if !isCodexToolCallItemType(itemType) {
continue
}
rawCallID := strings.TrimSpace(firstNonEmptyString(item["call_id"]))
if rawCallID == "" {
continue
}
normalized := normalizeCodexFilterCallID(itemType, rawCallID, preserveCallIDs)
if existing, exists := mappings[rawCallID]; exists && existing != normalized {
delete(mappings, rawCallID)
ambiguous[rawCallID] = struct{}{}
continue
}
if _, conflict := ambiguous[rawCallID]; !conflict {
mappings[rawCallID] = normalized
}
}
return mappings
}
func codexInputItemIDs(input []any) map[string]struct{} {
itemIDs := make(map[string]struct{})
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) == "item_reference" {
continue
}
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
id := strings.TrimSpace(firstNonEmptyString(item["id"]))
if id != "" && !shouldStripOpenAIResponsesInputItemID(itemType, id) {
itemIDs[id] = struct{}{}
}
}
return itemIDs
}
func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any {
filtered := make([]any, 0, len(input))
referenceIDMappings := codexItemReferenceIDMappings(input, opts.PreserveCallIDs)
inputItemIDs := codexInputItemIDs(input)
for _, item := range input {
m, ok := item.(map[string]any)
if !ok {
@@ -1408,18 +1616,7 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
// 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id
// 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。
fixCallIDPrefix := func(id string) string {
if opts.PreserveCallIDs {
// preserve 模式尽量原样透传客户端 id 以维持 tool_use/tool_result
// 配对,但上游对 call_id 有 64 字符硬上限,超长原样透传必然被
// 400 拒绝("Invalid 'input[N].call_id': string too long")。
// 超长时退回确定性压缩:同一逻辑 id 在 function_call 与
// function_call_output 两侧结果一致,配对不受影响。
if len(id) <= codexCallIDMaxLength {
return id
}
return compactCodexCallID(id)
}
return normalizeCodexCallID(id)
return normalizeCodexFilterCallID(typ, id, opts.PreserveCallIDs)
}
if typ == "item_reference" {
@@ -1430,8 +1627,12 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
for key, value := range m {
newItem[key] = value
}
if id, ok := newItem["id"].(string); ok && strings.HasPrefix(id, "call_") {
newItem["id"] = fixCallIDPrefix(id)
if id, ok := newItem["id"].(string); ok && strings.HasPrefix(strings.TrimSpace(id), "call_") {
trimmedID := strings.TrimSpace(id)
_, referencesExistingItem := inputItemIDs[trimmedID]
if normalizedID, mapped := referenceIDMappings[trimmedID]; mapped && !referencesExistingItem {
newItem["id"] = normalizedID
}
}
filtered = append(filtered, newItem)
continue
@@ -233,7 +233,7 @@ func TestApplyCodexOAuthTransform_CompactsOverlongCallIDsWhenPreserveRequested(t
require.Len(t, compacted, codexCallIDMaxLength)
require.True(t, strings.HasPrefix(compacted, codexCallIDPrefix))
require.Equal(t, compacted, output["call_id"], "两侧压缩结果必须一致以保持配对")
require.Equal(t, compactCodexCallID(callID), compacted, "压缩必须是确定性的")
require.Equal(t, normalizeCodexCallID(callID), compacted, "压缩必须是确定性的")
}
func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T) {
@@ -253,7 +253,7 @@ func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T)
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "tool_search_output", first["type"])
require.Equal(t, "fc_1", first["call_id"])
require.Equal(t, "tsc_1", first["call_id"])
}
func TestApplyCodexOAuthTransform_CustomAndMCPToolOutputsPreserveCallID(t *testing.T) {
@@ -273,13 +273,81 @@ func TestApplyCodexOAuthTransform_CustomAndMCPToolOutputsPreserveCallID(t *testi
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "fc_custom", first["call_id"])
require.Equal(t, "ctc_custom", first["call_id"])
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "fc_mcp", second["call_id"])
}
func TestApplyCodexOAuthTransform_NormalizesNativeToolCallPairsByType(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.6-sol",
"input": []any{
map[string]any{"type": "custom_tool_call", "id": "fc_custom", "call_id": "call_custom", "name": "apply_patch"},
map[string]any{"type": "custom_tool_call_output", "call_id": "fc_custom", "output": "done"},
map[string]any{"type": "tool_search_call", "id": "fc_search", "call_id": "call_search"},
map[string]any{"type": "tool_search_output", "call_id": "fc_search", "output": "result"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
input := reqBody["input"].([]any)
custom := input[0].(map[string]any)
customOutput := input[1].(map[string]any)
search := input[2].(map[string]any)
searchOutput := input[3].(map[string]any)
require.NotContains(t, custom, "id", "the invalid replay item id must be removed, not fabricated")
require.Equal(t, "ctc_custom", custom["call_id"])
require.Equal(t, custom["call_id"], customOutput["call_id"])
require.NotContains(t, search, "id", "the invalid replay item id must be removed, not fabricated")
require.Equal(t, "tsc_search", search["call_id"])
require.Equal(t, search["call_id"], searchOutput["call_id"])
}
func TestApplyCodexOAuthTransform_PreservesNativeCallIDsWhenRequested(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.6-sol",
"input": []any{
map[string]any{"type": "custom_tool_call", "id": "ctc_custom", "call_id": "call_custom", "name": "apply_patch"},
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "done"},
map[string]any{"type": "tool_search_call", "id": "tsc_search", "call_id": "call_search"},
map[string]any{"type": "tool_search_output", "call_id": "call_search", "output": "result"},
},
}
applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{PreserveToolCallIDs: true})
input := reqBody["input"].([]any)
require.Equal(t, "ctc_custom", input[0].(map[string]any)["id"])
require.Equal(t, "call_custom", input[0].(map[string]any)["call_id"])
require.Equal(t, "call_custom", input[1].(map[string]any)["call_id"])
require.Equal(t, "tsc_search", input[2].(map[string]any)["id"])
require.Equal(t, "call_search", input[2].(map[string]any)["call_id"])
require.Equal(t, "call_search", input[3].(map[string]any)["call_id"])
}
func TestApplyCodexOAuthTransform_BoundsEquivalentNativeToolCallIDsWithPairing(t *testing.T) {
suffix := strings.Repeat("x", 70)
reqBody := map[string]any{
"model": "gpt-5.6-sol",
"input": []any{
map[string]any{"type": "custom_tool_call", "call_id": "call_" + suffix, "name": "apply_patch"},
map[string]any{"type": "custom_tool_call_output", "call_id": "fc_" + suffix, "output": "done"},
},
}
applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{PreserveToolCallIDs: true})
input := reqBody["input"].([]any)
callID := input[0].(map[string]any)["call_id"].(string)
outputCallID := input[1].(map[string]any)["call_id"].(string)
require.LessOrEqual(t, len(callID), codexCallIDMaxLength)
require.True(t, strings.HasPrefix(callID, "ctc_"))
require.Equal(t, callID, outputCallID)
}
func TestApplyCodexOAuthTransform_ImageAndWebSearchCallsDoNotGainCallID(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
@@ -468,7 +536,7 @@ func TestApplyCodexOAuthTransform_PreservesFunctionCallInputName(t *testing.T) {
item, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "shell", item["name"])
require.Equal(t, "fc_1", item["call_id"])
require.Equal(t, "ctc_1", item["call_id"])
}
func TestApplyCodexOAuthTransform_PreservesMCPToolCallIDAndName(t *testing.T) {
@@ -1651,11 +1719,14 @@ func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) {
func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.4",
"chat_template_kwargs": map[string]any{"enable_thinking": true},
"user": "user_123",
"metadata": map[string]any{"trace_id": "abc"},
"prompt_cache_retention": "24h",
"safety_identifier": "sid",
"stream_options": map[string]any{"include_usage": true},
"truncation": "auto",
"stop_sequences": []any{"END"},
"input": []any{
map[string]any{"role": "user", "content": "hi"},
},
@@ -1669,6 +1740,40 @@ func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *test
}
}
func TestApplyCodexOAuthTransform_NormalizesPromptAndCommands(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.5",
"prompt": "hello",
"commands": []any{"unsupported"},
}
result := applyCodexOAuthTransform(reqBody, true, false)
require.True(t, result.Modified)
require.Equal(t, []any{
map[string]any{"type": "message", "role": "user", "content": "hello"},
}, reqBody["input"])
require.NotContains(t, reqBody, "prompt")
require.NotContains(t, reqBody, "commands")
}
func TestNormalizeOpenAIResponsesImageGenerationTools_StripsGPTImage2InputFidelity(t *testing.T) {
reqBody := map[string]any{"tools": []any{
map[string]any{"type": "image_generation", "model": "gpt-image-2-codex", "input_fidelity": "high"},
map[string]any{"type": "image_generation", "model": "gpt-image-1.5", "input_fidelity": "high"},
}}
require.True(t, normalizeOpenAIResponsesImageGenerationTools(reqBody))
tools := reqBody["tools"].([]any)
require.NotContains(t, tools[0].(map[string]any), "input_fidelity")
require.Equal(t, "high", tools[1].(map[string]any)["input_fidelity"])
}
func TestOpenAIRequestBodyImageGenerationToolNeedsNormalization_GPTImage2InputFidelity(t *testing.T) {
body := []byte(`{"tools":[{"type":"image_generation","model":"gpt-image-2-codex","input_fidelity":"high"}]}`)
require.True(t, openAIRequestBodyImageGenerationToolNeedsNormalization(body))
}
func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
@@ -7,6 +7,8 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -249,6 +251,19 @@ func TestWriteOpenAIPassthroughResponseHeaders_RelaysAndClearsTurnState(t *testi
require.Empty(t, dst.Get("X-Codex-Turn-State"))
}
func TestWriteOpenAIPassthroughResponseHeaders_RelaysReasoningIncluded(t *testing.T) {
dst := http.Header{}
src := http.Header{}
src.Set("X-Reasoning-Included", "1")
writeOpenAIPassthroughResponseHeaders(
dst,
src,
responseheaders.CompileHeaderFilter(config.ResponseHeaderConfig{}),
)
require.Equal(t, "1", dst.Get("X-Reasoning-Included"))
}
func TestEnsureOpenAIRemoteCompactionV2BetaFeature(t *testing.T) {
t.Run("absent_sets_feature", func(t *testing.T) {
h := http.Header{}
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"net/http"
"strings"
@@ -22,6 +23,47 @@ func MarkOpenAINativeCompactionV2(c *gin.Context) {
}
}
// NormalizeCompactionTriggerInputOrder keeps a single compaction trigger as
// the final Responses input item, as required by the upstream v2 wire format.
func NormalizeCompactionTriggerInputOrder(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return body, false, err
}
input, ok := payload["input"].([]any)
if !ok || len(input) == 0 {
return body, false, nil
}
triggerCount := 0
normalized := make([]any, 0, len(input))
for _, raw := range input {
item, itemOK := raw.(map[string]any)
if itemOK && item["type"] == "compaction_trigger" {
triggerCount++
continue
}
normalized = append(normalized, raw)
}
if triggerCount == 0 {
return body, false, nil
}
if triggerCount == 1 {
if last, ok := input[len(input)-1].(map[string]any); ok && last["type"] == "compaction_trigger" {
return body, false, nil
}
}
normalized = append(normalized, map[string]any{"type": "compaction_trigger"})
payload["input"] = normalized
encoded, err := json.Marshal(payload)
if err != nil {
return body, false, err
}
return encoded, true, nil
}
func isOpenAINativeCompactionV2(c *gin.Context) bool {
if c == nil {
return false
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestHasCompactionTriggerInInput_DetectsCompactSignal(t *testing.T) {
@@ -54,3 +55,22 @@ func TestHasCompactionTriggerInInput_CompactTriggerOnly(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
require.True(t, HasCompactionTriggerInInput(body))
}
func TestNormalizeCompactionTriggerInputOrder_MovesAndCollapsesTriggers(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"},{"type":"message","role":"user","content":"tail"},{"type":"compaction_trigger"}]}`)
normalized, changed, err := NormalizeCompactionTriggerInputOrder(body)
require.NoError(t, err)
require.True(t, changed)
items := gjson.GetBytes(normalized, "input").Array()
require.Len(t, items, 2)
require.Equal(t, "message", items[0].Get("type").String())
require.Equal(t, "compaction_trigger", items[1].Get("type").String())
}
func TestNormalizeCompactionTriggerInputOrder_AlreadyFinalPreservesBytes(t *testing.T) {
body := []byte(`{"input":[{"type":"message"},{"type":"compaction_trigger"}]}`)
normalized, changed, err := NormalizeCompactionTriggerInputOrder(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(normalized))
}
@@ -39,6 +39,10 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes
{"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"},
{"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]},
{"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"},
{"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
{"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},
{"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
{"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}},
{"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"},
{"type":"web_search_call","id":"item_unconstrained"}
]
@@ -58,9 +62,54 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes
require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String())
require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String())
require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String())
require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.4.id").String())
require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.4.call_id").String())
require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.5.id").String())
require.False(t, gjson.GetBytes(forwarded, "input.4.id").Exists())
require.Equal(t, "ctc_valid", gjson.GetBytes(forwarded, "input.5.id").String())
require.False(t, gjson.GetBytes(forwarded, "input.6.id").Exists())
require.Equal(t, "tsc_valid", gjson.GetBytes(forwarded, "input.7.id").String())
require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.8.id").String())
require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.8.call_id").String())
require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.9.id").String())
}
func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := newOpenAIImageGenerationControlTestService(upstream)
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
account := newOpenAIImageGenerationControlTestAccount()
account.Type = AccountTypeOAuth
account.Credentials = map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
}
account.Extra = map[string]any{"openai_passthrough": true}
body := []byte(`{
"model":"gpt-5.6-sol",
"stream":true,
"instructions":"test",
"input":[
{"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
{"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},
{"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
{"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}}
]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
}
// TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs
@@ -119,7 +168,12 @@ func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
{"message msg id", "message", "msg_abc", false},
{"message item id", "message", "item_x", true},
{"function_call fc id", "function_call", "fc_abc", false},
{"function_call ctc id", "function_call", "ctc_abc", true},
{"function_call item id", "function_call", "item_x", true},
{"custom tool ctc id", "custom_tool_call", "ctc_abc", false},
{"custom tool fc id", "custom_tool_call", "fc_abc", true},
{"tool search tsc id", "tool_search_call", "tsc_abc", false},
{"tool search fc id", "tool_search_call", "fc_abc", true},
{"unconstrained type", "web_search_call", "ws_001", false},
}
for _, tc := range cases {
@@ -60,6 +60,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
setCodexToolNameReverse(c, nil)
restrictionResult := s.detectCodexClientRestriction(c, account, body)
logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body)
@@ -212,6 +213,11 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
SkipDefaultInstructions: !isResponsesShape,
OmitPromotedSystemMessagesFromInput: !isResponsesShape && !isJSONObjectFormat,
})
if codexResult.Error != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}})
return nil, codexResult.Error
}
setCodexToolNameReverse(c, codexResult.ToolNameReverse)
if !isResponsesShape {
ensureCodexOAuthInstructionsField(reqBody)
}
@@ -421,7 +427,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
) (*OpenAIForwardResult, error) {
requestID := resp.Header.Get("x-request-id")
finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai chat_completions buffered", requestID)
finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai chat_completions buffered", requestID)
if err != nil {
return nil, s.newOpenAICompatBufferedReadFailoverError(c, account, resp, requestID, err)
}
@@ -635,6 +641,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}
processDataLine := func(payload string) bool {
payload = string(restoreCodexToolNamesFromContext(c, []byte(payload)))
if firstChunk {
firstChunk = false
ms := int(time.Since(startTime).Milliseconds())
@@ -23,6 +23,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
clearGrokResponsesClientToolMapping(c)
clearOpenAIResponsesClientToolMapping(c)
clearOpenAIResponsesNamespaceNames(c)
setCodexToolNameReverse(c, nil)
startTime := time.Now()
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
canonicalImageIntentBody := body
@@ -58,6 +59,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if toolSchemaSanitized {
body = sanitizedToolBody
}
patternSanitizedBody, patternSanitized, patternErr := sanitizeOpenAIResponsesToolSchemaPatterns(body)
if patternErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", patternErr)
}
if patternSanitized {
body = patternSanitizedBody
}
if account.IsOpenAI() && account.IsOAuth() {
reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body)
if reasoningErr != nil {
return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr)
}
if reasoningChanged {
body = reasoningBody
}
}
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
if liteErr != nil {
@@ -119,7 +136,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey {
if account.Platform == PlatformOpenAI && (account.Type == AccountTypeAPIKey || account.Type == AccountTypeOAuth) {
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
if sanitizeErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
@@ -325,6 +342,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
markPatchSet("reasoning.effort", "none")
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name)
}
if strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()) == "json_schema" ||
strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String()) == "json_schema" {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if normalizeOpenAIResponseFormatSchemas(decoded) {
markDecodedModified()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized Responses JSON schema compatibility")
}
}
imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel)
if imageIntent && !imageGenerationAllowed {
@@ -415,6 +443,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} else {
codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest)
}
if codexResult.Error != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}})
return nil, codexResult.Error
}
setCodexToolNameReverse(c, codexResult.ToolNameReverse)
if codexResult.Modified {
markDecodedModified()
}
@@ -538,6 +571,29 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
if account.Type == AccountTypeOAuth {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if input, ok := decoded["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
decoded,
input,
strings.TrimSpace(firstNonEmptyString(decoded["previous_response_id"])) != "",
) {
markDecodedModified()
}
}
if reqBody != nil || openAIResponsesInputMayNeedTruncation(body) {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if truncateOpenAIResponsesInputText(decoded) {
markDecodedModified()
}
}
if bodyModified {
if requestView.HasPatches() {
if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil {
@@ -35,6 +35,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
setCodexToolNameReverse(c, nil)
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时,
// /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留
@@ -193,6 +194,11 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
SkipDefaultInstructions: true,
PreserveToolCallIDs: true,
})
if codexResult.Error != nil {
writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", codexResult.Error.Error())
return nil, codexResult.Error
}
setCodexToolNameReverse(c, codexResult.ToolNameReverse)
forcedTemplateText := ""
if s.cfg != nil {
forcedTemplateText = s.cfg.Gateway.ForcedCodexInstructionsTemplate
@@ -551,7 +557,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
) (*OpenAIForwardResult, error) {
requestID := resp.Header.Get("x-request-id")
finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai messages buffered", requestID)
finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai messages buffered", requestID)
if err != nil {
var readErr *openAICompatBufferedReadError
if errors.As(err, &readErr) && readErr != nil {
@@ -690,6 +696,7 @@ func (e *openAICompatBufferedReadError) Unwrap() error { return e.cause }
func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
resp *http.Response,
c *gin.Context,
logPrefix string,
requestID string,
) (*apicompat.ResponsesResponse, OpenAIUsage, *apicompat.BufferedResponseAccumulator, error) {
@@ -769,6 +776,7 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
if !ok {
if frame, ok := parser.Finish(); ok {
payload := openAICompatPayloadWithEventType(frame.Data, frame.EventType)
payload = string(restoreCodexToolNamesFromContext(c, []byte(payload)))
var event apicompat.ResponsesStreamEvent
if err := json.Unmarshal([]byte(payload), &event); err == nil {
acc.ProcessEvent(&event)
@@ -807,6 +815,7 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
continue
}
payload := openAICompatPayloadWithEventType(frame.Data, frame.EventType)
payload = string(restoreCodexToolNamesFromContext(c, []byte(payload)))
var event apicompat.ResponsesStreamEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
@@ -918,6 +927,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
// processDataLine handles a single "data: ..." SSE line from upstream.
processDataLine := func(payload string) bool {
payload = string(restoreCodexToolNamesFromContext(c, []byte(payload)))
if firstChunk {
firstChunk = false
ms := int(time.Since(startTime).Milliseconds())
@@ -1572,6 +1572,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if restoreErr != nil {
return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
}
restoredData = restoreCodexToolNamesFromContext(c, restoredData)
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
trimmedData = strings.TrimSpace(string(restoredData))
@@ -1818,6 +1819,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
if err != nil {
return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err)
}
body = restoreCodexToolNamesFromContext(c, body)
if mapping, ok := openAIResponsesClientToolMapping(c); ok && json.Valid(body) {
body, _, err = apicompat.RestoreResponsesClientToolPayload(body, mapping)
if err != nil {
@@ -1869,6 +1871,7 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
if restoreErr != nil {
return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
}
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
body = restoredBody
} else {
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
@@ -729,6 +729,188 @@ func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, p
return view.Model, view.Stream, view.PromptCacheKey
}
func normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
changed := false
if prompt, exists := reqBody["prompt"]; exists {
if input, hasInput := reqBody["input"]; !hasInput || input == nil {
if prompt != nil {
reqBody["input"] = prompt
}
}
delete(reqBody, "prompt")
changed = true
}
if _, exists := reqBody["commands"]; exists {
delete(reqBody, "commands")
changed = true
}
return changed
}
func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
normalized := body
changed := false
prompt := gjson.GetBytes(normalized, "prompt")
if prompt.Exists() {
input := gjson.GetBytes(normalized, "input")
if prompt.Type != gjson.Null && (!input.Exists() || input.Type == gjson.Null) {
next, err := sjson.SetRawBytes(normalized, "input", []byte(prompt.Raw))
if err != nil {
return body, false, fmt.Errorf("normalize oauth responses prompt: %w", err)
}
normalized = next
}
next, err := sjson.DeleteBytes(normalized, "prompt")
if err != nil {
return body, false, fmt.Errorf("normalize oauth responses delete prompt: %w", err)
}
normalized = next
changed = true
}
if gjson.GetBytes(normalized, "commands").Exists() {
next, err := sjson.DeleteBytes(normalized, "commands")
if err != nil {
return body, false, fmt.Errorf("normalize oauth responses delete commands: %w", err)
}
normalized = next
changed = true
}
return normalized, changed, nil
}
func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
mode := gjson.GetBytes(body, "reasoning.mode")
if !mode.Exists() || mode.Type != gjson.String {
return body, false, nil
}
updated := body
effort := gjson.GetBytes(body, "reasoning.effort")
if (!effort.Exists() || effort.Type == gjson.Null || strings.TrimSpace(effort.String()) == "") &&
strings.EqualFold(strings.TrimSpace(mode.String()), "pro") {
var err error
updated, err = sjson.SetBytes(updated, "reasoning.effort", "max")
if err != nil {
return body, false, fmt.Errorf("set reasoning effort for mode=pro: %w", err)
}
}
updated, err := sjson.DeleteBytes(updated, "reasoning.mode")
if err != nil {
return body, false, fmt.Errorf("delete unsupported reasoning.mode: %w", err)
}
if reasoning := gjson.GetBytes(updated, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 {
updated, err = sjson.DeleteBytes(updated, "reasoning")
if err != nil {
return body, false, fmt.Errorf("delete empty reasoning object: %w", err)
}
}
return updated, true, nil
}
func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
textFormat := strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String())
responseFormat := strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String())
if textFormat != "json_schema" && responseFormat != "json_schema" {
return body, false, nil
}
var reqBody map[string]any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&reqBody); err != nil {
return body, false, fmt.Errorf("normalize responses schema body: %w", err)
}
if !normalizeOpenAIResponseFormatSchemas(reqBody) {
return body, false, nil
}
normalized, err := json.Marshal(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized responses schema body: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) {
normalized := body
changed := false
if sanitized, idsChanged, err := sanitizeOpenAIResponsesInputItemIDs(normalized); err != nil {
return body, false, fmt.Errorf("sanitize websocket Responses input item IDs: %w", err)
} else if idsChanged {
normalized = sanitized
changed = true
}
if account != nil && account.IsOpenAI() && account.IsOAuth() {
if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized); err != nil {
return body, false, err
} else if reasoningChanged {
normalized = reasoningBody
changed = true
}
}
if account != nil && account.IsOpenAIOAuth() {
oauthBody, oauthChanged, err := normalizeOpenAIOAuthResponsesCompatibilityBody(normalized)
if err != nil {
return body, false, err
}
normalized = oauthBody
changed = changed || oauthChanged
for _, field := range openAIChatGPTInternalUnsupportedFields {
if !gjson.GetBytes(normalized, field).Exists() {
continue
}
next, deleteErr := sjson.DeleteBytes(normalized, field)
if deleteErr != nil {
return body, false, fmt.Errorf("normalize websocket body delete %s: %w", field, deleteErr)
}
normalized = next
changed = true
}
}
if schemaBody, schemaChanged, err := normalizeOpenAIResponseFormatSchemasBody(normalized); err != nil {
return body, false, err
} else if schemaChanged {
normalized = schemaBody
changed = true
}
if openAIRequestBodyImageGenerationToolNeedsNormalization(normalized) {
var reqBody map[string]any
if err := json.Unmarshal(normalized, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize websocket image tool body: %w", err)
}
if normalizeOpenAIResponsesImageGenerationTools(reqBody) {
next, err := json.Marshal(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized websocket image tool body: %w", err)
}
normalized = next
changed = true
}
}
if toolBody, toolChanged, err := sanitizeOpenAIResponsesToolParameterTypes(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket tool parameter types: %w", err)
} else if toolChanged {
normalized = toolBody
changed = true
}
if patternBody, patternChanged, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket tool schema patterns: %w", err)
} else if patternChanged {
normalized = patternBody
changed = true
}
return normalized, changed, nil
}
// normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为:
// 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数
// 2) store=false 3) 非 compact 保持 stream=truecompact 强制 stream=false
@@ -737,8 +919,16 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo
return body, false, nil
}
normalized := body
changed := false
normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body)
if err != nil {
return body, false, err
}
if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized); reasoningErr != nil {
return body, false, reasoningErr
} else if reasoningChanged {
normalized = reasoningBody
changed = true
}
for _, field := range openAIChatGPTInternalUnsupportedFields {
if value := gjson.GetBytes(normalized, field); !value.Exists() {
@@ -751,6 +941,12 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo
normalized = next
changed = true
}
if schemaBody, schemaChanged, schemaErr := normalizeOpenAIResponseFormatSchemasBody(normalized); schemaErr != nil {
return body, false, schemaErr
} else if schemaChanged {
normalized = schemaBody
changed = true
}
if inputResult := gjson.GetBytes(normalized, "input"); inputResult.Exists() {
switch {
@@ -566,6 +566,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
streamEarlyErr = fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
return
}
restoredData = restoreCodexToolNamesFromContext(c, restoredData)
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
data = string(restoredData)
@@ -1060,21 +1061,43 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
}
func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) {
if usage == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) {
if usage == nil || len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) {
return
}
// 选择性解析:仅在数据中包含终止事件标识时才进入字段提取。
if len(data) < 72 {
parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data)
if !ok {
return
}
eventType := gjson.GetBytes(data, "type").String()
if eventType != "response.completed" && eventType != "response.done" && eventType != "response.failed" &&
eventType != "response.incomplete" && eventType != "response.cancelled" && eventType != "response.canceled" {
return
}
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data); ok {
if openAIStreamEventTypeIsTerminal(strings.TrimSpace(gjson.GetBytes(data, "type").String())) {
*usage = parsedUsage
return
}
mergeOpenAIUsageNonZero(usage, parsedUsage)
}
// Compatible Responses upstreams may report usage before the terminal event.
// Retain those non-zero fields as a fallback; terminal usage remains authoritative.
func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
if dst == nil {
return
}
if src.InputTokens > 0 {
dst.InputTokens = src.InputTokens
}
if src.ImageInputTokens > 0 {
dst.ImageInputTokens = src.ImageInputTokens
}
if src.OutputTokens > 0 {
dst.OutputTokens = src.OutputTokens
}
if src.CacheCreationInputTokens > 0 {
dst.CacheCreationInputTokens = src.CacheCreationInputTokens
}
if src.CacheReadInputTokens > 0 {
dst.CacheReadInputTokens = src.CacheReadInputTokens
}
if src.ImageOutputTokens > 0 {
dst.ImageOutputTokens = src.ImageOutputTokens
}
}
@@ -1310,6 +1333,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
if err != nil {
return nil, fmt.Errorf("restore OpenAI namespace response: %w", err)
}
body = restoreCodexToolNamesFromContext(c, body)
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
// Codex 协议要求 /responses/compact JSON 响应携带 x-codex-turn-state
// codex-api/src/endpoint/compact.rs 从响应头捕获),显式回传。
@@ -1391,6 +1415,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
if restoreErr != nil {
return nil, fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
}
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
body = restoredBody
} else {
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
@@ -327,10 +327,29 @@ func TestIsOpenAIContextWindowError(t *testing.T) {
"maximum context length exceeded",
nil,
))
require.True(t, isOpenAIContextWindowError(
"",
[]byte(`maximum context length exceeded`),
))
require.False(t, isOpenAIContextWindowError(
"context canceled",
nil,
))
require.False(t, isOpenAIContextWindowError(
"upstream unavailable",
[]byte(`{"error":{"message":"upstream unavailable","code":"upstream_error"},"echo":"context_length_exceeded maximum context length"}`),
))
}
func TestOpenAITransientAndCapacityClassificationIgnoresEchoedJSON(t *testing.T) {
body := []byte(`{"error":{"message":"upstream unavailable","code":"upstream_error"},"echo":"server is overloaded; selected model is at capacity"}`)
require.False(t, isOpenAITransientProcessingError(http.StatusBadRequest, "upstream unavailable", body))
require.False(t, isOpenAIRequestScopedCapacityShed("upstream unavailable", body))
plainText := []byte(`server is overloaded; please retry later`)
require.True(t, isOpenAITransientProcessingError(http.StatusServiceUnavailable, "", plainText))
require.True(t, isOpenAIRequestScopedCapacityShed("", plainText))
}
func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) {
@@ -339,6 +358,11 @@ func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) {
require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "", body))
require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`)))
require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(
http.StatusBadGateway,
"temporary upstream outage",
[]byte(`{"error":{"message":"temporary upstream outage"},"echo":"context_length_exceeded"}`),
))
}
func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing.T) {
@@ -3391,10 +3391,10 @@ func TestParseSSEUsage_SelectiveParsing(t *testing.T) {
svc := &OpenAIGatewayService{}
usage := &OpenAIUsage{InputTokens: 9, OutputTokens: 8, CacheReadInputTokens: 7}
// 非 completed 事件,不应覆盖 usage
// 非终态事件中的显式 usage 作为兼容 fallback,非零字段会被合并。
svc.parseSSEUsage(`{"type":"response.in_progress","response":{"usage":{"input_tokens":1,"output_tokens":2}}}`, usage)
require.Equal(t, 9, usage.InputTokens)
require.Equal(t, 8, usage.OutputTokens)
require.Equal(t, 1, usage.InputTokens)
require.Equal(t, 2, usage.OutputTokens)
require.Equal(t, 7, usage.CacheReadInputTokens)
// completed 事件,应提取 usage
@@ -3421,6 +3421,43 @@ func TestParseSSEUsage_SelectiveParsing(t *testing.T) {
require.Equal(t, 6, usage.CacheReadInputTokens)
}
func TestParseSSEUsage_NonTerminalUsageMergesNonZeroFields(t *testing.T) {
svc := &OpenAIGatewayService{}
usage := &OpenAIUsage{}
svc.parseSSEUsage(`{"type":"response.in_progress","usage":{"input_tokens":17,"output_tokens":1,"input_tokens_details":{"cached_tokens":4}}}`, usage)
svc.parseSSEUsage(`{"type":"response.output_text.done","usage":{"input_tokens":0,"output_tokens":5,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":3}}}`, usage)
require.Equal(t, 17, usage.InputTokens)
require.Equal(t, 5, usage.OutputTokens)
require.Equal(t, 4, usage.CacheReadInputTokens)
require.Equal(t, 3, usage.CacheCreationInputTokens)
}
func TestParseSSEUsage_TerminalUsageReplacesFallback(t *testing.T) {
svc := &OpenAIGatewayService{}
usage := &OpenAIUsage{}
svc.parseSSEUsage(`{"type":"response.output_text.done","usage":{"input_tokens":17,"output_tokens":5,"input_tokens_details":{"cached_tokens":4}}}`, usage)
svc.parseSSEUsage(`{"type":"response.completed","response":{"usage":{"input_tokens":19,"output_tokens":7}}}`, usage)
require.Equal(t, 19, usage.InputTokens)
require.Equal(t, 7, usage.OutputTokens)
require.Zero(t, usage.CacheReadInputTokens)
}
func TestParseSSEUsage_TerminalWithoutUsageKeepsFallback(t *testing.T) {
svc := &OpenAIGatewayService{}
usage := &OpenAIUsage{}
svc.parseSSEUsage(`{"type":"response.in_progress","usage":{"input_tokens":17,"output_tokens":5}}`, usage)
svc.parseSSEUsage(`{"type":"response.completed","response":{"id":"resp_1"}}`, usage)
svc.parseSSEUsage(" [DONE]\n", usage)
require.Equal(t, 17, usage.InputTokens)
require.Equal(t, 5, usage.OutputTokens)
}
func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *testing.T) {
usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":5,"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":4}}}`))
require.True(t, ok)
@@ -135,7 +135,7 @@ func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string
if isOpenAICapacityShedMessage(upstreamMsg) ||
isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "error.message").String()) ||
isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
isOpenAICapacityShedMessage(string(upstreamBody)) {
(!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody))) {
return true
}
if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable {
@@ -170,7 +170,14 @@ func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string
if match(gjson.GetBytes(upstreamBody, "error.message").String()) {
return true
}
return match(string(upstreamBody))
if match(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
match(gjson.GetBytes(upstreamBody, "message").String()) {
return true
}
// A valid JSON error may echo arbitrary request content. Only its explicit
// error fields are authoritative; scan the whole body only for non-JSON
// providers that return a plain-text error response.
return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody))
}
func isOpenAICapacityShedMessage(text string) bool {
@@ -183,7 +190,7 @@ func isOpenAICapacityShedMessage(text string) bool {
func isOpenAIRequestScopedCapacityShed(upstreamMsg string, upstreamBody []byte) bool {
return isOpenAIUpstreamCapacityShedEvent(upstreamBody) ||
isOpenAICapacityShedMessage(upstreamMsg) ||
isOpenAICapacityShedMessage(string(upstreamBody))
(!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody)))
}
func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool {
@@ -228,7 +235,10 @@ func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool {
return true
}
}
return match(string(upstreamBody))
// Do not let echoed request content in a structured JSON error change the
// retry/client-status classification. Plain-text upstream errors remain
// supported by scanning the whole body only when it is not valid JSON.
return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody))
}
func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
@@ -20,6 +20,194 @@ func TestNormalizeOpenAIPassthroughOAuthBody_RemovesUnsupportedUser(t *testing.T
require.False(t, gjson.GetBytes(normalized, "store").Bool())
}
func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesCompatibilityFields(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","prompt":"hello","commands":["unsupported"],"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`)
normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "hello", gjson.GetBytes(normalized, "input.0.content").String())
for _, field := range []string{"prompt", "commands", "truncation", "stop_sequences", "chat_template_kwargs"} {
require.False(t, gjson.GetBytes(normalized, field).Exists(), field)
}
}
func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesReasoningMode(t *testing.T) {
body := []byte(`{"model":"gpt-5.6-sol","input":"hello","reasoning":{"mode":"pro"}}`)
normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
}
func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","input":"explicit","prompt":"legacy"}`)
normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "explicit", gjson.GetBytes(normalized, "input").String())
require.False(t, gjson.GetBytes(normalized, "prompt").Exists())
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) {
body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`)
oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth})
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String())
for _, field := range []string{"prompt", "commands", "truncation", "stop_sequences", "chat_template_kwargs"} {
require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field)
}
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(body), string(apiKeyBody))
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemIDs(t *testing.T) {
body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[` +
`{"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},` +
`{"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},` +
`{"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},` +
`{"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}}]}`)
for _, oauth := range []bool{false, true} {
accountType := AccountTypeAPIKey
if oauth {
accountType = AccountTypeOAuth
}
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String())
require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
require.Equal(t, "ctc_valid", gjson.GetBytes(normalized, "input.1.id").String())
require.False(t, gjson.GetBytes(normalized, "input.2.id").Exists())
require.Equal(t, "tsc_valid", gjson.GetBytes(normalized, "input.3.id").String())
// Native Responses call_id values are correlation keys, not item IDs.
require.Equal(t, "call_custom_1", gjson.GetBytes(normalized, "input.0.call_id").String())
require.Equal(t, "call_search_1", gjson.GetBytes(normalized, "input.2.call_id").String())
}
}
func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) {
tests := []struct {
name string
body string
wantEffort string
}{
{name: "pro maps to max", body: `{"reasoning":{"mode":"pro"}}`, wantEffort: "max"},
{name: "explicit effort wins", body: `{"reasoning":{"mode":"pro","effort":"high"}}`, wantEffort: "high"},
{name: "other mode only removed", body: `{"reasoning":{"mode":"standard"}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body))
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
require.Equal(t, tt.wantEffort, gjson.GetBytes(normalized, "reasoning.effort").String())
})
}
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) {
body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`)
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
}
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(body), string(apiKeyBody))
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) {
body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`)
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
require.False(t, gjson.GetBytes(normalized, "tools.0.parameters.properties.q.pattern").Exists())
}
}
func TestNormalizeOpenAIResponseFormatSchemasBody_PreservesNonStrictOptionalFields(t *testing.T) {
body := []byte(`{"text":{"format":{"type":"json_schema","strict":false,"schema":{"properties":{"tags":{"items":{"type":"string"},"uniqueItems":true}},"minProperties":1,"maxProperties":4}}}}`)
normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.type").String())
require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.properties.tags.type").String())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.minProperties").Exists())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.properties.tags.uniqueItems").Exists())
require.Equal(t, int64(4), gjson.GetBytes(normalized, "text.format.schema.maxProperties").Int())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.required").Exists())
}
func TestNormalizeOpenAIResponseFormatSchemasBody_DoesNotExpandStrictSchema(t *testing.T) {
body := []byte(`{"response_format":{"type":"json_schema","json_schema":{"strict":true,"schema":{"properties":{"name":{"type":"string"}}}}}}`)
normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
require.NoError(t, err)
require.True(t, changed) // Safe type inference still applies.
require.Equal(t, "object", gjson.GetBytes(normalized, "response_format.json_schema.schema.type").String())
require.False(t, gjson.GetBytes(normalized, "response_format.json_schema.schema.required").Exists())
require.False(t, gjson.GetBytes(normalized, "response_format.json_schema.schema.additionalProperties").Exists())
}
func TestNormalizeOpenAIResponseFormatSchemasBody_TraversesNestedSchemaContainers(t *testing.T) {
body := []byte(`{
"text":{"format":{"type":"json_schema","schema":{
"$defs":{"entry":{"properties":{"name":{"type":"string"}},"minProperties":1,"maxProperties":3}},
"additionalProperties":{"items":{"type":"string"},"uniqueItems":true},
"prefixItems":[{"properties":{"id":{"type":"string"}},"minProperties":1}],
"dependentSchemas":{"kind":{"properties":{"value":{"type":"string"}},"uniqueItems":true}},
"not":{"items":{"type":"string"},"minProperties":1}
}}}
}`)
normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.$defs.entry.type").String())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.$defs.entry.minProperties").Exists())
require.Equal(t, int64(3), gjson.GetBytes(normalized, "text.format.schema.$defs.entry.maxProperties").Int())
require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.additionalProperties.type").String())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.additionalProperties.uniqueItems").Exists())
require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.prefixItems.0.type").String())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.prefixItems.0.minProperties").Exists())
require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.dependentSchemas.kind.type").String())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.dependentSchemas.kind.uniqueItems").Exists())
require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.not.type").String())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.not.minProperties").Exists())
require.False(t, gjson.GetBytes(normalized, "text.format.schema.required").Exists())
}
func TestNormalizeOpenAIResponseFormatSchemasBody_PreservesExistingTypeValues(t *testing.T) {
body := []byte(`{"text":{"format":{"type":"json_schema","schema":{"properties":{"union":{"type":["object","null"],"properties":{"name":{"type":"string"}}},"custom":{"type":{"vendor":"shape"},"properties":{"id":{"type":"string"}}},"inferred":{"type":null,"items":{"type":"string"}}}}}}}`)
normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.type").String())
require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.properties.union.type.0").String())
require.Equal(t, "null", gjson.GetBytes(normalized, "text.format.schema.properties.union.type.1").String())
require.True(t, gjson.GetBytes(normalized, "text.format.schema.properties.custom.type").IsObject())
require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.properties.inferred.type").String())
}
func TestNormalizeOpenAIPassthroughOAuthBody_CompactRemovesUnsupportedUser(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","input":"hello","user":"user_123","metadata":{"user_id":"user_123"},"stream":true,"store":true}`)
@@ -0,0 +1,152 @@
package service
import (
"bytes"
"strings"
)
const openAIResponsesInputTextMaxChars = 10000000
// sanitizeOpenAIResponsesOrphanToolOutputs removes tool-output items that have
// no matching call or item reference anywhere in the current input.
func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []any, hasPreviousResponseID bool) bool {
if len(input) == 0 || hasPreviousResponseID {
return false
}
toolCallIDs := make(map[string]struct{}, len(input))
referenceIDs := make(map[string]struct{}, len(input))
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
if itemType == "item_reference" {
if id := strings.TrimSpace(firstNonEmptyString(item["id"])); id != "" {
referenceIDs[id] = struct{}{}
}
continue
}
if !isCodexToolCallContextItemType(itemType) {
continue
}
if id := strings.TrimSpace(firstNonEmptyString(item["call_id"], item["id"])); id != "" {
toolCallIDs[id] = struct{}{}
}
}
modified := false
normalized := make([]any, 0, len(input))
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok || !isCodexToolCallOutputItemType(strings.TrimSpace(firstNonEmptyString(item["type"]))) {
normalized = append(normalized, rawItem)
continue
}
callID := strings.TrimSpace(firstNonEmptyString(item["call_id"]))
_, hasToolCall := toolCallIDs[callID]
_, hasReference := referenceIDs[callID]
if callID != "" && (hasToolCall || hasReference) {
normalized = append(normalized, rawItem)
continue
}
modified = true
}
if !modified {
return false
}
reqBody["input"] = normalized
return true
}
func truncateOpenAIResponsesInputText(reqBody map[string]any) bool {
input, ok := reqBody["input"].([]any)
if !ok || len(input) == 0 {
return false
}
modified := false
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
if isCodexToolCallOutputItemType(itemType) {
if output, ok := item["output"].(string); ok {
if truncated, changed := truncateOpenAIResponsesInputString(output); changed {
item["output"] = truncated
modified = true
}
}
}
if truncateOpenAIResponsesMessageText(item) {
modified = true
}
}
return modified
}
func openAIResponsesInputMayNeedTruncation(body []byte) bool {
if len(body) <= openAIResponsesInputTextMaxChars {
return false
}
if bytes.Contains(body, []byte(`"text"`)) && bytes.Contains(body, []byte(`"content"`)) {
return true
}
for _, itemType := range []string{
"function_call_output",
"tool_search_output",
"custom_tool_call_output",
"mcp_tool_call_output",
} {
if bytes.Contains(body, []byte(itemType)) {
return true
}
}
return false
}
func truncateOpenAIResponsesMessageText(item map[string]any) bool {
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
role := strings.TrimSpace(firstNonEmptyString(item["role"]))
if itemType != "message" && role == "" {
return false
}
parts, ok := item["content"].([]any)
if !ok {
return false
}
modified := false
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
continue
}
text, ok := part["text"].(string)
if !ok {
continue
}
if truncated, changed := truncateOpenAIResponsesInputString(text); changed {
part["text"] = truncated
modified = true
}
}
return modified
}
func truncateOpenAIResponsesInputString(value string) (string, bool) {
if len(value) <= openAIResponsesInputTextMaxChars {
return value, false
}
chars := 0
for index := range value {
if chars == openAIResponsesInputTextMaxChars {
return value[:index], true
}
chars++
}
return value, false
}
@@ -0,0 +1,159 @@
package service
import (
"context"
"net/http"
"strings"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) {
t.Run("preserves matches regardless of item order", func(t *testing.T) {
input := []any{
map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"},
map[string]any{"type": "tool_search_call", "id": "search_1", "query": "docs"},
map[string]any{"type": "custom_tool_call_output", "call_id": "custom_1", "output": "second"},
map[string]any{"type": "item_reference", "id": "custom_1"},
}
reqBody := map[string]any{"input": input}
require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
require.Equal(t, input, reqBody["input"])
})
t.Run("outputs do not legitimize each other", func(t *testing.T) {
input := []any{
map[string]any{"type": "function_call_output", "call_id": "missing", "output": "one"},
map[string]any{"type": "tool_search_output", "call_id": "missing", "output": "two"},
map[string]any{"type": "custom_tool_call_output", "call_id": "missing", "output": "three"},
map[string]any{"type": "mcp_tool_call_output", "call_id": "missing", "output": "four"},
}
reqBody := map[string]any{"input": input}
require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
got := reqBody["input"].([]any)
require.Empty(t, got)
})
t.Run("preserves all output variants with matching calls", func(t *testing.T) {
pairs := []struct {
callType string
outputType string
}{
{callType: "function_call", outputType: "function_call_output"},
{callType: "tool_search_call", outputType: "tool_search_output"},
{callType: "custom_tool_call", outputType: "custom_tool_call_output"},
{callType: "mcp_tool_call", outputType: "mcp_tool_call_output"},
}
input := make([]any, 0, len(pairs)*2)
for index, pair := range pairs {
callID := string(rune('a' + index))
input = append(input,
map[string]any{"type": pair.callType, "call_id": callID},
map[string]any{"type": pair.outputType, "call_id": callID, "output": "ok"},
)
}
reqBody := map[string]any{"input": input}
require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
})
t.Run("previous response may contain the missing call", func(t *testing.T) {
input := []any{map[string]any{"type": "function_call_output", "call_id": "remote", "output": "ok"}}
reqBody := map[string]any{"input": input, "previous_response_id": "resp_1"}
require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, true))
require.Equal(t, input, reqBody["input"])
})
}
func TestTruncateOpenAIResponsesInputText(t *testing.T) {
oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars+1)
input := []any{
map[string]any{"type": "function_call_output", "call_id": "a", "output": oversized},
map[string]any{"type": "tool_search_output", "call_id": "b", "output": oversized},
map[string]any{"type": "custom_tool_call_output", "call_id": "c", "output": oversized},
map[string]any{"type": "mcp_tool_call_output", "call_id": "d", "output": oversized},
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "input_text", "text": "short"},
map[string]any{"type": "input_text", "text": oversized},
},
},
}
reqBody := map[string]any{"input": input}
require.True(t, truncateOpenAIResponsesInputText(reqBody))
for _, rawItem := range input[:4] {
item := rawItem.(map[string]any)
require.Len(t, item["output"].(string), openAIResponsesInputTextMaxChars)
}
content := input[4].(map[string]any)["content"].([]any)
require.Equal(t, "short", content[0].(map[string]any)["text"])
require.Len(t, content[1].(map[string]any)["text"].(string), openAIResponsesInputTextMaxChars)
}
func TestTruncateOpenAIResponsesInputStringPreservesUTF8Boundary(t *testing.T) {
value := strings.Repeat("a", openAIResponsesInputTextMaxChars-1) + "中中"
got, changed := truncateOpenAIResponsesInputString(value)
require.True(t, changed)
require.True(t, utf8.ValidString(got))
require.Equal(t, openAIResponsesInputTextMaxChars, utf8.RuneCountInString(got))
require.Equal(t, "中", got[len(got)-len("中"):])
}
func TestOpenAIResponsesInputMayNeedTruncation(t *testing.T) {
short := []byte(`{"input":[{"type":"function_call_output","output":"ok"}]}`)
largeUnrelated := []byte(`{"input":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}`)
largeOutput := []byte(`{"input":[{"type":"function_call_output","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
require.False(t, openAIResponsesInputMayNeedTruncation(short))
require.False(t, openAIResponsesInputMayNeedTruncation(largeUnrelated))
require.True(t, openAIResponsesInputMayNeedTruncation(largeOutput))
}
func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"previous_response_id":"resp_missing","input":[{"type":"function_call_output","call_id":"call_missing","output":"keep this result"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(),
newOpenAIRejectedFieldTestContext(body),
newOpenAIOAuthNamespaceTestAccount(),
body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1)
require.False(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists())
require.Empty(t, gjson.GetBytes(upstream.bodies[0], "input").Array())
}
func TestOpenAIGatewayService_TruncatesOversizedToolOutputBeforeForward(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(),
newOpenAIRejectedFieldTestContext(body),
newOpenAIRejectedFieldTestAccount(),
body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1)
require.Equal(t, openAIResponsesInputTextMaxChars, len(gjson.GetBytes(upstream.bodies[0], "input.1.output").String()))
}
@@ -8,22 +8,46 @@ import (
"github.com/tidwall/sjson"
)
func openAIResponsesInputItemIDPrefix(itemType string) (string, bool) {
switch strings.TrimSpace(itemType) {
case "message":
return "msg", true
case "reasoning":
return "rs", true
case "custom_tool_call":
return openAIResponsesToolCallIDPrefix(itemType), true
case "tool_search_call":
return openAIResponsesToolCallIDPrefix(itemType), true
default:
if isCodexToolCallInputType(itemType) {
return openAIResponsesToolCallIDPrefix(itemType), true
}
return "", false
}
}
func openAIResponsesToolCallIDPrefix(itemType string) string {
switch strings.TrimSpace(itemType) {
case "custom_tool_call", "custom_tool_call_output":
return "ctc"
case "tool_search_call", "tool_search_output":
return "tsc"
default:
return "fc"
}
}
// Invalid replayed IDs are removed rather than rewritten because a fabricated
// msg/fc ID may point at a different upstream object.
// ID may point at a different upstream object.
func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool {
if id == "" {
return false
}
if itemType == "message" {
return !strings.HasPrefix(id, "msg")
prefix, constrained := openAIResponsesInputItemIDPrefix(itemType)
if !constrained {
return false
}
if itemType == "reasoning" {
return !strings.HasPrefix(id, "rs")
}
if isCodexToolCallInputType(itemType) {
return !strings.HasPrefix(id, "fc")
}
return false
return !strings.HasPrefix(id, prefix)
}
func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) {
@@ -15,8 +15,13 @@ import (
const maxOpenAIResponsesRejectedFieldRetries = 6
var (
openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`)
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.namespace)(?:["']|\b)`)
openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`)
openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`)
openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`)
openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`)
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`)
openAIResponsesCacheModelRejectionPattern = regexp.MustCompile(`(?i)["']?(prompt_cache_breakpoint|input\[\d+\]\.prompt_cache_breakpoint)["']?\s+is\s+not\s+supported\s+on\s+this\s+model\b`)
)
type openAIResponsesRejectedFieldRetryState struct {
@@ -62,23 +67,53 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody)))
message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
if !isExplicitOpenAIResponsesFieldRejection(code, message) {
return nil, "", false, nil
param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
cacheMessageParam := openAIResponsesCacheModelRejectionParamFromMessage(message)
cacheParam := param
if cacheParam == "" {
cacheParam = cacheMessageParam
}
cacheParamMatchesMessage := cacheMessageParam == "" || cacheParam == cacheMessageParam
cacheModelRejection := code == "invalid_parameter" || cacheMessageParam != ""
if cacheParam != "" && cacheParamMatchesMessage && cacheModelRejection {
if cacheParam == "prompt_cache_breakpoint" && gjson.GetBytes(body, cacheParam).Exists() {
retryBody, err := sjson.DeleteBytes(body, cacheParam)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint: %w", err)
}
return retryBody, "prompt_cache_breakpoint parameter rejection", true, nil
}
if index, ok := openAIResponsesRejectedCacheIndex(cacheParam); ok {
return removeOpenAIResponsesRejectedCacheAtIndex(body, index)
}
}
if isExplicitOpenAIResponsesFieldRejection(code, message) {
if param == "" {
param = openAIResponsesRejectedParamFromMessage(message)
}
if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
}
if index, ok := openAIResponsesRejectedStatusIndex(param); ok {
return removeOpenAIResponsesRejectedStatusAtIndex(body, index)
}
if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() {
retryBody, err := sjson.DeleteBytes(body, "max_output_tokens")
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err)
}
return retryBody, "max_output_tokens parameter rejection", true, nil
}
}
param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
if param == "" {
param = openAIResponsesRejectedParamFromMessage(message)
messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message)
contentParam := param
if contentParam == "" {
contentParam = messageContentParam
}
if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
}
if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() {
retryBody, err := sjson.DeleteBytes(body, "max_output_tokens")
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err)
}
return retryBody, "max_output_tokens parameter rejection", true, nil
if index, ok := openAIResponsesRejectedContentIndex(contentParam); ok &&
contentParam == messageContentParam && isExplicitOpenAIResponsesNullContentRejection(code, message) {
return normalizeOpenAIResponsesRejectedNullContentAtIndex(body, index)
}
return nil, "", false, nil
}
@@ -100,8 +135,46 @@ func openAIResponsesRejectedParamFromMessage(message string) string {
return strings.ToLower(strings.TrimSpace(match[1]))
}
func openAIResponsesInvalidTypeParamFromMessage(message string) string {
match := openAIResponsesInvalidTypeMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
if len(match) != 2 {
return ""
}
return strings.ToLower(strings.TrimSpace(match[1]))
}
func openAIResponsesCacheModelRejectionParamFromMessage(message string) string {
match := openAIResponsesCacheModelRejectionPattern.FindStringSubmatch(strings.TrimSpace(message))
if len(match) != 2 {
return ""
}
return strings.ToLower(strings.TrimSpace(match[1]))
}
func isExplicitOpenAIResponsesNullContentRejection(code, message string) bool {
code = strings.TrimSpace(code)
return (code == "invalid_type" || code == "invalid_request_error" || code == "") &&
openAIResponsesInvalidTypeMessageParamPattern.MatchString(strings.TrimSpace(message))
}
func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) {
match := openAIResponsesRejectedNamespaceParamPattern.FindStringSubmatch(strings.TrimSpace(param))
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedNamespaceParamPattern, param)
}
func openAIResponsesRejectedStatusIndex(param string) (int, bool) {
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedStatusParamPattern, param)
}
func openAIResponsesRejectedContentIndex(param string) (int, bool) {
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedContentParamPattern, param)
}
func openAIResponsesRejectedCacheIndex(param string) (int, bool) {
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedCacheParamPattern, param)
}
func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (int, bool) {
match := pattern.FindStringSubmatch(strings.TrimSpace(param))
if len(match) != 2 {
return 0, false
}
@@ -112,6 +185,67 @@ func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) {
return 0, false
}
func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
if !gjson.GetBytes(body, itemPath).IsObject() {
return nil, "", false, nil
}
statusPath := itemPath + ".status"
if !gjson.GetBytes(body, statusPath).Exists() {
return nil, "", false, nil
}
retryBody, err := sjson.DeleteBytes(body, statusPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
}
return retryBody, "indexed status parameter rejection", true, nil
}
func removeOpenAIResponsesRejectedCacheAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
if !gjson.GetBytes(body, itemPath).IsObject() {
return nil, "", false, nil
}
cachePath := itemPath + ".prompt_cache_breakpoint"
if !gjson.GetBytes(body, cachePath).Exists() {
return nil, "", false, nil
}
retryBody, err := sjson.DeleteBytes(body, cachePath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint at input[%d]: %w", index, err)
}
return retryBody, "indexed prompt_cache_breakpoint parameter rejection", true, nil
}
func normalizeOpenAIResponsesRejectedNullContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
item := gjson.GetBytes(body, itemPath)
content := gjson.GetBytes(body, itemPath+".content")
if !item.IsObject() || !content.Exists() || content.Type != gjson.Null {
return nil, "", false, nil
}
itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
role := strings.TrimSpace(item.Get("role").String())
contentPath := itemPath + ".content"
switch {
case itemType == "reasoning":
retryBody, err := sjson.DeleteBytes(body, contentPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected null content at input[%d]: %w", index, err)
}
return retryBody, "indexed reasoning null content rejection", true, nil
case itemType == "message" || role != "":
retryBody, err := sjson.SetBytes(body, contentPath, "")
if err != nil {
return nil, "", false, fmt.Errorf("normalize rejected null content at input[%d]: %w", index, err)
}
return retryBody, "indexed message null content rejection", true, nil
default:
return nil, "", false, nil
}
}
func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String()))
@@ -114,6 +114,270 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRej
require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists())
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactIndexedStatus(t *testing.T) {
body := []byte(`{"input":[{"type":"message","status":"keep","content":"one"},{"type":"reasoning","status":"remove","summary":[]}]}`)
responses := []struct {
name string
body []byte
}{
{
name: "structured param",
body: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`),
},
{
name: "message param",
body: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: input[1].status."}}`),
},
}
for _, tt := range responses {
t.Run(tt.name, func(t *testing.T) {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.status").String())
require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
})
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyNormalizesExactNullContent(t *testing.T) {
tests := []struct {
name string
body []byte
wantChange bool
wantValue string
wantExists bool
}{
{
name: "message becomes empty string",
body: []byte(`{"input":[{"type":"message","role":"assistant","content":null}]}`),
wantChange: true,
wantValue: "",
wantExists: true,
},
{
name: "reasoning content is removed",
body: []byte(`{"input":[{"type":"reasoning","content":null,"summary":[]}]}`),
wantChange: true,
wantExists: false,
},
{
name: "unknown item is unchanged",
body: []byte(`{"input":[{"type":"future_item","content":null}]}`),
wantChange: false,
},
{
name: "non null content is unchanged",
body: []byte(`{"input":[{"type":"message","content":"keep"}]}`),
wantChange: false,
},
}
responseBody := []byte(`{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, responseBody)
require.NoError(t, err)
require.Equal(t, tt.wantChange, changed)
if !tt.wantChange {
require.Nil(t, retryBody)
return
}
content := gjson.GetBytes(retryBody, "input.0.content")
require.Equal(t, tt.wantExists, content.Exists())
if tt.wantExists {
require.Equal(t, tt.wantValue, content.String())
require.Equal(t, gjson.String, content.Type)
}
})
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeIndexedMutations(t *testing.T) {
tests := []struct {
name string
body []byte
responseBody []byte
}{
{
name: "nested status path",
body: []byte(`{"input":[{"type":"message","content":{"status":"keep"}}]}`),
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.status.","param":"input[0].content.status"}}`),
},
{
name: "status index out of bounds",
body: []byte(`{"input":[{"type":"message","status":"keep"}]}`),
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[4].status.","param":"input[4].status"}}`),
},
{
name: "status path only mentioned",
body: []byte(`{"input":[{"type":"message","status":"keep"}]}`),
responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"input[0].status must be completed","param":"input[0].status"}}`),
},
{
name: "content param and message disagree",
body: []byte(`{"input":[{"type":"message","content":null},{"type":"message","content":null}]}`),
responseBody: []byte(`{"error":{"code":"invalid_type","message":"Invalid type for input[1].content: got null instead.","param":"input[0].content"}}`),
},
{
name: "content error only mentions null",
body: []byte(`{"input":[{"type":"message","content":null}]}`),
responseBody: []byte(`{"error":{"code":"invalid_type","message":"content cannot be null","param":"input[0].content"}}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
require.NoError(t, err)
require.False(t, changed)
require.Nil(t, retryBody)
})
}
}
func TestOpenAIGatewayService_OAuthRetriesExactRejectedStatus(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"test","input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"),
}}
upstream.responses[1].Header.Set("Content-Type", "text/event-stream")
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIOAuthNamespaceTestAccount(), body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists())
}
func TestOpenAIGatewayService_APIKeyRetriesExactRejectedNullMessageContent(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"message","role":"assistant","content":null},{"type":"message","role":"user","content":"continue"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, gjson.Null, gjson.GetBytes(upstream.bodies[0], "input.0.content").Type)
require.Equal(t, gjson.String, gjson.GetBytes(upstream.bodies[1], "input.0.content").Type)
require.Equal(t, "continue", gjson.GetBytes(upstream.bodies[1], "input.1.content").String())
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesModelRejectedPromptCacheBreakpoint(t *testing.T) {
tests := []struct {
name string
body []byte
responseBody []byte
removedPath string
preserved string
reason string
}{
{
name: "top level",
body: []byte(`{"model":"gpt-5.6-sol","prompt_cache_breakpoint":{"type":"message_start"},"input":"hello"}`),
responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
removedPath: "prompt_cache_breakpoint",
preserved: "input",
reason: "prompt_cache_breakpoint parameter rejection",
},
{
name: "indexed path from message",
body: []byte(`{"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_start"}},{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`),
responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[1].prompt_cache_breakpoint is not supported on this model"}}`),
removedPath: "input.1.prompt_cache_breakpoint",
preserved: "input.0.prompt_cache_breakpoint",
reason: "indexed prompt_cache_breakpoint parameter rejection",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, tt.reason, reason)
require.False(t, gjson.GetBytes(retryBody, tt.removedPath).Exists())
require.True(t, gjson.GetBytes(retryBody, tt.preserved).Exists())
})
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousPromptCacheBreakpointErrors(t *testing.T) {
body := []byte(`{"prompt_cache_breakpoint":{"type":"message_start"},"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`)
tests := []struct {
name string
responseBody []byte
}{
{
name: "structured param disagrees",
responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
},
{
name: "index out of bounds",
responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[4].prompt_cache_breakpoint is not supported on this model","param":"input[4].prompt_cache_breakpoint"}}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.responseBody)
require.NoError(t, err)
require.False(t, changed)
require.Nil(t, retryBody)
})
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyAcceptsEitherCacheModelRejectionSignal(t *testing.T) {
tests := []struct {
name string
responseBody []byte
}{
{
name: "invalid parameter code",
responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"This optional cache hint cannot be used here","param":"prompt_cache_breakpoint"}}`),
},
{
name: "model rejection message",
responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, []byte(`{"prompt_cache_breakpoint":true,"input":"keep"}`), tt.responseBody)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(retryBody, "prompt_cache_breakpoint").Exists())
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
})
}
}
func TestOpenAIResponsesRejectedFieldRetryStateAllowsPromptCacheBreakpointVariantOnce(t *testing.T) {
body := []byte(`{"input":[{"prompt_cache_breakpoint":{"type":"message_start"}}]}`)
responseBody := []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"input[0].prompt_cache_breakpoint"}}`)
state := newOpenAIResponsesRejectedFieldRetryState(body)
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.True(t, state.Allow(retryBody))
require.False(t, state.Allow(retryBody))
}
func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
@@ -2,11 +2,103 @@ package service
import (
"bytes"
"encoding/json"
"sort"
"strings"
"github.com/tidwall/gjson"
)
func sanitizeOpenAIResponsesToolSchemaPatterns(body []byte) ([]byte, bool, error) {
if len(body) == 0 || !bytes.Contains(body, []byte(`"pattern"`)) {
return body, false, nil
}
var root any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&root); err != nil {
return nil, false, err
}
changed := false
var visitSchema func(any)
visitSchema = func(value any) {
node, ok := value.(map[string]any)
if !ok {
return
}
if pattern, ok := node["pattern"].(string); ok && hasRegexLookaround(pattern) {
delete(node, "pattern")
changed = true
}
for _, key := range []string{
"additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
"propertyNames", "unevaluatedProperties", "unevaluatedItems",
} {
visitSchema(node[key])
}
if items, ok := node["items"].(map[string]any); ok {
visitSchema(items)
} else if items, ok := node["items"].([]any); ok {
for _, child := range items {
visitSchema(child)
}
}
for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
children, _ := node[key].([]any)
for _, child := range children {
visitSchema(child)
}
}
for _, key := range []string{"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} {
children, _ := node[key].(map[string]any)
for _, child := range children {
visitSchema(child)
}
}
if dependencies, ok := node["dependencies"].(map[string]any); ok {
for _, child := range dependencies {
visitSchema(child)
}
}
}
var visitTools func(any)
visitTools = func(value any) {
switch node := value.(type) {
case map[string]any:
if parameters, ok := node["parameters"]; ok {
visitSchema(parameters)
}
if function, ok := node["function"]; ok {
visitTools(function)
}
if tools, ok := node["tools"]; ok {
visitTools(tools)
}
case []any:
for _, child := range node {
visitTools(child)
}
}
}
if document, ok := root.(map[string]any); ok {
visitTools(document["tools"])
visitTools(document["input"])
}
if !changed {
return body, false, nil
}
sanitized, err := json.Marshal(root)
if err != nil {
return nil, false, err
}
return sanitized, true, nil
}
func hasRegexLookaround(pattern string) bool {
return strings.Contains(pattern, "(?=") || strings.Contains(pattern, "(?!") ||
strings.Contains(pattern, "(?<=") || strings.Contains(pattern, "(?<!")
}
const (
// 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体
// 造成无界递归。
@@ -201,6 +201,38 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotMutateInputBody(t *tes
require.NotEqual(t, string(original), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_RemovesLookaroundOnly(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","name":"search","parameters":{"type":"object","properties":{"q":{"type":"string","pattern":"^(?=.*foo)[a-z]+$"},"id":{"type":"string","pattern":"^[a-z]+$"},"z":{"type":"string","pattern":"(?<!bad)ok"}}}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties.q.pattern").Exists())
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties.z.pattern").Exists())
require.Equal(t, "^[a-z]+$", gjson.GetBytes(sanitized, "tools.0.parameters.properties.id.pattern").String())
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTouchUserInputPattern(t *testing.T) {
body := []byte(`{"input":{"pattern":"(?=keep)"},"metadata":{"pattern":"(?!keep)"}}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTraverseInstanceData(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","parameters":{"type":"object","properties":{"config":{"type":"object","pattern":"(?=remove)","default":{"pattern":"(?=keep-default)"},"examples":[{"pattern":"(?=keep-example)"}],"const":{"pattern":"(?=keep-const)"},"enum":[{"pattern":"(?=keep-enum)"}]}}}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.pattern").Exists())
require.Equal(t, "(?=keep-default)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.default.pattern").String())
require.Equal(t, "(?=keep-example)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.examples.0.pattern").String())
require.Equal(t, "(?=keep-const)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.const.pattern").String())
require.Equal(t, "(?=keep-enum)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.enum.0.pattern").String())
}
func buildToolSchemaNullTypeBody(t *testing.T, hits int) []byte {
t.Helper()
tools := make([]any, 0, hits)
@@ -220,6 +220,21 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
normalized = capped
}
}
if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account); compatibilityErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr)
} else if compatibilityChanged {
normalized = compatibilityBody
}
if account.IsOpenAIOAuth() && !forceHTTPBridge {
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(normalized)
if aliasErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr)
}
setCodexToolNameReverse(c, reverse)
if aliased {
normalized = aliasedBody
}
}
originalModel := strings.TrimSpace(values[1].String())
modelMissing := originalModel == ""
@@ -411,6 +426,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
writeClientMessage := func(message []byte) error {
writeCtx, cancel := newOpenAIWSDownstreamWriteContext(ctx, hooks, s.openAIWSWriteTimeout())
defer cancel()
message = restoreCodexToolNamesFromContext(c, message)
return clientConn.Write(writeCtx, coderws.MessageText, message)
}
@@ -572,6 +572,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
message = corrected
}
}
message = restoreCodexToolNamesFromContext(c, message)
}
if openAIWSEventShouldParseUsage(eventType) {
parseOpenAIWSResponseUsageFromCompletedEvent(message, usage)
@@ -28,6 +28,7 @@ type openAIWSClientFrameConn struct {
// The relay observes upstream payloads, while clients must keep seeing the
// model identifier they supplied for the current turn.
restoreResponseModel func([]byte) []byte
restoreToolNames func([]byte) []byte
}
// openAIWSPolicyEnforcingFrameConn wraps a client-side FrameConn and runs
@@ -639,6 +640,9 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw
if c.restoreResponseModel != nil {
payload = c.restoreResponseModel(payload)
}
if c.restoreToolNames != nil {
payload = c.restoreToolNames(payload)
}
}
return c.conn.Write(ctx, msgType, payload)
}
@@ -729,6 +733,21 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) {
firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel)
}
if account.IsOpenAIOAuth() {
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(firstClientMessage)
if aliasErr != nil {
return aliasErr
}
setCodexToolNameReverse(c, reverse)
if aliased {
firstClientMessage = aliasedBody
}
}
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account); normalizeErr != nil {
return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr)
} else if compatibilityChanged {
firstClientMessage = normalized
}
usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage)
updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage)
if policyErr != nil {
@@ -929,6 +948,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
requestModel, upstreamModel := usageMeta.turnModels("")
return replaceOpenAIWSMessageModel(payload, upstreamModel, requestModel)
},
restoreToolNames: func(payload []byte) []byte {
return restoreCodexToolNamesFromContext(c, payload)
},
}
policyClientConn := &openAIWSPolicyEnforcingFrameConn{
inner: clientFrameConn,
@@ -957,6 +979,21 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}()
}
if isResponseCreate {
if account.IsOpenAIOAuth() {
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(payload)
if aliasErr != nil {
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr)
}
setCodexToolNameReverse(c, reverse)
if aliased {
payload = aliasedBody
}
}
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account); normalizeErr != nil {
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr)
} else if compatibilityChanged {
payload = normalized
}
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(payload) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload)
if liteErr != nil {
@@ -32,6 +32,9 @@ var defaultAllowed = map[string]struct{}{
"retry-after": {},
"location": {},
"www-authenticate": {},
// Codex uses this response header to avoid estimating reasoning tokens a
// second time when upstream usage already includes them.
"x-reasoning-included": {},
}
// hopByHopHeaders 是跳过的 hop-by-hop 头部,这些头部由 HTTP 库自动处理
@@ -38,6 +38,29 @@ func TestFilterHeadersDisabledUsesDefaultAllowlist(t *testing.T) {
}
}
func TestFilterHeadersAllowsReasoningIncludedByDefault(t *testing.T) {
src := http.Header{}
src.Set("X-Reasoning-Included", "1")
filtered := FilterHeaders(src, CompileHeaderFilter(config.ResponseHeaderConfig{}))
if got := filtered.Get("X-Reasoning-Included"); got != "1" {
t.Fatalf("expected X-Reasoning-Included passthrough, got %q", got)
}
}
func TestFilterHeadersForceRemoveOverridesReasoningIncluded(t *testing.T) {
src := http.Header{}
src.Set("X-Reasoning-Included", "1")
filtered := FilterHeaders(src, CompileHeaderFilter(config.ResponseHeaderConfig{
Enabled: true,
ForceRemove: []string{"x-reasoning-included"},
}))
if got := filtered.Get("X-Reasoning-Included"); got != "" {
t.Fatalf("expected X-Reasoning-Included removal, got %q", got)
}
}
func TestFilterHeadersEnabledUsesAllowlist(t *testing.T) {
src := http.Header{}
src.Add("Content-Type", "application/json")