From eb7a062dc65a4d1e5e18196336828257d989f5cc Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 15:35:47 +0800 Subject: [PATCH 1/4] feat(mineru): add MinerU /v1/file_parse relay endpoint - New RelayFormatMinerU / RelayModeMinerU / EndpointTypeMinerU (MinerU) - New channel type 64 (MinerU) + APIType + adaptor (base_url + /file_parse) - POST /v1/file_parse route with multipart passthrough - Distributor defaults model to mineru for file_parse (multipart form) - Per-call billing compatible, dual-channel LB local/upstream - web: channel type 64 label --- common/api_type.go | 2 + common/endpoint_defaults.go | 1 + constant/api_type.go | 1 + constant/channel.go | 3 + constant/endpoint_type.go | 1 + controller/relay.go | 2 + middleware/distributor.go | 7 ++ relay/channel/mineru/adaptor.go | 96 ++++++++++++++++++++++++ relay/common/relay_info.go | 8 ++ relay/constant/relay_mode.go | 4 + relay/helper/valid_request.go | 13 ++++ relay/mineru_handler.go | 66 ++++++++++++++++ relay/relay_adaptor.go | 3 + relaykit/dto/mineru.go | 15 ++++ relaykit/relayconvert/convmeta/format.go | 2 + relaykit/types/endpoint_type.go | 1 + relaykit/types/relay_format.go | 1 + router/relay-router.go | 5 ++ web/src/features/channels/constants.ts | 3 +- 19 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 relay/channel/mineru/adaptor.go create mode 100644 relay/mineru_handler.go create mode 100644 relaykit/dto/mineru.go diff --git a/common/api_type.go b/common/api_type.go index 560dd77e3966..ec64693adc18 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -81,6 +81,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeSub2API case constant.ChannelTypeNewAPI: apiType = constant.APITypeNewAPI + case constant.ChannelTypeMinerU: + apiType = constant.APITypeMinerU } if apiType == -1 { // Task plugin channels are served by the task relay and must never diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index 80ed7c80d446..f4408843d8b9 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -26,6 +26,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, + constant.EndpointTypeMinerU: {Path: "/v1/file_parse", Method: "POST"}, } // GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 diff --git a/constant/api_type.go b/constant/api_type.go index 2a561c6d2bfd..d0ebebb9e55a 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -39,5 +39,6 @@ const ( APITypeAdvancedCustom APITypeSub2API APITypeNewAPI + APITypeMinerU APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 7549bc258eef..75c2ce498e20 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -61,6 +61,7 @@ const ( ChannelTypeTaskPlugin = 61 ChannelTypeVLLM = 62 ChannelTypeSGLang = 63 + ChannelTypeMinerU = 64 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -132,6 +133,7 @@ var ChannelBaseURLs = []string{ "", //61 "", //62 "", //63 + "", //64 } func GetChannelBaseURL(channelType int) string { @@ -202,6 +204,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeTaskPlugin: "Task Plugin", ChannelTypeVLLM: "vLLM", ChannelTypeSGLang: "SGLang", + ChannelTypeMinerU: "MinerU", } func GetChannelTypeName(channelType int) string { diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go index 50eed0d1f478..ef9d8b24f37c 100644 --- a/constant/endpoint_type.go +++ b/constant/endpoint_type.go @@ -17,4 +17,5 @@ const ( EndpointTypeImageGeneration = types.EndpointTypeImageGeneration EndpointTypeEmbeddings = types.EndpointTypeEmbeddings EndpointTypeOpenAIVideo = types.EndpointTypeOpenAIVideo + EndpointTypeMinerU = types.EndpointTypeMinerU ) diff --git a/controller/relay.go b/controller/relay.go index 21c91381906c..a0032be9168b 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -49,6 +49,8 @@ func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIErro err = relay.ResponsesHelper(c, info) case relayconstant.RelayModeAlphaSearch: err = relay.AlphaSearchHelper(c, info) + case relayconstant.RelayModeMinerU: + err = relay.MinerUHelper(c, info) default: err = relay.TextHelper(c, info) } diff --git a/middleware/distributor.go b/middleware/distributor.go index 4595ca3a8632..bb06fc89b4c9 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -441,6 +441,13 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { modelRequest.Model = modelName } c.Set("relay_mode", relayMode) + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/file_parse") { + // MinerU document parsing: multipart form, model optional (defaults to mineru) + if req, err := getModelFromRequest(c); err == nil && req.Model != "" { + modelRequest.Model = req.Model + } + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "mineru") + c.Set("relay_mode", relayconstant.RelayModeMinerU) } else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { req, err := getModelFromRequest(c) if err != nil { diff --git a/relay/channel/mineru/adaptor.go b/relay/channel/mineru/adaptor.go new file mode 100644 index 000000000000..98e0a4525aae --- /dev/null +++ b/relay/channel/mineru/adaptor.go @@ -0,0 +1,96 @@ +package mineru + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + + "github.com/gin-gonic/gin" +) + +const ChannelName = "mineru" + +var ModelList = []string{"mineru"} + +type Adaptor struct { +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +// GetRequestURL 返回渠道 base_url + /file_parse。 +// 本地 MinerU: base_url = http://mineru-api:8000 +// 上游 New-API: base_url = https://api.playground.ai.gcable.cc/v1 +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseUrl := strings.TrimRight(info.ChannelBaseUrl, "/") + if baseUrl == "" { + return "", errors.New("mineru channel base_url is empty") + } + return fmt.Sprintf("%s/file_parse", baseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + // multipart 透传:Content-Type(含 boundary)由 DoFormRequest 按入站请求设置 + if info.ApiKey != "" { + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + } + return nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoFormRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + // 响应体已在 MinerUHelper 中原样透传,此处仅返回零 usage(按次计费) + return &dto.Usage{}, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +// 以下为 channel.Adaptor 接口的占位实现(MinerU 转发不涉及) + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("not implemented") +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 675815a7e632..8f14ea5a55af 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -468,6 +468,12 @@ func GenRelayInfoOpenAIAudio(c *gin.Context, request dto.Request) *RelayInfo { return info } +func GenRelayInfoMinerU(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatMinerU + return info +} + func GenRelayInfoEmbedding(c *gin.Context, request dto.Request) *RelayInfo { info := genBaseRelayInfo(c, request) info.RelayFormat = types.RelayFormatEmbedding @@ -674,6 +680,8 @@ func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Req info = GenRelayInfoOpenAI(c, request) case types.RelayFormatOpenAIAudio: info = GenRelayInfoOpenAIAudio(c, request) + case types.RelayFormatMinerU: + info = GenRelayInfoMinerU(c, request) case types.RelayFormatOpenAIImage: info = GenRelayInfoImage(c, request) case types.RelayFormatOpenAIRealtime: diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index f191c3b66f8d..76d8e862be66 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -49,6 +49,8 @@ const ( RelayModeResponsesCompact RelayModeAlphaSearch + + RelayModeMinerU ) func Path2RelayMode(path string) int { @@ -87,6 +89,8 @@ func Path2RelayMode(path string) int { relayMode = RelayModeRealtime } else if strings.HasPrefix(path, "/v1beta/models") || strings.HasPrefix(path, "/v1/models") { relayMode = RelayModeGemini + } else if strings.HasPrefix(path, "/v1/file_parse") { + relayMode = RelayModeMinerU } else if strings.HasPrefix(path, "/mj") { relayMode = Path2RelayModeMidjourney(path) } diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index b9211ccc5f11..a9220d72e8ae 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -50,6 +50,8 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt request, err = GetAndValidateRerankRequest(c) case types.RelayFormatOpenAIAudio: request, err = GetAndValidAudioRequest(c, relayMode) + case types.RelayFormatMinerU: + request, err = GetAndValidateMinerURequest(c) case types.RelayFormatOpenAIRealtime: request = &dto.BaseRequest{} default: @@ -80,6 +82,17 @@ func GetAndValidAudioRequest(c *gin.Context, relayMode int) (*dto.AudioRequest, return audioRequest, nil } +func GetAndValidateMinerURequest(c *gin.Context) (*dto.MinerURequest, error) { + request := &dto.MinerURequest{} + if err := common.UnmarshalBodyReusable(c, request); err != nil { + return nil, err + } + if request.Model == "" { + request.Model = "mineru" + } + return request, nil +} + func GetAndValidateRerankRequest(c *gin.Context) (*dto.RerankRequest, error) { var rerankRequest *dto.RerankRequest err := common.UnmarshalBodyReusable(c, &rerankRequest) diff --git a/relay/mineru_handler.go b/relay/mineru_handler.go new file mode 100644 index 000000000000..97ef73df4f83 --- /dev/null +++ b/relay/mineru_handler.go @@ -0,0 +1,66 @@ +package relay + +import ( + "errors" + "fmt" + "io" + "net/http" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +// MinerUHelper 将 /v1/file_parse(multipart/form-data)请求转发到 +// 渠道 base_url + /file_parse,并将上游响应原样透传给客户端。 +// 渠道约定: +// - 本地 MinerU: base_url = http://mineru-api:8000 +// - 上游 New-API: base_url = https://api.playground.ai.gcable.cc/v1 +func MinerUHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + if _, ok := info.Request.(*dto.MinerURequest); !ok { + return types.NewError(errors.New("invalid request type"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + // multipart 请求体已由 controller.Relay 置为可重放的 BodyStorage,直接透传 + resp, err := adaptor.DoRequest(c, info, c.Request.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + httpResp, ok := resp.(*http.Response) + if !ok || httpResp == nil { + return types.NewError(errors.New("invalid upstream response"), types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry()) + } + defer httpResp.Body.Close() + + statusCodeMappingStr := c.GetString("status_code_mapping") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + // 原样回传响应(JSON / zip 均透传) + if contentType := httpResp.Header.Get("Content-Type"); contentType != "" { + c.Writer.Header().Set("Content-Type", contentType) + } + if cd := httpResp.Header.Get("Content-Disposition"); cd != "" { + c.Writer.Header().Set("Content-Disposition", cd) + } + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, httpResp.Body) + + // 按次计费(mineru 为 quota_type=1 按次价格,usage 置零) + service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) + return nil +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 68f09087aee1..465632330b52 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -24,6 +24,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/jimeng" "github.com/QuantumNous/new-api/relay/channel/jina" "github.com/QuantumNous/new-api/relay/channel/minimax" + "github.com/QuantumNous/new-api/relay/channel/mineru" "github.com/QuantumNous/new-api/relay/channel/mistral" "github.com/QuantumNous/new-api/relay/channel/mokaai" "github.com/QuantumNous/new-api/relay/channel/moonshot" @@ -123,6 +124,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &sub2api.Adaptor{} case constant.APITypeNewAPI: return &newapi.Adaptor{} + case constant.APITypeMinerU: + return &mineru.Adaptor{} } return nil } diff --git a/relaykit/dto/mineru.go b/relaykit/dto/mineru.go new file mode 100644 index 000000000000..b9c2e4a42f3e --- /dev/null +++ b/relaykit/dto/mineru.go @@ -0,0 +1,15 @@ +package dto + +// MinerURequest 表示 /v1/file_parse 的 multipart 表单请求。 +// 表单内容(files 及解析参数)原样透传给上游 MinerU 服务, +// 这里只提取渠道选择所需的元信息(model,可省略,默认 mineru)。 +type MinerURequest struct { + BaseRequest + Model string `json:"model" form:"model"` +} + +func (r *MinerURequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} diff --git a/relaykit/relayconvert/convmeta/format.go b/relaykit/relayconvert/convmeta/format.go index ab8bf0187387..8eebb7996945 100644 --- a/relaykit/relayconvert/convmeta/format.go +++ b/relaykit/relayconvert/convmeta/format.go @@ -25,6 +25,8 @@ func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) { return types.RelayFormatOpenAIImage, true case *dto.AudioRequest, dto.AudioRequest: return types.RelayFormatOpenAIAudio, true + case *dto.MinerURequest, dto.MinerURequest: + return types.RelayFormatMinerU, true default: return "", false } diff --git a/relaykit/types/endpoint_type.go b/relaykit/types/endpoint_type.go index f2f96fa93c41..a9f5d799c81c 100644 --- a/relaykit/types/endpoint_type.go +++ b/relaykit/types/endpoint_type.go @@ -16,6 +16,7 @@ const ( EndpointTypeImageGeneration EndpointType = "image-generation" EndpointTypeEmbeddings EndpointType = "embeddings" EndpointTypeOpenAIVideo EndpointType = "openai-video" + EndpointTypeMinerU = "MinerU" ) // Finish reasons shared by the OpenAI-compatible response formats. diff --git a/relaykit/types/relay_format.go b/relaykit/types/relay_format.go index 86c2de176e6f..f309b3dafebf 100644 --- a/relaykit/types/relay_format.go +++ b/relaykit/types/relay_format.go @@ -15,6 +15,7 @@ const ( RelayFormatRerank = "rerank" RelayFormatEmbedding = "embedding" + RelayFormatMinerU = "mineru" RelayFormatTask = "task" RelayFormatMjProxy = "mj_proxy" ) diff --git a/router/relay-router.go b/router/relay-router.go index 90deaaa047e9..e37e8fb8e1a9 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -144,6 +144,11 @@ func SetRelayRouter(router *gin.Engine) { controller.Relay(c, types.RelayFormatRerank) }) + // mineru document parsing (file_parse) + httpRouter.POST("/file_parse", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatMinerU) + }) + // gemini relay routes httpRouter.POST("/engines/:model/embeddings", func(c *gin.Context) { controller.Relay(c, types.RelayFormatGemini) diff --git a/web/src/features/channels/constants.ts b/web/src/features/channels/constants.ts index be13e819c7e2..378539f8681a 100644 --- a/web/src/features/channels/constants.ts +++ b/web/src/features/channels/constants.ts @@ -94,6 +94,7 @@ export const CHANNEL_TYPES = { 61: 'Task Plugin', 62: 'vLLM', 63: 'SGLang', + 64: 'MinerU', } as const export type ChannelProviderPresentation = { @@ -185,7 +186,7 @@ export const CHANNEL_PROVIDER_PRESENTATION: Partial< const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ 1, 14, 24, 33, 43, 3, 41, 17, 45, 25, 26, 23, 48, 60, 58, 59, 61, 42, 34, 20, 4, 62, 40, 27, 15, 46, 18, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, - 44, 2, 5, 36, 50, 51, 52, 53, 54, 55, 56, + 44, 2, 5, 36, 50, 51, 52, 53, 54, 55, 56, 64, ] export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => { From 2523a2f1776b4db65ae8053304586005372c11e0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 16:05:22 +0800 Subject: [PATCH 2/4] fix(mineru): address code review findings - adaptor: refuse Bearer credential over cleartext http:// to non-private targets (CWE-319); loopback/RFC1918/single-label hostnames stay allowed for documented local MinerU deployments, everything else requires https - handler: treat all 2xx as success and preserve the upstream status code instead of only http.StatusOK - handler: propagate io.Copy failure as a non-retryable error so the request is not marked successful and the reserved charge is refunded --- relay/channel/mineru/adaptor.go | 57 +++++++++++++++++++++++++++++---- relay/mineru_handler.go | 30 ++++++++++------- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/relay/channel/mineru/adaptor.go b/relay/channel/mineru/adaptor.go index 98e0a4525aae..3f0722f9bf60 100644 --- a/relay/channel/mineru/adaptor.go +++ b/relay/channel/mineru/adaptor.go @@ -4,7 +4,9 @@ import ( "errors" "fmt" "io" + "net" "net/http" + "net/url" "strings" "github.com/QuantumNous/new-api/relay/channel" @@ -25,9 +27,9 @@ type Adaptor struct { func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } -// GetRequestURL 返回渠道 base_url + /file_parse。 -// 本地 MinerU: base_url = http://mineru-api:8000 -// 上游 New-API: base_url = https://api.playground.ai.gcable.cc/v1 +// GetRequestURL returns channel base_url + /file_parse. +// Local MinerU: base_url = http://mineru-api:8000 +// Upstream API: base_url = https://gateway.example.com/v1 func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { baseUrl := strings.TrimRight(info.ChannelBaseUrl, "/") if baseUrl == "" { @@ -37,19 +39,62 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { - // multipart 透传:Content-Type(含 boundary)由 DoFormRequest 按入站请求设置 + // Multipart passthrough: Content-Type (with boundary) is set by + // DoFormRequest from the incoming request. if info.ApiKey != "" { + if err := ensureSecureCredentialTransport(info.ChannelBaseUrl); err != nil { + return err + } req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) } return nil } +// ensureSecureCredentialTransport refuses to send a Bearer credential over +// cleartext http:// to a non-private target (CWE-319). Documented local +// MinerU deployments on loopback / RFC1918 private networks / single-label +// container hostnames (e.g. http://mineru-api:8000) remain supported; +// any other target must use https. +func ensureSecureCredentialTransport(baseURL string) error { + u, err := url.Parse(baseURL) + if err != nil || u.Host == "" { + return fmt.Errorf("invalid mineru channel base_url: %q", baseURL) + } + if strings.EqualFold(u.Scheme, "https") { + return nil + } + if isPrivateOrLocalHost(u.Hostname()) { + return nil + } + return fmt.Errorf("refusing to send Authorization over insecure %s channel base_url %q: use https or a private-network address", u.Scheme, baseURL) +} + +// isPrivateOrLocalHost reports whether host points at a trusted local +// target: "localhost", loopback, RFC1918/RFC4193 private or link-local +// address, or a single-label hostname (container / intranet DNS name). +func isPrivateOrLocalHost(host string) bool { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "" { + return false + } + if h == "localhost" { + return true + } + if ip := net.ParseIP(h); ip != nil { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() + } + // Non-IP literal: single-label hostnames (no dot) are treated as + // container/intranet names, e.g. "mineru-api". + return !strings.Contains(h, ".") +} + func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { return channel.DoFormRequest(a, c, info, requestBody) } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - // 响应体已在 MinerUHelper 中原样透传,此处仅返回零 usage(按次计费) + // The response body is streamed back verbatim by MinerUHelper; here we + // only return zero usage (per-call billing). return &dto.Usage{}, nil } @@ -61,7 +106,7 @@ func (a *Adaptor) GetChannelName() string { return ChannelName } -// 以下为 channel.Adaptor 接口的占位实现(MinerU 转发不涉及) +// Stubs required by the channel.Adaptor interface (not used by MinerU relay). func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { return nil, errors.New("not implemented") diff --git a/relay/mineru_handler.go b/relay/mineru_handler.go index 97ef73df4f83..69458d8a950d 100644 --- a/relay/mineru_handler.go +++ b/relay/mineru_handler.go @@ -14,11 +14,11 @@ import ( "github.com/gin-gonic/gin" ) -// MinerUHelper 将 /v1/file_parse(multipart/form-data)请求转发到 -// 渠道 base_url + /file_parse,并将上游响应原样透传给客户端。 -// 渠道约定: -// - 本地 MinerU: base_url = http://mineru-api:8000 -// - 上游 New-API: base_url = https://api.playground.ai.gcable.cc/v1 +// MinerUHelper forwards /v1/file_parse (multipart/form-data) requests to +// channel base_url + /file_parse and streams the upstream response back to +// the client verbatim. Channel conventions: +// - Local MinerU: base_url = http://mineru-api:8000 +// - Upstream API: base_url = https://gateway.example.com/v1 func MinerUHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { info.InitChannelMeta(c) @@ -32,7 +32,8 @@ func MinerUHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } adaptor.Init(info) - // multipart 请求体已由 controller.Relay 置为可重放的 BodyStorage,直接透传 + // The multipart body has already been made replayable (BodyStorage) by + // controller.Relay; forward it as-is. resp, err := adaptor.DoRequest(c, info, c.Request.Body) if err != nil { return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) @@ -44,23 +45,30 @@ func MinerUHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ defer httpResp.Body.Close() statusCodeMappingStr := c.GetString("status_code_mapping") - if httpResp.StatusCode != http.StatusOK { + if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 { newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } - // 原样回传响应(JSON / zip 均透传) + // Stream the successful response back verbatim (JSON / ZIP alike), + // preserving the upstream status code. if contentType := httpResp.Header.Get("Content-Type"); contentType != "" { c.Writer.Header().Set("Content-Type", contentType) } if cd := httpResp.Header.Get("Content-Disposition"); cd != "" { c.Writer.Header().Set("Content-Disposition", cd) } - c.Status(http.StatusOK) - _, _ = io.Copy(c.Writer, httpResp.Body) + c.Status(httpResp.StatusCode) + if _, err := io.Copy(c.Writer, httpResp.Body); err != nil { + // The 2xx status line is already committed and cannot be replaced; + // return a non-retryable error so the request is not marked + // successful and the reserved charge gets refunded on the existing + // failure path. + return types.NewError(err, types.ErrorCodeReadResponseBodyFailed, types.ErrOptionWithSkipRetry()) + } - // 按次计费(mineru 为 quota_type=1 按次价格,usage 置零) + // Per-call billing (mineru is quota_type=1, priced per call; zero usage). service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) return nil } From 47d1872b49ad05b35a9561fc827c9f7f8152df1f Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 16:21:56 +0800 Subject: [PATCH 3/4] fix(mineru): close credential-transport bypasses found in review - run ensureSecureCredentialTransport also when the effective channel Authorization header override is non-empty (DoFormRequest applies overrides after SetupRequestHeader, which could otherwise bypass the check on channels with an empty ApiKey) - single-label hostnames are now trusted only when they resolve exclusively to loopback/RFC1918/link-local addresses (3s bounded resolution, fail closed); public resolutions are rejected before the credential is sent --- relay/channel/mineru/adaptor.go | 57 +++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/relay/channel/mineru/adaptor.go b/relay/channel/mineru/adaptor.go index 3f0722f9bf60..b6de06a5de22 100644 --- a/relay/channel/mineru/adaptor.go +++ b/relay/channel/mineru/adaptor.go @@ -1,6 +1,7 @@ package mineru import ( + "context" "errors" "fmt" "io" @@ -8,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/QuantumNous/new-api/relay/channel" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -19,6 +21,10 @@ import ( const ChannelName = "mineru" +// dnsResolveTimeout bounds hostname resolution during the credential +// transport check so an unresponsive resolver cannot stall the request. +const dnsResolveTimeout = 3 * time.Second + var ModelList = []string{"mineru"} type Adaptor struct { @@ -41,15 +47,36 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { // Multipart passthrough: Content-Type (with boundary) is set by // DoFormRequest from the incoming request. - if info.ApiKey != "" { + // + // The transport check must also cover channels that leave ApiKey empty + // and inject the credential through a channel-level Authorization + // header override instead: DoFormRequest applies header overrides after + // this hook, so an override would otherwise bypass the check. + if info.ApiKey != "" || hasAuthorizationOverride(info) { if err := ensureSecureCredentialTransport(info.ChannelBaseUrl); err != nil { return err } + } + if info.ApiKey != "" { req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) } return nil } +// hasAuthorizationOverride reports whether the effective channel header +// override configures a non-empty Authorization header. +func hasAuthorizationOverride(info *relaycommon.RelayInfo) bool { + for key, value := range relaycommon.GetEffectiveHeaderOverride(info) { + if !strings.EqualFold(strings.TrimSpace(key), "authorization") { + continue + } + if str, ok := value.(string); ok && strings.TrimSpace(str) != "" { + return true + } + } + return false +} + // ensureSecureCredentialTransport refuses to send a Bearer credential over // cleartext http:// to a non-private target (CWE-319). Documented local // MinerU deployments on loopback / RFC1918 private networks / single-label @@ -70,8 +97,9 @@ func ensureSecureCredentialTransport(baseURL string) error { } // isPrivateOrLocalHost reports whether host points at a trusted local -// target: "localhost", loopback, RFC1918/RFC4193 private or link-local -// address, or a single-label hostname (container / intranet DNS name). +// target: "localhost", a loopback / RFC1918-RFC4193 private / link-local +// address, or a single-label hostname (container / intranet DNS name) that +// resolves exclusively to such addresses. Resolution failures fail closed. func isPrivateOrLocalHost(host string) bool { h := strings.ToLower(strings.TrimSpace(host)) if h == "" { @@ -83,9 +111,26 @@ func isPrivateOrLocalHost(host string) bool { if ip := net.ParseIP(h); ip != nil { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() } - // Non-IP literal: single-label hostnames (no dot) are treated as - // container/intranet names, e.g. "mineru-api". - return !strings.Contains(h, ".") + // Non-IP literal: multi-label names are not trusted; single-label + // hostnames are trusted only when every resolved address is + // private/local, so a name that resolves to a public address is + // rejected before the credential is sent. + if strings.Contains(h, ".") { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), dnsResolveTimeout) + defer cancel() + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, h) + if err != nil || len(addrs) == 0 { + // Fail closed: an unresolvable host never receives credentials. + return false + } + for _, addr := range addrs { + if !(addr.IP.IsLoopback() || addr.IP.IsPrivate() || addr.IP.IsLinkLocalUnicast()) { + return false + } + } + return true } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { From 736cc30880606639c29b379821a084e9bf64f6dd Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 16:52:56 +0800 Subject: [PATCH 4/4] fix(mineru): pin validated address to close DNS rebinding window http:// single-label hostnames are now resolved, validated and pinned to the IP literal inside the request URL by GetRequestURL, so the shared http.Transport dials the address that passed validation instead of re-resolving the hostname (CWE-319 TOCTOU). Public resolutions are rejected fail-closed; SetupRequestHeader skips the redundant re-check when the transport was already pinned; https and IP-literal URLs are unchanged (IP literals cannot rebind). --- relay/channel/mineru/adaptor.go | 70 +++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/relay/channel/mineru/adaptor.go b/relay/channel/mineru/adaptor.go index b6de06a5de22..b200f69bc2bc 100644 --- a/relay/channel/mineru/adaptor.go +++ b/relay/channel/mineru/adaptor.go @@ -28,6 +28,11 @@ const dnsResolveTimeout = 3 * time.Second var ModelList = []string{"mineru"} type Adaptor struct { + // validatedBaseURL is set by GetRequestURL when the base_url is an + // http:// single-label hostname: the host is replaced by a validated + // private/local IP literal, and this field records that the transport + // has already been validated (and pinned) for this request. + validatedBaseURL string } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -36,12 +41,65 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { // GetRequestURL returns channel base_url + /file_parse. // Local MinerU: base_url = http://mineru-api:8000 // Upstream API: base_url = https://gateway.example.com/v1 +// +// For http:// single-label hostnames the host is resolved, validated and +// pinned to the IP literal inside the returned URL, so the shared +// http.Transport cannot re-resolve the hostname to a different address at +// dial time (DNS rebinding). func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { baseUrl := strings.TrimRight(info.ChannelBaseUrl, "/") if baseUrl == "" { return "", errors.New("mineru channel base_url is empty") } - return fmt.Sprintf("%s/file_parse", baseUrl), nil + a.validatedBaseURL = "" + pinned, pinnedApplied, err := pinSingleLabelHost(baseUrl) + if err != nil { + return "", err + } + if pinnedApplied { + a.validatedBaseURL = pinned + } + return fmt.Sprintf("%s/file_parse", pinned), nil +} + +// pinSingleLabelHost resolves an http:// single-label hostname once and +// returns the URL with the host replaced by a validated private/local IP +// literal (CWE-319: binds the dial to the address that passed validation). +// https URLs and IP literals are returned unchanged (IP literals cannot +// rebind; https is always allowed). A single-label host that resolves to +// any public address, or fails to resolve, is rejected (fail closed). +// The outgoing Host header becomes the pinned IP literal; channels that +// need the original Host can set a channel-level "Host" header override. +func pinSingleLabelHost(baseURL string) (string, bool, error) { + u, err := url.Parse(baseURL) + if err != nil || u.Host == "" { + return baseURL, false, fmt.Errorf("invalid mineru channel base_url: %q", baseURL) + } + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + if strings.EqualFold(u.Scheme, "https") || net.ParseIP(host) != nil || strings.Contains(host, ".") { + return baseURL, false, nil + } + ctx, cancel := context.WithTimeout(context.Background(), dnsResolveTimeout) + defer cancel() + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(addrs) == 0 { + return baseURL, false, fmt.Errorf("mineru channel host %q could not be resolved to a private address: use https or verify the hostname", host) + } + var pinned net.IP + for _, addr := range addrs { + if !(addr.IP.IsLoopback() || addr.IP.IsPrivate() || addr.IP.IsLinkLocalUnicast()) { + return baseURL, false, fmt.Errorf("refusing insecure http channel base_url %q: host %q resolves to non-private address %s; use https or a private-network address", baseURL, host, addr.IP) + } + if pinned == nil { + pinned = addr.IP + } + } + pinnedHost := pinned.String() + if port := u.Port(); port != "" { + pinnedHost = net.JoinHostPort(pinned.String(), port) + } + u.Host = pinnedHost + return u.String(), true, nil } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { @@ -52,9 +110,15 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel // and inject the credential through a channel-level Authorization // header override instead: DoFormRequest applies header overrides after // this hook, so an override would otherwise bypass the check. + // + // When GetRequestURL already validated and pinned an http single-label + // host (validatedBaseURL != ""), the dial is bound to the validated + // address and the check is not repeated. if info.ApiKey != "" || hasAuthorizationOverride(info) { - if err := ensureSecureCredentialTransport(info.ChannelBaseUrl); err != nil { - return err + if a.validatedBaseURL == "" { + if err := ensureSecureCredentialTransport(info.ChannelBaseUrl); err != nil { + return err + } } } if info.ApiKey != "" {