Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions common/endpoint_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 返回指定端点类型的默认信息以及是否存在
Expand Down
1 change: 1 addition & 0 deletions constant/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ const (
APITypeAdvancedCustom
APITypeSub2API
APITypeNewAPI
APITypeMinerU
APITypeDummy // this one is only for count, do not add any channel after this
)
3 changes: 3 additions & 0 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

)
Expand Down Expand Up @@ -132,6 +133,7 @@ var ChannelBaseURLs = []string{
"", //61
"", //62
"", //63
"", //64
}

func GetChannelBaseURL(channelType int) string {
Expand Down Expand Up @@ -202,6 +204,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeTaskPlugin: "Task Plugin",
ChannelTypeVLLM: "vLLM",
ChannelTypeSGLang: "SGLang",
ChannelTypeMinerU: "MinerU",
}

func GetChannelTypeName(channelType int) string {
Expand Down
1 change: 1 addition & 0 deletions constant/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ const (
EndpointTypeImageGeneration = types.EndpointTypeImageGeneration
EndpointTypeEmbeddings = types.EndpointTypeEmbeddings
EndpointTypeOpenAIVideo = types.EndpointTypeOpenAIVideo
EndpointTypeMinerU = types.EndpointTypeMinerU
)
2 changes: 2 additions & 0 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions middleware/distributor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
141 changes: 141 additions & 0 deletions relay/channel/mineru/adaptor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package mineru

import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"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 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 == "" {
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 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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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, ".")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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")
}
8 changes: 8 additions & 0 deletions relay/common/relay_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions relay/constant/relay_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const (
RelayModeResponsesCompact

RelayModeAlphaSearch

RelayModeMinerU
)

func Path2RelayMode(path string) int {
Expand Down Expand Up @@ -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)
}
Expand Down
13 changes: 13 additions & 0 deletions relay/helper/valid_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions relay/mineru_handler.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions relay/relay_adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions relaykit/dto/mineru.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading