354 lines
14 KiB
Go
354 lines
14 KiB
Go
package handler
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"net/http"
|
|||
|
|
"net/http/httptest"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
"github.com/tidwall/gjson"
|
|||
|
|
"go.uber.org/zap"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func newCompactBodySignalTestContext(t *testing.T, path string, body []byte) *gin.Context {
|
|||
|
|
t.Helper()
|
|||
|
|
gin.SetMode(gin.TestMode)
|
|||
|
|
rec := httptest.NewRecorder()
|
|||
|
|
c, _ := gin.CreateTestContext(rec)
|
|||
|
|
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
|||
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2StaysOnResponses(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
betaHeader string
|
|||
|
|
userAgent string
|
|||
|
|
}{
|
|||
|
|
{name: "headerless"},
|
|||
|
|
{name: "unrelated_header", betaHeader: "responses_websockets_v2"},
|
|||
|
|
{name: "wrong_case_header", betaHeader: "REMOTE_COMPACTION_V2"},
|
|||
|
|
{name: "declared_header", betaHeader: "remote_compaction_v2"},
|
|||
|
|
{name: "codex_cli_user_agent", userAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color"},
|
|||
|
|
{name: "codex_desktop_user_agent", userAgent: "Codex Desktop/0.139.0 (Mac OS X 14; arm64) unknown"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
body := []byte(`{
|
|||
|
|
"model":"gpt-5.6-sol",
|
|||
|
|
"stream":true,
|
|||
|
|
"store":true,
|
|||
|
|
"prompt_cache_key":"pck-signal-1",
|
|||
|
|
"reasoning":{"effort":"max","context":"all_turns"},
|
|||
|
|
"input":[
|
|||
|
|
{"type":"message","role":"user","content":"hello"},
|
|||
|
|
{"type":"compaction_trigger"}
|
|||
|
|
]
|
|||
|
|
}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses", body)
|
|||
|
|
if tt.betaHeader != "" {
|
|||
|
|
c.Request.Header.Set("x-codex-beta-features", tt.betaHeader)
|
|||
|
|
}
|
|||
|
|
if tt.userAgent != "" {
|
|||
|
|
c.Request.Header.Set("User-Agent", tt.userAgent)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
|
|||
|
|
require.Equal(t, "/v1/responses", c.Request.URL.Path)
|
|||
|
|
require.False(t, isOpenAILegacyCompactPath(c))
|
|||
|
|
require.Equal(t, body, normalized)
|
|||
|
|
require.True(t, gjson.GetBytes(normalized, "stream").Bool())
|
|||
|
|
require.True(t, gjson.GetBytes(normalized, "store").Bool())
|
|||
|
|
require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String())
|
|||
|
|
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
|
|||
|
|
require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String())
|
|||
|
|
legacyCompact := service.IsOpenAIResponsesCompactPath(c)
|
|||
|
|
nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
|
|||
|
|
require.False(t, legacyCompact)
|
|||
|
|
require.True(t, nativeV2)
|
|||
|
|
require.Equal(t, service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI))
|
|||
|
|
|
|||
|
|
reqStream, streamOK := parseOpenAICompatibleStream(normalized)
|
|||
|
|
require.True(t, streamOK)
|
|||
|
|
require.True(t, reqStream)
|
|||
|
|
|
|||
|
|
_, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest())
|
|||
|
|
require.False(t, seedExists)
|
|||
|
|
_, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest())
|
|||
|
|
require.False(t, streamMarkerExists)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2PathAliasesStayOnResponses(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`)
|
|||
|
|
for _, path := range []string{
|
|||
|
|
"/v1/responses/",
|
|||
|
|
"/openai/v1/responses",
|
|||
|
|
"/responses",
|
|||
|
|
"/backend-api/codex/responses",
|
|||
|
|
} {
|
|||
|
|
t.Run(path, func(t *testing.T) {
|
|||
|
|
c := newCompactBodySignalTestContext(t, path, body)
|
|||
|
|
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, path, c.Request.URL.Path)
|
|||
|
|
require.Equal(t, body, normalized)
|
|||
|
|
legacyCompact := service.IsOpenAIResponsesCompactPath(c)
|
|||
|
|
nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
|
|||
|
|
require.False(t, legacyCompact)
|
|||
|
|
require.True(t, nativeV2)
|
|||
|
|
require.Equal(t, service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI))
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestOpenAIResponsesCompactionRoutingFlags(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
body []byte
|
|||
|
|
path string
|
|||
|
|
wantLegacyBefore bool
|
|||
|
|
wantNativeBefore bool
|
|||
|
|
wantLegacyAfter bool
|
|||
|
|
wantNativeAfter bool
|
|||
|
|
wantCapabilityAfter service.OpenAIEndpointCapability
|
|||
|
|
wantPathAfter string
|
|||
|
|
wantBodyUnchanged bool
|
|||
|
|
}{
|
|||
|
|
{
|
|||
|
|
name: "native_v2_stream_trigger",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
path: "/v1/responses",
|
|||
|
|
wantLegacyBefore: false,
|
|||
|
|
wantNativeBefore: true,
|
|||
|
|
wantLegacyAfter: false,
|
|||
|
|
wantNativeAfter: true,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
wantPathAfter: "/v1/responses",
|
|||
|
|
wantBodyUnchanged: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "native_v2_without_trigger",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`),
|
|||
|
|
path: "/v1/responses",
|
|||
|
|
wantLegacyBefore: false,
|
|||
|
|
wantNativeBefore: false,
|
|||
|
|
wantLegacyAfter: false,
|
|||
|
|
wantNativeAfter: false,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions,
|
|||
|
|
wantPathAfter: "/v1/responses",
|
|||
|
|
wantBodyUnchanged: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "explicit_compact",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
path: "/v1/responses/compact",
|
|||
|
|
wantLegacyBefore: true,
|
|||
|
|
wantNativeBefore: false,
|
|||
|
|
wantLegacyAfter: true,
|
|||
|
|
wantNativeAfter: false,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
wantPathAfter: "/v1/responses/compact",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "nested_compact",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
path: "/v1/responses/compact/detail",
|
|||
|
|
wantLegacyBefore: true,
|
|||
|
|
wantNativeBefore: false,
|
|||
|
|
wantLegacyAfter: true,
|
|||
|
|
wantNativeAfter: false,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
wantPathAfter: "/v1/responses/compact/detail",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "responses_subpath_with_native_signal",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
path: "/v1/responses/resp_123/responses",
|
|||
|
|
wantLegacyBefore: false,
|
|||
|
|
wantNativeBefore: false,
|
|||
|
|
wantLegacyAfter: false,
|
|||
|
|
wantNativeAfter: false,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions,
|
|||
|
|
wantPathAfter: "/v1/responses/resp_123/responses",
|
|||
|
|
wantBodyUnchanged: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "stream_false_promotes",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
path: "/v1/responses",
|
|||
|
|
wantLegacyBefore: false,
|
|||
|
|
wantNativeBefore: false,
|
|||
|
|
wantLegacyAfter: true,
|
|||
|
|
wantNativeAfter: false,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
wantPathAfter: "/v1/responses/compact",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "stream_absent_promotes",
|
|||
|
|
body: []byte(`{"model":"gpt-5.6-sol","input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
path: "/v1/responses",
|
|||
|
|
wantLegacyBefore: false,
|
|||
|
|
wantNativeBefore: false,
|
|||
|
|
wantLegacyAfter: true,
|
|||
|
|
wantNativeAfter: false,
|
|||
|
|
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
|||
|
|
wantPathAfter: "/v1/responses/compact",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
c := newCompactBodySignalTestContext(t, tt.path, tt.body)
|
|||
|
|
legacyBefore := service.IsOpenAIResponsesCompactPath(c)
|
|||
|
|
nativeBefore := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(tt.body)
|
|||
|
|
require.Equal(t, tt.wantLegacyBefore, legacyBefore)
|
|||
|
|
require.Equal(t, tt.wantNativeBefore, nativeBefore)
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, tt.wantPathAfter, c.Request.URL.Path)
|
|||
|
|
legacyAfter := service.IsOpenAIResponsesCompactPath(c)
|
|||
|
|
nativeAfter := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
|
|||
|
|
require.Equal(t, tt.wantLegacyAfter, legacyAfter)
|
|||
|
|
require.Equal(t, tt.wantNativeAfter, nativeAfter)
|
|||
|
|
require.Equal(t, tt.wantCapabilityAfter,
|
|||
|
|
openAIResponsesRequiredCapabilityForRequest(false, nativeAfter || legacyAfter, service.PlatformOpenAI))
|
|||
|
|
if tt.wantBodyUnchanged {
|
|||
|
|
require.Equal(t, tt.body, normalized)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_BodySignalTrailingSlashPromoted(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses/", body)
|
|||
|
|
|
|||
|
|
_, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, "/v1/responses/compact", c.Request.URL.Path)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_CodexDirectAliasPromoted(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/backend-api/codex/responses", body)
|
|||
|
|
|
|||
|
|
_, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, "/backend-api/codex/responses/compact", c.Request.URL.Path)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_NonRemoteV2BodySignalPromoted(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
body []byte
|
|||
|
|
betaHeader string
|
|||
|
|
wantMarked bool
|
|||
|
|
}{
|
|||
|
|
{
|
|||
|
|
name: "stream_false_headerless",
|
|||
|
|
body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "stream_absent_headerless",
|
|||
|
|
body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "stream_false_declared_header",
|
|||
|
|
body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
betaHeader: "remote_compaction_v2",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "stream_absent_wrong_case_header",
|
|||
|
|
body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
|
|||
|
|
betaHeader: "REMOTE_COMPACTION_V2",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses", tt.body)
|
|||
|
|
if tt.betaHeader != "" {
|
|||
|
|
c.Request.Header.Set("x-codex-beta-features", tt.betaHeader)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, "/v1/responses/compact", c.Request.URL.Path)
|
|||
|
|
require.False(t, gjson.GetBytes(normalized, "stream").Exists())
|
|||
|
|
|
|||
|
|
marked, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
|
|||
|
|
require.Equal(t, tt.wantMarked, exists)
|
|||
|
|
if tt.wantMarked {
|
|||
|
|
require.Equal(t, true, marked)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_NoTriggerUntouched(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses", body)
|
|||
|
|
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, "/v1/responses", c.Request.URL.Path)
|
|||
|
|
require.False(t, isOpenAILegacyCompactPath(c))
|
|||
|
|
require.Equal(t, body, normalized)
|
|||
|
|
require.True(t, gjson.GetBytes(normalized, "stream").Bool())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_PathBasedNoDoubleSuffix(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.5","stream":true,"store":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body)
|
|||
|
|
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, "/v1/responses/compact", c.Request.URL.Path)
|
|||
|
|
require.False(t, gjson.GetBytes(normalized, "stream").Exists())
|
|||
|
|
require.False(t, gjson.GetBytes(normalized, "store").Exists())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_SubpathNotPromoted(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses/resp_123/cancel", body)
|
|||
|
|
|
|||
|
|
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, "/v1/responses/resp_123/cancel", c.Request.URL.Path)
|
|||
|
|
require.Equal(t, body, normalized)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// path-based compact(Codex v1 unary 协议)即使 body 带 stream:true 也不标记,
|
|||
|
|
// 保持 JSON 写回行为不变。
|
|||
|
|
func TestNormalizeOpenAIResponsesCompactRequest_PathBasedStreamTrueNotMarked(t *testing.T) {
|
|||
|
|
h := &OpenAIGatewayHandler{}
|
|||
|
|
body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
|
|||
|
|
c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body)
|
|||
|
|
|
|||
|
|
_, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
_, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
|
|||
|
|
require.False(t, exists)
|
|||
|
|
}
|