fix(compact): 识别 /v1/responses body 中的 compaction_trigger 信号

Codex remote compact v2 可以把 compact 触发器作为 input item
(type=compaction_trigger)嵌入普通 POST /v1/responses 请求体,
而非直接调用 /v1/responses/compact。此前 isCompactRequest 仅检查
URL path 后缀,导致 body-signal 形式的 compact 请求被当作普通
Responses 处理——上游路径、模型映射、body 归一化全部错误,Codex
收到非 compact 响应后报 "expected exactly one compaction output
item, got 0",长会话无法继续。

新增 hasCompactionTriggerInInput 检测 input 中的 compaction_trigger
item,命中后提升为 compact 请求并改写 URL path,使后续所有
isCompactRequest 分支(模型映射、body 归一化、上游 URL 构建)
自动生效。

Fixes #3777 (part 1: body-signal routing)
This commit is contained in:
li
2026-07-07 21:23:02 +08:00
parent f68f3b86c6
commit 2dd2be9922
3 changed files with 99 additions and 0 deletions
@@ -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
}
@@ -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))
}
@@ -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)