- 错误日志新增 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_name"] = c.GetString("channel_name") | |||
| 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["use_channel"] = c.GetStringSlice("use_channel") | |||
| isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) | |||
| @@ -367,10 +367,18 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { | |||
| return nil, false, err | |||
| } | |||
| 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) | |||
| // channel_id: body 优先,fallback 到 header | |||
| if req.ChannelId > 0 { | |||
| 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) { | |||
| 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) | |||
| if err != nil { | |||
| return | |||
| } | |||
| 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 | |||
| buildErrWithBody := func(message string) error { | |||
| if message == "" { | |||
| @@ -115,6 +129,8 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai | |||
| oaiError := errResponse.TryToOpenAIError() | |||
| if oaiError != nil { | |||
| newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode) | |||
| newApiErr.UpstreamRequestId = upstreamReqId | |||
| newApiErr.UpstreamBody = bodyStr | |||
| if showBodyWhenFail { | |||
| 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.UpstreamRequestId = upstreamReqId | |||
| newApiErr.UpstreamBody = bodyStr | |||
| if showBodyWhenFail { | |||
| newApiErr.Err = buildErrWithBody(newApiErr.Error()) | |||
| } | |||
| @@ -88,14 +88,16 @@ const ( | |||
| ) | |||
| 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. | |||
| @@ -173,7 +173,7 @@ export const useApiRequest = ( | |||
| // 非流式请求 | |||
| const handleNonStreamRequest = useCallback( | |||
| async (payload) => { | |||
| async (payload, extraHeaders = {}) => { | |||
| setDebugData((prev) => ({ | |||
| ...prev, | |||
| request: payload, | |||
| @@ -190,6 +190,7 @@ export const useApiRequest = ( | |||
| headers: { | |||
| 'Content-Type': 'application/json', | |||
| 'New-Api-User': getUserIdFromLocalStorage(), | |||
| ...extraHeaders, | |||
| }, | |||
| body: JSON.stringify(payload), | |||
| }); | |||
| @@ -290,7 +291,7 @@ export const useApiRequest = ( | |||
| // SSE请求 | |||
| const handleSSE = useCallback( | |||
| (payload) => { | |||
| (payload, extraHeaders = {}) => { | |||
| setDebugData((prev) => ({ | |||
| ...prev, | |||
| request: payload, | |||
| @@ -305,6 +306,7 @@ export const useApiRequest = ( | |||
| headers: { | |||
| 'Content-Type': 'application/json', | |||
| 'New-Api-User': getUserIdFromLocalStorage(), | |||
| ...extraHeaders, | |||
| }, | |||
| method: 'POST', | |||
| payload: JSON.stringify(payload), | |||
| @@ -499,11 +501,11 @@ export const useApiRequest = ( | |||
| // 发送请求 | |||
| const sendRequest = useCallback( | |||
| (payload, isStream) => { | |||
| (payload, isStream, extraHeaders = {}) => { | |||
| if (isStream) { | |||
| handleSSE(payload); | |||
| handleSSE(payload, extraHeaders); | |||
| } else { | |||
| handleNonStreamRequest(payload); | |||
| handleNonStreamRequest(payload, extraHeaders); | |||
| } | |||
| }, | |||
| [handleSSE, handleNonStreamRequest], | |||
| @@ -286,8 +286,17 @@ const Playground = () => { | |||
| setMessage((prevMessage) => { | |||
| 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); | |||
| @@ -323,10 +332,16 @@ const Playground = () => { | |||
| inputs, | |||
| parameterEnabled, | |||
| ); | |||
| const extraHeaders = {}; | |||
| 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) { | |||