Merge pull request #4804 from scp-planet/fix/openai-strip-input-namespace

fix(openai): strip input item namespaces before HTTP forwarding / HTTP 转发前移除 input 项 namespace
This commit is contained in:
Wesley Liddick
2026-07-25 13:42:17 +08:00
committed by GitHub
6 changed files with 225 additions and 17 deletions
@@ -72,6 +72,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return nil, err
}
}
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
body, err = stripOpenAIResponsesInputNamespaces(body)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": err.Error(), "param": "input",
}})
return nil, err
}
}
originalBody := body
requestView := newOpenAIRequestView(body)
@@ -450,7 +450,10 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]}
],
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"},
"input":[{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}]
"input":[
{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"},
{"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]}
]
}`)
upstreamSSE := strings.Join([]string{
@@ -488,6 +491,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").Exists())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists())
require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String())
require.Len(t, upstream.bodies, 1)
downstream := rec.Body.String()
require.NotContains(t, downstream, "collaboration__spawn_agent")
@@ -7,6 +7,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
@@ -26,6 +28,19 @@ func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUp
return true
}
// shouldStripOpenAIResponsesInputNamespaces removes residual input item
// namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps
// namespaces because that protocol supports them and does not restore payloads.
func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) {
return false
}
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
return false
}
return true
}
func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
@@ -49,6 +64,55 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro
return rebuilt, nil
}
// stripOpenAIResponsesInputNamespaces removes namespace only from direct input
// array items. Namespace declarations and nested namespace fields are left
// untouched. Rebuilding the input array once keeps this linear for long
// histories and avoids decoding JSON numbers through float64.
func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, nil
}
var rebuilt bytes.Buffer
rebuilt.Grow(len(input.Raw))
_ = rebuilt.WriteByte('[')
changed := false
first := true
var stripErr error
input.ForEach(func(_, item gjson.Result) bool {
if !first {
_ = rebuilt.WriteByte(',')
}
first = false
itemBody := []byte(item.Raw)
if item.IsObject() && item.Get("namespace").Exists() {
itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
if stripErr != nil {
return false
}
changed = true
}
_, _ = rebuilt.Write(itemBody)
return true
})
if stripErr != nil {
return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
}
if !changed {
return body, nil
}
_ = rebuilt.WriteByte(']')
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
if err != nil {
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
}
return stripped, nil
}
func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat.ResponsesNamespaceName) {
if c != nil && len(names) > 0 {
c.Set(openAIResponsesNamespaceNamesContextKey, names)
@@ -1,9 +1,11 @@
package service
import (
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
@@ -34,3 +36,78 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
})
}
}
func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) {
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
tests := []struct {
name string
account *Account
transport OpenAIUpstreamTransport
passthroughEnabled bool
want bool
}{
{name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
{name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
{name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
{name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldStripOpenAIResponsesInputNamespaces(tt.account, tt.transport, tt.passthroughEnabled))
})
}
}
func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
body := []byte(`{
"meta":9007199254740993,
"scientific":1.25e+42,
"escaped":"line\\n\\u003ctag\\u003e",
"tools":[{"type":"function","name":"keep","namespace":"tool-namespace"}],
"input":[
{"type":"function_call","namespace":"n0","name":"one","content":{"namespace":"nested"},"large":9007199254740993},
{"type":"message","namespace":"n1","content":[{"type":"input_text","text":"hello","namespace":"nested-content"}]},
{"type":"custom_tool_call","namespace":"n2","input":"{}"},
{"type":"function_call_output","namespace":"n3","output":"ok"},
{"type":"item","namespace":"n4"},
{"type":"item","namespace":"n5"},
{"type":"item","namespace":"n6"},
{"type":"item","namespace":"n7"}
]
}`)
stripped, err := stripOpenAIResponsesInputNamespaces(body)
require.NoError(t, err)
for index := 0; index < 8; index++ {
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
}
require.Equal(t, "nested", gjson.GetBytes(stripped, "input.0.content.namespace").String())
require.Equal(t, "nested-content", gjson.GetBytes(stripped, "input.1.content.0.namespace").String())
require.Equal(t, "tool-namespace", gjson.GetBytes(stripped, "tools.0.namespace").String())
require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
require.Equal(t, gjson.GetBytes(body, "scientific").Raw, gjson.GetBytes(stripped, "scientific").Raw)
require.Equal(t, gjson.GetBytes(body, "escaped").Raw, gjson.GetBytes(stripped, "escaped").Raw)
require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
}
func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testing.T) {
tests := [][]byte{
[]byte(`{"input":"text","namespace":"top-level"}`),
[]byte(`{"input":{"namespace":"single-object"}}`),
[]byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
}
for _, body := range tests {
stripped, err := stripOpenAIResponsesInputNamespaces(body)
require.NoError(t, err)
require.Equal(t, body, stripped)
}
}
@@ -114,10 +114,9 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRej
require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists())
}
func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`)
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{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace","type":"invalid_request_error"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
@@ -130,9 +129,44 @@ func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.namespace").String())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
require.Len(t, upstream.bodies, 1)
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
}
func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
accounts := []struct {
name string
account *Account
}{
{name: "oauth", account: newOpenAIOAuthNamespaceTestAccount()},
{name: "apikey", account: newOpenAIRejectedFieldTestAccount()},
}
for _, tt := range accounts {
for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
t.Run(tt.name+path, func(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"test","input":[{"type":"message","role":"user","namespace":"remove","content":[{"type":"input_text","text":"hello","namespace":"nested-keep"}]}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_namespace_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
c := newOpenAIRejectedFieldTestContext(body)
c.Request.URL.Path = path
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(),
c,
tt.account,
body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1, "namespace must be removed before the first upstream request")
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
require.Equal(t, "nested-keep", gjson.GetBytes(upstream.bodies[0], "input.0.content.0.namespace").String())
})
}
}
}
func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) {
@@ -157,10 +191,9 @@ func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String())
}
func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`)
func TestOpenAIGatewayService_ComposesProactiveNamespaceStripWithRejectedFieldRetry(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"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{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
@@ -174,12 +207,13 @@ func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 3)
require.True(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Int())
require.False(t, gjson.GetBytes(upstream.bodies[2], "input.1.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[2], "max_output_tokens").Exists())
require.Len(t, upstream.bodies, 2)
for _, forwardedBody := range upstream.bodies {
require.False(t, gjson.GetBytes(forwardedBody, "input.0.namespace").Exists())
require.False(t, gjson.GetBytes(forwardedBody, "input.1.namespace").Exists())
}
require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
}
func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
@@ -221,6 +255,22 @@ func newOpenAIRejectedFieldTestAccount() *Account {
}
}
func newOpenAIOAuthNamespaceTestAccount() *Account {
return &Account{
ID: 5108,
Name: "openai-oauth-namespace",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
},
Status: StatusActive,
Schedulable: true,
}
}
func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
@@ -733,7 +733,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
},
}
body := []byte(`{"model":"gpt-5.1","stream":false,"store":true,"input":[{"type":"input_text","text":"hello"}]}`)
body := []byte(`{"model":"gpt-5.1","stream":false,"store":true,"input":[{"type":"input_text","text":"hello","namespace":"native-wsv2"}]}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
@@ -745,6 +745,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
require.False(t, gjson.Get(requestJSON, "store").Bool(), "默认策略应将 OAuth store 置为 false")
require.True(t, gjson.Get(requestJSON, "stream").Exists(), "WSv2 payload 应保留 stream 字段")
require.True(t, gjson.Get(requestJSON, "stream").Bool(), "OAuth Codex 规范化后应强制 stream=true")
require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace")
require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta"))
require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features"))
// OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离,