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..b200f69bc2bc --- /dev/null +++ b/relay/channel/mineru/adaptor.go @@ -0,0 +1,250 @@ +package mineru + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "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" + +// 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 { + // 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) { +} + +// 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") + } + 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 { + // Multipart passthrough: Content-Type (with boundary) is set by + // DoFormRequest from the incoming request. + // + // 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. + // + // 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 a.validatedBaseURL == "" { + 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 +// 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", 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 == "" { + return false + } + if h == "localhost" { + return true + } + if ip := net.ParseIP(h); ip != nil { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() + } + // 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) { + 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) { + // The response body is streamed back verbatim by MinerUHelper; here we + // only return zero usage (per-call billing). + return &dto.Usage{}, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +// 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") +} + +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..69458d8a950d --- /dev/null +++ b/relay/mineru_handler.go @@ -0,0 +1,74 @@ +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 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) + + 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) + + // 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) + } + 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 < 200 || httpResp.StatusCode > 299 { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + // 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(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()) + } + + // Per-call billing (mineru is quota_type=1, priced per call; zero 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 }[] = (() => {