diff --git a/backend/internal/service/openai_compact_body_signal.go b/backend/internal/service/openai_compact_body_signal.go new file mode 100644 index 0000000000..fa0e44e5bf --- /dev/null +++ b/backend/internal/service/openai_compact_body_signal.go @@ -0,0 +1,30 @@ +package service + +import "github.com/tidwall/gjson" + +// hasCompactionTriggerInInput detects the Codex remote compact v2 body signal: +// an input item with type "compaction_trigger". When the client sends this +// inside a normal POST /v1/responses (instead of POST /v1/responses/compact), +// the request must still be treated as a compact request — otherwise the +// upstream path, model mapping, and body normalization are all wrong, causing +// Codex to receive a non-compact response and fail with: +// +// "remote compaction v2 expected exactly one compaction output item, got 0" +func hasCompactionTriggerInInput(body []byte) bool { + if len(body) == 0 { + return false + } + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return false + } + found := false + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "compaction_trigger" { + found = true + return false + } + return true + }) + return found +} diff --git a/backend/internal/service/openai_compact_body_signal_test.go b/backend/internal/service/openai_compact_body_signal_test.go new file mode 100644 index 0000000000..78543cb08e --- /dev/null +++ b/backend/internal/service/openai_compact_body_signal_test.go @@ -0,0 +1,56 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHasCompactionTriggerInInput_DetectsCompactSignal(t *testing.T) { + body := []byte(`{ + "model":"gpt-5.5", + "stream":true, + "input":[ + {"type":"message","role":"user","content":"hello"}, + {"type":"compaction_trigger"} + ] + }`) + require.True(t, hasCompactionTriggerInInput(body)) +} + +func TestHasCompactionTriggerInInput_NoTrigger(t *testing.T) { + body := []byte(`{ + "model":"gpt-5.5", + "input":[ + {"type":"message","role":"user","content":"hello"} + ] + }`) + require.False(t, hasCompactionTriggerInInput(body)) +} + +func TestHasCompactionTriggerInInput_EmptyInput(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":[]}`) + require.False(t, hasCompactionTriggerInInput(body)) +} + +func TestHasCompactionTriggerInInput_NoInputField(t *testing.T) { + body := []byte(`{"model":"gpt-5.5"}`) + require.False(t, hasCompactionTriggerInInput(body)) +} + +func TestHasCompactionTriggerInInput_EmptyBody(t *testing.T) { + require.False(t, hasCompactionTriggerInInput(nil)) + require.False(t, hasCompactionTriggerInInput([]byte{})) +} + +func TestHasCompactionTriggerInInput_StringInput(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":"compaction_trigger"}`) + require.False(t, hasCompactionTriggerInInput(body)) +} + +func TestHasCompactionTriggerInInput_CompactTriggerOnly(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`) + require.True(t, hasCompactionTriggerInInput(body)) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index de3192ae54..e4e1e14fe6 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2777,6 +2777,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } upstreamModel := billingModel isCompactRequest := isOpenAIResponsesCompactPath(c) + // Body-signal detection: Codex remote compact v2 can send the compact + // trigger inside a normal POST /v1/responses body (input item with + // type "compaction_trigger") instead of calling /v1/responses/compact + // directly. Detect and promote to compact request (#3777). + if !isCompactRequest && hasCompactionTriggerInInput(body) { + isCompactRequest = true + // Rewrite the request path so downstream URL builders + // (appendOpenAIResponsesRequestPathSuffix) route to /responses/compact. + if c != nil && c.Request != nil && c.Request.URL != nil { + c.Request.URL.Path = strings.TrimRight(c.Request.URL.Path, "/") + "/compact" + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact body-signal detected: input contains compaction_trigger (account: %s)", account.Name) + } compactMapped := false if isCompactRequest { compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)