fix(openai): enforce Responses Lite tool call mode

This commit is contained in:
Jiao Ziang
2026-08-25 00:21:29 +08:00
parent e2d9b823f6
commit 53d76ad800
6 changed files with 162 additions and 16 deletions
@@ -76,8 +76,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
body = reasoningBody
}
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader))
if responsesLite {
liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account)
if liteErr != nil {
param := "tools"
var validationErr *openAIResponsesLiteValidationError
@@ -101,8 +101,17 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
}
func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) {
parallel := reqBody["parallel_tool_calls"]
parallel, exists := reqBody["parallel_tool_calls"]
if exists {
if _, ok := parallel.(bool); !ok {
return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
}
}
if !openAIResponsesLiteHasTools(reqBody) {
if exists {
delete(reqBody, "parallel_tool_calls")
return true, nil
}
return changed, nil
}
if parallel == false {
@@ -267,3 +276,29 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error)
}
return rebuilt, true, nil
}
func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) {
var requestBody map[string]any
if err := json.Unmarshal(body, &requestBody); err != nil {
return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
}
changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false)
if err != nil || !changed {
return body, false, err
}
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return body, false, fmt.Errorf("encode responses Lite request body: %w", err)
}
return rebuilt, true, nil
}
func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) {
if account == nil || !account.IsOpenAI() {
return body, false, nil
}
if account.IsOpenAIOAuthLike() {
return normalizeOpenAIResponsesLiteToolsPayload(body)
}
return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body)
}
@@ -201,17 +201,25 @@ func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testi
}
}
func TestNormalizeOpenAIResponsesLiteTools_DoesNotAddParallelToolCallsWithoutTools(t *testing.T) {
reqBody := map[string]any{
"reasoning": map[string]any{"context": "all_turns"},
"parallel_tool_calls": true,
func TestNormalizeOpenAIResponsesLiteTools_RemovesParallelToolCallsWithoutTools(t *testing.T) {
for _, value := range []bool{true, false} {
reqBody := map[string]any{
"reasoning": map[string]any{"context": "all_turns"},
"parallel_tool_calls": value,
}
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
require.NoError(t, err)
require.True(t, changed)
require.NotContains(t, reqBody, "parallel_tool_calls")
}
reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}}
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, true, reqBody["parallel_tool_calls"])
require.NotContains(t, reqBody, "parallel_tool_calls")
}
func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) {
@@ -453,3 +461,97 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
})
}
}
func TestOpenAIGatewayServiceForward_RemovesParallelToolCallsForToollessResponsesLite(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, passthrough := range []bool{false, true} {
name := "managed"
if passthrough {
name = "passthrough"
}
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
c.Request.Header.Set(responsesLiteHeader, "true")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
"data: [DONE]\n\n",
)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Extra: map[string]any{"openai_passthrough": passthrough},
}
body := []byte(`{
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
"reasoning":{"effort":"high","context":"current_turn"},
"parallel_tool_calls":true,
"input":[{"type":"message","role":"user","content":"hello"}]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists(), string(upstream.lastBody))
})
}
}
func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, passthrough := range []bool{false, true} {
name := "managed"
if passthrough {
name = "passthrough"
}
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
c.Request.Header.Set(responsesLiteHeader, "true")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
"data: [DONE]\n\n",
)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
Credentials: map[string]any{"api_key": "sk-test"},
Extra: map[string]any{"openai_passthrough": passthrough},
}
body := []byte(`{
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
"parallel_tool_calls":true,
"input":[{"type":"message","role":"user","content":"hello"}]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray())
require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
})
}
}
@@ -320,8 +320,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if accountScoped {
normalized = accountScopedPayload
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized)
if isOpenAIResponsesLiteWebSocketPayload(normalized) {
litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account)
if liteErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
coderws.StatusPolicyViolation,
@@ -1361,7 +1361,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
}()
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
"type":"response.create","model":"gpt-5.1","stream":false,
"parallel_tool_calls":true,
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]
}`))
cancelWrite()
require.NoError(t, err)
@@ -1402,6 +1407,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
}
require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游")
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) {
@@ -679,8 +679,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
if err := validateOpenAIWSBearerToken(account, token); err != nil {
return err
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) {
liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage)
if isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) {
liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(firstClientMessage, account)
if liteErr != nil {
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
}
@@ -1011,8 +1011,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
}
if isResponseCreate {
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload)
if isOpenAIResponsesLiteWebSocketPayload(payload) {
litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account)
if liteErr != nil {
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
}