diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index af4eae36ea6d..4949892288bb 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -39,6 +39,28 @@ var cursorResponsesUnsupportedFields = []string{ "stream_options", } +// validateChatCompletionsTokenLimits rejects negative output token limits at +// the Chat Completions ingress. The upstream-facing conversion only forwards +// positive limits, so without this check a negative max_tokens is silently +// dropped instead of returning the 400 that OpenAI clients expect. +// +// max_completion_tokens takes precedence over max_tokens, matching the +// conversion path and the OpenAI API. +func validateChatCompletionsTokenLimits(body []byte) error { + maxCompletionTokens := gjson.GetBytes(body, "max_completion_tokens") + if maxCompletionTokens.Exists() && maxCompletionTokens.Type != gjson.Null { + if maxCompletionTokens.Type == gjson.Number && maxCompletionTokens.Float() < 0 { + return fmt.Errorf("max_completion_tokens must be greater than or equal to 0") + } + return nil + } + maxTokens := gjson.GetBytes(body, "max_tokens") + if maxTokens.Exists() && maxTokens.Type == gjson.Number && maxTokens.Float() < 0 { + return fmt.Errorf("max_tokens must be greater than or equal to 0") + } + return nil +} + // ForwardAsChatCompletions accepts a Chat Completions request body, converts it // to OpenAI Responses API format, forwards to the OpenAI upstream, and converts // the response back to Chat Completions format. @@ -76,6 +98,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( rememberOpenCodeInboundBody(c, body) beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) + if err := validateChatCompletionsTokenLimits(body); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") } diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 723f60ebcfe6..e6f1dce14e83 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -742,6 +742,97 @@ func TestForwardAsChatCompletions_UnknownModelWithoutMessagesDispatchKeepsReques require.Equal(t, http.StatusBadRequest, rec.Code) } +func TestValidateChatCompletionsTokenLimits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantErr string + }{ + { + name: "no limits", + body: `{"model":"gpt-5.4","messages":[{"role":"user","content":"hi"}]}`, + }, + { + name: "negative max_tokens", + body: `{"model":"gpt-5.4","max_tokens":-1,"messages":[{"role":"user","content":"hi"}]}`, + wantErr: "max_tokens must be greater than or equal to 0", + }, + { + name: "negative max_completion_tokens", + body: `{"model":"gpt-5.4","max_completion_tokens":-1,"messages":[{"role":"user","content":"hi"}]}`, + wantErr: "max_completion_tokens must be greater than or equal to 0", + }, + { + name: "max_completion_tokens takes precedence over max_tokens", + body: `{"model":"gpt-5.4","max_tokens":-1,"max_completion_tokens":64,"messages":[{"role":"user","content":"hi"}]}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateChatCompletionsTokenLimits([]byte(tc.body)) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, tc.wantErr) + }) + } +} + +func TestForwardAsChatCompletions_RejectsNegativeTokenLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + wantMessage string + }{ + { + name: "negative max_tokens", + body: `{"model":"gpt-5.4","max_tokens":-1,"messages":[{"role":"user","content":"hi"}],"stream":false}`, + wantMessage: "max_tokens must be greater than or equal to 0", + }, + { + name: "negative max_completion_tokens", + body: `{"model":"gpt-5.4","max_completion_tokens":-1,"messages":[{"role":"user","content":"hi"}],"stream":false}`, + wantMessage: "max_completion_tokens must be greater than or equal to 0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte(tc.body))) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"unexpected upstream call"}}`)), + }, + } + svc := &OpenAIGatewayService{ + cfg: structuredInputRetryTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, structuredInputRetryTestAccount(), []byte(tc.body), "", "") + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), tc.wantMessage) + require.Empty(t, upstream.requests) + }) + } +} + func TestForwardAsChatCompletions_APIKeyPropagatesPromptCacheKeyInResponsesBody(t *testing.T) { gin.SetMode(gin.TestMode)