fix(openai): strip residual input namespaces before HTTP forwarding
This commit is contained in:
@@ -71,6 +71,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}})
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
@@ -49,6 +51,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,48 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,32 @@ func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T)
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
|
||||
for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
|
||||
t.Run(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,
|
||||
newOpenAIOAuthNamespaceTestAccount(),
|
||||
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) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":4096,"input":[{"type":"message","role":"user","content":{"max_output_tokens":"keep"}}]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
@@ -221,6 +247,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 隔离,
|
||||
|
||||
Reference in New Issue
Block a user