- 错误日志新增 upstream_request_id(从 Anthropic/OpenAI 响应 header 提取)和 upstream_body(截断 2KB) - 修复 RelayErrorHandler 内部 WithOpenAIError/NewOpenAIError 分支丢失上游字段的 bug - Playground 渠道和分组改为通过 X-Channel-Id/X-Group header 传递,而非 body 字段 - Distributor 中间件支持从 header 回退读取 channel_id 和 group Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -369,6 +369,12 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t | |||||
| other["channel_id"] = channelId | other["channel_id"] = channelId | ||||
| other["channel_name"] = c.GetString("channel_name") | other["channel_name"] = c.GetString("channel_name") | ||||
| other["channel_type"] = c.GetInt("channel_type") | other["channel_type"] = c.GetInt("channel_type") | ||||
| if err.UpstreamRequestId != "" { | |||||
| other["upstream_request_id"] = err.UpstreamRequestId | |||||
| } | |||||
| if err.UpstreamBody != "" { | |||||
| other["upstream_body"] = err.UpstreamBody | |||||
| } | |||||
| adminInfo := make(map[string]interface{}) | adminInfo := make(map[string]interface{}) | ||||
| adminInfo["use_channel"] = c.GetStringSlice("use_channel") | adminInfo["use_channel"] = c.GetStringSlice("use_channel") | ||||
| isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) | isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) | ||||
| @@ -367,10 +367,18 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { | |||||
| return nil, false, err | return nil, false, err | ||||
| } | } | ||||
| modelRequest.Model = req.Model | modelRequest.Model = req.Model | ||||
| modelRequest.Group = req.Group | |||||
| // group: body 优先,fallback 到 header | |||||
| if req.Group != "" { | |||||
| modelRequest.Group = req.Group | |||||
| } else if g := c.GetHeader("X-Group"); g != "" { | |||||
| modelRequest.Group = g | |||||
| } | |||||
| common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) | common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) | ||||
| // channel_id: body 优先,fallback 到 header | |||||
| if req.ChannelId > 0 { | if req.ChannelId > 0 { | ||||
| common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, strconv.Itoa(req.ChannelId)) | common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, strconv.Itoa(req.ChannelId)) | ||||
| } else if ch := c.GetHeader("X-Channel-Id"); ch != "" { | |||||
| common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, ch) | |||||
| } | } | ||||
| } | } | ||||
| @@ -86,11 +86,25 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude | |||||
| func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { | func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { | ||||
| newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) | newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) | ||||
| // Capture upstream request-id from response headers | |||||
| upstreamReqId := resp.Header.Get("request-id") | |||||
| if upstreamReqId == "" { | |||||
| upstreamReqId = resp.Header.Get("x-request-id") | |||||
| } | |||||
| newApiErr.UpstreamRequestId = upstreamReqId | |||||
| responseBody, err := io.ReadAll(resp.Body) | responseBody, err := io.ReadAll(resp.Body) | ||||
| if err != nil { | if err != nil { | ||||
| return | return | ||||
| } | } | ||||
| CloseResponseBodyGracefully(resp) | CloseResponseBodyGracefully(resp) | ||||
| // Capture upstream response body (truncate to 2KB to avoid oversized logs) | |||||
| const maxBodyLen = 2048 | |||||
| bodyStr := string(responseBody) | |||||
| if len(bodyStr) > maxBodyLen { | |||||
| bodyStr = bodyStr[:maxBodyLen] + "...(truncated)" | |||||
| } | |||||
| newApiErr.UpstreamBody = bodyStr | |||||
| var errResponse dto.GeneralErrorResponse | var errResponse dto.GeneralErrorResponse | ||||
| buildErrWithBody := func(message string) error { | buildErrWithBody := func(message string) error { | ||||
| if message == "" { | if message == "" { | ||||
| @@ -115,6 +129,8 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai | |||||
| oaiError := errResponse.TryToOpenAIError() | oaiError := errResponse.TryToOpenAIError() | ||||
| if oaiError != nil { | if oaiError != nil { | ||||
| newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode) | newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode) | ||||
| newApiErr.UpstreamRequestId = upstreamReqId | |||||
| newApiErr.UpstreamBody = bodyStr | |||||
| if showBodyWhenFail { | if showBodyWhenFail { | ||||
| newApiErr.Err = buildErrWithBody(newApiErr.Error()) | newApiErr.Err = buildErrWithBody(newApiErr.Error()) | ||||
| } | } | ||||
| @@ -122,6 +138,8 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai | |||||
| } | } | ||||
| } | } | ||||
| newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) | newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) | ||||
| newApiErr.UpstreamRequestId = upstreamReqId | |||||
| newApiErr.UpstreamBody = bodyStr | |||||
| if showBodyWhenFail { | if showBodyWhenFail { | ||||
| newApiErr.Err = buildErrWithBody(newApiErr.Error()) | newApiErr.Err = buildErrWithBody(newApiErr.Error()) | ||||
| } | } | ||||
| @@ -88,14 +88,16 @@ const ( | |||||
| ) | ) | ||||
| type NewAPIError struct { | type NewAPIError struct { | ||||
| Err error | |||||
| RelayError any | |||||
| skipRetry bool | |||||
| recordErrorLog *bool | |||||
| errorType ErrorType | |||||
| errorCode ErrorCode | |||||
| StatusCode int | |||||
| Metadata json.RawMessage | |||||
| Err error | |||||
| RelayError any | |||||
| skipRetry bool | |||||
| recordErrorLog *bool | |||||
| errorType ErrorType | |||||
| errorCode ErrorCode | |||||
| StatusCode int | |||||
| Metadata json.RawMessage | |||||
| UpstreamRequestId string | |||||
| UpstreamBody string | |||||
| } | } | ||||
| // Unwrap enables errors.Is / errors.As to work with NewAPIError by exposing the underlying error. | // Unwrap enables errors.Is / errors.As to work with NewAPIError by exposing the underlying error. | ||||
| @@ -173,7 +173,7 @@ export const useApiRequest = ( | |||||
| // 非流式请求 | // 非流式请求 | ||||
| const handleNonStreamRequest = useCallback( | const handleNonStreamRequest = useCallback( | ||||
| async (payload) => { | |||||
| async (payload, extraHeaders = {}) => { | |||||
| setDebugData((prev) => ({ | setDebugData((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| request: payload, | request: payload, | ||||
| @@ -190,6 +190,7 @@ export const useApiRequest = ( | |||||
| headers: { | headers: { | ||||
| 'Content-Type': 'application/json', | 'Content-Type': 'application/json', | ||||
| 'New-Api-User': getUserIdFromLocalStorage(), | 'New-Api-User': getUserIdFromLocalStorage(), | ||||
| ...extraHeaders, | |||||
| }, | }, | ||||
| body: JSON.stringify(payload), | body: JSON.stringify(payload), | ||||
| }); | }); | ||||
| @@ -290,7 +291,7 @@ export const useApiRequest = ( | |||||
| // SSE请求 | // SSE请求 | ||||
| const handleSSE = useCallback( | const handleSSE = useCallback( | ||||
| (payload) => { | |||||
| (payload, extraHeaders = {}) => { | |||||
| setDebugData((prev) => ({ | setDebugData((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| request: payload, | request: payload, | ||||
| @@ -305,6 +306,7 @@ export const useApiRequest = ( | |||||
| headers: { | headers: { | ||||
| 'Content-Type': 'application/json', | 'Content-Type': 'application/json', | ||||
| 'New-Api-User': getUserIdFromLocalStorage(), | 'New-Api-User': getUserIdFromLocalStorage(), | ||||
| ...extraHeaders, | |||||
| }, | }, | ||||
| method: 'POST', | method: 'POST', | ||||
| payload: JSON.stringify(payload), | payload: JSON.stringify(payload), | ||||
| @@ -499,11 +501,11 @@ export const useApiRequest = ( | |||||
| // 发送请求 | // 发送请求 | ||||
| const sendRequest = useCallback( | const sendRequest = useCallback( | ||||
| (payload, isStream) => { | |||||
| (payload, isStream, extraHeaders = {}) => { | |||||
| if (isStream) { | if (isStream) { | ||||
| handleSSE(payload); | |||||
| handleSSE(payload, extraHeaders); | |||||
| } else { | } else { | ||||
| handleNonStreamRequest(payload); | |||||
| handleNonStreamRequest(payload, extraHeaders); | |||||
| } | } | ||||
| }, | }, | ||||
| [handleSSE, handleNonStreamRequest], | [handleSSE, handleNonStreamRequest], | ||||
| @@ -286,8 +286,17 @@ const Playground = () => { | |||||
| setMessage((prevMessage) => { | setMessage((prevMessage) => { | ||||
| const newMessages = [...prevMessage, userMessage, loadingMessage]; | const newMessages = [...prevMessage, userMessage, loadingMessage]; | ||||
| // 发送自定义请求体 | |||||
| sendRequest(customPayload, customPayload.stream !== false); | |||||
| // 自定义请求体也通过 header 传递网关字段 | |||||
| const customHeaders = {}; | |||||
| if (inputs.channelId && inputs.channelId > 0) { | |||||
| customHeaders['X-Channel-Id'] = String(inputs.channelId); | |||||
| } | |||||
| if (customPayload.group) { | |||||
| customHeaders['X-Group'] = customPayload.group; | |||||
| delete customPayload.group; | |||||
| } | |||||
| delete customPayload.channel_id; | |||||
| sendRequest(customPayload, customPayload.stream !== false, customHeaders); | |||||
| // 发送消息后保存,传入新消息列表 | // 发送消息后保存,传入新消息列表 | ||||
| setTimeout(() => saveMessagesImmediately(newMessages), 0); | setTimeout(() => saveMessagesImmediately(newMessages), 0); | ||||
| @@ -323,10 +332,16 @@ const Playground = () => { | |||||
| inputs, | inputs, | ||||
| parameterEnabled, | parameterEnabled, | ||||
| ); | ); | ||||
| const extraHeaders = {}; | |||||
| if (inputs.channelId && inputs.channelId > 0) { | if (inputs.channelId && inputs.channelId > 0) { | ||||
| payload.channel_id = inputs.channelId; | |||||
| extraHeaders['X-Channel-Id'] = String(inputs.channelId); | |||||
| } | |||||
| if (payload.group) { | |||||
| extraHeaders['X-Group'] = payload.group; | |||||
| delete payload.group; | |||||
| } | } | ||||
| sendRequest(payload, inputs.stream); | |||||
| delete payload.channel_id; | |||||
| sendRequest(payload, inputs.stream, extraHeaders); | |||||
| // 禁用图片模式 | // 禁用图片模式 | ||||
| if (inputs.imageEnabled) { | if (inputs.imageEnabled) { | ||||