From ae1903d76bf0851020e9b874eb9e44dd028e6efc Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Sat, 13 Jun 2026 23:15:55 +0800 Subject: [PATCH 1/2] feat: chat-responses-reasoning --- core/controller/relay-controller.go | 59 ++++++--- core/controller/relay-controller_test.go | 83 ++++++++++++ core/model/log.go | 13 ++ core/model/log_test.go | 27 ++++ core/relay/adaptor/openai/chat.go | 105 +++++++++++++-- core/relay/adaptor/openai/chat_test.go | 149 +++++++++++++++++++++- core/relay/controller/dohelper.go | 30 +++-- core/relay/controller/dohelper_test.go | 86 +++++++++++++ core/relay/controller/handle.go | 61 +++++++-- core/relay/model/response.go | 1 + core/relay/plugin/monitor/monitor.go | 8 +- core/relay/plugin/monitor/monitor_test.go | 20 +++ 12 files changed, 588 insertions(+), 54 deletions(-) diff --git a/core/controller/relay-controller.go b/core/controller/relay-controller.go index da18f687..dad01cd0 100644 --- a/core/controller/relay-controller.go +++ b/core/controller/relay-controller.go @@ -401,23 +401,14 @@ func recordResult( firstByteAt = result.BodyDetail.FirstByteAt } - if config.GetSaveAllLogDetail() || meta.ModelConfig.ForceSaveDetail || code != http.StatusOK { - if result.BodyDetail != nil { - requestBodyMaxSize := effectiveDetailBodyMaxSize( - meta.ModelConfig.RequestBodyStorageMaxSize, - config.GetLogDetailRequestBodyMaxSize(), - ) - responseBodyMaxSize := effectiveDetailBodyMaxSize( - meta.ModelConfig.ResponseBodyStorageMaxSize, - config.GetLogDetailResponseBodyMaxSize(), - ) - - detail = &model.RequestDetail{ - RequestBody: result.BodyDetail.RequestBody, - ResponseBody: result.BodyDetail.ResponseBody, - } - detail.ApplyBodySizeLimits(requestBodyMaxSize, responseBodyMaxSize) - } + forceSaveDetail := config.GetSaveAllLogDetail() || meta.ModelConfig.ForceSaveDetail + if forceSaveDetail || code != http.StatusOK { + detail = buildRequestDetailForLog( + result.BodyDetail, + meta.ModelConfig, + code, + forceSaveDetail, + ) } gbc := middleware.GetGroupBalanceConsumerFromContext(c) @@ -502,6 +493,40 @@ func effectiveDetailBodyMaxSize(modelLimit, globalLimit int64) int64 { return globalLimit } +func buildRequestDetailForLog( + bodyDetail *controller.BodyDetail, + modelConfig model.ModelConfig, + code int, + forceSaveDetail bool, +) *model.RequestDetail { + if bodyDetail == nil { + return nil + } + + requestBodyMaxSize := effectiveDetailBodyMaxSize( + modelConfig.RequestBodyStorageMaxSize, + config.GetLogDetailRequestBodyMaxSize(), + ) + responseBodyMaxSize := effectiveDetailBodyMaxSize( + modelConfig.ResponseBodyStorageMaxSize, + config.GetLogDetailResponseBodyMaxSize(), + ) + + detail := &model.RequestDetail{ + RequestBody: bodyDetail.RequestBody, + ResponseBody: bodyDetail.ResponseBody, + } + detail.DropInvalidUTF8Bodies() + + if controller.ShouldSkipRequestBodyDetailForStatus(code) && !forceSaveDetail { + detail.RequestBody = "" + } + + detail.ApplyBodySizeLimits(requestBodyMaxSize, responseBodyMaxSize) + + return detail +} + func buildBodyDetailOption(meta *meta.Meta) controller.BodyDetailOption { requestBodyMaxSize := effectiveDetailBodyMaxSize( meta.ModelConfig.RequestBodyStorageMaxSize, diff --git a/core/controller/relay-controller_test.go b/core/controller/relay-controller_test.go index ae115f5d..140cb50d 100644 --- a/core/controller/relay-controller_test.go +++ b/core/controller/relay-controller_test.go @@ -2,6 +2,7 @@ package controller import ( + "net/http" "reflect" "testing" "time" @@ -209,3 +210,85 @@ func TestSaveAsyncUsageInfoDoesNotStoreInitialUsage(t *testing.T) { require.Zero(t, captured.Usage.TotalTokens) require.Equal(t, "priority", captured.UsageContext.ServiceTier) } + +func TestBuildRequestDetailForLogSkipsRequestBodyForUpstreamOnlyStatuses(t *testing.T) { + t.Parallel() + + bodyDetail := &relaycontroller.BodyDetail{ + RequestBody: `{"prompt":"secret"}`, + ResponseBody: `{"error":"upstream"}`, + } + + for _, statusCode := range []int{ + http.StatusUnauthorized, + http.StatusPaymentRequired, + http.StatusForbidden, + http.StatusNotFound, + http.StatusMethodNotAllowed, + http.StatusTooManyRequests, + } { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + t.Parallel() + + detail := buildRequestDetailForLog(bodyDetail, model.ModelConfig{}, statusCode, false) + + require.NotNil(t, detail) + assert.Empty(t, detail.RequestBody) + assert.Equal(t, `{"error":"upstream"}`, detail.ResponseBody) + }) + } +} + +func TestBuildRequestDetailForLogKeepsRequestBodyWhenForced(t *testing.T) { + t.Parallel() + + detail := buildRequestDetailForLog( + &relaycontroller.BodyDetail{ + RequestBody: `{"prompt":"secret"}`, + ResponseBody: `{"error":"limited"}`, + }, + model.ModelConfig{}, + http.StatusTooManyRequests, + true, + ) + + require.NotNil(t, detail) + assert.Equal(t, `{"prompt":"secret"}`, detail.RequestBody) + assert.Equal(t, `{"error":"limited"}`, detail.ResponseBody) +} + +func TestBuildRequestDetailForLogKeepsRequestBodyForClientPayloadErrors(t *testing.T) { + t.Parallel() + + detail := buildRequestDetailForLog( + &relaycontroller.BodyDetail{ + RequestBody: `{"prompt":"secret"}`, + ResponseBody: `{"error":"bad request"}`, + }, + model.ModelConfig{}, + http.StatusBadRequest, + false, + ) + + require.NotNil(t, detail) + assert.Equal(t, `{"prompt":"secret"}`, detail.RequestBody) + assert.Equal(t, `{"error":"bad request"}`, detail.ResponseBody) +} + +func TestBuildRequestDetailForLogDropsInvalidUTF8Bodies(t *testing.T) { + t.Parallel() + + detail := buildRequestDetailForLog( + &relaycontroller.BodyDetail{ + RequestBody: string([]byte{0xff, 0xfe}), + ResponseBody: string([]byte{'o', 'k', 0xff}), + }, + model.ModelConfig{}, + http.StatusBadRequest, + false, + ) + + require.NotNil(t, detail) + assert.Empty(t, detail.RequestBody) + assert.Empty(t, detail.ResponseBody) +} diff --git a/core/model/log.go b/core/model/log.go index f355f90d..d11aad26 100644 --- a/core/model/log.go +++ b/core/model/log.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "time" + "unicode/utf8" "github.com/bytedance/sonic" "github.com/labring/aiproxy/core/common" @@ -45,6 +46,18 @@ func (d *RequestDetail) ApplyBodySizeLimits(requestMaxSize, responseMaxSize int6 d.ResponseBody, d.ResponseBodyTruncated = truncateDetailBody(d.ResponseBody, responseMaxSize) } +func (d *RequestDetail) DropInvalidUTF8Bodies() { + if d.RequestBody != "" && !utf8.ValidString(d.RequestBody) { + d.RequestBody = "" + d.RequestBodyTruncated = false + } + + if d.ResponseBody != "" && !utf8.ValidString(d.ResponseBody) { + d.ResponseBody = "" + d.ResponseBodyTruncated = false + } +} + type Log struct { RequestDetail *RequestDetail `gorm:"foreignKey:LogID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"request_detail,omitempty"` RequestAt time.Time ` json:"request_at"` diff --git a/core/model/log_test.go b/core/model/log_test.go index ecbe8631..376fb072 100644 --- a/core/model/log_test.go +++ b/core/model/log_test.go @@ -58,6 +58,33 @@ func TestRequestDetailApplyBodySizeLimitsZeroKeepsOriginalBody(t *testing.T) { } } +func TestRequestDetailDropInvalidUTF8Bodies(t *testing.T) { + detail := &model.RequestDetail{ + RequestBody: string([]byte{0xff, 0xfe}), + ResponseBody: "valid", + RequestBodyTruncated: true, + ResponseBodyTruncated: true, + } + + detail.DropInvalidUTF8Bodies() + + if detail.RequestBody != "" { + t.Fatalf("expected invalid request body to be cleared, got %q", detail.RequestBody) + } + + if detail.RequestBodyTruncated { + t.Fatal("expected request body truncated flag to be cleared") + } + + if detail.ResponseBody != "valid" { + t.Fatalf("expected valid response body to remain unchanged, got %q", detail.ResponseBody) + } + + if !detail.ResponseBodyTruncated { + t.Fatal("expected valid response body truncated flag to remain unchanged") + } +} + func TestRecordConsumeLogPersistsWebSearchCount(t *testing.T) { db, err := model.OpenSQLite(filepath.Join(t.TempDir(), "logs.db")) if err != nil { diff --git a/core/relay/adaptor/openai/chat.go b/core/relay/adaptor/openai/chat.go index d2fef83c..fcf22bba 100644 --- a/core/relay/adaptor/openai/chat.go +++ b/core/relay/adaptor/openai/chat.go @@ -67,6 +67,62 @@ func responseToChatFinishReason(response *relaymodel.Response) relaymodel.Finish } } +func responseReasoningSummaryText(response *relaymodel.Response) string { + if response == nil { + return "" + } + + var summaryParts []string + + for _, outputItem := range response.Output { + if outputItem.Type != relaymodel.InputItemTypeReasoning { + continue + } + + summaryParts = append(summaryParts, reasoningSummaryText(outputItem.Summary)...) + } + + return strings.Join(summaryParts, "\n") +} + +func reasoningSummaryText(summary any) []string { + switch value := summary.(type) { + case string: + if value == "" { + return nil + } + + return []string{value} + case []relaymodel.SummaryPart: + parts := make([]string, 0, len(value)) + for _, part := range value { + if part.Text != "" { + parts = append(parts, part.Text) + } + } + + return parts + case []any: + parts := make([]string, 0, len(value)) + for _, item := range value { + switch typedItem := item.(type) { + case relaymodel.SummaryPart: + if typedItem.Text != "" { + parts = append(parts, typedItem.Text) + } + case map[string]any: + if text, ok := typedItem["text"].(string); ok && text != "" { + parts = append(parts, text) + } + } + } + + return parts + default: + return nil + } +} + // handleResponseCreated handles response.created event for ChatCompletion func (s *chatCompletionStreamState) handleResponseCreated( event *relaymodel.ResponseStreamEvent, @@ -117,6 +173,29 @@ func (s *chatCompletionStreamState) handleOutputTextDelta( } } +func (s *chatCompletionStreamState) handleReasoningSummaryTextDelta( + event *relaymodel.ResponseStreamEvent, +) *relaymodel.ChatCompletionsStreamResponse { + if event.Delta == "" { + return nil + } + + return &relaymodel.ChatCompletionsStreamResponse{ + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: time.Now().Unix(), + Model: responseModelName(s.meta), + Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: relaymodel.Message{ + ReasoningContent: event.Delta, + }, + }, + }, + } +} + // handleOutputItemAdded handles response.output_item.added event for ChatCompletion func (s *chatCompletionStreamState) handleOutputItemAdded( event *relaymodel.ResponseStreamEvent, @@ -1189,11 +1268,8 @@ func ConvertChatCompletionToResponsesRequest( responsesReq.User = &chatReq.User } - applyReasoningToResponsesRequestForModel( - meta, - &responsesReq, - utils.ParseOpenAIReasoning(&chatReq), - ) + reasoning := utils.ParseOpenAIReasoning(&chatReq) + applyReasoningToResponsesRequestForModel(meta, &responsesReq, reasoning) // Map metadata if chatReq.Metadata != nil { @@ -1263,6 +1339,8 @@ func ConvertResponsesToChatCompletionResponse( Usage: relaymodel.ChatUsage{}, } + reasonContent := responseReasoningSummaryText(&responsesResp) + // Convert output items to choices for _, outputItem := range responsesResp.Output { switch outputItem.Type { @@ -1275,8 +1353,9 @@ func ConvertResponsesToChatCompletionResponse( choice := relaymodel.TextResponseChoice{ Index: len(chatResp.Choices), Message: relaymodel.Message{ - Role: role, - Content: "", + Role: role, + Content: "", + ReasoningContent: reasonContent, }, } @@ -1293,6 +1372,7 @@ func ConvertResponsesToChatCompletionResponse( choice.FinishReason = responseToChatFinishReason(&responsesResp) chatResp.Choices = append(chatResp.Choices, &choice) + reasonContent = "" case relaymodel.InputItemTypeFunctionCall: toolCallID := outputItem.CallID @@ -1308,7 +1388,8 @@ func ConvertResponsesToChatCompletionResponse( chatResp.Choices = append(chatResp.Choices, &relaymodel.TextResponseChoice{ Index: len(chatResp.Choices), Message: relaymodel.Message{ - Role: relaymodel.RoleAssistant, + Role: relaymodel.RoleAssistant, + ReasoningContent: reasonContent, ToolCalls: []relaymodel.ToolCall{ { Index: 0, @@ -1323,6 +1404,7 @@ func ConvertResponsesToChatCompletionResponse( }, FinishReason: finishReason, }) + reasonContent = "" default: continue @@ -1333,8 +1415,9 @@ func ConvertResponsesToChatCompletionResponse( chatResp.Choices = append(chatResp.Choices, &relaymodel.TextResponseChoice{ Index: 0, Message: relaymodel.Message{ - Role: relaymodel.RoleAssistant, - Content: "", + Role: relaymodel.RoleAssistant, + Content: "", + ReasoningContent: reasonContent, }, FinishReason: responseToChatFinishReason(&responsesResp), }) @@ -1546,6 +1629,8 @@ func ConvertResponsesToChatCompletionStreamResponse( pendingInitialChunk = state.handleResponseCreated(&event) case relaymodel.EventOutputTextDelta: chatStreamResp = state.handleOutputTextDelta(&event) + case relaymodel.EventReasoningSummaryTextDelta: + chatStreamResp = state.handleReasoningSummaryTextDelta(&event) case relaymodel.EventOutputItemAdded: chatStreamResp = state.handleOutputItemAdded(&event) case relaymodel.EventFunctionCallArgumentsDelta: diff --git a/core/relay/adaptor/openai/chat_test.go b/core/relay/adaptor/openai/chat_test.go index 5bc98acb..3205cb09 100644 --- a/core/relay/adaptor/openai/chat_test.go +++ b/core/relay/adaptor/openai/chat_test.go @@ -250,6 +250,39 @@ func TestConvertChatCompletionToResponsesRequest(t *testing.T) { assert.Equal(t, "gpt-5-codex", responsesReq.Model) assert.NotNil(t, responsesReq.Store) assert.False(t, *responsesReq.Store) + assert.Nil(t, responsesReq.Reasoning) + }, + }, + { + name: "reasoning effort maps effort without enabling summary", + inputRequest: relaymodel.GeneralOpenAIRequest{ + Model: "gpt-5-codex", + Messages: []relaymodel.Message{ + {Role: "user", Content: "Hello"}, + }, + ReasoningEffort: new("medium"), + }, + checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) { + t.Helper() + require.NotNil(t, responsesReq.Reasoning) + require.NotNil(t, responsesReq.Reasoning.Effort) + assert.Equal(t, "medium", *responsesReq.Reasoning.Effort) + assert.Nil(t, responsesReq.Reasoning.Summary) + }, + }, + { + name: "none reasoning effort does not enable reasoning summary", + inputRequest: relaymodel.GeneralOpenAIRequest{ + Model: "gpt-5-codex", + Messages: []relaymodel.Message{ + {Role: "user", Content: "Hello"}, + }, + ReasoningEffort: new("none"), + }, + checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) { + t.Helper() + require.NotNil(t, responsesReq.Reasoning) + assert.Nil(t, responsesReq.Reasoning.Summary) }, }, { @@ -745,9 +778,9 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { CreatedAt: 1234567890, Output: []relaymodel.OutputItem{ { - Type: "reasoning", - Content: []relaymodel.OutputContent{ - {Type: "text", Text: "Let me think about this..."}, + Type: relaymodel.InputItemTypeReasoning, + Summary: []relaymodel.SummaryPart{ + {Type: "summary_text", Text: "Need answer the math question."}, }, }, { @@ -768,6 +801,11 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { t.Helper() require.Len(t, chatResp.Choices, 1) assert.Contains(t, chatResp.Choices[0].Message.Content, "The answer is 42.") + assert.Equal( + t, + "Need answer the math question.", + chatResp.Choices[0].Message.ReasoningContent, + ) }, expectedStatus: http.StatusOK, }, @@ -800,6 +838,53 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { }, expectedStatus: http.StatusOK, }, + { + name: "reasoning summary attaches only to first converted choice", + responsesResp: relaymodel.Response{ + ID: "resp_multi_reasoning", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusCompleted, + CreatedAt: 1781355958, + Output: []relaymodel.OutputItem{ + { + Type: relaymodel.InputItemTypeReasoning, + Summary: []relaymodel.SummaryPart{ + {Type: "summary_text", Text: "Need compare options."}, + }, + }, + { + Type: relaymodel.InputItemTypeMessage, + Role: relaymodel.RoleAssistant, + Content: []relaymodel.OutputContent{ + {Type: "output_text", Text: "Option A"}, + }, + }, + { + Type: relaymodel.InputItemTypeMessage, + Role: relaymodel.RoleAssistant, + Content: []relaymodel.OutputContent{ + {Type: "output_text", Text: "Option B"}, + }, + }, + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 8, + OutputTokens: 4, + TotalTokens: 12, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 2) + assert.Equal( + t, + "Need compare options.", + chatResp.Choices[0].Message.ReasoningContent, + ) + assert.Empty(t, chatResp.Choices[1].Message.ReasoningContent) + }, + expectedStatus: http.StatusOK, + }, { name: "incomplete reasoning-only response", responsesResp: relaymodel.Response{ @@ -809,7 +894,7 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { CreatedAt: 1781355958, Output: []relaymodel.OutputItem{ { - Type: "reasoning", + Type: relaymodel.InputItemTypeReasoning, Summary: []relaymodel.SummaryPart{}, }, }, @@ -1316,6 +1401,62 @@ func TestConvertResponsesToChatCompletionStreamResponseHandlesIncompleteReasonin assert.Equal(t, 1, strings.Count(w.Body.String(), "data: [DONE]")) } +func TestConvertResponsesToChatCompletionStreamResponseMapsReasoningSummary( + t *testing.T, +) { + gin.SetMode(gin.TestMode) + + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_reasoning","object":"response","created_at":1781355623,"status":"in_progress","model":"gpt-5-mini","output":[],"parallel_tool_calls":true,"store":false}}`, + "", + `event: response.reasoning_summary_text.delta`, + `data: {"type":"response.reasoning_summary_text.delta","delta":"Checking facts","sequence_number":2}`, + "", + `event: response.reasoning_summary_text.delta`, + `data: {"type":"response.reasoning_summary_text.delta","delta":" and constraints.","sequence_number":3}`, + "", + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Final answer","sequence_number":4}`, + "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_reasoning","object":"response","created_at":1781355623,"status":"completed","model":"gpt-5-mini","output":[],"parallel_tool_calls":true,"store":false,"usage":{"input_tokens":5,"output_tokens":6,"total_tokens":11}},"sequence_number":5}`, + "", + }, "\n") + + httpResp := &http.Response{ + StatusCode: http.StatusOK, + Body: &mockReadCloser{Reader: bytes.NewReader([]byte(stream))}, + Header: make(http.Header), + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + + m := &meta.Meta{ + ActualModel: "gpt-5-mini", + } + + _, err := openai.ConvertResponsesToChatCompletionStreamResponse(m, c, httpResp) + require.Nil(t, err) + + chunks := collectChatCompletionStreamChunks(t, w.Body.String()) + require.Len(t, chunks, 5) + + assert.Equal(t, relaymodel.RoleAssistant, chunks[0].Choices[0].Delta.Role) + assert.Equal(t, "Checking facts", chunks[1].Choices[0].Delta.ReasoningContent) + assert.Equal(t, " and constraints.", chunks[2].Choices[0].Delta.ReasoningContent) + assert.Equal(t, "Final answer", chunks[3].Choices[0].Delta.Content) + assert.Equal(t, relaymodel.FinishReasonStop, chunks[4].Choices[0].FinishReason) + assert.Contains(t, w.Body.String(), `"reasoning_content":"Checking facts"`) +} + func TestConvertResponsesToChatCompletionStreamResponseHandlesIncompleteContentFilter( t *testing.T, ) { diff --git a/core/relay/controller/dohelper.go b/core/relay/controller/dohelper.go index de0f5d2e..a4c4cf95 100644 --- a/core/relay/controller/dohelper.go +++ b/core/relay/controller/dohelper.go @@ -359,11 +359,9 @@ func handleResponse( result, relayErr := a.DoResponse(meta, store, c, resp) if relayErr != nil && opt.IncludeResponseBody && opt.MaxResponseBodySize >= 0 { respBody, _ := relayErr.MarshalJSON() - detail.ResponseBody = limitBodyDetail(conv.BytesToString(respBody), opt.MaxResponseBodySize) + detail.ResponseBody = responseBodyDetail(respBody, opt.MaxResponseBodySize) } else if rw.body != nil { - // copy body buffer - // do not use bytes conv - detail.ResponseBody = limitBodyDetail(rw.body.String(), opt.MaxResponseBodySize) + detail.ResponseBody = capturedResponseBodyDetail(rw.body.Bytes(), opt.MaxResponseBodySize) } if result.UpstreamID == "" && resp != nil && resp.Header != nil && @@ -396,19 +394,31 @@ func requestBodyDetail(c *gin.Context, opt BodyDetailOption) (string, error) { return "", err } - return limitBodyDetailString(string(limitBodyDetailBytes(body, opt.MaxRequestBodySize))), nil + return bodyDetailFromBytes(body, opt.MaxRequestBodySize), nil } -func limitBodyDetail(body string, maxSize int64) string { - return limitBodyDetailString(limitBodyDetailStringLength(body, maxSize)) +func bodyDetailFromBytes(body []byte, maxSize int64) string { + if !utf8.Valid(body) { + return "" + } + + return limitBodyDetailString(string(limitBodyDetailBytes(body, maxSize))) +} + +func responseBodyDetail(body []byte, maxSize int64) string { + return bodyDetailFromBytes(body, maxSize) } -func limitBodyDetailStringLength(body string, maxSize int64) string { +func capturedResponseBodyDetail(body []byte, maxSize int64) string { if maxSize == 0 || int64(len(body)) <= maxSize { - return body + if !utf8.Valid(body) { + return "" + } + + return conv.BytesToString(body) } - return body[:min(len(body), int(maxSize)+1)] + return limitBodyDetailString(conv.BytesToString(limitBodyDetailBytes(body, maxSize))) } func limitBodyDetailString(body string) string { diff --git a/core/relay/controller/dohelper_test.go b/core/relay/controller/dohelper_test.go index 5c06e552..6cf63ec1 100644 --- a/core/relay/controller/dohelper_test.go +++ b/core/relay/controller/dohelper_test.go @@ -2,6 +2,7 @@ package controller import ( + "bytes" "context" "errors" "io" @@ -11,12 +12,14 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/labring/aiproxy/core/common/config" "github.com/labring/aiproxy/core/model" "github.com/labring/aiproxy/core/relay/adaptor" "github.com/labring/aiproxy/core/relay/meta" "github.com/labring/aiproxy/core/relay/mode" relaymodel "github.com/labring/aiproxy/core/relay/model" "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -344,6 +347,89 @@ func TestHandleCapturesBoundedBodyDetail(t *testing.T) { require.False(t, result.BodyDetail.FirstByteAt.IsZero()) } +func TestHandleDropsInvalidUTF8BodyDetail(t *testing.T) { + c, relayMeta := newTestRelayContext() + requestBody := []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'} + + c.Request.Header.Set("Content-Type", "application/json") + c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) + c.Request.ContentLength = int64(len(requestBody)) + + result := Handle( + testAdaptor{ + convertRequest: func( + _ *meta.Meta, + _ adaptor.Store, + _ *http.Request, + ) (adaptor.ConvertResult, error) { + return adaptor.ConvertResult{Body: http.NoBody}, nil + }, + doRequest: func( + _ *meta.Meta, + _ adaptor.Store, + _ *gin.Context, + _ *http.Request, + ) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("upstream")), + Header: make(http.Header), + }, nil + }, + doResponse: func( + _ *meta.Meta, + _ adaptor.Store, + c *gin.Context, + _ *http.Response, + ) (adaptor.DoResponseResult, adaptor.Error) { + _, _ = c.Writer.Write([]byte{'o', 'k', 0xff}) + return adaptor.DoResponseResult{}, nil + }, + }, + c, + relayMeta, + nil, + BodyDetailOption{ + IncludeRequestBody: true, + IncludeResponseBody: true, + MaxRequestBodySize: 0, + MaxResponseBodySize: 0, + }, + ) + + require.NoError(t, result.Error) + require.NotNil(t, result.BodyDetail) + require.Empty(t, result.BodyDetail.RequestBody) + require.Empty(t, result.BodyDetail.ResponseBody) +} + +func TestLogHandleErrorLogsOnlyAvailableBodyDetails(t *testing.T) { + oldDebug := config.DebugEnabled + config.DebugEnabled = true + t.Cleanup(func() { + config.DebugEnabled = oldDebug + }) + + var logBuf bytes.Buffer + + logger := logrus.New() + logger.SetOutput(&logBuf) + logger.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true, DisableColors: true}) + entry := logrus.NewEntry(logger) + respErr := relaymodel.WrapperErrorWithMessage( + mode.ChatCompletions, + http.StatusTooManyRequests, + "limited", + ) + + logHandleError(entry, respErr, &BodyDetail{ResponseBody: `{"error":"limited"}`}) + + assert.Contains(t, logBuf.String(), "handle failed") + assert.Contains(t, logBuf.String(), "response detail:") + assert.Contains(t, logBuf.String(), "limited") + assert.NotContains(t, logBuf.String(), "request detail:") +} + func TestHandleWithoutBodyDetailOptionSkipsBodies(t *testing.T) { c, relayMeta := newTestRelayContext() requestBody := "1234567890" diff --git a/core/relay/controller/handle.go b/core/relay/controller/handle.go index 66c4f44c..a4eca485 100644 --- a/core/relay/controller/handle.go +++ b/core/relay/controller/handle.go @@ -1,12 +1,16 @@ package controller import ( + "net/http" + "github.com/gin-gonic/gin" "github.com/labring/aiproxy/core/common" "github.com/labring/aiproxy/core/common/config" "github.com/labring/aiproxy/core/model" "github.com/labring/aiproxy/core/relay/adaptor" "github.com/labring/aiproxy/core/relay/meta" + monitorplugin "github.com/labring/aiproxy/core/relay/plugin/monitor" + "github.com/sirupsen/logrus" ) // HandleResult contains all the information needed for consumption recording @@ -19,6 +23,51 @@ type HandleResult struct { BodyDetail *BodyDetail } +func ShouldSkipRequestBodyDetailForStatus(statusCode int) bool { + if !monitorplugin.ChannelStatusHasPermission(statusCode) { + return true + } + + switch statusCode { + case http.StatusMethodNotAllowed, + http.StatusTooManyRequests: + return true + default: + return false + } +} + +func logHandleError(log *logrus.Entry, respErr adaptor.Error, detail *BodyDetail) { + if detail == nil || !config.DebugEnabled { + log.Errorf("handle failed: %+v", respErr) + return + } + + switch { + case detail.RequestBody != "" && detail.ResponseBody != "": + log.Errorf( + "handle failed: %+v\nrequest detail:\n%s\nresponse detail:\n%s", + respErr, + detail.RequestBody, + detail.ResponseBody, + ) + case detail.RequestBody != "": + log.Errorf( + "handle failed: %+v\nrequest detail:\n%s", + respErr, + detail.RequestBody, + ) + case detail.ResponseBody != "": + log.Errorf( + "handle failed: %+v\nresponse detail:\n%s", + respErr, + detail.ResponseBody, + ) + default: + log.Errorf("handle failed: %+v", respErr) + } +} + func Handle( adaptor adaptor.Adaptor, c *gin.Context, @@ -30,17 +79,7 @@ func Handle( result, detail, respErr := DoHelper(adaptor, c, meta, store, opts...) if respErr != nil { - if detail != nil && config.DebugEnabled && - (detail.RequestBody != "" || detail.ResponseBody != "") { - log.Errorf( - "handle failed: %+v\nrequest detail:\n%s\nresponse detail:\n%s", - respErr, - detail.RequestBody, - detail.ResponseBody, - ) - } else { - log.Errorf("handle failed: %+v", respErr) - } + logHandleError(log, respErr, detail) return &HandleResult{ Error: respErr, diff --git a/core/relay/model/response.go b/core/relay/model/response.go index 608d8d65..4687cc1b 100644 --- a/core/relay/model/response.go +++ b/core/relay/model/response.go @@ -9,6 +9,7 @@ type InputItemType = string const ( InputItemTypeMessage InputItemType = "message" + InputItemTypeReasoning InputItemType = "reasoning" InputItemTypeFunctionCall InputItemType = "function_call" InputItemTypeFunctionCallOutput InputItemType = "function_call_output" ) diff --git a/core/relay/plugin/monitor/monitor.go b/core/relay/plugin/monitor/monitor.go index 795ddc05..9a774f1d 100644 --- a/core/relay/plugin/monitor/monitor.go +++ b/core/relay/plugin/monitor/monitor.go @@ -50,11 +50,15 @@ var channelNoPermissionStatusCodesMap = map[int]struct{}{ http.StatusNotFound: {}, } -func ChannelHasPermission(relayErr adaptor.Error) bool { - _, ok := channelNoPermissionStatusCodesMap[relayErr.StatusCode()] +func ChannelStatusHasPermission(statusCode int) bool { + _, ok := channelNoPermissionStatusCodesMap[statusCode] return !ok } +func ChannelHasPermission(relayErr adaptor.Error) bool { + return ChannelStatusHasPermission(relayErr.StatusCode()) +} + func getRequestDuration(meta *meta.Meta) time.Duration { requestAt, ok := meta.Get("requestAt") if !ok { diff --git a/core/relay/plugin/monitor/monitor_test.go b/core/relay/plugin/monitor/monitor_test.go index 72931210..9dfbcf0c 100644 --- a/core/relay/plugin/monitor/monitor_test.go +++ b/core/relay/plugin/monitor/monitor_test.go @@ -2,6 +2,7 @@ package monitor import ( + "net/http" "testing" "github.com/labring/aiproxy/core/common/config" @@ -53,3 +54,22 @@ func TestShouldTryBanNoPermissionRequiresChannelSwitch(t *testing.T) { require.True(t, shouldTryBanNoPermission(meta, false)) require.False(t, shouldTryBanNoPermission(meta, true)) } + +func TestChannelStatusHasPermission(t *testing.T) { + t.Parallel() + + for _, statusCode := range []int{ + http.StatusUnauthorized, + http.StatusPaymentRequired, + http.StatusForbidden, + http.StatusNotFound, + } { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + t.Parallel() + + require.False(t, ChannelStatusHasPermission(statusCode)) + }) + } + + require.True(t, ChannelStatusHasPermission(http.StatusBadRequest)) +} From 35f33b924a02b3235ed1b90b830fe900c3002327 Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Sat, 13 Jun 2026 23:28:14 +0800 Subject: [PATCH 2/2] fix --- core/relay/controller/dohelper_test.go | 9 +-------- core/relay/controller/handle.go | 11 ++++++++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/core/relay/controller/dohelper_test.go b/core/relay/controller/dohelper_test.go index 6cf63ec1..37cec598 100644 --- a/core/relay/controller/dohelper_test.go +++ b/core/relay/controller/dohelper_test.go @@ -12,7 +12,6 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/labring/aiproxy/core/common/config" "github.com/labring/aiproxy/core/model" "github.com/labring/aiproxy/core/relay/adaptor" "github.com/labring/aiproxy/core/relay/meta" @@ -404,12 +403,6 @@ func TestHandleDropsInvalidUTF8BodyDetail(t *testing.T) { } func TestLogHandleErrorLogsOnlyAvailableBodyDetails(t *testing.T) { - oldDebug := config.DebugEnabled - config.DebugEnabled = true - t.Cleanup(func() { - config.DebugEnabled = oldDebug - }) - var logBuf bytes.Buffer logger := logrus.New() @@ -422,7 +415,7 @@ func TestLogHandleErrorLogsOnlyAvailableBodyDetails(t *testing.T) { "limited", ) - logHandleError(entry, respErr, &BodyDetail{ResponseBody: `{"error":"limited"}`}) + logHandleError(entry, respErr, &BodyDetail{ResponseBody: `{"error":"limited"}`}, true) assert.Contains(t, logBuf.String(), "handle failed") assert.Contains(t, logBuf.String(), "response detail:") diff --git a/core/relay/controller/handle.go b/core/relay/controller/handle.go index a4eca485..ae196902 100644 --- a/core/relay/controller/handle.go +++ b/core/relay/controller/handle.go @@ -37,8 +37,13 @@ func ShouldSkipRequestBodyDetailForStatus(statusCode int) bool { } } -func logHandleError(log *logrus.Entry, respErr adaptor.Error, detail *BodyDetail) { - if detail == nil || !config.DebugEnabled { +func logHandleError( + log *logrus.Entry, + respErr adaptor.Error, + detail *BodyDetail, + debugEnabled bool, +) { + if detail == nil || !debugEnabled { log.Errorf("handle failed: %+v", respErr) return } @@ -79,7 +84,7 @@ func Handle( result, detail, respErr := DoHelper(adaptor, c, meta, store, opts...) if respErr != nil { - logHandleError(log, respErr, detail) + logHandleError(log, respErr, detail, config.DebugEnabled) return &HandleResult{ Error: respErr,