From 35538ecb3b11614544283df1c83785fb52621ca6 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 3 Nov 2025 17:33:02 +0800 Subject: [PATCH 01/72] fix: ensure overwrite works correctly when no missing models --- controller/model_sync.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/controller/model_sync.go b/controller/model_sync.go index e321ee0c5..38eace06e 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -260,14 +260,6 @@ func SyncUpstreamModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } - if len(missing) == 0 { - c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ - "created_models": 0, - "created_vendors": 0, - "skipped_models": []string{}, - }}) - return - } // 2) 拉取上游 vendors 与 models timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) From 138810f19cb56010221901bfec3106b64206615a Mon Sep 17 00:00:00 2001 From: NoahCode Date: Sat, 8 Nov 2025 20:33:14 +0800 Subject: [PATCH 02/72] fix(channel): update channel identification logic in error processing --- controller/relay.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/relay.go b/controller/relay.go index f8a233e99..1049dc2de 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -285,7 +285,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously - if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan { + if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan { gopool.Go(func() { service.DisableChannel(channelError, err.Error()) }) From c6125eccb1aacb356bda8222205d37354da0635d Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Sat, 15 Nov 2025 13:24:00 +0800 Subject: [PATCH 03/72] fix: Set default to unsupported value for gpt-5 model series requests --- relay/channel/openai/adaptor.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 55bd1402c..035288978 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -306,10 +306,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn request.Temperature = nil } + // gpt-5系列模型适配 归零不再支持的参数 if strings.HasPrefix(info.UpstreamModelName, "gpt-5") { - if info.UpstreamModelName != "gpt-5-chat-latest" { - request.Temperature = nil - } + request.Temperature = nil + request.TopP = 0 // oai 的 top_p 默认值是 1.0,但是为了 omitempty 属性直接不传,这里显式设置为 0 + request.LogProbs = false } // 转换模型推理力度后缀 From 2e37347851bf924558a95eb096efbefe7de1b4b6 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 2 Dec 2025 22:56:58 +0800 Subject: [PATCH 04/72] feat: zhipu v4 image generations --- dto/openai_image.go | 7 +- relay/channel/zhipu_4v/adaptor.go | 8 +- relay/channel/zhipu_4v/image.go | 127 ++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 relay/channel/zhipu_4v/image.go diff --git a/dto/openai_image.go b/dto/openai_image.go index bf35b0b12..130d1dde8 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -27,8 +27,11 @@ type ImageRequest struct { OutputCompression json.RawMessage `json:"output_compression,omitempty"` PartialImages json.RawMessage `json:"partial_images,omitempty"` // Stream bool `json:"stream,omitempty"` - Watermark *bool `json:"watermark,omitempty"` - Image json.RawMessage `json:"image,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + // zhipu 4v + WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"` + UserId json.RawMessage `json:"user_id,omitempty"` + Image json.RawMessage `json:"image,omitempty"` // 用匿名参数接收额外参数 Extra map[string]json.RawMessage `json:"-"` } diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index 4fd6956eb..b11bea105 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -36,8 +36,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { - //TODO implement me - return nil, errors.New("not implemented") + return request, nil } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -63,6 +62,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/embeddings", specialPlan.OpenAIBaseURL), nil } return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil + case relayconstant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil default: if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil @@ -114,6 +115,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) } default: + if info.RelayMode == relayconstant.RelayModeImagesGenerations { + return zhipu4vImageHandler(c, resp, info) + } adaptor := openai.Adaptor{} return adaptor.DoResponse(c, resp, info) } diff --git a/relay/channel/zhipu_4v/image.go b/relay/channel/zhipu_4v/image.go new file mode 100644 index 000000000..b1fd2c8e3 --- /dev/null +++ b/relay/channel/zhipu_4v/image.go @@ -0,0 +1,127 @@ +package zhipu_4v + +import ( + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type zhipuImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Quality string `json:"quality,omitempty"` + Size string `json:"size,omitempty"` + WatermarkEnabled *bool `json:"watermark_enabled,omitempty"` + UserID string `json:"user_id,omitempty"` +} + +type zhipuImageResponse struct { + Created *int64 `json:"created,omitempty"` + Data []zhipuImageData `json:"data,omitempty"` + ContentFilter any `json:"content_filter,omitempty"` + Usage *dto.Usage `json:"usage,omitempty"` + Error *zhipuImageError `json:"error,omitempty"` + RequestID string `json:"request_id,omitempty"` + ExtendParam map[string]string `json:"extendParam,omitempty"` +} + +type zhipuImageError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type zhipuImageData struct { + Url string `json:"url,omitempty"` + ImageUrl string `json:"image_url,omitempty"` + B64Json string `json:"b64_json,omitempty"` + B64Image string `json:"b64_image,omitempty"` +} + +type openAIImagePayload struct { + Created int64 `json:"created"` + Data []openAIImageData `json:"data"` +} + +type openAIImageData struct { + B64Json string `json:"b64_json"` +} + +func zhipu4vImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + + var zhipuResp zhipuImageResponse + if err := common.Unmarshal(responseBody, &zhipuResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if zhipuResp.Error != nil && zhipuResp.Error.Message != "" { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: zhipuResp.Error.Message, + Type: "zhipu_image_error", + Code: zhipuResp.Error.Code, + }, resp.StatusCode) + } + + payload := openAIImagePayload{} + if zhipuResp.Created != nil && *zhipuResp.Created != 0 { + payload.Created = *zhipuResp.Created + } else { + payload.Created = info.StartTime.Unix() + } + for _, data := range zhipuResp.Data { + url := data.Url + if url == "" { + url = data.ImageUrl + } + if url == "" { + logger.LogWarn(c, "zhipu_image_missing_url") + continue + } + + var b64 string + switch { + case data.B64Json != "": + b64 = data.B64Json + case data.B64Image != "": + b64 = data.B64Image + default: + _, downloaded, err := service.GetImageFromUrl(url) + if err != nil { + logger.LogError(c, "zhipu_image_get_b64_failed: "+err.Error()) + continue + } + b64 = downloaded + } + + if b64 == "" { + logger.LogWarn(c, "zhipu_image_empty_b64") + continue + } + + imageData := openAIImageData{ + B64Json: b64, + } + payload.Data = append(payload.Data, imageData) + } + + jsonResp, err := common.Marshal(payload) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + service.IOCopyBytesGracefully(c, resp, jsonResp) + + return &dto.Usage{}, nil +} From 8e7be254290c299863dace279e4ab29858a0ed5d Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 2 Dec 2025 23:15:20 +0800 Subject: [PATCH 05/72] chore(go): enable greenteagc --- Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index c7348add8..d737e3d93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS ARG TARGETARCH ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} - +ENV GOEXPERIMENT=greenteagc WORKDIR /build @@ -25,10 +25,11 @@ COPY . . COPY --from=builder /build/dist ./web/dist RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api -FROM alpine +FROM debian:bookworm-slim -RUN apk upgrade --no-cache \ - && apk add --no-cache ca-certificates tzdata \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 \ + && rm -rf /var/lib/apt/lists/* \ && update-ca-certificates COPY --from=builder2 /build/new-api / From 896e4ac671b115d1e2c0c23f37a7c42a9cd492d6 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 3 Dec 2025 00:41:47 +0800 Subject: [PATCH 06/72] fix: regex repeat compile --- common/json.go | 6 +++--- common/str.go | 26 ++++++++++++++------------ go.mod | 2 +- go.sum | 2 ++ relay/common/override.go | 5 +++-- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/common/json.go b/common/json.go index a65da462e..54f8baa34 100644 --- a/common/json.go +++ b/common/json.go @@ -23,11 +23,11 @@ func Marshal(v any) ([]byte, error) { } func GetJsonType(data json.RawMessage) string { - data = bytes.TrimSpace(data) - if len(data) == 0 { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { return "unknown" } - firstChar := bytes.TrimSpace(data)[0] + firstChar := trimmed[0] switch firstChar { case '{': return "object" diff --git a/common/str.go b/common/str.go index 6debce28b..a5ac5d447 100644 --- a/common/str.go +++ b/common/str.go @@ -3,12 +3,19 @@ package common import ( "encoding/base64" "encoding/json" - "math/rand" "net/url" "regexp" "strconv" "strings" "unsafe" + + "github.com/samber/lo" +) + +var ( + maskURLPattern = regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`) + maskDomainPattern = regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`) + maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) ) func GetStringIfEmpty(str string, defaultValue string) string { @@ -19,12 +26,10 @@ func GetStringIfEmpty(str string, defaultValue string) string { } func GetRandomString(length int) string { - //rand.Seed(time.Now().UnixNano()) - key := make([]byte, length) - for i := 0; i < length; i++ { - key[i] = keyChars[rand.Intn(len(keyChars))] + if length <= 0 { + return "" } - return string(key) + return lo.RandomString(length, lo.AlphanumericCharset) } func MapToJsonStr(m map[string]interface{}) string { @@ -170,8 +175,7 @@ func maskHostForPlainDomain(domain string) string { // api.openai.com -> ***.***.com func MaskSensitiveInfo(str string) string { // Mask URLs - urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`) - str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string { + str = maskURLPattern.ReplaceAllStringFunc(str, func(urlStr string) string { u, err := url.Parse(urlStr) if err != nil { return urlStr @@ -224,14 +228,12 @@ func MaskSensitiveInfo(str string) string { }) // Mask domain names without protocol (like openai.com, www.openai.com) - domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`) - str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string { + str = maskDomainPattern.ReplaceAllStringFunc(str, func(domain string) string { return maskHostForPlainDomain(domain) }) // Mask IP addresses - ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) - str = ipPattern.ReplaceAllString(str, "***.***.***.***") + str = maskIPPattern.ReplaceAllString(str, "***.***.***.***") return str } diff --git a/go.mod b/go.mod index ff03c03d3..f315e428f 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/mewkiz/flac v1.0.13 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 - github.com/samber/lo v1.39.0 + github.com/samber/lo v1.52.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 github.com/stripe/stripe-go/v81 v81.4.0 diff --git a/go.sum b/go.sum index f43717973..48a607d54 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,8 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= diff --git a/relay/common/override.go b/relay/common/override.go index 1d0794d26..3850218c3 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -11,6 +11,8 @@ import ( "github.com/tidwall/sjson" ) +var negativeIndexRegexp = regexp.MustCompile(`\.(-\d+)`) + type ConditionOperation struct { Path string `json:"path"` // JSON路径 Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte @@ -186,8 +188,7 @@ func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperat } func processNegativeIndex(jsonStr string, path string) string { - re := regexp.MustCompile(`\.(-\d+)`) - matches := re.FindAllStringSubmatch(path, -1) + matches := negativeIndexRegexp.FindAllStringSubmatch(path, -1) if len(matches) == 0 { return path From c07347f24f721291c6aed80e3d465a45bb10e42a Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 3 Dec 2025 00:47:40 +0800 Subject: [PATCH 07/72] fix: qwen chat_template_kwargs --- dto/openai_request.go | 1 + 1 file changed, 1 insertion(+) diff --git a/dto/openai_request.go b/dto/openai_request.go index fccb44af0..5415e67f3 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -83,6 +83,7 @@ type GeneralOpenAIRequest struct { // Ali Qwen Params VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` EnableThinking any `json:"enable_thinking,omitempty"` + ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"` // ollama Params Think json.RawMessage `json:"think,omitempty"` // baidu v2 From 293a5de0f8aa555b7f2bf0c816fa43454f1f8c84 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 2 Dec 2025 23:10:32 +0800 Subject: [PATCH 08/72] feat: update price display use current currency symbol --- web/src/helpers/render.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 425abb318..450c5799b 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1086,9 +1086,12 @@ function renderPriceSimpleCore({ ); const finalGroupRatio = effectiveGroupRatio; + const { symbol, rate } = getCurrencyConfig(); if (modelPrice !== -1) { - return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', { - price: modelPrice, + const displayPrice = (modelPrice * rate).toFixed(6); + return i18next.t('价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}}', { + symbol: symbol, + price: displayPrice, ratioType: ratioLabel, ratio: finalGroupRatio, }); From d64205e35afa6cdef0519573092c5fe0b33db17f Mon Sep 17 00:00:00 2001 From: oudi Date: Thu, 4 Dec 2025 11:18:51 +0800 Subject: [PATCH 09/72] Increase token name length limit from 30 to 50 --- controller/token.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/token.go b/controller/token.go index 04e31f8c1..0f0ae7fdf 100644 --- a/controller/token.go +++ b/controller/token.go @@ -142,7 +142,7 @@ func AddToken(c *gin.Context) { common.ApiError(c, err) return } - if len(token.Name) > 30 { + if len(token.Name) > 50 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "令牌名称过长", @@ -208,7 +208,7 @@ func UpdateToken(c *gin.Context) { common.ApiError(c, err) return } - if len(token.Name) > 30 { + if len(token.Name) > 50 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "令牌名称过长", From c3c119a9b41dbd2dabbdcc334e301fd9de856cfb Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Fri, 5 Dec 2025 18:54:20 +0800 Subject: [PATCH 10/72] feat: add claude-haiku-4-5-20251001 model support - Add model to Claude ModelList - Add model ratio (0.5, $1/1M input tokens) - Add completion ratio support (5x, $5/1M output tokens) - Add cache read ratio (0.1, $0.10/1M tokens) - Add cache write ratio (1.25, $1.25/1M tokens) Model specs: - Context window: 200K tokens - Max output: 64K tokens - Release date: October 1, 2025 --- relay/channel/claude/constants.go | 1 + setting/ratio_setting/cache_ratio.go | 2 ++ setting/ratio_setting/model_ratio.go | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index a31f34162..7debb353e 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -9,6 +9,7 @@ var ModelList = []string{ "claude-3-opus-20240229", "claude-3-haiku-20240307", "claude-3-5-haiku-20241022", + "claude-haiku-4-5-20251001", "claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20241022", "claude-3-7-sonnet-20250219", diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 3b317bc18..cf54cb313 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -43,6 +43,7 @@ var defaultCacheRatio = map[string]float64{ "claude-3-opus-20240229": 0.1, "claude-3-haiku-20240307": 0.1, "claude-3-5-haiku-20241022": 0.1, + "claude-haiku-4-5-20251001": 0.1, "claude-3-5-sonnet-20240620": 0.1, "claude-3-5-sonnet-20241022": 0.1, "claude-3-7-sonnet-20250219": 0.1, @@ -64,6 +65,7 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-3-opus-20240229": 1.25, "claude-3-haiku-20240307": 1.25, "claude-3-5-haiku-20241022": 1.25, + "claude-haiku-4-5-20251001": 1.25, "claude-3-5-sonnet-20240620": 1.25, "claude-3-5-sonnet-20241022": 1.25, "claude-3-7-sonnet-20250219": 1.25, diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index bd533db5c..bef82e57e 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -137,6 +137,7 @@ var defaultModelRatio = map[string]float64{ "claude-2.1": 4, // $8 / 1M tokens "claude-3-haiku-20240307": 0.125, // $0.25 / 1M tokens "claude-3-5-haiku-20241022": 0.5, // $1 / 1M tokens + "claude-haiku-4-5-20251001": 0.5, // $1 / 1M tokens "claude-3-sonnet-20240229": 1.5, // $3 / 1M tokens "claude-3-5-sonnet-20240620": 1.5, "claude-3-5-sonnet-20241022": 1.5, @@ -560,7 +561,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { if strings.Contains(name, "claude-3") { return 5, true - } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") { + } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") || strings.Contains(name, "claude-haiku-4") { return 5, true } else if strings.Contains(name, "claude-instant-1") || strings.Contains(name, "claude-2") { return 3, true From 121746a79e3925e2d9cf1f0df2017b4d8f65c6ac Mon Sep 17 00:00:00 2001 From: firstmelody Date: Mon, 8 Dec 2025 01:12:29 +0800 Subject: [PATCH 11/72] fix(adaptor): fix reasoning suffix not processing in vertex adapter --- relay/channel/vertex/adaptor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 920041ce6..c47eeccc1 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -17,6 +17,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -181,6 +182,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") + } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { + info.UpstreamModelName = baseModel } } From 3d282ac5480e5ef278a7bed9acdefcc16ba2cfca Mon Sep 17 00:00:00 2001 From: borx <53216212+binorxin@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:16:30 +0800 Subject: [PATCH 12/72] =?UTF-8?q?fix(go.mod):=20=E6=9B=B4=E6=96=B0modernc.?= =?UTF-8?q?org/sqlite=E4=BE=9D=E8=B5=96=E9=A1=B9=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 11 ++++++----- go.sum | 13 +++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index ff03c03d3..11a846e2f 100644 --- a/go.mod +++ b/go.mod @@ -99,6 +99,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tidwall/match v1.1.1 // indirect @@ -110,13 +111,13 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.21.0 // indirect - golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.40.1 // indirect ) diff --git a/go.sum b/go.sum index f43717973..a39ab97ce 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,7 @@ github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -193,6 +194,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -285,6 +288,8 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -345,9 +350,17 @@ gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= From c7539d11a0e8ed6c652165cc44e09dc37c89e2be Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 8 Dec 2025 21:14:50 +0800 Subject: [PATCH 13/72] fix: fetch upstream models --- controller/channel.go | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 809a2e932..b2db2b777 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -165,6 +165,30 @@ func GetAllChannels(c *gin.Context) { return } +func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, error) { + var headers http.Header + switch channel.Type { + case constant.ChannelTypeAnthropic: + headers = GetClaudeAuthHeader(key) + default: + headers = GetAuthHeader(key) + } + + headerOverride := channel.GetHeaderOverride() + for k, v := range headerOverride { + str, ok := v.(string) + if !ok { + return nil, fmt.Errorf("invalid header override for key %s", k) + } + if strings.Contains(str, "{api_key}") { + str = strings.ReplaceAll(str, "{api_key}", key) + } + headers.Set(k, str) + } + + return headers, nil +} + func FetchUpstreamModels(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -223,14 +247,13 @@ func FetchUpstreamModels(c *gin.Context) { } key = strings.TrimSpace(key) - // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader - var body []byte - switch channel.Type { - case constant.ChannelTypeAnthropic: - body, err = GetResponseBody("GET", url, channel, GetClaudeAuthHeader(key)) - default: - body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) + headers, err := buildFetchModelsHeaders(channel, key) + if err != nil { + common.ApiError(c, err) + return } + + body, err := GetResponseBody("GET", url, channel, headers) if err != nil { common.ApiError(c, err) return From ea70c20f8eade6d9a8ad8989484df9bb606d28d4 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 8 Dec 2025 21:25:21 +0800 Subject: [PATCH 14/72] fix: sidebar color overlap --- web/src/components/layout/SiderBar.jsx | 2 +- web/src/index.css | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 39d6d4489..1b6fc6924 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -377,7 +377,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { className='sidebar-container' style={{ width: 'var(--sidebar-current-width)', - background: 'var(--semi-color-bg-0)', + background: 'var(--semi-color-bg-1)', }} > Date: Mon, 8 Dec 2025 22:32:45 +0800 Subject: [PATCH 15/72] fix: Try to fix login error "already logged in" issue --- web/src/components/auth/LoginForm.jsx | 13 +++++--- web/src/components/auth/RegisterForm.jsx | 13 +++++--- web/src/helpers/api.js | 38 +++++++++++++++++++----- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 489de2276..8beb0f085 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -294,7 +294,7 @@ const LoginForm = () => { setGithubButtonDisabled(true); }, 20000); try { - onGitHubOAuthClicked(status.github_client_id); + onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true }); } finally { // 由于重定向,这里不会执行到,但为了完整性添加 setTimeout(() => setGithubLoading(false), 3000); @@ -309,7 +309,7 @@ const LoginForm = () => { } setDiscordLoading(true); try { - onDiscordOAuthClicked(status.discord_client_id); + onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true }); } finally { // 由于重定向,这里不会执行到,但为了完整性添加 setTimeout(() => setDiscordLoading(false), 3000); @@ -324,7 +324,12 @@ const LoginForm = () => { } setOidcLoading(true); try { - onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id); + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id, + false, + { shouldLogout: true }, + ); } finally { // 由于重定向,这里不会执行到,但为了完整性添加 setTimeout(() => setOidcLoading(false), 3000); @@ -339,7 +344,7 @@ const LoginForm = () => { } setLinuxdoLoading(true); try { - onLinuxDOOAuthClicked(status.linuxdo_client_id); + onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true }); } finally { // 由于重定向,这里不会执行到,但为了完整性添加 setTimeout(() => setLinuxdoLoading(false), 3000); diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 021a7803d..c6b5bc183 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -261,7 +261,7 @@ const RegisterForm = () => { setGithubButtonDisabled(true); }, 20000); try { - onGitHubOAuthClicked(status.github_client_id); + onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true }); } finally { setTimeout(() => setGithubLoading(false), 3000); } @@ -270,7 +270,7 @@ const RegisterForm = () => { const handleDiscordClick = () => { setDiscordLoading(true); try { - onDiscordOAuthClicked(status.discord_client_id); + onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true }); } finally { setTimeout(() => setDiscordLoading(false), 3000); } @@ -279,7 +279,12 @@ const RegisterForm = () => { const handleOIDCClick = () => { setOidcLoading(true); try { - onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id); + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id, + false, + { shouldLogout: true }, + ); } finally { setTimeout(() => setOidcLoading(false), 3000); } @@ -288,7 +293,7 @@ const RegisterForm = () => { const handleLinuxDOClick = () => { setLinuxdoLoading(true); try { - onLinuxDOOAuthClicked(status.linuxdo_client_id); + onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true }); } finally { setTimeout(() => setLinuxdoLoading(false), 3000); } diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index b87e5a2f8..6e09bf43c 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -231,8 +231,22 @@ export async function getOAuthState() { } } -export async function onDiscordOAuthClicked(client_id) { - const state = await getOAuthState(); +async function prepareOAuthState(options = {}) { + const { shouldLogout = false } = options; + if (shouldLogout) { + try { + await API.get('/api/user/logout', { skipErrorHandler: true }); + } catch (err) { + + } + localStorage.removeItem('user'); + updateAPI(); + } + return await getOAuthState(); +} + +export async function onDiscordOAuthClicked(client_id, options = {}) { + const state = await prepareOAuthState(options); if (!state) return; const redirect_uri = `${window.location.origin}/oauth/discord`; const response_type = 'code'; @@ -242,8 +256,13 @@ export async function onDiscordOAuthClicked(client_id) { ); } -export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) { - const state = await getOAuthState(); +export async function onOIDCClicked( + auth_url, + client_id, + openInNewTab = false, + options = {}, +) { + const state = await prepareOAuthState(options); if (!state) return; const url = new URL(auth_url); url.searchParams.set('client_id', client_id); @@ -258,16 +277,19 @@ export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) { } } -export async function onGitHubOAuthClicked(github_client_id) { - const state = await getOAuthState(); +export async function onGitHubOAuthClicked(github_client_id, options = {}) { + const state = await prepareOAuthState(options); if (!state) return; window.open( `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`, ); } -export async function onLinuxDOOAuthClicked(linuxdo_client_id) { - const state = await getOAuthState(); +export async function onLinuxDOOAuthClicked( + linuxdo_client_id, + options = { shouldLogout: false }, +) { + const state = await prepareOAuthState(options); if (!state) return; window.open( `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`, From 2e33948842b83d933fab08820568603c75bb35f7 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 9 Dec 2025 10:46:16 +0800 Subject: [PATCH 16/72] fix: Add styles only on mobile --- web/src/components/layout/SiderBar.jsx | 1 - web/src/index.css | 25 ++++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 1b6fc6924..096ea08cc 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -377,7 +377,6 @@ const SiderBar = ({ onNavigate = () => {} }) => { className='sidebar-container' style={{ width: 'var(--sidebar-current-width)', - background: 'var(--semi-color-bg-1)', }} > Date: Tue, 9 Dec 2025 11:15:27 +0800 Subject: [PATCH 17/72] fix: Use channel proxy settings for task query scenarios --- controller/task.go | 3 ++- controller/task_video.go | 3 ++- controller/video_proxy.go | 19 ++++++++++++++++--- controller/video_proxy_gemini.go | 3 ++- relay/channel/adapter.go | 2 +- relay/channel/task/ali/adaptor.go | 8 ++++++-- relay/channel/task/doubao/adaptor.go | 8 ++++++-- relay/channel/task/gemini/adaptor.go | 8 ++++++-- relay/channel/task/hailuo/adaptor.go | 8 ++++++-- relay/channel/task/jimeng/adaptor.go | 8 ++++++-- relay/channel/task/kling/adaptor.go | 8 ++++++-- relay/channel/task/sora/adaptor.go | 8 ++++++-- relay/channel/task/suno/adaptor.go | 8 ++++---- relay/channel/task/vertex/adaptor.go | 16 ++++++++++++---- relay/channel/task/vidu/adaptor.go | 8 ++++++-- relay/relay_task.go | 3 ++- service/http_client.go | 28 ++++++++++++++++++---------- 17 files changed, 107 insertions(+), 42 deletions(-) diff --git a/controller/task.go b/controller/task.go index ad034d61e..16acc2269 100644 --- a/controller/task.go +++ b/controller/task.go @@ -116,9 +116,10 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas if adaptor == nil { return errors.New("adaptor not found") } + proxy := channel.GetSetting().Proxy resp, err := adaptor.FetchTask(*channel.BaseURL, channel.Key, map[string]any{ "ids": taskIds, - }) + }, proxy) if err != nil { common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err)) return err diff --git a/controller/task_video.go b/controller/task_video.go index 8c9f9719e..86095307d 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -67,6 +67,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } + proxy := channel.GetSetting().Proxy task := taskM[taskId] if task == nil { @@ -76,7 +77,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{ "task_id": taskId, "action": task.Action, - }) + }, proxy) if err != nil { return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) } diff --git a/controller/video_proxy.go b/controller/video_proxy.go index a577cf819..f102baae4 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -1,6 +1,7 @@ package controller import ( + "context" "fmt" "io" "net/http" @@ -10,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) @@ -75,11 +77,22 @@ func VideoProxy(c *gin.Context) { } var videoURL string - client := &http.Client{ - Timeout: 60 * time.Second, + proxy := channel.GetSetting().Proxy + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create proxy client for task %s: %s", taskID, err.Error())) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "Failed to create proxy client", + "type": "server_error", + }, + }) + return } - req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil) + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error())) c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/controller/video_proxy_gemini.go b/controller/video_proxy_gemini.go index 4e2e60e62..053ac6515 100644 --- a/controller/video_proxy_gemini.go +++ b/controller/video_proxy_gemini.go @@ -35,10 +35,11 @@ func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) return "", fmt.Errorf("api key not available for task") } + proxy := channel.GetSetting().Proxy resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{ "task_id": task.TaskID, "action": task.Action, - }) + }, proxy) if err != nil { return "", fmt.Errorf("fetch task failed: %w", err) } diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 7f8faf22d..ff7606e2e 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -47,7 +47,7 @@ type TaskAdaptor interface { GetChannelName() string // FetchTask - FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) + FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) } diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index 32d5da398..eef699665 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -393,7 +393,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } // FetchTask 查询任务状态 -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -408,7 +408,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Authorization", "Bearer "+key) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 1bacb2019..dd21fb75a 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -146,7 +146,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } // FetchTask fetch task status -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -163,7 +163,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+key) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/gemini/adaptor.go b/relay/channel/task/gemini/adaptor.go index 0fa9dda4b..16c6919b7 100644 --- a/relay/channel/task/gemini/adaptor.go +++ b/relay/channel/task/gemini/adaptor.go @@ -200,7 +200,7 @@ func (a *TaskAdaptor) GetChannelName() string { } // FetchTask fetch task status -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -223,7 +223,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Accept", "application/json") req.Header.Set("x-goog-api-key", key) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index cb6f1eebd..c77905bfb 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -110,7 +110,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return hResp.TaskID, responseBody, nil } -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -126,7 +126,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+key) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index da4a1f8fe..d6973531f 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -210,7 +210,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } // FetchTask fetch task status -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -251,7 +251,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http return nil, errors.Wrap(err, "sign request failed") } } - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index c1bbd9d59..d00350652 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -199,7 +199,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } // FetchTask fetch task status -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -228,7 +228,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", "kling-sdk/1.0") - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index 17aec18f0..214561b5b 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -125,7 +125,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relayco } // FetchTask fetch task status -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -140,7 +140,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Authorization", "Bearer "+key) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go index c4858d0c0..f7c891723 100644 --- a/relay/channel/task/suno/adaptor.go +++ b/relay/channel/task/suno/adaptor.go @@ -132,7 +132,7 @@ func (a *TaskAdaptor) GetChannelName() string { return ChannelName } -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { requestUrl := fmt.Sprintf("%s/suno/fetch", baseUrl) byteBody, err := json.Marshal(body) if err != nil { @@ -153,11 +153,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req = req.WithContext(ctx) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+key) - resp, err := service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) if err != nil { - return nil, err + return nil, fmt.Errorf("new proxy http client failed: %w", err) } - return resp, nil + return client.Do(req) } func actionValidate(c *gin.Context, sunoRequest *dto.SunoSubmitReq, action string) (err error) { diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go index d98ac53cf..8ec77266e 100644 --- a/relay/channel/task/vertex/adaptor.go +++ b/relay/channel/task/vertex/adaptor.go @@ -120,7 +120,11 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info return fmt.Errorf("failed to decode credentials: %w", err) } - token, err := vertexcore.AcquireAccessToken(*adc, "") + proxy := "" + if info != nil { + proxy = info.ChannelSetting.Proxy + } + token, err := vertexcore.AcquireAccessToken(*adc, proxy) if err != nil { return fmt.Errorf("failed to acquire access token: %w", err) } @@ -216,7 +220,7 @@ func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generat func (a *TaskAdaptor) GetChannelName() string { return "vertex" } // FetchTask fetch task status -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -249,7 +253,7 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http if err := json.Unmarshal([]byte(key), adc); err != nil { return nil, fmt.Errorf("failed to decode credentials: %w", err) } - token, err := vertexcore.AcquireAccessToken(*adc, "") + token, err := vertexcore.AcquireAccessToken(*adc, proxy) if err != nil { return nil, fmt.Errorf("failed to acquire access token: %w", err) } @@ -261,7 +265,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("x-goog-user-project", adc.ProjectID) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go index 6b62f1f01..3657161c0 100644 --- a/relay/channel/task/vidu/adaptor.go +++ b/relay/channel/task/vidu/adaptor.go @@ -188,7 +188,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return vResp.TaskId, responseBody, nil } -func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") @@ -204,7 +204,11 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Token "+key) - return service.GetHttpClient().Do(req) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/relay_task.go b/relay/relay_task.go index 61e2af523..ba9fe1e8f 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -326,6 +326,7 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d if channelModel.GetBaseURL() != "" { baseURL = channelModel.GetBaseURL() } + proxy := channelModel.GetSetting().Proxy adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type))) if adaptor == nil { return @@ -333,7 +334,7 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d resp, err2 := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{ "task_id": originTask.TaskID, "action": originTask.Action, - }) + }, proxy) if err2 != nil || resp == nil { return } diff --git a/service/http_client.go b/service/http_client.go index 2fa9e51cf..be89c73c0 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -35,9 +35,9 @@ func checkRedirect(req *http.Request, via []*http.Request) error { func InitHttpClient() { transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, } if common.RelayTimeout == 0 { @@ -58,6 +58,14 @@ func GetHttpClient() *http.Client { return httpClient } +// GetHttpClientWithProxy returns the default client or a proxy-enabled one when proxyURL is provided. +func GetHttpClientWithProxy(proxyURL string) (*http.Client, error) { + if proxyURL == "" { + return GetHttpClient(), nil + } + return NewProxyHttpClient(proxyURL) +} + // ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化 func ResetProxyClientCache() { proxyClientLock.Lock() @@ -92,10 +100,10 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { case "http", "https": client := &http.Client{ Transport: &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - Proxy: http.ProxyURL(parsedURL), + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + Proxy: http.ProxyURL(parsedURL), }, CheckRedirect: checkRedirect, } @@ -127,9 +135,9 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { client := &http.Client{ Transport: &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) }, From 1cb2b6f88246e5571d70774153860f92a7fac9f8 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 9 Dec 2025 13:55:52 +0800 Subject: [PATCH 18/72] fix:try to fix tool call issues --- service/convert.go | 301 +++++++++++++++++++++++++++++---------------- 1 file changed, 198 insertions(+), 103 deletions(-) diff --git a/service/convert.go b/service/convert.go index 93fff2386..beec76a79 100644 --- a/service/convert.go +++ b/service/convert.go @@ -201,6 +201,10 @@ func generateStopBlock(index int) *dto.ClaudeResponse { } func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse { + if info.ClaudeConvertInfo.Done { + return nil + } + var claudeResponses []*dto.ClaudeResponse if info.SendResponseCount == 1 { msg := &dto.ClaudeMediaMessage{ @@ -218,45 +222,117 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon Type: "message_start", Message: msg, }) - claudeResponses = append(claudeResponses) //claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ // Type: "ping", //}) if openAIResponse.IsToolCall() { info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + var toolCall dto.ToolCallResponse + if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 { + toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0] + } else { + first := openAIResponse.GetFirstToolCall() + if first != nil { + toolCall = *first + } else { + toolCall = dto.ToolCallResponse{} + } + } resp := &dto.ClaudeResponse{ Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{ - Id: openAIResponse.GetFirstToolCall().ID, + Id: toolCall.ID, Type: "tool_use", - Name: openAIResponse.GetFirstToolCall().Function.Name, + Name: toolCall.Function.Name, Input: map[string]interface{}{}, }, } resp.SetIndex(0) claudeResponses = append(claudeResponses, resp) + // 首块包含工具 delta,则追加 input_json_delta + if toolCall.Function.Arguments != "" { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "input_json_delta", + PartialJson: &toolCall.Function.Arguments, + }, + }) + } } else { } // 判断首个响应是否存在内容(非标准的 OpenAI 响应) - if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.GetContentString()) > 0 { + if len(openAIResponse.Choices) > 0 { + reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent() + content := openAIResponse.Choices[0].Delta.GetContentString() + + if reasoning != "" { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: common.GetPointer[string](""), + }, + }) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "thinking_delta", + Thinking: &reasoning, + }, + }) + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking + } else if content != "" { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "text_delta", + Text: common.GetPointer[string](content), + }, + }) + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + } + } + + // 如果首块就带 finish_reason,需要立即发送停止块 + if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" { + info.FinishReason = *openAIResponse.Choices[0].FinishReason + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + oaiUsage := openAIResponse.Usage + if oaiUsage == nil { + oaiUsage = info.ClaudeConvertInfo.Usage + } + if oaiUsage != nil { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_delta", + Usage: &dto.ClaudeUsage{ + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, + }, + Delta: &dto.ClaudeMediaMessage{ + StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), + }, + }) + } claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, - Type: "content_block_start", - ContentBlock: &dto.ClaudeMediaMessage{ - Type: "text", - Text: common.GetPointer[string](""), - }, + Type: "message_stop", }) - claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, - Type: "content_block_delta", - Delta: &dto.ClaudeMediaMessage{ - Type: "text_delta", - Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), - }, - }) - info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + info.ClaudeConvertInfo.Done = true } return claudeResponses } @@ -264,7 +340,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon if len(openAIResponse.Choices) == 0 { // no choices // 可能为非标准的 OpenAI 响应,判断是否已经完成 - if info.Done { + if info.ClaudeConvertInfo.Done { claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) oaiUsage := info.ClaudeConvertInfo.Usage if oaiUsage != nil { @@ -288,16 +364,110 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon return claudeResponses } else { chosenChoice := openAIResponse.Choices[0] - if chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" { - // should be done + doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" + if doneChunk { info.FinishReason = *chosenChoice.FinishReason - if !info.Done { - return claudeResponses + } + + var claudeResponse dto.ClaudeResponse + var isEmpty bool + claudeResponse.Type = "content_block_delta" + if len(chosenChoice.Delta.ToolCalls) > 0 { + toolCalls := chosenChoice.Delta.ToolCalls + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + + for i, toolCall := range toolCalls { + blockIndex := info.ClaudeConvertInfo.Index + if toolCall.Index != nil { + blockIndex = *toolCall.Index + } else if len(toolCalls) > 1 { + blockIndex = info.ClaudeConvertInfo.Index + i + } + + idx := blockIndex + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &idx, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: toolCall.ID, + Type: "tool_use", + Name: toolCall.Function.Name, + Input: map[string]interface{}{}, + }, + }) + + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &idx, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "input_json_delta", + PartialJson: &toolCall.Function.Arguments, + }, + }) + + info.ClaudeConvertInfo.Index = blockIndex + } + } else { + reasoning := chosenChoice.Delta.GetReasoningContent() + textContent := chosenChoice.Delta.GetContentString() + if reasoning != "" || textContent != "" { + if reasoning != "" { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: common.GetPointer[string](""), + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "thinking_delta", + Thinking: &reasoning, + } + } else { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText { + if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeThinking || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "text_delta", + Text: common.GetPointer[string](textContent), + } + } + } else { + isEmpty = true } } - if info.Done { + + claudeResponse.Index = &info.ClaudeConvertInfo.Index + if !isEmpty && claudeResponse.Delta != nil { + claudeResponses = append(claudeResponses, &claudeResponse) + } + + if doneChunk || info.ClaudeConvertInfo.Done { claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - oaiUsage := info.ClaudeConvertInfo.Usage + oaiUsage := openAIResponse.Usage + if oaiUsage == nil { + oaiUsage = info.ClaudeConvertInfo.Usage + } if oaiUsage != nil { claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Type: "message_delta", @@ -315,83 +485,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Type: "message_stop", }) - } else { - var claudeResponse dto.ClaudeResponse - var isEmpty bool - claudeResponse.Type = "content_block_delta" - if len(chosenChoice.Delta.ToolCalls) > 0 { - if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ - claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, - Type: "content_block_start", - ContentBlock: &dto.ClaudeMediaMessage{ - Id: openAIResponse.GetFirstToolCall().ID, - Type: "tool_use", - Name: openAIResponse.GetFirstToolCall().Function.Name, - Input: map[string]interface{}{}, - }, - }) - } - info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools - // tools delta - claudeResponse.Delta = &dto.ClaudeMediaMessage{ - Type: "input_json_delta", - PartialJson: &chosenChoice.Delta.ToolCalls[0].Function.Arguments, - } - } else { - reasoning := chosenChoice.Delta.GetReasoningContent() - textContent := chosenChoice.Delta.GetContentString() - if reasoning != "" || textContent != "" { - if reasoning != "" { - if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { - //info.ClaudeConvertInfo.Index++ - claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, - Type: "content_block_start", - ContentBlock: &dto.ClaudeMediaMessage{ - Type: "thinking", - Thinking: common.GetPointer[string](""), - }, - }) - } - info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking - // text delta - claudeResponse.Delta = &dto.ClaudeMediaMessage{ - Type: "thinking_delta", - Thinking: &reasoning, - } - } else { - if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText { - if info.LastMessagesType == relaycommon.LastMessageTypeThinking || info.LastMessagesType == relaycommon.LastMessageTypeTools { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ - } - claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, - Type: "content_block_start", - ContentBlock: &dto.ClaudeMediaMessage{ - Type: "text", - Text: common.GetPointer[string](""), - }, - }) - } - info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText - // text delta - claudeResponse.Delta = &dto.ClaudeMediaMessage{ - Type: "text_delta", - Text: common.GetPointer[string](textContent), - } - } - } else { - isEmpty = true - } - } - claudeResponse.Index = &info.ClaudeConvertInfo.Index - if !isEmpty { - claudeResponses = append(claudeResponses, &claudeResponse) - } + info.ClaudeConvertInfo.Done = true + return claudeResponses } } From 4e69c98b4222f7a7862e58b880adecff0e302247 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:35:23 +0800 Subject: [PATCH 19/72] Merge pull request #2412 from seefs001/pr-2372 feat: add openai video remix endpoint --- constant/task.go | 1 + middleware/distributor.go | 4 + relay/channel/task/sora/adaptor.go | 21 ++++ relay/relay_task.go | 115 +++++++++++++----- router/video-router.go | 1 + .../table/task-logs/TaskLogsColumnDefs.jsx | 10 +- web/src/constants/common.constant.js | 1 + web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/fr.json | 1 + web/src/i18n/locales/ja.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/vi.json | 1 + web/src/i18n/locales/zh.json | 1 + 13 files changed, 130 insertions(+), 29 deletions(-) diff --git a/constant/task.go b/constant/task.go index e174fd60e..ecccf4dfe 100644 --- a/constant/task.go +++ b/constant/task.go @@ -15,6 +15,7 @@ const ( TaskActionTextGenerate = "textGenerate" TaskActionFirstTailGenerate = "firstTailGenerate" TaskActionReferenceGenerate = "referenceGenerate" + TaskActionRemix = "remixGenerate" ) var SunoModel2Action = map[string]string{ diff --git a/middleware/distributor.go b/middleware/distributor.go index 5a9deb23c..3c8529d96 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -181,6 +181,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } c.Set("platform", string(constant.TaskPlatformSuno)) c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/v1/videos/") && strings.HasSuffix(c.Request.URL.Path, "/remix") { + relayMode := relayconstant.RelayModeVideoSubmit + c.Set("relay_mode", relayMode) + shouldSelectChannel = false } else if strings.Contains(c.Request.URL.Path, "/v1/videos") { //curl https://api.openai.com/v1/videos \ // -H "Authorization: Bearer $OPENAI_API_KEY" \ diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index 214561b5b..9dc03796c 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -5,8 +5,10 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/relay/channel" @@ -67,11 +69,30 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.apiKey = info.ApiKey } +func validateRemixRequest(c *gin.Context) *dto.TaskError { + var req struct { + Prompt string `json:"prompt"` + } + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if strings.TrimSpace(req.Prompt) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("field prompt is required"), "invalid_request", http.StatusBadRequest) + } + return nil +} + func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + if info.Action == constant.TaskActionRemix { + return validateRemixRequest(c) + } return relaycommon.ValidateMultipartDirect(c, info) } func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.Action == constant.TaskActionRemix { + return fmt.Sprintf("%s/v1/videos/%s/remix", a.baseURL, info.OriginTaskID), nil + } return fmt.Sprintf("%s/v1/videos", a.baseURL), nil } diff --git a/relay/relay_task.go b/relay/relay_task.go index ba9fe1e8f..bac05e0ee 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -32,7 +32,94 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto. if info.TaskRelayInfo == nil { info.TaskRelayInfo = &relaycommon.TaskRelayInfo{} } + path := c.Request.URL.Path + if strings.Contains(path, "/v1/videos/") && strings.HasSuffix(path, "/remix") { + info.Action = constant.TaskActionRemix + } + + // 提取 remix 任务的 video_id + if info.Action == constant.TaskActionRemix { + videoID := c.Param("video_id") + if strings.TrimSpace(videoID) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("video_id is required"), "invalid_request", http.StatusBadRequest) + } + info.OriginTaskID = videoID + } + platform := constant.TaskPlatform(c.GetString("platform")) + + // 获取原始任务信息 + if info.OriginTaskID != "" { + originTask, exist, err := model.GetByTaskId(info.UserId, info.OriginTaskID) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + if info.OriginModelName == "" { + if originTask.Properties.OriginModelName != "" { + info.OriginModelName = originTask.Properties.OriginModelName + } else if originTask.Properties.UpstreamModelName != "" { + info.OriginModelName = originTask.Properties.UpstreamModelName + } else { + var taskData map[string]interface{} + _ = json.Unmarshal(originTask.Data, &taskData) + if m, ok := taskData["model"].(string); ok && m != "" { + info.OriginModelName = m + platform = originTask.Platform + } + } + } + if originTask.ChannelId != info.ChannelId { + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest) + return + } + if channel.Status != common.ChannelStatusEnabled { + taskErr = service.TaskErrorWrapperLocal(errors.New("the channel of the origin task is disabled"), "task_channel_disable", http.StatusBadRequest) + return + } + key, _, newAPIError := channel.GetNextEnabledKey() + if newAPIError != nil { + taskErr = service.TaskErrorWrapper(newAPIError, "channel_no_available_key", newAPIError.StatusCode) + return + } + common.SetContextKey(c, constant.ContextKeyChannelKey, key) + common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL()) + common.SetContextKey(c, constant.ContextKeyChannelId, originTask.ChannelId) + + info.ChannelBaseUrl = channel.GetBaseURL() + info.ChannelId = originTask.ChannelId + info.ChannelType = channel.Type + info.ApiKey = key + platform = originTask.Platform + } + + // 使用原始任务的参数 + if info.Action == constant.TaskActionRemix { + var taskData map[string]interface{} + _ = json.Unmarshal(originTask.Data, &taskData) + secondsStr, _ := taskData["seconds"].(string) + seconds, _ := strconv.Atoi(secondsStr) + if seconds <= 0 { + seconds = 4 + } + sizeStr, _ := taskData["size"].(string) + if info.PriceData.OtherRatios == nil { + info.PriceData.OtherRatios = map[string]float64{} + } + info.PriceData.OtherRatios["seconds"] = float64(seconds) + info.PriceData.OtherRatios["size"] = 1 + if sizeStr == "1792x1024" || sizeStr == "1024x1792" { + info.PriceData.OtherRatios["size"] = 1.666667 + } + } + } if platform == "" { platform = GetTaskPlatform(c) } @@ -94,34 +181,6 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto. return } - if info.OriginTaskID != "" { - originTask, exist, err := model.GetByTaskId(info.UserId, info.OriginTaskID) - if err != nil { - taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError) - return - } - if !exist { - taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest) - return - } - if originTask.ChannelId != info.ChannelId { - channel, err := model.GetChannelById(originTask.ChannelId, true) - if err != nil { - taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest) - return - } - if channel.Status != common.ChannelStatusEnabled { - return service.TaskErrorWrapperLocal(errors.New("该任务所属渠道已被禁用"), "task_channel_disable", http.StatusBadRequest) - } - c.Set("base_url", channel.GetBaseURL()) - c.Set("channel_id", originTask.ChannelId) - c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) - - info.ChannelBaseUrl = channel.GetBaseURL() - info.ChannelId = originTask.ChannelId - } - } - // build body requestBody, err := adaptor.BuildRequestBody(c, info) if err != nil { diff --git a/router/video-router.go b/router/video-router.go index 87097cf86..d5fed1d78 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -14,6 +14,7 @@ func SetVideoRouter(router *gin.Engine) { videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy) videoV1Router.POST("/video/generations", controller.RelayTask) videoV1Router.GET("/video/generations/:task_id", controller.RelayTask) + videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask) } // openai compatible API video routes // docs: https://platform.openai.com/docs/api-reference/videos/create diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index 530518d18..969977d17 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -39,6 +39,7 @@ import { TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE, TASK_ACTION_TEXT_GENERATE, + TASK_ACTION_REMIX_GENERATE, } from '../../../constants/common.constant'; import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; @@ -125,6 +126,12 @@ const renderType = (type, t) => { {t('参照生视频')} ); + case TASK_ACTION_REMIX_GENERATE: + return ( + }> + {t('视频Remix')} + + ); default: return ( }> @@ -359,7 +366,8 @@ export const getTaskLogsColumns = ({ record.action === TASK_ACTION_GENERATE || record.action === TASK_ACTION_TEXT_GENERATE || record.action === TASK_ACTION_FIRST_TAIL_GENERATE || - record.action === TASK_ACTION_REFERENCE_GENERATE; + record.action === TASK_ACTION_REFERENCE_GENERATE || + record.action === TASK_ACTION_REMIX_GENERATE; const isSuccess = record.status === 'SUCCESS'; const isUrl = typeof text === 'string' && /^https?:\/\//.test(text); if (isSuccess && isVideoTask && isUrl) { diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index 57fbbbde5..a142a0eb5 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.js @@ -42,3 +42,4 @@ export const TASK_ACTION_GENERATE = 'generate'; export const TASK_ACTION_TEXT_GENERATE = 'textGenerate'; export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate'; export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate'; +export const TASK_ACTION_REMIX_GENERATE = 'remixGenerate'; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3f279e13a..efdb89a59 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -548,6 +548,7 @@ "参数值": "Parameter value", "参数覆盖": "Parameters override", "参照生视频": "Reference video generation", + "视频Remix": "Video remix", "友情链接": "Friendly links", "发布日期": "Publish Date", "发布时间": "Publish Time", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index ed1df8a83..c10229e48 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -551,6 +551,7 @@ "参数值": "Valeur du paramètre", "参数覆盖": "Remplacement des paramètres", "参照生视频": "Générer une vidéo par référence", + "视频Remix": "Remix vidéo", "友情链接": "Liens amicaux", "发布日期": "Date de publication", "发布时间": "Heure de publication", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 0e4786c68..b67f5529e 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -510,6 +510,7 @@ "参数值": "パラメータ値", "参数覆盖": "パラメータの上書き", "参照生视频": "参照動画生成", + "视频Remix": "動画リミックス", "友情链接": "関連リンク", "发布日期": "公開日", "发布时间": "公開日時", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 92171a0c3..eb13f6106 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -555,6 +555,7 @@ "参数值": "Значение параметра", "参数覆盖": "Переопределение параметров", "参照生视频": "Ссылка на генерацию видео", + "视频Remix": "Видео ремикс", "友情链接": "Дружественные ссылки", "发布日期": "Дата публикации", "发布时间": "Время публикации", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 8af562f7a..39b80674d 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -510,6 +510,7 @@ "参数值": "Giá trị tham số", "参数覆盖": "Ghi đè tham số", "参照生视频": "Tạo video tham chiếu", + "视频Remix": "Remix video", "友情链接": "Liên kết thân thiện", "发布日期": "Ngày xuất bản", "发布时间": "Thời gian xuất bản", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a07885638..d3441f3b5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -543,6 +543,7 @@ "参数值": "参数值", "参数覆盖": "参数覆盖", "参照生视频": "参照生视频", + "视频Remix": "视频 Remix", "友情链接": "友情链接", "发布日期": "发布日期", "发布时间": "发布时间", From c992919d15dd148db9573c27348348cf969c3d8e Mon Sep 17 00:00:00 2001 From: "zhiheng.wang" Date: Fri, 12 Dec 2025 16:19:14 +0800 Subject: [PATCH 20/72] fix: correct sender format issues - Adjust sender field format, add space to separate nickname and email address - Ensure email header format complies with standard RFC specifications - Fix potential email client sending exceptions (Tencent Cloud) --- common/email.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/email.go b/common/email.go index e27d8bcd1..9f574f06e 100644 --- a/common/email.go +++ b/common/email.go @@ -32,7 +32,7 @@ func SendEmail(subject string, receiver string, content string) error { } encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject))) mail := []byte(fmt.Sprintf("To: %s\r\n"+ - "From: %s<%s>\r\n"+ + "From: %s <%s>\r\n"+ "Subject: %s\r\n"+ "Date: %s\r\n"+ "Message-ID: %s\r\n"+ // 添加 Message-ID 头 From e1bee481522fdc542ab70391e619009a46ca5451 Mon Sep 17 00:00:00 2001 From: zdwy5 <65889142+zdwy5@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:09:27 +0800 Subject: [PATCH 21/72] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81aws=20=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=85=A8=E5=B1=80=E5=8F=82=E6=95=B0=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E6=88=96=E8=80=85=E6=B8=A0=E9=81=93=E5=8F=82=E6=95=B0=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E6=9D=A5=20=E8=B0=83=E7=94=A8=20(#2423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 支持aws 通过全局参数透传或者渠道参数透传来 调用 * fix(aws): replace json.Unmarshal with common.Unmarshal for request body processing --------- Co-authored-by: r0 Co-authored-by: CaIon --- relay/channel/aws/relay-aws.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index d2ac2f0bb..a5bc896b2 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pkg/errors" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" @@ -129,7 +130,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, Accept: aws.String("application/json"), ContentType: aws.String("application/json"), } - awsReq.Body, err = common.Marshal(awsClaudeReq) + awsReq.Body, err = buildAwsRequestBody(c, info, awsClaudeReq) if err != nil { return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody) } @@ -141,7 +142,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, Accept: aws.String("application/json"), ContentType: aws.String("application/json"), } - awsReq.Body, err = common.Marshal(awsClaudeReq) + awsReq.Body, err = buildAwsRequestBody(c, info, awsClaudeReq) if err != nil { return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody) } @@ -151,6 +152,24 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, } } +// buildAwsRequestBody prepares the payload for AWS requests, applying passthrough rules when enabled. +func buildAwsRequestBody(c *gin.Context, info *relaycommon.RelayInfo, awsClaudeReq any) ([]byte, error) { + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return nil, errors.Wrap(err, "get request body for pass-through fail") + } + var data map[string]interface{} + if err := common.Unmarshal(body, &data); err != nil { + return nil, errors.Wrap(err, "pass-through unmarshal request body fail") + } + delete(data, "model") + delete(data, "stream") + return common.Marshal(data) + } + return common.Marshal(awsClaudeReq) +} + func getAwsRegionPrefix(awsRegionId string) string { parts := strings.Split(awsRegionId, "-") regionPrefix := "" From 01b4039e96a2d424c1ae718b9aac70b6929c4600 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Dec 2025 17:59:21 +0800 Subject: [PATCH 22/72] feat(token): add cross-group retry option for token processing --- constant/context_key.go | 2 ++ controller/token.go | 1 + middleware/auth.go | 1 + model/token.go | 3 ++- relay/channel/openai/helper.go | 2 +- service/channel_select.go | 22 +++++++++++++++---- service/token_counter.go | 2 +- .../table/tokens/modals/EditTokenModal.jsx | 13 ++++++++++- 8 files changed, 38 insertions(+), 8 deletions(-) diff --git a/constant/context_key.go b/constant/context_key.go index 4de704619..ecc5178ee 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -18,8 +18,10 @@ const ( ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id" ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" ContextKeyTokenModelLimit ContextKey = "token_model_limit" + ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" /* channel related keys */ + ContextKeyAutoGroupIndex ContextKey = "auto_group_index" ContextKeyChannelId ContextKey = "channel_id" ContextKeyChannelName ContextKey = "channel_name" ContextKeyChannelCreateTime ContextKey = "channel_create_time" diff --git a/controller/token.go b/controller/token.go index 04e31f8c1..832438e83 100644 --- a/controller/token.go +++ b/controller/token.go @@ -248,6 +248,7 @@ func UpdateToken(c *gin.Context) { cleanToken.ModelLimits = token.ModelLimits cleanToken.AllowIps = token.AllowIps cleanToken.Group = token.Group + cleanToken.CrossGroupRetry = token.CrossGroupRetry } err = cleanToken.Update() if err != nil { diff --git a/middleware/auth.go b/middleware/auth.go index dc59df9af..b1fca4712 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -308,6 +308,7 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e c.Set("token_model_limit_enabled", false) } c.Set("token_group", token.Group) + c.Set("token_cross_group_retry", token.CrossGroupRetry) if len(parts) > 1 { if model.IsAdmin(token.UserId) { c.Set("specific_channel_id", parts[1]) diff --git a/model/token.go b/model/token.go index c1fe2a670..a6a307ac2 100644 --- a/model/token.go +++ b/model/token.go @@ -27,6 +27,7 @@ type Token struct { AllowIps *string `json:"allow_ips" gorm:"default:''"` UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota Group string `json:"group" gorm:"default:''"` + CrossGroupRetry bool `json:"cross_group_retry" gorm:"default:false"` // 跨分组重试,仅auto分组有效 DeletedAt gorm.DeletedAt `gorm:"index"` } @@ -185,7 +186,7 @@ func (token *Token) Update() (err error) { } }() err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", - "model_limits_enabled", "model_limits", "allow_ips", "group").Updates(token).Error + "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error return err } diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 69731d4d2..18cada8e0 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -172,7 +172,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int shouldSendLastResp *bool) error { var lastStreamResponse dto.ChatCompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &lastStreamResponse); err != nil { + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &lastStreamResponse); err != nil { return err } diff --git a/service/channel_select.go b/service/channel_select.go index 53f7d2c2a..348b89e55 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" ) +// CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName string, retry int) (*model.Channel, string, error) { var channel *model.Channel var err error @@ -20,15 +21,28 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName stri if len(setting.GetAutoGroups()) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") } - for _, autoGroup := range GetUserAutoGroup(userGroup) { - logger.LogDebug(c, "Auto selecting group:", autoGroup) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, modelName, retry) + autoGroups := GetUserAutoGroup(userGroup) + // 如果 token 启用了跨分组重试,获取上次失败的 auto group 索引,从下一个开始尝试 + startIndex := 0 + crossGroupRetry := common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) + if crossGroupRetry && retry > 0 { + logger.LogDebug(c, "Auto group retry cross group, retry: %d", retry) + if lastIndex, exists := c.Get(string(constant.ContextKeyAutoGroupIndex)); exists { + startIndex = lastIndex.(int) + 1 + } + logger.LogDebug(c, "Auto group retry cross group, start index: %d", startIndex) + } + for i := startIndex; i < len(autoGroups); i++ { + autoGroup := autoGroups[i] + logger.LogDebug(c, "Auto selecting group: %s", autoGroup) + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, modelName, 0) if channel == nil { continue } else { c.Set("auto_group", autoGroup) + c.Set(string(constant.ContextKeyAutoGroupIndex), i) selectGroup = autoGroup - logger.LogDebug(c, "Auto selected group:", autoGroup) + logger.LogDebug(c, "Auto selected group: %s", autoGroup) break } } diff --git a/service/token_counter.go b/service/token_counter.go index ebf0e243d..c70c54a88 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -317,7 +317,7 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela for i, file := range meta.Files { switch file.FileType { case types.FileTypeImage: - if common.IsOpenAITextModel(info.OriginModelName) { + if common.IsOpenAITextModel(model) { token, err := getImageToken(file, model, info.IsStream) if err != nil { return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err) diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index 59a3894af..c7db40d66 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -73,6 +73,7 @@ const EditTokenModal = (props) => { model_limits: [], allow_ips: '', group: '', + cross_group_retry: false, tokenCount: 1, }); @@ -377,6 +378,16 @@ const EditTokenModal = (props) => { /> )} + + + { Date: Fri, 12 Dec 2025 18:28:33 +0800 Subject: [PATCH 23/72] feat: implement cross-group retry functionality and update translations --- service/channel_select.go | 8 +++++--- web/src/components/table/tokens/TokensColumnDefs.jsx | 8 ++++---- web/src/i18n/locales/en.json | 5 ++++- web/src/i18n/locales/fr.json | 5 ++++- web/src/i18n/locales/ja.json | 5 ++++- web/src/i18n/locales/ru.json | 5 ++++- web/src/i18n/locales/vi.json | 5 ++++- web/src/i18n/locales/zh.json | 5 ++++- 8 files changed, 33 insertions(+), 13 deletions(-) diff --git a/service/channel_select.go b/service/channel_select.go index 348b89e55..b95aa025b 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -27,8 +27,10 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName stri crossGroupRetry := common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) if crossGroupRetry && retry > 0 { logger.LogDebug(c, "Auto group retry cross group, retry: %d", retry) - if lastIndex, exists := c.Get(string(constant.ContextKeyAutoGroupIndex)); exists { - startIndex = lastIndex.(int) + 1 + if lastIndex, exists := common.GetContextKey(c, constant.ContextKeyAutoGroupIndex); exists { + if idx, ok := lastIndex.(int); ok { + startIndex = idx + 1 + } } logger.LogDebug(c, "Auto group retry cross group, start index: %d", startIndex) } @@ -40,7 +42,7 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName stri continue } else { c.Set("auto_group", autoGroup) - c.Set(string(constant.ContextKeyAutoGroupIndex), i) + common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, i) selectGroup = autoGroup logger.LogDebug(c, "Auto selected group: %s", autoGroup) break diff --git a/web/src/components/table/tokens/TokensColumnDefs.jsx b/web/src/components/table/tokens/TokensColumnDefs.jsx index 4e092f9cc..ce8eab807 100644 --- a/web/src/components/table/tokens/TokensColumnDefs.jsx +++ b/web/src/components/table/tokens/TokensColumnDefs.jsx @@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => { }; // Render group column -const renderGroupColumn = (text, t) => { +const renderGroupColumn = (text, record, t) => { if (text === 'auto') { return ( { position='top' > - {' '} - {t('智能熔断')}{' '} + {t('智能熔断')} + {record && record.cross_group_retry ? `(${t('跨分组')})` : ''} ); @@ -455,7 +455,7 @@ export const getTokensColumns = ({ title: t('分组'), dataIndex: 'group', key: 'group', - render: (text) => renderGroupColumn(text, t), + render: (text, record) => renderGroupColumn(text, record, t), }, { title: t('密钥'), diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3f279e13a..2539c9d1c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2176,6 +2176,9 @@ "默认区域,如: us-central1": "Default region, e.g.: us-central1", "默认折叠侧边栏": "Default collapse sidebar", "默认测试模型": "Default Test Model", - "默认补全倍率": "Default completion ratio" + "默认补全倍率": "Default completion ratio", + "跨分组重试": "Cross-group retry", + "跨分组": "Cross-group", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order" } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index ed1df8a83..2e07dd1d3 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2225,6 +2225,9 @@ "默认助手消息": "Bonjour ! Comment puis-je vous aider aujourd'hui ?", "可选,用于复现结果": "Optionnel, pour des résultats reproductibles", "随机种子 (留空为随机)": "Graine aléatoire (laisser vide pour aléatoire)", - "默认补全倍率": "Taux de complétion par défaut" + "默认补全倍率": "Taux de complétion par défaut", + "跨分组重试": "Nouvelle tentative inter-groupes", + "跨分组": "Inter-groupes", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre" } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 0e4786c68..f59ff6042 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2124,6 +2124,9 @@ "默认用户消息": "こんにちは", "默认助手消息": "こんにちは!何かお手伝いできることはありますか?", "可选,用于复现结果": "オプション、結果の再現用", - "随机种子 (留空为随机)": "ランダムシード(空欄でランダム)" + "随机种子 (留空为随机)": "ランダムシード(空欄でランダム)", + "跨分组重试": "グループ間リトライ", + "跨分组": "グループ間", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 92171a0c3..ad85a9dd9 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2235,6 +2235,9 @@ "默认用户消息": "Здравствуйте", "默认助手消息": "Здравствуйте! Чем я могу вам помочь?", "可选,用于复现结果": "Необязательно, для воспроизводимых результатов", - "随机种子 (留空为随机)": "Случайное зерно (оставьте пустым для случайного)" + "随机种子 (留空为随机)": "Случайное зерно (оставьте пустым для случайного)", + "跨分组重试": "Повторная попытка между группами", + "跨分组": "Межгрупповой", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку" } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 8af562f7a..85da47cc1 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2735,6 +2735,9 @@ "默认用户消息": "Xin chào", "默认助手消息": "Xin chào! Tôi có thể giúp gì cho bạn?", "可选,用于复现结果": "Tùy chọn, để tái tạo kết quả", - "随机种子 (留空为随机)": "Hạt giống ngẫu nhiên (để trống cho ngẫu nhiên)" + "随机种子 (留空为随机)": "Hạt giống ngẫu nhiên (để trống cho ngẫu nhiên)", + "跨分组重试": "Thử lại giữa các nhóm", + "跨分组": "Giữa các nhóm", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a07885638..8215ba145 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2202,6 +2202,9 @@ "默认用户消息": "你好", "默认助手消息": "你好!有什么我可以帮助你的吗?", "可选,用于复现结果": "可选,用于复现结果", - "随机种子 (留空为随机)": "随机种子 (留空为随机)" + "随机种子 (留空为随机)": "随机种子 (留空为随机)", + "跨分组重试": "跨分组重试", + "跨分组": "跨分组", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道" } } From 48a17efade0d0f6857e532b039344e437029c982 Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 12 Dec 2025 20:37:32 +0800 Subject: [PATCH 24/72] fix: health check --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d737e3d93..aa43de1c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$ FROM debian:bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 \ + && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ && rm -rf /var/lib/apt/lists/* \ && update-ca-certificates From 50854c17bb8e76153049c56f17a5eb624bb9baf7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Dec 2025 20:53:48 +0800 Subject: [PATCH 25/72] feat(adaptor): add '-xhigh' suffix to reasoning effort options for model parsing --- relay/channel/openai/adaptor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 55bd1402c..d2ac75664 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -42,7 +42,7 @@ type Adaptor struct { // support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc... // minimal effort only available in gpt-5 func parseReasoningEffortFromModelSuffix(model string) (string, string) { - effortSuffixes := []string{"-high", "-minimal", "-low", "-medium", "-none"} + effortSuffixes := []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"} for _, suffix := range effortSuffixes { if strings.HasSuffix(model, suffix) { effort := strings.TrimPrefix(suffix, "-") From ce6fb95f96c581ba320657c2244a91acee636d21 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Dec 2025 22:04:38 +0800 Subject: [PATCH 26/72] refactor(relay): update channel retrieval to use RelayInfo structure --- controller/playground.go | 14 +++++++++----- controller/relay.go | 24 +++++++++++++----------- relay/common/relay_info.go | 2 ++ service/channel_select.go | 10 +++++----- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/controller/playground.go b/controller/playground.go index 342f47cf0..d9e2ba9a1 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -9,6 +9,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -31,8 +32,11 @@ func Playground(c *gin.Context) { return } - group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) - modelName := c.GetString("original_model") + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAI, nil, nil) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + return + } userId := c.GetInt("id") @@ -46,11 +50,11 @@ func Playground(c *gin.Context) { tempToken := &model.Token{ UserId: userId, - Name: fmt.Sprintf("playground-%s", group), - Group: group, + Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup), + Group: relayInfo.UsingGroup, } _ = middleware.SetupContextForToken(c, tempToken) - _, newAPIError = getChannel(c, group, modelName, 0) + _, newAPIError = getChannel(c, relayInfo, 0) if newAPIError != nil { return } diff --git a/controller/relay.go b/controller/relay.go index 50ad9dabb..2013b9c0f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -64,8 +64,8 @@ func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewA func Relay(c *gin.Context, relayFormat types.RelayFormat) { requestId := c.GetString(common.RequestIdKey) - group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) - originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + //group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + //originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) var ( newAPIError *types.NewAPIError @@ -158,7 +158,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { }() for i := 0; i <= common.RetryTimes; i++ { - channel, err := getChannel(c, group, originalModel, i) + channel, err := getChannel(c, relayInfo, i) if err != nil { logger.LogError(c, err.Error()) newAPIError = err @@ -211,7 +211,7 @@ func addUsedChannel(c *gin.Context, channelId int) { c.Set("use_channel", useChannel) } -func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) { +func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryCount int) (*model.Channel, *types.NewAPIError) { if retryCount == 0 { autoBan := c.GetBool("auto_ban") autoBanInt := 1 @@ -225,14 +225,18 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m AutoBan: &autoBanInt, }, nil } - channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) + channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, info.TokenGroup, info.OriginModelName, retryCount) + + info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) + if err != nil { - return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } if channel == nil { - return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } - newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel) + + newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName) if newAPIError != nil { return nil, newAPIError } @@ -392,8 +396,6 @@ func RelayNotFound(c *gin.Context) { func RelayTask(c *gin.Context) { retryTimes := common.RetryTimes channelId := c.GetInt("channel_id") - group := c.GetString("group") - originalModel := c.GetString("original_model") c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)}) relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) if err != nil { @@ -404,7 +406,7 @@ func RelayTask(c *gin.Context) { retryTimes = 0 } for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { - channel, newAPIError := getChannel(c, group, originalModel, i) + channel, newAPIError := getChannel(c, relayInfo, i) if newAPIError != nil { logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error())) taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 1882eca89..8bc47bb52 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -81,6 +81,7 @@ type TokenCountMeta struct { type RelayInfo struct { TokenId int TokenKey string + TokenGroup string UserId int UsingGroup string // 使用的分组 UserGroup string // 用户所在分组 @@ -400,6 +401,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId), TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey), TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited), + TokenGroup: common.GetContextKeyString(c, constant.ContextKeyTokenGroup), isFirstResponse: true, RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), diff --git a/service/channel_select.go b/service/channel_select.go index b95aa025b..aea522d96 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -12,12 +12,12 @@ import ( ) // CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. -func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName string, retry int) (*model.Channel, string, error) { +func CacheGetRandomSatisfiedChannel(c *gin.Context, tokenGroup string, modelName string, retry int) (*model.Channel, string, error) { var channel *model.Channel var err error - selectGroup := group + selectGroup := tokenGroup userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - if group == "auto" { + if tokenGroup == "auto" { if len(setting.GetAutoGroups()) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") } @@ -49,9 +49,9 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName stri } } } else { - channel, err = model.GetRandomSatisfiedChannel(group, modelName, retry) + channel, err = model.GetRandomSatisfiedChannel(tokenGroup, modelName, retry) if err != nil { - return nil, group, err + return nil, tokenGroup, err } } return channel, selectGroup, nil From b523f6a0ba222d295fe6c60a388921814b77bc78 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 01:04:10 +0800 Subject: [PATCH 27/72] fix(channel_select): adjust priority retry logic for cross-group channel selection --- service/channel_select.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/service/channel_select.go b/service/channel_select.go index aea522d96..ab33bcd19 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -22,23 +22,26 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, tokenGroup string, modelName return nil, selectGroup, errors.New("auto groups is not enabled") } autoGroups := GetUserAutoGroup(userGroup) - // 如果 token 启用了跨分组重试,获取上次失败的 auto group 索引,从下一个开始尝试 startIndex := 0 + priorityRetry := retry crossGroupRetry := common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) if crossGroupRetry && retry > 0 { logger.LogDebug(c, "Auto group retry cross group, retry: %d", retry) if lastIndex, exists := common.GetContextKey(c, constant.ContextKeyAutoGroupIndex); exists { if idx, ok := lastIndex.(int); ok { startIndex = idx + 1 + priorityRetry = 0 } } logger.LogDebug(c, "Auto group retry cross group, start index: %d", startIndex) } + for i := startIndex; i < len(autoGroups); i++ { autoGroup := autoGroups[i] logger.LogDebug(c, "Auto selecting group: %s", autoGroup) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, modelName, 0) + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, modelName, priorityRetry) if channel == nil { + priorityRetry = 0 continue } else { c.Set("auto_group", autoGroup) From 1c167c1068aa6d847438d63f75bfa4c12a1c8db1 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 01:38:12 +0800 Subject: [PATCH 28/72] refactor(auth): replace direct token group setting with context key retrieval --- middleware/auth.go | 2 +- relay/common/relay_info.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/middleware/auth.go b/middleware/auth.go index b1fca4712..cefc4e068 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -307,7 +307,7 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e } else { c.Set("token_model_limit_enabled", false) } - c.Set("token_group", token.Group) + common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group) c.Set("token_cross_group_retry", token.CrossGroupRetry) if len(parts) > 1 { if model.IsAdmin(token.UserId) { diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 8bc47bb52..40f79463f 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -83,7 +83,7 @@ type RelayInfo struct { TokenKey string TokenGroup string UserId int - UsingGroup string // 使用的分组 + UsingGroup string // 使用的分组,当auto跨分组重试时,会变动 UserGroup string // 用户所在分组 TokenUnlimited bool StartTime time.Time @@ -374,6 +374,12 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { //channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId) //paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + // 当令牌分组为空时,表示使用用户分组 + if tokenGroup == "" { + tokenGroup = common.GetContextKeyString(c, constant.ContextKeyUserGroup) + } + startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) if startTime.IsZero() { startTime = time.Now() @@ -401,7 +407,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId), TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey), TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited), - TokenGroup: common.GetContextKeyString(c, constant.ContextKeyTokenGroup), + TokenGroup: tokenGroup, isFirstResponse: true, RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), From b58fa3debca1df0ba9f80bae53884eb107c9fc7d Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 13:29:21 +0800 Subject: [PATCH 29/72] fix(helper): improve error handling in FlushWriter and related functions --- relay/channel/gemini/relay-gemini-native.go | 4 +- relay/helper/common.go | 53 ++++++++++++++++----- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index f25d9ebf0..5f9ff7cdf 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -94,10 +94,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn helper.SetEventStreamHeaders(c) return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { - // 直接发送 GeminiChatResponse 响应 err := helper.StringData(c, data) if err != nil { - logger.LogError(c, err.Error()) + logger.LogError(c, "failed to write stream data: "+err.Error()) + return false } info.SendResponseCount++ return true diff --git a/relay/helper/common.go b/relay/helper/common.go index 3bb1c80c9..17ce79d2a 100644 --- a/relay/helper/common.go +++ b/relay/helper/common.go @@ -14,15 +14,28 @@ import ( "github.com/gorilla/websocket" ) -func FlushWriter(c *gin.Context) error { - if c.Writer == nil { +func FlushWriter(c *gin.Context) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("flush panic recovered: %v", r) + } + }() + + if c == nil || c.Writer == nil { return nil } - if flusher, ok := c.Writer.(http.Flusher); ok { - flusher.Flush() - return nil + + if c.Request != nil && c.Request.Context().Err() != nil { + return fmt.Errorf("request context done: %w", c.Request.Context().Err()) } - return errors.New("streaming error: flusher not found") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return errors.New("streaming error: flusher not found") + } + + flusher.Flush() + return nil } func SetEventStreamHeaders(c *gin.Context) { @@ -66,17 +79,31 @@ func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data st } func StringData(c *gin.Context, str string) error { - //str = strings.TrimPrefix(str, "data: ") - //str = strings.TrimSuffix(str, "\r") + if c == nil || c.Writer == nil { + return errors.New("context or writer is nil") + } + + if c.Request != nil && c.Request.Context().Err() != nil { + return fmt.Errorf("request context done: %w", c.Request.Context().Err()) + } + c.Render(-1, common.CustomEvent{Data: "data: " + str}) - _ = FlushWriter(c) - return nil + return FlushWriter(c) } func PingData(c *gin.Context) error { - c.Writer.Write([]byte(": PING\n\n")) - _ = FlushWriter(c) - return nil + if c == nil || c.Writer == nil { + return errors.New("context or writer is nil") + } + + if c.Request != nil && c.Request.Context().Err() != nil { + return fmt.Errorf("request context done: %w", c.Request.Context().Err()) + } + + if _, err := c.Writer.Write([]byte(": PING\n\n")); err != nil { + return fmt.Errorf("write ping data failed: %w", err) + } + return FlushWriter(c) } func ObjectData(c *gin.Context, object interface{}) error { From fcafadc6bb1f41f529e23f7d2c3bde019a100b5e Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 13 Dec 2025 13:49:38 +0800 Subject: [PATCH 30/72] feat: pyroscope integrate --- common/pyro.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 +++- go.sum | 48 ++++++++++++++++++++++++++++++++---------------- main.go | 5 +++++ 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 common/pyro.go diff --git a/common/pyro.go b/common/pyro.go new file mode 100644 index 000000000..4fb4f7bb7 --- /dev/null +++ b/common/pyro.go @@ -0,0 +1,49 @@ +package common + +import ( + "os" + "runtime" + + "github.com/grafana/pyroscope-go" +) + +func StartPyroScope() error { + + pyroscopeUrl := os.Getenv("PYROSCOPE_URL") + if pyroscopeUrl == "" { + return nil + } + + // These 2 lines are only required if you're using mutex or block profiling + // Read the explanation below for how to set these rates: + runtime.SetMutexProfileFraction(5) + runtime.SetBlockProfileRate(5) + + _, err := pyroscope.Start(pyroscope.Config{ + ApplicationName: SystemName, + + ServerAddress: pyroscopeUrl, + + Logger: nil, + + Tags: map[string]string{"hostname": GetEnvOrDefaultString("HOSTNAME", "new-api")}, + + ProfileTypes: []pyroscope.ProfileType{ + pyroscope.ProfileCPU, + pyroscope.ProfileAllocObjects, + pyroscope.ProfileAllocSpace, + pyroscope.ProfileInuseObjects, + pyroscope.ProfileInuseSpace, + + pyroscope.ProfileGoroutines, + pyroscope.ProfileMutexCount, + pyroscope.ProfileMutexDuration, + pyroscope.ProfileBlockCount, + pyroscope.ProfileBlockDuration, + }, + }) + if err != nil { + return err + } + return nil +} diff --git a/go.mod b/go.mod index 87af3c22e..4b5d63e49 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 + github.com/grafana/pyroscope-go v1.2.7 github.com/jfreymuth/oggvorbis v1.0.5 github.com/jinzhu/copier v0.4.0 github.com/joho/godotenv v1.5.1 @@ -77,11 +78,11 @@ require ( github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-webauthn/x v0.1.25 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-tpm v0.9.5 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -91,6 +92,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 1138c747a..697a313d8 100644 --- a/go.sum +++ b/go.sum @@ -118,9 +118,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -132,6 +131,10 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= @@ -160,12 +163,15 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -214,14 +220,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= -github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= @@ -231,6 +234,7 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -288,12 +292,12 @@ golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -321,6 +325,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= @@ -350,19 +356,29 @@ gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBp gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index 481d0a600..8484257bf 100644 --- a/main.go +++ b/main.go @@ -124,6 +124,11 @@ func main() { common.SysLog("pprof enabled") } + err = common.StartPyroScope() + if err != nil { + common.SysError(fmt.Sprintf("start pyroscope error : %v", err)) + } + // Initialize HTTP server server := gin.New() server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { From c51936e068c112ad77526fcd4c0b4fd517eb5435 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 16:43:38 +0800 Subject: [PATCH 31/72] refactor(channel_select): enhance retry logic and context key usage for channel selection --- constant/context_key.go | 5 +- controller/playground.go | 9 --- controller/relay.go | 35 ++++++--- middleware/auth.go | 2 +- middleware/distributor.go | 7 +- service/channel_select.go | 147 ++++++++++++++++++++++++++++++-------- service/quota.go | 2 +- 7 files changed, 155 insertions(+), 52 deletions(-) diff --git a/constant/context_key.go b/constant/context_key.go index ecc5178ee..833aabae1 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -21,7 +21,6 @@ const ( ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" /* channel related keys */ - ContextKeyAutoGroupIndex ContextKey = "auto_group_index" ContextKeyChannelId ContextKey = "channel_id" ContextKeyChannelName ContextKey = "channel_name" ContextKeyChannelCreateTime ContextKey = "channel_create_time" @@ -39,6 +38,10 @@ const ( ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" ContextKeyChannelKey ContextKey = "channel_key" + ContextKeyAutoGroup ContextKey = "auto_group" + ContextKeyAutoGroupIndex ContextKey = "auto_group_index" + ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index" + /* user related keys */ ContextKeyUserId ContextKey = "id" ContextKeyUserSetting ContextKey = "user_setting" diff --git a/controller/playground.go b/controller/playground.go index d9e2ba9a1..501c4e156 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -3,10 +3,7 @@ package controller import ( "errors" "fmt" - "time" - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -54,12 +51,6 @@ func Playground(c *gin.Context) { Group: relayInfo.UsingGroup, } _ = middleware.SetupContextForToken(c, tempToken) - _, newAPIError = getChannel(c, relayInfo, 0) - if newAPIError != nil { - return - } - //middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model) - common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) Relay(c, types.RelayFormatOpenAI) } diff --git a/controller/relay.go b/controller/relay.go index 2013b9c0f..a0618452c 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -157,8 +157,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } }() - for i := 0; i <= common.RetryTimes; i++ { - channel, err := getChannel(c, relayInfo, i) + retryParam := &service.RetryParam{ + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + } + + for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { + channel, err := getChannel(c, relayInfo, retryParam) if err != nil { logger.LogError(c, err.Error()) newAPIError = err @@ -186,7 +193,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) - if !shouldRetry(c, newAPIError, common.RetryTimes-i) { + if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { break } } @@ -211,8 +218,8 @@ func addUsedChannel(c *gin.Context, channelId int) { c.Set("use_channel", useChannel) } -func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryCount int) (*model.Channel, *types.NewAPIError) { - if retryCount == 0 { +func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) { + if info.ChannelMeta == nil { autoBan := c.GetBool("auto_ban") autoBanInt := 1 if !autoBan { @@ -225,7 +232,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryCount int) (*m AutoBan: &autoBanInt, }, nil } - channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, info.TokenGroup, info.OriginModelName, retryCount) + channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) @@ -370,7 +377,7 @@ func RelayMidjourney(c *gin.Context) { } func RelayNotImplemented(c *gin.Context) { - err := dto.OpenAIError{ + err := types.OpenAIError{ Message: "API not implemented", Type: "new_api_error", Param: "", @@ -382,7 +389,7 @@ func RelayNotImplemented(c *gin.Context) { } func RelayNotFound(c *gin.Context) { - err := dto.OpenAIError{ + err := types.OpenAIError{ Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), Type: "invalid_request_error", Param: "", @@ -405,8 +412,14 @@ func RelayTask(c *gin.Context) { if taskErr == nil { retryTimes = 0 } - for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { - channel, newAPIError := getChannel(c, relayInfo, i) + retryParam := &service.RetryParam{ + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + } + for ; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && retryParam.GetRetry() < retryTimes; retryParam.IncreaseRetry() { + channel, newAPIError := getChannel(c, relayInfo, retryParam) if newAPIError != nil { logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error())) taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError) @@ -416,7 +429,7 @@ func RelayTask(c *gin.Context) { useChannel := c.GetStringSlice("use_channel") useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) c.Set("use_channel", useChannel) - logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i)) + logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, retryParam.GetRetry())) //middleware.SetupContextForSelectedChannel(c, channel, originalModel) requestBody, _ := common.GetRequestBody(c) diff --git a/middleware/auth.go b/middleware/auth.go index cefc4e068..d24120042 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -308,7 +308,7 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e c.Set("token_model_limit_enabled", false) } common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group) - c.Set("token_cross_group_retry", token.CrossGroupRetry) + common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry) if len(parts) > 1 { if model.IsAdmin(token.UserId) { c.Set("specific_channel_id", parts[1]) diff --git a/middleware/distributor.go b/middleware/distributor.go index 3c8529d96..390dc059f 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -97,7 +97,12 @@ func Distribute() func(c *gin.Context) { common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) } } - channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(c, usingGroup, modelRequest.Model, 0) + channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ + Ctx: c, + ModelName: modelRequest.Model, + TokenGroup: usingGroup, + Retry: common.GetPointer(0), + }) if err != nil { showGroup := usingGroup if usingGroup == "auto" { diff --git a/service/channel_select.go b/service/channel_select.go index ab33bcd19..afaf4f04e 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -11,50 +11,141 @@ import ( "github.com/gin-gonic/gin" ) +type RetryParam struct { + Ctx *gin.Context + TokenGroup string + ModelName string + Retry *int +} + +func (p *RetryParam) GetRetry() int { + if p.Retry == nil { + return 0 + } + return *p.Retry +} + +func (p *RetryParam) SetRetry(retry int) { + p.Retry = &retry +} + +func (p *RetryParam) IncreaseRetry() { + if p.Retry == nil { + p.Retry = new(int) + } + *p.Retry++ +} + // CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. -func CacheGetRandomSatisfiedChannel(c *gin.Context, tokenGroup string, modelName string, retry int) (*model.Channel, string, error) { +// 尝试获取一个满足要求的随机渠道。 +// +// For "auto" tokenGroup with cross-group Retry enabled: +// 对于启用了跨分组重试的 "auto" tokenGroup: +// +// - Each group will exhaust all its priorities before moving to the next group. +// 每个分组会用完所有优先级后才会切换到下一个分组。 +// +// - Uses ContextKeyAutoGroupIndex to track current group index. +// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。 +// +// - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started. +// 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。 +// +// - priorityRetry = Retry - startRetryIndex, represents the priority level within current group. +// priorityRetry = Retry - startRetryIndex,表示当前分组内的优先级级别。 +// +// - When GetRandomSatisfiedChannel returns nil (priorities exhausted), moves to next group. +// 当 GetRandomSatisfiedChannel 返回 nil(优先级用完)时,切换到下一个分组。 +// +// Example flow (2 groups, each with 2 priorities, RetryTimes=3): +// 示例流程(2个分组,每个有2个优先级,RetryTimes=3): +// +// Retry=0: GroupA, priority0 (startRetryIndex=0, priorityRetry=0) +// 分组A, 优先级0 +// +// Retry=1: GroupA, priority1 (startRetryIndex=0, priorityRetry=1) +// 分组A, 优先级1 +// +// Retry=2: GroupA exhausted → GroupB, priority0 (startRetryIndex=2, priorityRetry=0) +// 分组A用完 → 分组B, 优先级0 +// +// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1) +// 分组B, 优先级1 +func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) { var channel *model.Channel var err error - selectGroup := tokenGroup - userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - if tokenGroup == "auto" { + selectGroup := param.TokenGroup + userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup) + + if param.TokenGroup == "auto" { if len(setting.GetAutoGroups()) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") } autoGroups := GetUserAutoGroup(userGroup) - startIndex := 0 - priorityRetry := retry - crossGroupRetry := common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) - if crossGroupRetry && retry > 0 { - logger.LogDebug(c, "Auto group retry cross group, retry: %d", retry) - if lastIndex, exists := common.GetContextKey(c, constant.ContextKeyAutoGroupIndex); exists { - if idx, ok := lastIndex.(int); ok { - startIndex = idx + 1 - priorityRetry = 0 - } + + // startGroupIndex: the group index to start searching from + // startGroupIndex: 开始搜索的分组索引 + startGroupIndex := 0 + crossGroupRetry := common.GetContextKeyBool(param.Ctx, constant.ContextKeyTokenCrossGroupRetry) + + if lastGroupIndex, exists := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex); exists { + if idx, ok := lastGroupIndex.(int); ok { + startGroupIndex = idx } - logger.LogDebug(c, "Auto group retry cross group, start index: %d", startIndex) } - for i := startIndex; i < len(autoGroups); i++ { + for i := startGroupIndex; i < len(autoGroups); i++ { autoGroup := autoGroups[i] - logger.LogDebug(c, "Auto selecting group: %s", autoGroup) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, modelName, priorityRetry) - if channel == nil { + // Calculate priorityRetry for current group + // 计算当前分组的 priorityRetry + priorityRetry := param.GetRetry() + // If moved to a new group, reset priorityRetry and update startRetryIndex + // 如果切换到新分组,重置 priorityRetry 并更新 startRetryIndex + if i > startGroupIndex { priorityRetry = 0 - continue - } else { - c.Set("auto_group", autoGroup) - common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, i) - selectGroup = autoGroup - logger.LogDebug(c, "Auto selected group: %s", autoGroup) - break } + logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) + + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) + if channel == nil { + // Current group has no available channel for this model, try next group + // 当前分组没有该模型的可用渠道,尝试下一个分组 + logger.LogDebug(param.Ctx, "No available channel in group %s for model %s at priorityRetry %d, trying next group", autoGroup, param.ModelName, priorityRetry) + // 重置状态以尝试下一个分组 + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0) + // Reset retry counter so outer loop can continue for next group + // 重置重试计数器,以便外层循环可以为下一个分组继续 + param.SetRetry(0) + continue + } + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup) + selectGroup = autoGroup + logger.LogDebug(param.Ctx, "Auto selected group: %s", autoGroup) + + // Prepare state for next retry + // 为下一次重试准备状态 + if crossGroupRetry && priorityRetry >= common.RetryTimes { + // Current group has exhausted all retries, prepare to switch to next group + // This request still uses current group, but next retry will use next group + // 当前分组已用完所有重试次数,准备切换到下一个分组 + // 本次请求仍使用当前分组,但下次重试将使用下一个分组 + logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes) + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) + // Reset retry counter so outer loop can continue for next group + // 重置重试计数器,以便外层循环可以为下一个分组继续 + param.SetRetry(-1) + } else { + // Stay in current group, save current state + // 保持在当前分组,保存当前状态 + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i) + } + break } } else { - channel, err = model.GetRandomSatisfiedChannel(tokenGroup, modelName, retry) + channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry()) if err != nil { - return nil, tokenGroup, err + return nil, param.TokenGroup, err } } return channel, selectGroup, nil diff --git a/service/quota.go b/service/quota.go index 0f41b851b..0da8dafd3 100644 --- a/service/quota.go +++ b/service/quota.go @@ -108,7 +108,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) modelRatio, _, _ := ratio_setting.GetModelRatio(modelName) - autoGroup, exists := ctx.Get("auto_group") + autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup) if exists { groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string)) log.Printf("final group ratio: %f", groupRatio) From 21fca238bfb52dfdb0b13c57722bf94199634f04 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 16:43:57 +0800 Subject: [PATCH 32/72] refactor(error): replace dto.OpenAIError with types.OpenAIError for consistency --- controller/billing.go | 6 ++-- controller/model.go | 3 +- dto/error.go | 63 ++++++++++++++++++++++++++--------- relay/channel/zhipu_4v/dto.go | 3 +- service/error.go | 16 +++++---- 5 files changed, 63 insertions(+), 28 deletions(-) diff --git a/controller/billing.go b/controller/billing.go index 1c92a2507..f75f68198 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -2,9 +2,9 @@ package controller import ( "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -29,7 +29,7 @@ func GetSubscription(c *gin.Context) { expiredTime = 0 } if err != nil { - openAIError := dto.OpenAIError{ + openAIError := types.OpenAIError{ Message: err.Error(), Type: "upstream_error", } @@ -81,7 +81,7 @@ func GetUsage(c *gin.Context) { quota, err = model.GetUserUsedQuota(userId) } if err != nil { - openAIError := dto.OpenAIError{ + openAIError := types.OpenAIError{ Message: err.Error(), Type: "new_api_error", } diff --git a/controller/model.go b/controller/model.go index c2409fc00..aa6c6e2b9 100644 --- a/controller/model.go +++ b/controller/model.go @@ -18,6 +18,7 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" "github.com/samber/lo" ) @@ -275,7 +276,7 @@ func RetrieveModel(c *gin.Context, modelType int) { c.JSON(200, aiModel) } } else { - openAIError := dto.OpenAIError{ + openAIError := types.OpenAIError{ Message: fmt.Sprintf("The model '%s' does not exist", modelId), Type: "invalid_request_error", Param: "model", diff --git a/dto/error.go b/dto/error.go index 79547671b..cf00d6772 100644 --- a/dto/error.go +++ b/dto/error.go @@ -1,26 +1,31 @@ package dto -import "github.com/QuantumNous/new-api/types" +import ( + "encoding/json" -type OpenAIError struct { - Message string `json:"message"` - Type string `json:"type"` - Param string `json:"param"` - Code any `json:"code"` -} + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" +) + +//type OpenAIError struct { +// Message string `json:"message"` +// Type string `json:"type"` +// Param string `json:"param"` +// Code any `json:"code"` +//} type OpenAIErrorWithStatusCode struct { - Error OpenAIError `json:"error"` - StatusCode int `json:"status_code"` + Error types.OpenAIError `json:"error"` + StatusCode int `json:"status_code"` LocalError bool } type GeneralErrorResponse struct { - Error types.OpenAIError `json:"error"` - Message string `json:"message"` - Msg string `json:"msg"` - Err string `json:"err"` - ErrorMsg string `json:"error_msg"` + Error json.RawMessage `json:"error"` + Message string `json:"message"` + Msg string `json:"msg"` + Err string `json:"err"` + ErrorMsg string `json:"error_msg"` Header struct { Message string `json:"message"` } `json:"header"` @@ -31,9 +36,35 @@ type GeneralErrorResponse struct { } `json:"response"` } +func (e GeneralErrorResponse) TryToOpenAIError() *types.OpenAIError { + var openAIError types.OpenAIError + if len(e.Error) > 0 { + err := common.Unmarshal(e.Error, &openAIError) + if err == nil && openAIError.Message != "" { + return &openAIError + } + } + return nil +} + func (e GeneralErrorResponse) ToMessage() string { - if e.Error.Message != "" { - return e.Error.Message + if len(e.Error) > 0 { + switch common.GetJsonType(e.Error) { + case "object": + var openAIError types.OpenAIError + err := common.Unmarshal(e.Error, &openAIError) + if err == nil && openAIError.Message != "" { + return openAIError.Message + } + case "string": + var msg string + err := common.Unmarshal(e.Error, &msg) + if err == nil && msg != "" { + return msg + } + default: + return string(e.Error) + } } if e.Message != "" { return e.Message diff --git a/relay/channel/zhipu_4v/dto.go b/relay/channel/zhipu_4v/dto.go index e5edd0ddf..e96feda6b 100644 --- a/relay/channel/zhipu_4v/dto.go +++ b/relay/channel/zhipu_4v/dto.go @@ -4,6 +4,7 @@ import ( "time" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/types" ) // type ZhipuMessage struct { @@ -37,7 +38,7 @@ type ZhipuV4Response struct { Model string `json:"model"` TextResponseChoices []dto.OpenAITextResponseChoice `json:"choices"` Usage dto.Usage `json:"usage"` - Error dto.OpenAIError `json:"error"` + Error types.OpenAIError `json:"error"` } // diff --git a/service/error.go b/service/error.go index 070335ec6..9e517e85a 100644 --- a/service/error.go +++ b/service/error.go @@ -96,19 +96,21 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai if showBodyWhenFail { newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) } else { - if common.DebugEnabled { - logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) - } + logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) } return } - if errResponse.Error.Message != "" { + + if common.GetJsonType(errResponse.Error) == "object" { // General format error (OpenAI, Anthropic, Gemini, etc.) - newApiErr = types.WithOpenAIError(errResponse.Error, resp.StatusCode) - } else { - newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + oaiError := errResponse.TryToOpenAIError() + if oaiError != nil { + newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + return + } } + newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) return } From b602843ce115d8630d3e1acf8848ab165257f7da Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 16:45:42 +0800 Subject: [PATCH 33/72] feat(token): add CrossGroupRetry field to token insertion --- controller/token.go | 1 + 1 file changed, 1 insertion(+) diff --git a/controller/token.go b/controller/token.go index eca4ce002..efefea0eb 100644 --- a/controller/token.go +++ b/controller/token.go @@ -171,6 +171,7 @@ func AddToken(c *gin.Context) { ModelLimits: token.ModelLimits, AllowIps: token.AllowIps, Group: token.Group, + CrossGroupRetry: token.CrossGroupRetry, } err = cleanToken.Insert() if err != nil { From e36e2e1b69d1f2337b97397f41dd66d07c35649e Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 17:24:23 +0800 Subject: [PATCH 34/72] feat(audio): enhance audio request handling with token type detection and streaming support --- dto/audio.go | 6 +- relay/audio_handler.go | 7 +- relay/channel/openai/audio.go | 145 +++++++++++++++++++++++++++ relay/channel/openai/relay-openai.go | 67 +------------ relay/compatible_handler.go | 2 +- setting/ratio_setting/model_ratio.go | 2 +- 6 files changed, 159 insertions(+), 70 deletions(-) create mode 100644 relay/channel/openai/audio.go diff --git a/dto/audio.go b/dto/audio.go index ea51516f8..c6f5b9479 100644 --- a/dto/audio.go +++ b/dto/audio.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "strings" "github.com/QuantumNous/new-api/types" @@ -24,11 +25,14 @@ func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta { CombineText: r.Input, TokenType: types.TokenTypeTextNumber, } + if strings.Contains(r.Model, "gpt") { + meta.TokenType = types.TokenTypeTokenizer + } return meta } func (r *AudioRequest) IsStream(c *gin.Context) bool { - return false + return r.StreamFormat == "sse" } func (r *AudioRequest) SetModelName(modelName string) { diff --git a/relay/audio_handler.go b/relay/audio_handler.go index 15fbb9390..39eb03d39 100644 --- a/relay/audio_handler.go +++ b/relay/audio_handler.go @@ -67,8 +67,11 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } - - postConsumeQuota(c, info, usage.(*dto.Usage), "") + if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 { + service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") + } else { + postConsumeQuota(c, info, usage.(*dto.Usage), "") + } return nil } diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go new file mode 100644 index 000000000..b267dcfbb --- /dev/null +++ b/relay/channel/openai/audio.go @@ -0,0 +1,145 @@ +package openai + +import ( + "bytes" + "fmt" + "io" + "math" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) *dto.Usage { + // the status code has been judged before, if there is a body reading failure, + // it should be regarded as a non-recoverable error, so it should not return err for external retry. + // Analogous to nginx's load balancing, it will only retry if it can't be requested or + // if the upstream returns a specific status code, once the upstream has already written the header, + // the subsequent failure of the response body should be regarded as a non-recoverable error, + // and can be terminated directly. + defer service.CloseResponseBodyGracefully(resp) + usage := &dto.Usage{} + usage.PromptTokens = info.GetEstimatePromptTokens() + usage.TotalTokens = info.GetEstimatePromptTokens() + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.WriteHeader(resp.StatusCode) + + if info.IsStream { + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + if service.SundaySearch(data, "usage") { + var simpleResponse dto.SimpleResponse + err := common.Unmarshal([]byte(data), &simpleResponse) + if err != nil { + logger.LogError(c, err.Error()) + } + if simpleResponse.Usage.TotalTokens != 0 { + usage.PromptTokens = simpleResponse.Usage.InputTokens + usage.CompletionTokens = simpleResponse.OutputTokens + usage.TotalTokens = simpleResponse.TotalTokens + } + } + _ = helper.StringData(c, data) + return true + }) + } else { + common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true) + // 读取响应体到缓冲区 + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + logger.LogError(c, fmt.Sprintf("failed to read TTS response body: %v", err)) + c.Writer.WriteHeaderNow() + return usage + } + + // 写入响应到客户端 + c.Writer.WriteHeaderNow() + _, err = c.Writer.Write(bodyBytes) + if err != nil { + logger.LogError(c, fmt.Sprintf("failed to write TTS response: %v", err)) + } + + // 计算音频时长并更新 usage + audioFormat := "mp3" // 默认格式 + if audioReq, ok := info.Request.(*dto.AudioRequest); ok && audioReq.ResponseFormat != "" { + audioFormat = audioReq.ResponseFormat + } + + var duration float64 + var durationErr error + + if audioFormat == "pcm" { + // PCM 格式没有文件头,根据 OpenAI TTS 的 PCM 参数计算时长 + // 采样率: 24000 Hz, 位深度: 16-bit (2 bytes), 声道数: 1 + const sampleRate = 24000 + const bytesPerSample = 2 + const channels = 1 + duration = float64(len(bodyBytes)) / float64(sampleRate*bytesPerSample*channels) + } else { + ext := "." + audioFormat + reader := bytes.NewReader(bodyBytes) + duration, durationErr = common.GetAudioDuration(c.Request.Context(), reader, ext) + } + + usage.PromptTokensDetails.TextTokens = usage.PromptTokens + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + + if durationErr != nil { + logger.LogWarn(c, fmt.Sprintf("failed to get audio duration: %v", durationErr)) + // 如果无法获取时长,则设置保底的 CompletionTokens,根据body大小计算 + sizeInKB := float64(len(bodyBytes)) / 1000.0 + estimatedTokens := int(math.Ceil(sizeInKB)) // 粗略估算每KB约等于1 token + usage.CompletionTokens = estimatedTokens + usage.CompletionTokenDetails.AudioTokens = estimatedTokens + } else if duration > 0 { + // 计算 token: ceil(duration) / 60.0 * 1000,即每分钟 1000 tokens + completionTokens := int(math.Round(math.Ceil(duration) / 60.0 * 1000)) + usage.CompletionTokens = completionTokens + usage.CompletionTokenDetails.AudioTokens = completionTokens + } + } + + return usage +} + +func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + // 写入新的 response body + service.IOCopyBytesGracefully(c, resp, responseBody) + + var responseData struct { + Usage *dto.Usage `json:"usage"` + } + if err := common.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil { + if responseData.Usage.TotalTokens > 0 { + usage := responseData.Usage + if usage.PromptTokens == 0 { + usage.PromptTokens = usage.InputTokens + } + if usage.CompletionTokens == 0 { + usage.CompletionTokens = usage.OutputTokens + } + return nil, usage + } + } + + usage := &dto.Usage{} + usage.PromptTokens = info.GetEstimatePromptTokens() + usage.CompletionTokens = 0 + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return nil, usage +} diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 8c55ae7a7..5819f7071 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -1,7 +1,6 @@ package openai import ( - "encoding/json" "fmt" "io" "net/http" @@ -151,7 +150,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re var streamResp struct { Usage *dto.Usage `json:"usage"` } - err := json.Unmarshal([]byte(secondLastStreamData), &streamResp) + err := common.Unmarshal([]byte(secondLastStreamData), &streamResp) if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) { usage = streamResp.Usage containStreamUsage = true @@ -327,68 +326,6 @@ func streamTTSResponse(c *gin.Context, resp *http.Response) { } } -func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) *dto.Usage { - // the status code has been judged before, if there is a body reading failure, - // it should be regarded as a non-recoverable error, so it should not return err for external retry. - // Analogous to nginx's load balancing, it will only retry if it can't be requested or - // if the upstream returns a specific status code, once the upstream has already written the header, - // the subsequent failure of the response body should be regarded as a non-recoverable error, - // and can be terminated directly. - defer service.CloseResponseBodyGracefully(resp) - usage := &dto.Usage{} - usage.PromptTokens = info.GetEstimatePromptTokens() - usage.TotalTokens = info.GetEstimatePromptTokens() - for k, v := range resp.Header { - c.Writer.Header().Set(k, v[0]) - } - c.Writer.WriteHeader(resp.StatusCode) - - isStreaming := resp.ContentLength == -1 || resp.Header.Get("Content-Length") == "" - if isStreaming { - streamTTSResponse(c, resp) - } else { - c.Writer.WriteHeaderNow() - _, err := io.Copy(c.Writer, resp.Body) - if err != nil { - logger.LogError(c, err.Error()) - } - } - return usage -} - -func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { - defer service.CloseResponseBodyGracefully(resp) - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil - } - // 写入新的 response body - service.IOCopyBytesGracefully(c, resp, responseBody) - - var responseData struct { - Usage *dto.Usage `json:"usage"` - } - if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil { - if responseData.Usage.TotalTokens > 0 { - usage := responseData.Usage - if usage.PromptTokens == 0 { - usage.PromptTokens = usage.InputTokens - } - if usage.CompletionTokens == 0 { - usage.CompletionTokens = usage.OutputTokens - } - return nil, usage - } - } - - usage := &dto.Usage{} - usage.PromptTokens = info.GetEstimatePromptTokens() - usage.CompletionTokens = 0 - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - return nil, usage -} - func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) { if info == nil || info.ClientWs == nil || info.TargetWs == nil { return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil @@ -687,7 +624,7 @@ func extractCachedTokensFromBody(body []byte) (int, bool) { } `json:"usage"` } - if err := json.Unmarshal(body, &payload); err != nil { + if err := common.Unmarshal(body, &payload); err != nil { return 0, false } diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 60934505d..f46ff9de9 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -181,7 +181,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return newApiErr } - if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { + if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 { service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") } else { postConsumeQuota(c, info, usage.(*dto.Usage), "") diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index bef82e57e..89e768a05 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -536,7 +536,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { if name == "gpt-4o-2024-05-13" { return 3, true } - return 4, true + return 4, false } // gpt-5 匹配 if strings.HasPrefix(name, "gpt-5") { From 7cae4a640b7a8f02f995336aab8688968e10f555 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 17:49:57 +0800 Subject: [PATCH 35/72] fix(audio): correct TotalTokens calculation for accurate usage reporting --- relay/channel/openai/audio.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go index b267dcfbb..877f5bb1c 100644 --- a/relay/channel/openai/audio.go +++ b/relay/channel/openai/audio.go @@ -91,7 +91,6 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel } usage.PromptTokensDetails.TextTokens = usage.PromptTokens - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens if durationErr != nil { logger.LogWarn(c, fmt.Sprintf("failed to get audio duration: %v", durationErr)) @@ -106,6 +105,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel usage.CompletionTokens = completionTokens usage.CompletionTokenDetails.AudioTokens = completionTokens } + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens } return usage From a2da6a9e9033edb426861e236fe0474c080c961e Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 18:09:10 +0800 Subject: [PATCH 36/72] refactor(channel_select): improve retry logic with reset functionality --- service/channel_select.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/service/channel_select.go b/service/channel_select.go index afaf4f04e..a3710ef8c 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -12,10 +12,11 @@ import ( ) type RetryParam struct { - Ctx *gin.Context - TokenGroup string - ModelName string - Retry *int + Ctx *gin.Context + TokenGroup string + ModelName string + Retry *int + resetNextTry bool } func (p *RetryParam) GetRetry() int { @@ -30,12 +31,20 @@ func (p *RetryParam) SetRetry(retry int) { } func (p *RetryParam) IncreaseRetry() { + if p.resetNextTry { + p.resetNextTry = false + return + } if p.Retry == nil { p.Retry = new(int) } *p.Retry++ } +func (p *RetryParam) ResetRetryNextTry() { + p.resetNextTry = true +} + // CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. // 尝试获取一个满足要求的随机渠道。 // @@ -134,7 +143,8 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) // Reset retry counter so outer loop can continue for next group // 重置重试计数器,以便外层循环可以为下一个分组继续 - param.SetRetry(-1) + param.SetRetry(0) + param.ResetRetryNextTry() } else { // Stay in current group, save current state // 保持在当前分组,保存当前状态 From 689c43143b8589b6a770f5d8453b8852ac34ba54 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 19:14:27 +0800 Subject: [PATCH 37/72] feat(model_ratio): add default ratios for gpt-4o-mini-tts --- setting/ratio_setting/model_ratio.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 89e768a05..00e8ccffa 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -297,6 +297,7 @@ var defaultModelPrice = map[string]float64{ "mj_upload": 0.05, "sora-2": 0.3, "sora-2-pro": 0.5, + "gpt-4o-mini-tts": 0.3, } var defaultAudioRatio = map[string]float64{ @@ -304,11 +305,13 @@ var defaultAudioRatio = map[string]float64{ "gpt-4o-mini-audio-preview": 66.67, "gpt-4o-realtime-preview": 8, "gpt-4o-mini-realtime-preview": 16.67, + "gpt-4o-mini-tts": 25, } var defaultAudioCompletionRatio = map[string]float64{ "gpt-4o-realtime": 2, "gpt-4o-mini-realtime": 2, + "gpt-4o-mini-tts": 1, } var ( @@ -536,6 +539,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { if name == "gpt-4o-2024-05-13" { return 3, true } + if strings.HasPrefix(name, "gpt-4o-mini-tts") { + return 20, false + } return 4, false } // gpt-5 匹配 From 9c2483ef48906f5e756309ffee81a98e9832de79 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 23:57:32 +0800 Subject: [PATCH 38/72] fix(audio): improve WAV duration calculation with enhanced PCM size handling --- common/audio.go | 59 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/common/audio.go b/common/audio.go index 1eb1079de..466cd2c79 100644 --- a/common/audio.go +++ b/common/audio.go @@ -71,15 +71,66 @@ func getMP3Duration(r io.Reader) (float64, error) { // getWAVDuration 解析 WAV 文件头以获取时长。 func getWAVDuration(r io.ReadSeeker) (float64, error) { + // 1. 强制复位指针 + r.Seek(0, io.SeekStart) + dec := wav.NewDecoder(r) + + // IsValidFile 会读取 fmt 块 if !dec.IsValidFile() { return 0, errors.New("invalid wav file") } - d, err := dec.Duration() - if err != nil { - return 0, errors.Wrap(err, "failed to get wav duration") + + // 尝试寻找 data 块 + if err := dec.FwdToPCM(); err != nil { + return 0, errors.Wrap(err, "failed to find PCM data chunk") } - return d.Seconds(), nil + + pcmSize := int64(dec.PCMSize) + + // 如果读出来的 Size 是 0,尝试用文件大小反推 + if pcmSize == 0 { + // 获取文件总大小 + currentPos, _ := r.Seek(0, io.SeekCurrent) // 当前通常在 data chunk header 之后 + endPos, _ := r.Seek(0, io.SeekEnd) + fileSize := endPos + + // 恢复位置(虽然如果不继续读也没关系) + r.Seek(currentPos, io.SeekStart) + + // 数据区大小 ≈ 文件总大小 - 当前指针位置(即Header大小) + // 注意:FwdToPCM 成功后,CurrentPos 应该刚好指向 Data 区数据的开始 + // 或者是 Data Chunk ID + Size 之后。 + // WAV Header 一般 44 字节。 + if fileSize > 44 { + // 如果 FwdToPCM 成功,Reader 应该位于 data 块的数据起始处 + // 所以剩余的所有字节理论上都是音频数据 + pcmSize = fileSize - currentPos + + // 简单的兜底:如果算出来还是负数或0,强制按文件大小-44计算 + if pcmSize <= 0 { + pcmSize = fileSize - 44 + } + } + } + + numChans := int64(dec.NumChans) + bitDepth := int64(dec.BitDepth) + sampleRate := float64(dec.SampleRate) + + if sampleRate == 0 || numChans == 0 || bitDepth == 0 { + return 0, errors.New("invalid wav header metadata") + } + + bytesPerFrame := numChans * (bitDepth / 8) + if bytesPerFrame == 0 { + return 0, errors.New("invalid byte depth calculation") + } + + totalFrames := pcmSize / bytesPerFrame + + durationSeconds := float64(totalFrames) / sampleRate + return durationSeconds, nil } // getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。 From e293be0138316e5c33313552a9038508ed4dbf34 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Dec 2025 23:59:58 +0800 Subject: [PATCH 39/72] feat(audio): replace SysLog with logger for improved logging in GetAudioDuration --- common/audio.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/audio.go b/common/audio.go index 466cd2c79..f0ad90db1 100644 --- a/common/audio.go +++ b/common/audio.go @@ -6,6 +6,7 @@ import ( "fmt" "io" + "github.com/QuantumNous/new-api/logger" "github.com/abema/go-mp4" "github.com/go-audio/aiff" "github.com/go-audio/wav" @@ -19,7 +20,7 @@ import ( // GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。 // 它不再依赖外部的 ffmpeg 或 ffprobe 程序。 func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) { - SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext)) + logger.LogInfo(ctx, fmt.Sprintf("GetAudioDuration: ext=%s", ext)) // 根据文件扩展名选择解析器 switch ext { case ".mp3": @@ -44,7 +45,7 @@ func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duratio default: return 0, fmt.Errorf("unsupported audio format: %s", ext) } - SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration)) + logger.LogInfo(ctx, fmt.Sprintf("GetAudioDuration: duration=%f", duration)) return duration, err } From 4ea8cbd207a57536868d964af811bf62a91fdda1 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 14 Dec 2025 00:04:40 +0800 Subject: [PATCH 40/72] Revert "feat(audio): replace SysLog with logger for improved logging in GetAudioDuration" This reverts commit e293be0138316e5c33313552a9038508ed4dbf34. --- common/audio.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/audio.go b/common/audio.go index f0ad90db1..466cd2c79 100644 --- a/common/audio.go +++ b/common/audio.go @@ -6,7 +6,6 @@ import ( "fmt" "io" - "github.com/QuantumNous/new-api/logger" "github.com/abema/go-mp4" "github.com/go-audio/aiff" "github.com/go-audio/wav" @@ -20,7 +19,7 @@ import ( // GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。 // 它不再依赖外部的 ffmpeg 或 ffprobe 程序。 func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) { - logger.LogInfo(ctx, fmt.Sprintf("GetAudioDuration: ext=%s", ext)) + SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext)) // 根据文件扩展名选择解析器 switch ext { case ".mp3": @@ -45,7 +44,7 @@ func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duratio default: return 0, fmt.Errorf("unsupported audio format: %s", ext) } - logger.LogInfo(ctx, fmt.Sprintf("GetAudioDuration: duration=%f", duration)) + SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration)) return duration, err } From 39593052b67ca186c8623f3173b1339a88f88716 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 15 Dec 2025 17:24:09 +0800 Subject: [PATCH 41/72] feat(auth): enhance IP restriction handling with CIDR support --- common/ip.go | 29 +++++++++++++++++++ common/ssrf_protection.go | 18 +----------- common/utils.go | 5 ---- middleware/auth.go | 15 ++++++++-- model/token.go | 15 +++++----- .../table/tokens/modals/EditTokenModal.jsx | 4 +-- web/src/i18n/locales/en.json | 4 +-- web/src/i18n/locales/fr.json | 4 +-- web/src/i18n/locales/ja.json | 4 +-- web/src/i18n/locales/ru.json | 4 +-- web/src/i18n/locales/vi.json | 4 +-- web/src/i18n/locales/zh.json | 4 +-- 12 files changed, 63 insertions(+), 47 deletions(-) diff --git a/common/ip.go b/common/ip.go index bfb64ee7f..0f2a41ffd 100644 --- a/common/ip.go +++ b/common/ip.go @@ -2,6 +2,15 @@ package common import "net" +func IsIP(s string) bool { + ip := net.ParseIP(s) + return ip != nil +} + +func ParseIP(s string) net.IP { + return net.ParseIP(s) +} + func IsPrivateIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true @@ -20,3 +29,23 @@ func IsPrivateIP(ip net.IP) bool { } return false } + +func IsIpInCIDRList(ip net.IP, cidrList []string) bool { + for _, cidr := range cidrList { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + // 尝试作为单个IP处理 + if whitelistIP := net.ParseIP(cidr); whitelistIP != nil { + if ip.Equal(whitelistIP) { + return true + } + } + continue + } + + if network.Contains(ip) { + return true + } + } + return false +} diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index 6f7d289f1..3cd5c2ea1 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -186,23 +186,7 @@ func isIPListed(ip net.IP, list []string) bool { return false } - for _, whitelistCIDR := range list { - _, network, err := net.ParseCIDR(whitelistCIDR) - if err != nil { - // 尝试作为单个IP处理 - if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil { - if ip.Equal(whitelistIP) { - return true - } - } - continue - } - - if network.Contains(ip) { - return true - } - } - return false + return IsIpInCIDRList(ip, list) } // IsIPAccessAllowed 检查IP是否允许访问 diff --git a/common/utils.go b/common/utils.go index 0ffa128e7..f63df857b 100644 --- a/common/utils.go +++ b/common/utils.go @@ -217,11 +217,6 @@ func IntMax(a int, b int) int { } } -func IsIP(s string) bool { - ip := net.ParseIP(s) - return ip != nil -} - func GetUUID() string { code := uuid.New().String() code = strings.Replace(code, "-", "", -1) diff --git a/middleware/auth.go b/middleware/auth.go index d24120042..9bc2f0428 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -2,12 +2,14 @@ package middleware import ( "fmt" + "net" "net/http" "strconv" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -240,13 +242,20 @@ func TokenAuth() func(c *gin.Context) { return } - allowIpsMap := token.GetIpLimitsMap() - if len(allowIpsMap) != 0 { + allowIpsMap := token.GetIpLimits() + if len(allowIpsMap) > 0 { clientIp := c.ClientIP() - if _, ok := allowIpsMap[clientIp]; !ok { + logger.LogDebug(c, "Token has IP restrictions, checking client IP %s", clientIp) + ip := net.ParseIP(clientIp) + if ip == nil { + abortWithOpenAiMessage(c, http.StatusForbidden, "无法解析客户端 IP 地址") + return + } + if common.IsIpInCIDRList(ip, allowIpsMap) == false { abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") return } + logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp) } userCache, err := model.GetUserCache(token.UserId) diff --git a/model/token.go b/model/token.go index a6a307ac2..357d9bdd2 100644 --- a/model/token.go +++ b/model/token.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/QuantumNous/new-api/common" - "github.com/bytedance/gopkg/util/gopool" "gorm.io/gorm" ) @@ -35,26 +34,26 @@ func (token *Token) Clean() { token.Key = "" } -func (token *Token) GetIpLimitsMap() map[string]any { +func (token *Token) GetIpLimits() []string { // delete empty spaces //split with \n - ipLimitsMap := make(map[string]any) + ipLimits := make([]string, 0) if token.AllowIps == nil { - return ipLimitsMap + return ipLimits } cleanIps := strings.ReplaceAll(*token.AllowIps, " ", "") if cleanIps == "" { - return ipLimitsMap + return ipLimits } ips := strings.Split(cleanIps, "\n") for _, ip := range ips { ip = strings.TrimSpace(ip) ip = strings.ReplaceAll(ip, ",", "") - if common.IsIP(ip) { - ipLimitsMap[ip] = true + if ip != "" { + ipLimits = append(ipLimits, ip) } } - return ipLimitsMap + return ipLimits } func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index c7db40d66..cc9f51b0e 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -557,11 +557,11 @@ const EditTokenModal = (props) => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 448559f82..188f9e693 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -97,7 +97,7 @@ "Homepage URL 填": "Fill in the Homepage URL", "ID": "ID", "IP": "IP", - "IP白名单": "IP whitelist", + "IP白名单(支持CIDR表达式)": "IP whitelist (supports CIDR expressions)", "IP限制": "IP restrictions", "IP黑名单": "IP blacklist", "JSON": "JSON", @@ -1752,7 +1752,7 @@ "请先阅读并同意用户协议和隐私政策": "Please read and agree to the user agreement and privacy policy first", "请再次输入新密码": "Please enter the new password again", "请前往个人设置 → 安全设置进行配置。": "Please go to Personal Settings → Security Settings to configure.", - "请勿过度信任此功能,IP可能被伪造": "Do not over-trust this feature, IP can be spoofed", + "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用": "Do not over-trust this feature, IP can be spoofed, please use it in conjunction with gateways such as nginx and CDN", "请在系统设置页面编辑分组倍率以添加新的分组:": "Please edit Group ratios in system settings to add new groups:", "请填写完整的产品信息": "Please fill in complete product information", "请填写完整的管理员账号信息": "Please fill in the complete administrator account information", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index a53d459c9..b314f8608 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -99,7 +99,7 @@ "Homepage URL 填": "Remplir l'URL de la page d'accueil", "ID": "ID", "IP": "IP", - "IP白名单": "Liste blanche d'adresses IP", + "IP白名单(支持CIDR表达式)": "Liste blanche d'adresses IP (prise en charge des expressions CIDR)", "IP限制": "Restrictions d'IP", "IP黑名单": "Liste noire d'adresses IP", "JSON": "JSON", @@ -1762,7 +1762,7 @@ "请先阅读并同意用户协议和隐私政策": "Veuillez d'abord lire et accepter l'accord utilisateur et la politique de confidentialité", "请再次输入新密码": "Veuillez saisir à nouveau le nouveau mot de passe", "请前往个人设置 → 安全设置进行配置。": "Veuillez aller dans Paramètres personnels → Paramètres de sécurité pour configurer.", - "请勿过度信任此功能,IP可能被伪造": "Ne faites pas trop confiance à cette fonctionnalité, l'IP peut être usurpée", + "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用": "Ne faites pas trop confiance à cette fonctionnalité, l'IP peut être usurpée, veuillez l'utiliser en conjonction avec des passerelles telles que nginx et cdn", "请在系统设置页面编辑分组倍率以添加新的分组:": "Veuillez modifier les ratios de groupe dans les paramètres système pour ajouter de nouveaux groupes :", "请填写完整的产品信息": "Veuillez renseigner l'ensemble des informations produit", "请填写完整的管理员账号信息": "Veuillez remplir les informations complètes du compte administrateur", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 72d17e0b9..b5767f662 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -82,7 +82,7 @@ "Homepage URL 填": "ホームページURLを入力してください", "ID": "ID", "IP": "IP", - "IP白名单": "IPホワイトリスト", + "IP白名单(支持CIDR表达式)": "IPホワイトリスト(CIDR表記に対応)", "IP限制": "IP制限", "IP黑名单": "IPブラックリスト", "JSON": "JSON", @@ -1669,7 +1669,7 @@ "请先阅读并同意用户协议和隐私政策": "まずユーザー利用規約とプライバシーポリシーをご確認の上、同意してください", "请再次输入新密码": "新しいパスワードを再入力してください", "请前往个人设置 → 安全设置进行配置。": "アカウント設定 → セキュリティ設定 にて設定してください。", - "请勿过度信任此功能,IP可能被伪造": "IPは偽装される可能性があるため、この機能を過信しないでください", + "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用": "IPは偽装される可能性があるため、この機能を過信しないでください。nginxやCDNなどのゲートウェイと組み合わせて使用してください。", "请在系统设置页面编辑分组倍率以添加新的分组:": "新規グループを追加するには、システム設定ページでグループ倍率を編集してください:", "请填写完整的管理员账号信息": "管理者アカウント情報をすべて入力してください", "请填写密钥": "APIキーを入力してください", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 85659818e..046a84bff 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -101,7 +101,7 @@ "Homepage URL 填": "URL домашней страницы:", "ID": "ID", "IP": "IP", - "IP白名单": "Белый список IP", + "IP白名单(支持CIDR表达式)": "Белый список IP (поддерживает выражения CIDR)", "IP限制": "Ограничения IP", "IP黑名单": "Черный список IP", "JSON": "JSON", @@ -1773,7 +1773,7 @@ "请先阅读并同意用户协议和隐私政策": "Пожалуйста, сначала прочтите и согласитесь с пользовательским соглашением и политикой конфиденциальности", "请再次输入新密码": "Пожалуйста, введите новый пароль ещё раз", "请前往个人设置 → 安全设置进行配置。": "Пожалуйста, перейдите в Личные настройки → Настройки безопасности для конфигурации.", - "请勿过度信任此功能,IP可能被伪造": "Не доверяйте этой функции чрезмерно, IP может быть подделан", + "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用": "Не доверяйте этой функции чрезмерно, IP может быть подделан, используйте её вместе с nginx и CDN и другими шлюзами", "请在系统设置页面编辑分组倍率以添加新的分组:": "Пожалуйста, отредактируйте коэффициенты групп на странице системных настроек для добавления новой группы:", "请填写完整的产品信息": "Пожалуйста, заполните всю информацию о продукте", "请填写完整的管理员账号信息": "Пожалуйста, заполните полную информацию об учётной записи администратора", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 6e8076c56..669cafec6 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -82,7 +82,7 @@ "Homepage URL 填": "Điền URL trang chủ", "ID": "ID", "IP": "IP", - "IP白名单": "Danh sách trắng IP", + "IP白名单(支持CIDR表达式)": "Danh sách trắng IP (hỗ trợ biểu thức CIDR)", "IP限制": "Hạn chế IP", "IP黑名单": "Danh sách đen IP", "JSON": "JSON", @@ -1987,7 +1987,7 @@ "请先阅读并同意用户协议和隐私政策": "Vui lòng đọc và đồng ý với thỏa thuận người dùng và chính sách bảo mật trước", "请再次输入新密码": "Vui lòng nhập lại mật khẩu mới", "请前往个人设置 → 安全设置进行配置。": "Vui lòng truy cập Cài đặt cá nhân → Cài đặt bảo mật để cấu hình.", - "请勿过度信任此功能,IP可能被伪造": "Đừng quá tin tưởng tính năng này, IP có thể bị giả mạo", + "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用": "Đừng quá tin tưởng tính năng này, IP có thể bị giả mạo, vui lòng sử dụng cùng với nginx và các cổng khác như cdn", "请在系统设置页面编辑分组倍率以添加新的分组:": "Vui lòng chỉnh sửa tỷ lệ nhóm trên trang cài đặt hệ thống để thêm nhóm mới:", "请填写完整的管理员账号信息": "Vui lòng điền đầy đủ thông tin tài khoản quản trị viên", "请填写密钥": "Vui lòng điền khóa", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 273cc24f2..304a13b03 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -95,7 +95,7 @@ "Homepage URL 填": "Homepage URL 填", "ID": "ID", "IP": "IP", - "IP白名单": "IP白名单", + "IP白名单(支持CIDR表达式)": "IP白名单(支持CIDR表达式)", "IP限制": "IP限制", "IP黑名单": "IP黑名单", "JSON": "JSON", @@ -1740,7 +1740,7 @@ "请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策", "请再次输入新密码": "请再次输入新密码", "请前往个人设置 → 安全设置进行配置。": "请前往个人设置 → 安全设置进行配置。", - "请勿过度信任此功能,IP可能被伪造": "请勿过度信任此功能,IP可能被伪造", + "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用": "请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用", "请在系统设置页面编辑分组倍率以添加新的分组:": "请在系统设置页面编辑分组倍率以添加新的分组:", "请填写完整的产品信息": "请填写完整的产品信息", "请填写完整的管理员账号信息": "请填写完整的管理员账号信息", From e16e7d6fb901f518f5a3f07488e1f9f7bac95953 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 15 Dec 2025 20:13:09 +0800 Subject: [PATCH 42/72] feat(auth): refactor IP restriction handling to use clearer variable naming --- middleware/auth.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/middleware/auth.go b/middleware/auth.go index 9bc2f0428..1396b2d5a 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -242,8 +242,8 @@ func TokenAuth() func(c *gin.Context) { return } - allowIpsMap := token.GetIpLimits() - if len(allowIpsMap) > 0 { + allowIps := token.GetIpLimits() + if len(allowIps) > 0 { clientIp := c.ClientIP() logger.LogDebug(c, "Token has IP restrictions, checking client IP %s", clientIp) ip := net.ParseIP(clientIp) @@ -251,7 +251,7 @@ func TokenAuth() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusForbidden, "无法解析客户端 IP 地址") return } - if common.IsIpInCIDRList(ip, allowIpsMap) == false { + if common.IsIpInCIDRList(ip, allowIps) == false { abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") return } From 2a511c6ee4f92dcdbf4375dd39af3091c96d7fe8 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 16 Dec 2025 13:08:58 +0800 Subject: [PATCH 43/72] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E4=BC=A0?= =?UTF-8?q?=E5=85=A5system=5Finstruction=E5=92=8CsystemInstruction?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E9=A3=8E=E6=A0=BC=E7=B3=BB=E7=BB=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=8F=82=E6=95=B0=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/gemini.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/dto/gemini.go b/dto/gemini.go index 1ee71a719..4d738c22a 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -22,6 +22,27 @@ type GeminiChatRequest struct { CachedContent string `json:"cachedContent,omitempty"` } +// UnmarshalJSON allows GeminiChatRequest to accept both snake_case and camelCase fields. +func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error { + type Alias GeminiChatRequest + var aux struct { + Alias + SystemInstructionSnake *GeminiChatContent `json:"system_instruction,omitempty"` + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + *r = GeminiChatRequest(aux.Alias) + + if aux.SystemInstructionSnake != nil { + r.SystemInstructions = aux.SystemInstructionSnake + } + + return nil +} + type ToolConfig struct { FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` From 8e3f9b1faa0716b93254ab378c4dc3967259c254 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 16 Dec 2025 17:00:19 +0800 Subject: [PATCH 44/72] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20prevent=20?= =?UTF-8?q?OOM=20on=20large/decompressed=20requests;=20skip=20heavy=20prom?= =?UTF-8?q?pt=20meta=20when=20token=20count=20is=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clamp request body size (including post-decompression) to avoid memory exhaustion caused by huge payloads/zip bombs, especially with large-context Claude requests. Add a configurable `MAX_REQUEST_BODY_MB` (default `32`) and document it. - Enforce max request body size after gzip/br decompression via `http.MaxBytesReader` - Add a secondary size guard in `common.GetRequestBody` and cache-safe handling - Return **413 Request Entity Too Large** on oversized bodies in relay entry - Avoid building large `TokenCountMeta.CombineText` when both token counting and sensitive check are disabled (use lightweight meta for pricing) - Update READMEs (CN/EN/FR/JA) with `MAX_REQUEST_BODY_MB` - Fix a handful of vet/formatting issues encountered during the change - `go test ./...` passes --- README.en.md | 1 + README.fr.md | 1 + README.ja.md | 1 + README.md | 1 + common/gin.go | 43 +++++++++++++++++++---- common/init.go | 2 ++ constant/env.go | 1 + controller/discord.go | 2 +- controller/relay.go | 47 +++++++++++++++++++++++-- controller/task.go | 4 +-- controller/topup_creem.go | 6 ++-- middleware/distributor.go | 2 +- middleware/gzip.go | 51 ++++++++++++++++++++++++---- relay/channel/aws/constants.go | 2 +- relay/channel/baidu/relay-baidu.go | 4 +-- relay/channel/coze/relay-coze.go | 2 +- relay/channel/task/jimeng/adaptor.go | 2 +- relay/channel/task/kling/adaptor.go | 2 +- relay/channel/task/suno/adaptor.go | 2 +- relay/relay_task.go | 2 +- setting/system_setting/discord.go | 6 ++-- 21 files changed, 149 insertions(+), 35 deletions(-) diff --git a/README.en.md b/README.en.md index e71f5e623..063d360b2 100644 --- a/README.en.md +++ b/README.en.md @@ -305,6 +305,7 @@ docker run --name new-api -d --restart always \ | `REDIS_CONN_STRING` | Redis connection string | - | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | +| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | diff --git a/README.fr.md b/README.fr.md index 35051223e..0aa212d1f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -301,6 +301,7 @@ docker run --name new-api -d --restart always \ | `REDIS_CONN_STRING` | Chaine de connexion Redis | - | | `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` | +| `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` | diff --git a/README.ja.md b/README.ja.md index 0c4b91f66..e76cd0ed4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -310,6 +310,7 @@ docker run --name new-api -d --restart always \ | `REDIS_CONN_STRING` | Redis接続文字列 | - | | `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限(MB)。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` | +| `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズ(MB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` | diff --git a/README.md b/README.md index 3d5b6923c..f1cb37480 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ docker run --name new-api -d --restart always \ | `REDIS_CONN_STRING` | Redis 连接字符串 | - | | `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` | +| `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | diff --git a/common/gin.go b/common/gin.go index db299f293..e927962cf 100644 --- a/common/gin.go +++ b/common/gin.go @@ -18,18 +18,47 @@ import ( const KeyRequestBody = "key_request_body" -func GetRequestBody(c *gin.Context) ([]byte, error) { - requestBody, _ := c.Get(KeyRequestBody) - if requestBody != nil { - return requestBody.([]byte), nil +var ErrRequestBodyTooLarge = errors.New("request body too large") + +func IsRequestBodyTooLargeError(err error) bool { + if err == nil { + return false } - requestBody, err := io.ReadAll(c.Request.Body) + if errors.Is(err, ErrRequestBodyTooLarge) { + return true + } + var mbe *http.MaxBytesError + return errors.As(err, &mbe) +} + +func GetRequestBody(c *gin.Context) ([]byte, error) { + cached, exists := c.Get(KeyRequestBody) + if exists && cached != nil { + if b, ok := cached.([]byte); ok { + return b, nil + } + } + maxMB := constant.MaxRequestBodyMB + if maxMB <= 0 { + maxMB = 64 + } + maxBytes := int64(maxMB) << 20 + + limited := io.LimitReader(c.Request.Body, maxBytes+1) + body, err := io.ReadAll(limited) if err != nil { + _ = c.Request.Body.Close() + if IsRequestBodyTooLargeError(err) { + return nil, ErrRequestBodyTooLarge + } return nil, err } _ = c.Request.Body.Close() - c.Set(KeyRequestBody, requestBody) - return requestBody.([]byte), nil + if int64(len(body)) > maxBytes { + return nil, ErrRequestBodyTooLarge + } + c.Set(KeyRequestBody, body) + return body, nil } func UnmarshalBodyReusable(c *gin.Context, v any) error { diff --git a/common/init.go b/common/init.go index 3f3bd1df4..ac27fd2c2 100644 --- a/common/init.go +++ b/common/init.go @@ -117,6 +117,8 @@ func initConstantEnv() { constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64) + // MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨 + constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 32) // ForceStreamOption 覆盖请求参数,强制返回usage信息 constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true) constant.CountToken = GetEnvOrDefaultBool("CountToken", true) diff --git a/constant/env.go b/constant/env.go index 975bced7c..c561c207d 100644 --- a/constant/env.go +++ b/constant/env.go @@ -9,6 +9,7 @@ var CountToken bool var GetMediaToken bool var GetMediaTokenNotStream bool var UpdateTask bool +var MaxRequestBodyMB int var AzureDefaultAPIVersion string var GeminiVisionMaxImageNum int var NotifyLimitCount int diff --git a/controller/discord.go b/controller/discord.go index 41dd59808..a0865de51 100644 --- a/controller/discord.go +++ b/controller/discord.go @@ -114,7 +114,7 @@ func DiscordOAuth(c *gin.Context) { DiscordBind(c) return } - if !system_setting.GetDiscordSettings().Enabled { + if !system_setting.GetDiscordSettings().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未开启通过 Discord 登录以及注册", diff --git a/controller/relay.go b/controller/relay.go index a0618452c..29fd209d2 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -2,6 +2,7 @@ package controller import ( "bytes" + "errors" "fmt" "io" "log" @@ -104,7 +105,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { request, err := helper.GetAndValidateRequest(c, relayFormat) if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) + // Map "request body too large" to 413 so clients can handle it correctly + if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) { + newAPIError = types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry()) + } else { + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) + } return } @@ -114,9 +120,17 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { return } - meta := request.GetTokenCountMeta() + needSensitiveCheck := setting.ShouldCheckPromptSensitive() + needCountToken := constant.CountToken + // Avoid building huge CombineText (strings.Join) when token counting and sensitive check are both disabled. + var meta *types.TokenCountMeta + if needSensitiveCheck || needCountToken { + meta = request.GetTokenCountMeta() + } else { + meta = fastTokenCountMetaForPricing(request) + } - if setting.ShouldCheckPromptSensitive() { + if needSensitiveCheck && meta != nil { contains, words := service.CheckSensitiveText(meta.CombineText) if contains { logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) @@ -218,6 +232,33 @@ func addUsedChannel(c *gin.Context, channelId int) { c.Set("use_channel", useChannel) } +func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta { + if request == nil { + return &types.TokenCountMeta{} + } + meta := &types.TokenCountMeta{ + TokenType: types.TokenTypeTokenizer, + } + switch r := request.(type) { + case *dto.GeneralOpenAIRequest: + if r.MaxCompletionTokens > r.MaxTokens { + meta.MaxTokens = int(r.MaxCompletionTokens) + } else { + meta.MaxTokens = int(r.MaxTokens) + } + case *dto.OpenAIResponsesRequest: + meta.MaxTokens = int(r.MaxOutputTokens) + case *dto.ClaudeRequest: + meta.MaxTokens = int(r.MaxTokens) + case *dto.ImageRequest: + // Pricing for image requests depends on ImagePriceRatio; safe to compute even when CountToken is disabled. + return r.GetTokenCountMeta() + default: + // Best-effort: leave CombineText empty to avoid large allocations. + } + return meta +} + func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) { if info.ChannelMeta == nil { autoBan := c.GetBool("auto_ban") diff --git a/controller/task.go b/controller/task.go index 16acc2269..244f9161c 100644 --- a/controller/task.go +++ b/controller/task.go @@ -88,7 +88,7 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM for channelId, taskIds := range taskChannelM { err := updateSunoTaskAll(ctx, channelId, taskIds, taskM) if err != nil { - logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error())) + logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error())) } } return nil @@ -141,7 +141,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas return err } if !responseItems.IsSuccess() { - common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %d", channelId, len(taskIds), string(responseBody))) + common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %s", channelId, len(taskIds), string(responseBody))) return err } diff --git a/controller/topup_creem.go b/controller/topup_creem.go index aab951c54..80a869673 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -7,12 +7,12 @@ import ( "encoding/hex" "encoding/json" "fmt" - "io" - "log" - "net/http" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" + "io" + "log" + "net/http" "time" "github.com/gin-gonic/gin" diff --git a/middleware/distributor.go b/middleware/distributor.go index 390dc059f..a33404726 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -162,7 +162,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest) if mjErr != nil { - return nil, false, fmt.Errorf(mjErr.Description) + return nil, false, fmt.Errorf("%s", mjErr.Description) } if midjourneyModel == "" { if !success { diff --git a/middleware/gzip.go b/middleware/gzip.go index 7fe2f3be3..e86d2fffc 100644 --- a/middleware/gzip.go +++ b/middleware/gzip.go @@ -5,32 +5,69 @@ import ( "io" "net/http" + "github.com/QuantumNous/new-api/constant" "github.com/andybalholm/brotli" "github.com/gin-gonic/gin" ) +type readCloser struct { + io.Reader + closeFn func() error +} + +func (rc *readCloser) Close() error { + if rc.closeFn != nil { + return rc.closeFn() + } + return nil +} + func DecompressRequestMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if c.Request.Body == nil || c.Request.Method == http.MethodGet { c.Next() return } + maxMB := constant.MaxRequestBodyMB + if maxMB <= 0 { + maxMB = 64 + } + maxBytes := int64(maxMB) << 20 + + origBody := c.Request.Body + wrapMaxBytes := func(body io.ReadCloser) io.ReadCloser { + return http.MaxBytesReader(c.Writer, body, maxBytes) + } + switch c.GetHeader("Content-Encoding") { case "gzip": - gzipReader, err := gzip.NewReader(c.Request.Body) + gzipReader, err := gzip.NewReader(origBody) if err != nil { + _ = origBody.Close() c.AbortWithStatus(http.StatusBadRequest) return } - defer gzipReader.Close() - - // Replace the request body with the decompressed data - c.Request.Body = io.NopCloser(gzipReader) + // Replace the request body with the decompressed data, and enforce a max size (post-decompression). + c.Request.Body = wrapMaxBytes(&readCloser{ + Reader: gzipReader, + closeFn: func() error { + _ = gzipReader.Close() + return origBody.Close() + }, + }) c.Request.Header.Del("Content-Encoding") case "br": - reader := brotli.NewReader(c.Request.Body) - c.Request.Body = io.NopCloser(reader) + reader := brotli.NewReader(origBody) + c.Request.Body = wrapMaxBytes(&readCloser{ + Reader: reader, + closeFn: func() error { + return origBody.Close() + }, + }) c.Request.Header.Del("Content-Encoding") + default: + // Even for uncompressed bodies, enforce a max size to avoid huge request allocations. + c.Request.Body = wrapMaxBytes(origBody) } // Continue processing the request diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 6323bb3b1..888d96eef 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -18,7 +18,7 @@ var awsModelIDMap = map[string]string{ "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", "claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0", - "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0", + "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0", // Nova models "nova-micro-v1:0": "amazon.nova-micro-v1:0", "nova-lite-v1:0": "amazon.nova-lite-v1:0", diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index 8597e50ef..691d41888 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -150,7 +150,7 @@ func baiduHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respon return types.NewError(err, types.ErrorCodeBadResponseBody), nil } if baiduResponse.ErrorMsg != "" { - return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil + return types.NewError(fmt.Errorf("%s", baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil } fullTextResponse := responseBaidu2OpenAI(&baiduResponse) jsonResponse, err := json.Marshal(fullTextResponse) @@ -175,7 +175,7 @@ func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht return types.NewError(err, types.ErrorCodeBadResponseBody), nil } if baiduResponse.ErrorMsg != "" { - return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil + return types.NewError(fmt.Errorf("%s", baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil } fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse) jsonResponse, err := json.Marshal(fullTextResponse) diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go index 7095a8b6d..2edeeee0d 100644 --- a/relay/channel/coze/relay-coze.go +++ b/relay/channel/coze/relay-coze.go @@ -208,7 +208,7 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st return } - common.SysLog(fmt.Sprintf("stream event error: ", errorData.Code, errorData.Message)) + common.SysLog(fmt.Sprintf("stream event error: %v %v", errorData.Code, errorData.Message)) } } diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index d6973531f..91d3f2361 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -196,7 +196,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } if jResp.Code != 10000 { - taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError) + taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError) return } diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index d00350652..4c3c9d61b 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -186,7 +186,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return } if kResp.Code != 0 { - taskErr = service.TaskErrorWrapperLocal(fmt.Errorf(kResp.Message), "task_failed", http.StatusBadRequest) + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("%s", kResp.Message), "task_failed", http.StatusBadRequest) return } ov := dto.NewOpenAIVideo() diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go index f7c891723..8ea9a1c7f 100644 --- a/relay/channel/task/suno/adaptor.go +++ b/relay/channel/task/suno/adaptor.go @@ -105,7 +105,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return } if !sunoResponse.IsSuccess() { - taskErr = service.TaskErrorWrapper(fmt.Errorf(sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError) + taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError) return } diff --git a/relay/relay_task.go b/relay/relay_task.go index bac05e0ee..04a905c7f 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -196,7 +196,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto. // handle response if resp != nil && resp.StatusCode != http.StatusOK { responseBody, _ := io.ReadAll(resp.Body) - taskErr = service.TaskErrorWrapper(fmt.Errorf(string(responseBody)), "fail_to_fetch_task", resp.StatusCode) + taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", string(responseBody)), "fail_to_fetch_task", resp.StatusCode) return } diff --git a/setting/system_setting/discord.go b/setting/system_setting/discord.go index f4e763ffa..f4789060b 100644 --- a/setting/system_setting/discord.go +++ b/setting/system_setting/discord.go @@ -3,9 +3,9 @@ package system_setting import "github.com/QuantumNous/new-api/setting/config" type DiscordSettings struct { - Enabled bool `json:"enabled"` - ClientId string `json:"client_id"` - ClientSecret string `json:"client_secret"` + Enabled bool `json:"enabled"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` } // 默认配置 From 8cb56fc319aa79ee848648331dcae5de1c5ecb45 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 16 Dec 2025 18:10:00 +0800 Subject: [PATCH 45/72] =?UTF-8?q?=F0=9F=A7=B9=20fix:=20harden=20request-bo?= =?UTF-8?q?dy=20size=20handling=20and=20error=20unwrapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten oversized request handling across relay paths and make error matching reliable. - Align `MAX_REQUEST_BODY_MB` fallback to `32` in request body reader and decompression middleware - Stop ignoring `GetRequestBody` errors in relay retry paths; return consistent **413** on oversized bodies (400 for other read errors) - Add `Unwrap()` to `types.NewAPIError` so `errors.Is/As` can match wrapped underlying errors - `go test ./...` passes --- common/gin.go | 2 +- controller/relay.go | 29 +++++++++++++++++++++++------ middleware/gzip.go | 2 +- types/error.go | 8 ++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/common/gin.go b/common/gin.go index e927962cf..95996b619 100644 --- a/common/gin.go +++ b/common/gin.go @@ -40,7 +40,7 @@ func GetRequestBody(c *gin.Context) ([]byte, error) { } maxMB := constant.MaxRequestBodyMB if maxMB <= 0 { - maxMB = 64 + maxMB = 32 } maxBytes := int64(maxMB) << 20 diff --git a/controller/relay.go b/controller/relay.go index 29fd209d2..9759fa30c 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -179,15 +179,24 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { - channel, err := getChannel(c, relayInfo, retryParam) - if err != nil { - logger.LogError(c, err.Error()) - newAPIError = err + channel, channelErr := getChannel(c, relayInfo, retryParam) + if channelErr != nil { + logger.LogError(c, channelErr.Error()) + newAPIError = channelErr break } addUsedChannel(c, channel.Id) - requestBody, _ := common.GetRequestBody(c) + requestBody, bodyErr := common.GetRequestBody(c) + if bodyErr != nil { + // Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path) + if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) { + newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry()) + } else { + newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + break + } c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) switch relayFormat { @@ -473,7 +482,15 @@ func RelayTask(c *gin.Context) { logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, retryParam.GetRetry())) //middleware.SetupContextForSelectedChannel(c, channel, originalModel) - requestBody, _ := common.GetRequestBody(c) + requestBody, err := common.GetRequestBody(c) + if err != nil { + if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) { + taskErr = service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusRequestEntityTooLarge) + } else { + taskErr = service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusBadRequest) + } + break + } c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) taskErr = taskRelayHandler(c, relayInfo) } diff --git a/middleware/gzip.go b/middleware/gzip.go index e86d2fffc..5e5682532 100644 --- a/middleware/gzip.go +++ b/middleware/gzip.go @@ -30,7 +30,7 @@ func DecompressRequestMiddleware() gin.HandlerFunc { } maxMB := constant.MaxRequestBodyMB if maxMB <= 0 { - maxMB = 64 + maxMB = 32 } maxBytes := int64(maxMB) << 20 diff --git a/types/error.go b/types/error.go index 9c12034e1..3bfd0399a 100644 --- a/types/error.go +++ b/types/error.go @@ -94,6 +94,14 @@ type NewAPIError struct { StatusCode int } +// Unwrap enables errors.Is / errors.As to work with NewAPIError by exposing the underlying error. +func (e *NewAPIError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + func (e *NewAPIError) GetErrorCode() ErrorCode { if e == nil { return "" From f88fc261505c84813280e80842000445549c9827 Mon Sep 17 00:00:00 2001 From: comeback01 <219462554+ScioNos@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:10:36 +0100 Subject: [PATCH 46/72] Refine French translations for UI conciseness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated web/src/i18n/locales/fr.json to improve French translations for the user interface. Removed verbose prefixes like 'Gestion des...' and 'Paramètres de...' to prevent truncation in sidebars and menus. Harmonized terms for consistency (e.g., 'Tâches', 'Journaux', 'Dessins'). Renamed 'Place du marché' to 'Marché des modèles'. --- web/src/i18n/locales/fr.json | 128 +++++++++++++++++------------------ 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index b314f8608..2487dca83 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -47,12 +47,12 @@ "API Key 模式下不支持批量创建": "Création en lot non prise en charge en mode clé API", "API 地址和相关配置": "URL de l'API et configuration associée", "API 密钥": "Clé API", - "API 文档": "Documentation de l'API", - "API 配置": "Configuration de l'API", - "API令牌管理": "Gestion des jetons d'API", - "API使用记录": "Enregistrements d'utilisation de l'API", + "API 文档": "Docs API", + "API 配置": "Config. API", + "API令牌管理": "Jetons API", + "API使用记录": "Journaux d'API", "API信息": "Informations sur l'API", - "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "Gestion des informations de l'API, vous pouvez configurer plusieurs adresses d'API pour l'affichage de l'état et l'équilibrage de charge (maximum 50)", + "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "Infos API, vous pouvez configurer plusieurs adresses d'API pour l'affichage de l'état et l'équilibrage de charge (maximum 50)", "API地址": "URL de base", "API渠道配置": "Configuration du canal de l'API", "API端点": "Points de terminaison de l'API", @@ -112,7 +112,7 @@ "LinuxDO": "LinuxDO", "LinuxDO ID": "ID LinuxDO", "Logo 图片地址": "Adresse de l'image du logo", - "Midjourney 任务记录": "Enregistrements de tâches Midjourney", + "Midjourney 任务记录": "Tâches Midjourney", "MIT许可证": "Licence MIT", "New API项目仓库地址:": "Adresse du référentiel du projet New API : ", "OIDC": "OIDC", @@ -136,7 +136,7 @@ "SMTP 访问凭证": "Informations d'identification d'accès SMTP", "SMTP 账户": "Compte SMTP", "SSRF防护开关详细说明": "L'interrupteur principal contrôle si la protection SSRF est activée. Lorsqu'elle est désactivée, toutes les vérifications SSRF sont contournées, autorisant l'accès à n'importe quelle URL. ⚠️ Ne désactivez cette fonctionnalité que dans des environnements entièrement fiables.", - "SSRF防护设置": "Paramètres de protection SSRF", + "SSRF防护设置": "Protection SSRF", "SSRF防护详细说明": "La protection SSRF empêche les utilisateurs malveillants d'utiliser votre serveur pour accéder aux ressources du réseau interne. Configurez des listes blanches pour les domaines/IP de confiance et limitez les ports autorisés. S'applique aux téléchargements de fichiers, aux webhooks et aux notifications.", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex", "Stripe 设置": "Paramètres Stripe", @@ -150,7 +150,7 @@ "Turnstile Site Key": "Clé du site Turnstile", "Unix时间戳": "Horodatage Unix", "Uptime Kuma地址": "Adresse Uptime Kuma", - "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Gestion des catégories de surveillance Uptime Kuma, vous pouvez configurer plusieurs catégories de surveillance pour l'affichage de l'état du service (maximum 20)", + "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Catégories de surveillance Uptime Kuma, vous pouvez configurer plusieurs catégories de surveillance pour l'affichage de l'état du service (maximum 20)", "URL链接": "Lien URL", "USD (美元)": "USD (Dollar US)", "User Info Endpoint": "Point de terminaison des informations utilisateur", @@ -203,9 +203,9 @@ "个": " individuel", "个人中心": "Centre personnel", "个人中心区域": "Zone du centre personnel", - "个人信息设置": "Paramètres des informations personnelles", - "个人设置": "Paramètres personnels", - "个性化设置": "Paramètres de personnalisation", + "个人信息设置": "Infos personnelles", + "个人设置": "Profil", + "个性化设置": "Personnalisation", "个性化设置左侧边栏的显示内容": "Personnaliser le contenu affiché dans la barre latérale gauche", "个未配置模型": "modèles non configurés", "个模型": "modèles", @@ -263,26 +263,26 @@ "令牌已重置并已复制到剪贴板": "Le jeton a été réinitialisé et copié dans le presse-papiers", "令牌更新成功!": "Jeton mis à jour avec succès !", "令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制": "Le quota du jeton est uniquement utilisé pour limiter l'utilisation maximale du quota du jeton lui-même, et l'utilisation réelle est limitée par le quota restant du compte", - "令牌管理": "Gestion des jetons", + "令牌管理": "Jetons", "以下上游数据可能不可信:": "Les données en amont suivantes peuvent ne pas être fiables : ", "以下文件解析失败,已忽略:{{list}}": "L'analyse des fichiers suivants a échoué, ignorés : {{list}}", "以及": "et", - "仪表盘设置": "Paramètres du tableau de bord", + "仪表盘设置": "Tableau de bord", "价格": "Tarifs", "价格:${{price}} * {{ratioType}}:{{ratio}}": "Prix : ${{price}} * {{ratioType}} : {{ratio}}", - "价格设置": "Paramètres de prix", + "价格设置": "Prix", "价格设置方式": "Méthode de configuration des prix", "任务 ID": "ID de la tâche", "任务ID": "ID de la tâche", - "任务日志": "Journaux de tâches", + "任务日志": "Tâches", "任务状态": "Statut de la tâche", - "任务记录": "Enregistrements de tâches", + "任务记录": "Tâches", "企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选": "Les comptes d'entreprise ont un format de retour spécial et nécessitent un traitement particulier. Si ce n'est pas un compte d'entreprise, veuillez ne pas cocher cette case.", "优先级": "Priorité", "优惠": "Remise", "低于此额度时将发送邮件提醒用户": "Un rappel par e-mail sera envoyé lorsque le quota tombera en dessous de ce seuil", "余额": "Solde", - "余额充值管理": "Gestion de la recharge du solde", + "余额充值管理": "Recharge du solde", "你似乎并没有修改什么": "Vous ne semblez rien avoir modifié", "你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Vous pouvez les ajouter manuellement dans « Noms de modèles personnalisés », cliquer sur Remplir puis soumettre, ou utiliser directement les actions ci-dessous pour les traiter automatiquement.", "使用 Discord 继续": "Continuer avec Discord", @@ -297,7 +297,7 @@ "使用 用户名 注册": "S'inscrire avec un nom d'utilisateur", "使用 邮箱或用户名 登录": "Connectez-vous avec votre e-mail ou votre nom d'utilisateur", "使用ID排序": "Trier par ID", - "使用日志": "Journaux d'utilisation", + "使用日志": "Journaux", "使用模式": "Mode d'utilisation", "使用统计": "Statistiques d'utilisation", "使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:": "Utilisez une application d'authentification (telle que Google Authenticator, Microsoft Authenticator) pour scanner le code QR ci-dessous :", @@ -327,7 +327,7 @@ "供应商名称": "Nom du fournisseur", "供应商图标": "Icône du fournisseur", "供应商更新成功!": "Fournisseur mis à jour avec succès !", - "侧边栏管理(全局控制)": "Gestion de la barre latérale (contrôle global)", + "侧边栏管理(全局控制)": "Barre latérale (Global)", "侧边栏设置保存成功": "Paramètres de la barre latérale enregistrés avec succès", "保存": "Enregistrer", "保存 Discord OAuth 设置": "Enregistrer les paramètres OAuth Discord", @@ -401,7 +401,7 @@ "充值数量": "Quantité de recharge", "充值数量,最低 ": "Quantité de recharge, minimum ", "充值数量不能小于": "Le montant de la recharge ne peut pas être inférieur à", - "充值方式设置": "Paramètres de la méthode de recharge", + "充值方式设置": "Méthodes recharge", "充值方式设置不是合法的 JSON 字符串": "Les paramètres de la méthode de recharge ne sont pas une chaîne JSON valide", "充值确认": "Confirmation de la recharge", "充值账单": "Factures de recharge", @@ -417,8 +417,8 @@ "兑换码创建成功!": "Code d'échange créé avec succès !", "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "Le code d'échange sera téléchargé sous forme de fichier texte, le nom de fichier étant le nom du code d'échange.", "兑换码更新成功!": "Code d'échange mis à jour avec succès !", - "兑换码生成管理": "Gestion de la génération de codes d'échange", - "兑换码管理": "Gestion des codes d'échange", + "兑换码生成管理": "Génération de codes", + "兑换码管理": "Codes d'échange", "兑换额度": "Utiliser", "全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用": "Contrôle global des zones et des fonctions de la barre latérale, les utilisateurs ne peuvent pas activer les fonctions masquées par les administrateurs", "全局设置": "Paramètres globaux", @@ -447,7 +447,7 @@ "共 {{total}} 项,当前显示 {{start}}-{{end}} 项": "Total {{total}} éléments, affichage actuel {{start}}-{{end}} éléments", "关": "Fermer", "关于": "À propos", - "关于我们": "À propos de nous", + "关于我们": "Nous", "关于系统的详细信息": "Informations détaillées sur le système", "关于项目": "À propos du projet", "关键字(id或者名称)": "Mot-clé (id ou nom)", @@ -459,7 +459,7 @@ "其他": "Autre", "其他注册选项": "Autres options d'inscription", "其他登录选项": "Autres options de connexion", - "其他设置": "Autres paramètres", + "其他设置": "Autres", "其他详情": "Autres détails", "内容": "Contenu", "内容较大,已启用性能优化模式": "Le contenu est volumineux, le mode d'optimisation des performances a été activé", @@ -471,14 +471,14 @@ "准备完成初始化": "Prêt à terminer l'initialisation", "分类名称": "Nom de la catégorie", "分组": "Groupe", - "分组与模型定价设置": "Paramètres de groupe et de tarification du modèle", + "分组与模型定价设置": "Groupe et tarification", "分组价格": "Prix de groupe", "分组倍率": "Ratio", - "分组倍率设置": "Paramètres de ratio de groupe", + "分组倍率设置": "Ratio de groupe", "分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{\"vip\": 0.5, \"test\": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1": "Paramètres de ratio de groupe, vous pouvez ajouter de nouveaux groupes ou modifier le ratio des groupes existants ici, au format de chaîne JSON, par exemple : {\"vip\": 0,5, \"test\": 1}, ce qui signifie que le ratio du groupe vip est 0,5 et celui du groupe test est 1", "分组特殊倍率": "Ratio spécial de groupe", "分组特殊可用分组": "Groupes spéciaux disponibles", - "分组设置": "Paramètres de groupe", + "分组设置": "Groupe", "分组速率配置优先级高于全局速率限制。": "La priorité de configuration du taux de groupe est supérieure à la limite de taux globale.", "分组速率限制": "Limitation du taux de groupe", "分钟": "minutes", @@ -491,7 +491,7 @@ "划转金额最低为": "Le montant minimum du virement est de", "划转额度": "Montant du virement", "列出的模型将不会自动添加或移除-thinking/-nothinking 后缀": "Les modèles listés ici n'ajouteront ni ne retireront automatiquement le suffixe -thinking/-nothinking.", - "列设置": "Paramètres de colonne", + "列设置": "Colonnes", "创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)": "Lors de la création d'un jeton, le groupe auto est sélectionné par défaut, et le jeton initial sera également défini sur auto (sinon laisser vide, pour le groupe par défaut de l'utilisateur)", "创建失败": "Échec de la création", "创建成功": "Création réussie", @@ -570,7 +570,7 @@ "可用端点类型": "Types de points de terminaison pris en charge", "可用邀请额度": "Quota d'invitation disponible", "可视化": "Visualisation", - "可视化倍率设置": "Paramètres de ratio de modèle visuel", + "可视化倍率设置": "Ratio visuel", "可视化编辑": "Édition visuelle", "可选,公告的补充说明": "Facultatif, informations supplémentaires pour l'avis", "可选值": "Valeur facultative", @@ -696,7 +696,7 @@ "字段透传控制": "Contrôle du passage des champs", "存在重复的键名:": "Il existe des noms de clés en double :", "安全提醒": "Rappel de sécurité", - "安全设置": "Paramètres de sécurité", + "安全设置": "Sécurité", "安全验证": "Vérification de sécurité", "安全验证级别": "Niveau de vérification de la sécurité", "安装指南": "Guide d'installation", @@ -719,7 +719,7 @@ "密码修改成功!": "Mot de passe changé avec succès !", "密码已复制到剪贴板:": "Le mot de passe a été copié dans le presse-papiers : ", "密码已重置并已复制到剪贴板:": "Le mot de passe a été réinitialisé et copié dans le presse-papiers : ", - "密码管理": "Gestion des mots de passe", + "密码管理": "Mots de passe", "密码重置": "Réinitialisation du mot de passe", "密码重置完成": "Réinitialisation du mot de passe terminée", "密码重置确认": "Confirmation de la réinitialisation du mot de passe", @@ -761,8 +761,8 @@ "小时": "Heure", "尚未使用": "Pas encore utilisé", "局部重绘-提交": "Varier la région", - "屏蔽词列表": "Liste des mots sensibles", - "屏蔽词过滤设置": "Paramètres de filtrage des mots sensibles", + "屏蔽词列表": "Mots sensibles", + "屏蔽词过滤设置": "Filtrage mots sensibles", "展开": "Développer", "展开更多": "Développer plus", "展示价格": "Prix affiché", @@ -997,7 +997,7 @@ "支付地址": "Adresse de paiement", "支付宝": "Alipay", "支付方式": "Mode de paiement", - "支付设置": "Paramètres de paiement", + "支付设置": "Paiement", "支付请求失败": "Échec de la demande de paiement", "支付金额": "Montant payé", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Prend en charge le code de vérification TOTP à 6 chiffres ou le code de sauvegarde à 8 chiffres, peut être configuré ou consulté dans `Paramètres personnels - Paramètres de sécurité - Paramètres d'authentification à deux facteurs`.", @@ -1027,9 +1027,9 @@ "数据格式错误": "Erreur de format de données", "数据看板": "Tableau de bord", "数据看板更新间隔": "Intervalle de mise à jour du tableau de bord des données", - "数据看板设置": "Paramètres du tableau de bord des données", + "数据看板设置": "Tableau de bord", "数据看板默认时间粒度": "Granularité temporelle par défaut du tableau de bord des données", - "数据管理和日志查看": "Gestion des données et affichage des journaux", + "数据管理和日志查看": "Données et journaux", "文件上传": "Téléchargement de fichier", "文件搜索价格:{{symbol}}{{price}} / 1K 次": "Prix de recherche de fichier : {{symbol}}{{price}} / 1K fois", "文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}": "Invite texte {{input}} tokens / 1M tokens * {{symbol}}{{price}} + Complétion texte {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}", @@ -1065,7 +1065,7 @@ "无限额度": "Quota illimité", "日志清理失败:": "Échec du nettoyage des journaux :", "日志类型": "Type de journal", - "日志设置": "Paramètres du journal", + "日志设置": "Config. journaux", "日志详情": "Détails du journal", "旧格式(直接覆盖):": "Ancien format (remplacement direct) :", "旧格式模板": "Modèle d'ancien format", @@ -1219,7 +1219,7 @@ "模型倍率值": "Valeur du ratio de modèle", "模型倍率和补全倍率": "Ratio de modèle et ratio de complétion", "模型倍率和补全倍率同时设置": "Le ratio de modèle et le ratio de complétion sont définis simultanément", - "模型倍率设置": "Paramètres de ratio de modèle", + "模型倍率设置": "Ratio modèle", "模型关键字": "mot-clé du modèle", "模型列表已复制到剪贴板": "Liste des modèles copiée dans le presse-papiers", "模型列表已更新": "La liste des modèles a été mise à jour", @@ -1229,7 +1229,7 @@ "模型固定价格": "Prix du modèle par appel", "模型图标": "Icône du modèle", "模型定价,需要登录访问": "Tarification du modèle, nécessite une connexion pour y accéder", - "模型广场": "Place du marché des modèles", + "模型广场": "Marché des modèles", "模型支持的接口端点信息": "Informations sur les points de terminaison de l'API pris en charge par le modèle", "模型数据分析": "Analyse des données du modèle", "模型映射必须是合法的 JSON 格式!": "Le mappage de modèles doit être au format JSON valide !", @@ -1241,7 +1241,7 @@ "模型的详细描述和基本特性": "Description détaillée et caractéristiques de base du modèle", "模型相关设置": "Paramètres liés au modèle", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "La communauté des modèles a besoin de la contribution de tous. Si vous trouvez des données incorrectes ou si vous souhaitez contribuer à de nouvelles données de modèle, veuillez visiter :", - "模型管理": "Gestion des modèles", + "模型管理": "Modèles", "模型组": "Groupe de modèles", "模型补全倍率(仅对自定义模型有效)": "Ratio d'achèvement de modèle (uniquement efficace pour les modèles personnalisés)", "模型请求速率限制": "Limite de débit de requête de modèle", @@ -1367,7 +1367,7 @@ "渠道的基本配置信息": "Informations de configuration de base du canal", "渠道的模型测试": "Test de modèle de canal", "渠道的高级配置选项": "Options de configuration avancées du canal", - "渠道管理": "Gestion des canaux", + "渠道管理": "Canaux", "渠道额外设置": "Paramètres supplémentaires du canal", "源地址": "Adresse source", "演示站点": "Site de démonstration", @@ -1410,7 +1410,7 @@ "用户信息": "Informations utilisateur", "用户信息更新成功!": "Informations utilisateur mises à jour avec succès !", "用户分组": "Votre groupe par défaut", - "用户分组和额度管理": "Gestion des groupes d'utilisateurs et des quotas", + "用户分组和额度管理": "Groupes et quotas", "用户分组配置": "Configuration du groupe d'utilisateurs", "用户协议": "Accord utilisateur", "用户协议已更新": "L'accord utilisateur a été mis à jour", @@ -1425,10 +1425,10 @@ "用户每周期最多请求次数": "Nombre maximal de requêtes utilisateur par période", "用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'", "用户的基本账户信息": "Informations de base du compte utilisateur", - "用户管理": "Gestion des utilisateurs", + "用户管理": "Utilisateurs", "用户组": "Groupe d'utilisateurs", "用户账户创建成功!": "Compte utilisateur créé avec succès !", - "用户账户管理": "Gestion des comptes utilisateurs", + "用户账户管理": "Comptes utilisateurs", "用时/首字": "Temps/premier mot", "留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée", "留空则使用默认端点;支持 {path, method}": "Laissez vide pour utiliser le point de terminaison par défaut ; prend en charge {path, method}", @@ -1439,7 +1439,7 @@ "登录过期,请重新登录!": "Session expirée, veuillez vous reconnecter !", "白名单": "Liste blanche", "的前提下使用。": "doit être utilisé conformément aux conditions.", - "监控设置": "Paramètres de surveillance", + "监控设置": "Surveillance", "目标用户:{{username}}": "Utilisateur cible : {{username}}", "直接提交": "Soumettre directement", "相关项目": "Projets connexes", @@ -1552,14 +1552,14 @@ "精确": "Exact", "系统": "Système", "系统令牌已复制到剪切板": "Le jeton système a été copié dans le presse-papiers", - "系统任务记录": "Enregistrements de tâches système", + "系统任务记录": "Tâches système", "系统信息": "Informations système", "系统公告": "Avis système", - "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "Gestion des avis système, vous pouvez publier des avis système et des messages importants (maximum 100, afficher les 20 derniers sur le front-end)", + "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "Avis système, vous pouvez publier des avis système et des messages importants (maximum 100, afficher les 20 derniers sur le front-end)", "系统初始化": "Initialisation du système", "系统初始化失败,请重试": "L'initialisation du système a échoué, veuillez réessayer", "系统初始化成功,正在跳转...": "Initialisation du système réussie, redirection en cours...", - "系统参数配置": "Configuration des paramètres système", + "系统参数配置": "Paramètres système", "系统名称": "Nom du système", "系统名称已更新": "Nom du système mis à jour", "系统名称更新失败": "Échec de la mise à jour du nom du système", @@ -1570,7 +1570,7 @@ "系统文档和帮助信息": "Documentation système et informations d'aide", "系统消息": "Messages système", "系统管理功能": "Fonctions de gestion du système", - "系统设置": "Paramètres système", + "系统设置": "Système", "系统访问令牌": "Jeton d'accès au système", "约": "Environ", "索引": "Index", @@ -1589,9 +1589,9 @@ "结束时间": "Heure de fin", "结果图片": "Résultat", "绘图": "Dessin", - "绘图任务记录": "Enregistrements de tâches de dessin", - "绘图日志": "Journaux de dessin", - "绘图设置": "Paramètres de dessin", + "绘图任务记录": "Tâches dessin", + "绘图日志": "Dessins", + "绘图设置": "Dessin", "统一的": "La Passerelle", "统计Tokens": "Jetons statistiques", "统计次数": "Nombre de statistiques", @@ -1638,11 +1638,11 @@ "置信度": "Confiance", "美元": "Dollar américain", "聊天": "Discuter", - "聊天会话管理": "Gestion des sessions de discussion", + "聊天会话管理": "Sessions de discussion", "聊天区域": "Zone de discussion", "聊天应用名称": "Nom de l'application de discussion", "聊天应用名称已存在,请使用其他名称": "Le nom de l'application de discussion existe déjà, veuillez utiliser un autre nom", - "聊天设置": "Paramètres de discussion", + "聊天设置": "Discussion", "聊天配置": "Configuration de la discussion", "聊天链接配置错误,请联系管理员": "Erreur de configuration du lien de discussion, veuillez contacter l'administrateur", "联系我们": "Contactez-nous", @@ -1986,19 +1986,19 @@ "输出价格": "Prix de sortie", "输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "Prix de sortie : {{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (ratio d'achèvement : {{completionRatio}})", "输出倍率 {{completionRatio}}": "Ratio de sortie {{completionRatio}}", - "边栏设置": "Paramètres de la barre latérale", + "边栏设置": "Barre latérale", "过期时间": "Date d'expiration", "过期时间不能早于当前时间!": "La date d'expiration ne peut pas être antérieure à l'heure actuelle !", "过期时间快捷设置": "Paramètres rapides de la date d'expiration", "过期时间格式错误!": "Erreur de format de la date d'expiration !", - "运营设置": "Paramètres de fonctionnement", + "运营设置": "Opérations", "返回修改": "Revenir pour modifier", "返回登录": "Retour à la connexion", "这是重复键中的最后一个,其值将被使用": "Ceci est la dernière clé dupliquée, sa valeur sera utilisée", "进度": "calendrier", "进行中": "En cours", "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "Lors de cette opération, cela peut entraîner des erreurs d'accès au canal. Veuillez ne l'utiliser que lorsqu'il y a un problème avec la base de données.", - "连接保活设置": "Paramètres de maintien de connexion", + "连接保活设置": "Maintien connexion", "连接已断开": "Connexion interrompue", "追加到现有密钥": "Ajouter aux clés existantes", "追加模式:将新密钥添加到现有密钥列表末尾": "Mode d'ajout : ajouter les nouvelles clés à la fin de la liste de clés existantes", @@ -2030,7 +2030,7 @@ "选择过期时间(可选,留空为永久)": "Sélectionnez la date d'expiration (facultatif, laissez vide pour permanent)", "透传请求体": "Corps de transmission", "通义千问": "Qwen", - "通用设置": "Paramètres généraux", + "通用设置": "Général", "通知": "Avis", "通知、价格和隐私相关设置": "Paramètres de notification, de prix et de confidentialité", "通知内容": "Contenu de la notification", @@ -2039,13 +2039,13 @@ "通知标题": "Titre de la notification", "通知类型 (quota_exceed: 额度预警)": "Type de notification (quota_exceed : avertissement de quota)", "通知邮箱": "E-mail de notification", - "通知配置": "Configuration des notifications", + "通知配置": "Notifications", "通过划转功能将奖励额度转入到您的账户余额中": "Transférez le montant de la récompense sur le solde de votre compte via la fonction de virement", "通过密码注册时需要进行邮箱验证": "La vérification par e-mail est requise lors de l'inscription via mot de passe", "通道 ${name} 余额更新成功!": "Le quota du canal ${name} a été mis à jour avec succès !", "通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Test du canal ${name} réussi, modèle ${model} a pris ${time.toFixed(2)} secondes.", "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Test du canal ${name} réussi, a pris ${time.toFixed(2)} secondes.", - "速率限制设置": "Paramètres de limitation de débit", + "速率限制设置": "Limitation débit", "邀请": "Invitations", "邀请人": "Inviteur", "邀请人数": "Nombre de personnes invitées", @@ -2107,7 +2107,7 @@ "重置邮件发送成功,请检查邮箱!": "L'e-mail de réinitialisation a été envoyé avec succès, veuillez vérifier votre e-mail !", "重置配置": "Réinitialiser la configuration", "重试": "Réessayer", - "钱包管理": "Gestion du portefeuille", + "钱包管理": "Portefeuille", "链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "Le {key} dans le lien sera automatiquement remplacé par sk-xxxx, le {address} sera automatiquement remplacé par l'adresse du serveur dans les paramètres système, et la fin n'aura pas / et /v1", "错误": "Erreur", "键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1": "La clé est le nom du groupe, la valeur est un autre objet JSON, la clé est le nom du groupe, la valeur est le ratio de groupe spécial des utilisateurs de ce groupe, par exemple : {\"vip\": {\"default\": 0.5, \"test\": 1}}, ce qui signifie que les utilisateurs du groupe vip ont un ratio de 0.5 lors de l'utilisation de jetons du groupe default et un ratio de 1 lors de l'utilisation du groupe test", @@ -2125,7 +2125,7 @@ "隐私政策": "Politique de confidentialité", "隐私政策已更新": "La politique de confidentialité a été mise à jour", "隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité", - "隐私设置": "Paramètres de confidentialité", + "隐私设置": "Confidentialité", "隐藏操作项": "Masquer les actions", "隐藏调试": "Masquer le débogage", "随机": "Aléatoire", @@ -2146,7 +2146,7 @@ "音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Paramètres de ratio liés à l'achèvement de la sortie audio, la clé est le nom du modèle, la valeur est le ratio", "页脚": "Pied de page", "页面未找到,请检查您的浏览器地址是否正确": "Page non trouvée, veuillez vérifier si l'adresse de votre navigateur est correcte", - "顶栏管理": "Gestion de l'en-tête", + "顶栏管理": "En-tête", "项目": "Élément", "项目内容": "Contenu de l'élément", "项目操作按钮组": "Groupe de boutons d'action du projet", @@ -2161,7 +2161,7 @@ "额度必须大于0": "Le quota doit être supérieur à 0", "额度提醒阈值": "Seuil de rappel de quota", "额度查询接口返回令牌额度而非用户额度": "Affiche le quota de jetons au lieu du quota utilisateur", - "额度设置": "Paramètres de quota", + "额度设置": "Quota", "额度预警阈值": "Seuil d'avertissement de quota", "首尾生视频": "Vidéo de début et de fin", "首页": "Accueil", From da24a165d0dfafa2465ae96dd71b6984623658a7 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 18 Dec 2025 08:10:46 +0800 Subject: [PATCH 47/72] fix(gemini): handle minimal reasoning effort budget - Add minimal case to clampThinkingBudgetByEffort to avoid defaulting to full thinking budget --- relay/channel/gemini/adaptor.go | 3 ++- relay/channel/gemini/relay-gemini.go | 13 ++++--------- setting/reasoning/suffix.go | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index e8b8212d6..d8616d2d9 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -13,6 +13,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -137,7 +138,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") - } else if baseModel, level := parseThinkingLevelSuffix(info.UpstreamModelName); level != "" { + } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { info.UpstreamModelName = baseModel } } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index f75a92140..db5ea489c 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -98,6 +98,7 @@ func clampThinkingBudget(modelName string, budget int) int { // "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) // "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens) // "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens) +// "effort": "minimal" - Allocates a minimal portion of tokens (approximately 5% of max_tokens) func clampThinkingBudgetByEffort(modelName string, effort string) int { isNew25Pro := isNew25ProModel(modelName) is25FlashLite := is25FlashLiteModel(modelName) @@ -118,18 +119,12 @@ func clampThinkingBudgetByEffort(modelName string, effort string) int { maxBudget = maxBudget * 50 / 100 case "low": maxBudget = maxBudget * 20 / 100 + case "minimal": + maxBudget = maxBudget * 5 / 100 } return clampThinkingBudget(modelName, maxBudget) } -func parseThinkingLevelSuffix(modelName string) (string, string) { - base, level, ok := reasoning.TrimEffortSuffix(modelName) - if !ok { - return modelName, "" - } - return base, level -} - func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName @@ -186,7 +181,7 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel ThinkingBudget: common.GetPointer(0), } } - } else if _, level := parseThinkingLevelSuffix(modelName); level != "" { + } else if _, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ IncludeThoughts: true, ThinkingLevel: level, diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go index 4cc74b612..da3bdc7d3 100644 --- a/setting/reasoning/suffix.go +++ b/setting/reasoning/suffix.go @@ -6,7 +6,7 @@ import ( "github.com/samber/lo" ) -var EffortSuffixes = []string{"-high", "-medium", "-low"} +var EffortSuffixes = []string{"-high", "-medium", "-low", "-minimal"} // TrimEffortSuffix -> modelName level(low) exists func TrimEffortSuffix(modelName string) (string, string, bool) { From 97132de2cab48bb0b729af406f2400a7ab24440e Mon Sep 17 00:00:00 2001 From: TinsFox Date: Fri, 19 Dec 2025 21:00:31 +0800 Subject: [PATCH 48/72] style: add card spacing --- web/src/components/table/channels/modals/EditChannelModal.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index b1d2545f2..e5cf66434 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -1604,7 +1604,7 @@ const EditChannelModal = (props) => { > {() => ( -
+
(formSectionRefs.current.basicInfo = el)}> {/* Header: Basic Info */} From 1168ddf9f9cde0bff06e0641c0e749d9d3223d29 Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 19 Dec 2025 22:27:35 +0800 Subject: [PATCH 49/72] fix: systemname --- common/pyro.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/common/pyro.go b/common/pyro.go index 4fb4f7bb7..9891bac1c 100644 --- a/common/pyro.go +++ b/common/pyro.go @@ -1,7 +1,6 @@ package common import ( - "os" "runtime" "github.com/grafana/pyroscope-go" @@ -9,18 +8,20 @@ import ( func StartPyroScope() error { - pyroscopeUrl := os.Getenv("PYROSCOPE_URL") + pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "") if pyroscopeUrl == "" { return nil } + pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api") + // These 2 lines are only required if you're using mutex or block profiling // Read the explanation below for how to set these rates: runtime.SetMutexProfileFraction(5) runtime.SetBlockProfileRate(5) _, err := pyroscope.Start(pyroscope.Config{ - ApplicationName: SystemName, + ApplicationName: pyroscopeAppName, ServerAddress: pyroscopeUrl, From 5ef7247eaccb4db69397cf176448db90312f3d21 Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 19 Dec 2025 23:03:04 +0800 Subject: [PATCH 50/72] docs: document pyroscope env var --- .env.example | 6 ++++++ README.en.md | 5 +++++ README.fr.md | 5 +++++ README.ja.md | 5 +++++ README.md | 5 +++++ common/pyro.go | 9 +++++++-- 6 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index f43f7b211..c059777d3 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ # ENABLE_PPROF=true # 启用调试模式 # DEBUG=true +# Pyroscope 配置 +# PYROSCOPE_URL=http://localhost:4040 +# PYROSCOPE_APP_NAME=new-api +# PYROSCOPE_BASIC_AUTH_USER=your-user +# PYROSCOPE_BASIC_AUTH_PASSWORD=your-password +# HOSTNAME=your-hostname # 数据库相关配置 # 数据库连接字符串 diff --git a/README.en.md b/README.en.md index e71f5e623..f53c9742a 100644 --- a/README.en.md +++ b/README.en.md @@ -307,6 +307,11 @@ docker run --name new-api -d --restart always \ | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | +| `PYROSCOPE_URL` | Pyroscope server address | - | +| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - | +| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` | 📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables) diff --git a/README.fr.md b/README.fr.md index 35051223e..362a8b652 100644 --- a/README.fr.md +++ b/README.fr.md @@ -303,6 +303,11 @@ docker run --name new-api -d --restart always \ | `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` | | `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` | +| `PYROSCOPE_URL` | Adresse du serveur Pyroscope | - | +| `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - | +| `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` | 📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) diff --git a/README.ja.md b/README.ja.md index 0c4b91f66..258280f07 100644 --- a/README.ja.md +++ b/README.ja.md @@ -312,6 +312,11 @@ docker run --name new-api -d --restart always \ | `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限(MB)。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` | | `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` | +| `PYROSCOPE_URL` | Pyroscopeサーバーのアドレス | - | +| `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - | +| `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` | 📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables) diff --git a/README.md b/README.md index 3d5b6923c..8210babe8 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,11 @@ docker run --name new-api -d --restart always \ | `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` | | `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | +| `PYROSCOPE_URL` | Pyroscope 服务地址 | - | +| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - | +| `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` | 📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables) diff --git a/common/pyro.go b/common/pyro.go index 9891bac1c..739f1d116 100644 --- a/common/pyro.go +++ b/common/pyro.go @@ -14,6 +14,9 @@ func StartPyroScope() error { } pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api") + pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "") + pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "") + pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api") // These 2 lines are only required if you're using mutex or block profiling // Read the explanation below for how to set these rates: @@ -23,11 +26,13 @@ func StartPyroScope() error { _, err := pyroscope.Start(pyroscope.Config{ ApplicationName: pyroscopeAppName, - ServerAddress: pyroscopeUrl, + ServerAddress: pyroscopeUrl, + BasicAuthUser: pyroscopeBasicAuthUser, + BasicAuthPassword: pyroscopeBasicAuthPassword, Logger: nil, - Tags: map[string]string{"hostname": GetEnvOrDefaultString("HOSTNAME", "new-api")}, + Tags: map[string]string{"hostname": pyroscopeHostname}, ProfileTypes: []pyroscope.ProfileType{ pyroscope.ProfileCPU, From e6ec551fbf9c51517c69e1d0c139a76390850d18 Mon Sep 17 00:00:00 2001 From: TinsFox Date: Fri, 19 Dec 2025 21:19:19 +0800 Subject: [PATCH 51/72] chore: add code-inspector-plugin integration --- web/bun.lock | 51 ++++++++++++++++++++++++++++++++++++++++++++-- web/package.json | 5 +++-- web/vite.config.js | 4 ++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/web/bun.lock b/web/bun.lock index fdec073ec..f9a60229b 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -48,6 +48,7 @@ "@so1ve/prettier-config": "^3.1.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.21", + "code-inspector-plugin": "^1.3.3", "eslint": "8.57.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-react-hooks": "^5.2.0", @@ -139,6 +140,18 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@code-inspector/core": ["@code-inspector/core@1.3.3", "", { "dependencies": { "@vue/compiler-dom": "^3.5.13", "chalk": "^4.1.1", "dotenv": "^16.1.4", "launch-ide": "1.3.0", "portfinder": "^1.0.28" } }, "sha512-1SUCY/XiJ3LuA9TPfS9i7/cUcmdLsgB0chuDcP96ixB2tvYojzgCrglP7CHUGZa1dtWuRLuCiDzkclLetpV4ew=="], + + "@code-inspector/esbuild": ["@code-inspector/esbuild@1.3.3", "", { "dependencies": { "@code-inspector/core": "1.3.3" } }, "sha512-GzX5LQbvh9DXINSUyWymG8Y7u5Tq4oJAnnrCoRiYxQvKBUuu2qVMzpZHIA2iDGxvazgZvr2OK+Sh/We4LutViA=="], + + "@code-inspector/mako": ["@code-inspector/mako@1.3.3", "", { "dependencies": { "@code-inspector/core": "1.3.3" } }, "sha512-YPTHwpDtz9zn1vimMcJFCM6ELdBoivY7t2GzgY/iCTfgm6pu1H+oWZiBC35edqYAB7+xE8frspnNsmBhsrA36A=="], + + "@code-inspector/turbopack": ["@code-inspector/turbopack@1.3.3", "", { "dependencies": { "@code-inspector/core": "1.3.3", "@code-inspector/webpack": "1.3.3" } }, "sha512-XhqsMtts/Int64LkpO00b4rlg1bw0otlRebX8dSVgZfsujj+Jdv2ngKmQ6RBN3vgj/zV7BfgBLeGgJn7D1kT3A=="], + + "@code-inspector/vite": ["@code-inspector/vite@1.3.3", "", { "dependencies": { "@code-inspector/core": "1.3.3", "chalk": "4.1.1" } }, "sha512-phsHVYBsxAhfi6jJ+vpmxuF6jYMuVbozs5e8pkEJL2hQyGVkzP77vfCh1wzmQHcmKUKb2tlrFcvAsRb7oA1W7w=="], + + "@code-inspector/webpack": ["@code-inspector/webpack@1.3.3", "", { "dependencies": { "@code-inspector/core": "1.3.3" } }, "sha512-qYih7syRXgM45KaWFNNk5Ed4WitVQHCI/2s/DZMFaF1Y2FA9qd1wPGiggNeqdcUsjf9TvVBQw/89gPQZIGwSqQ=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -713,6 +726,12 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.26", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.26", "", { "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A=="], + + "@vue/shared": ["@vue/shared@3.5.26", "", {}, "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A=="], + "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -747,6 +766,8 @@ "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-validator": ["async-validator@3.5.2", "", {}, "sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -793,7 +814,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -825,6 +846,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "code-inspector-plugin": ["code-inspector-plugin@1.3.3", "", { "dependencies": { "@code-inspector/core": "1.3.3", "@code-inspector/esbuild": "1.3.3", "@code-inspector/mako": "1.3.3", "@code-inspector/turbopack": "1.3.3", "@code-inspector/vite": "1.3.3", "@code-inspector/webpack": "1.3.3", "chalk": "4.1.1" } }, "sha512-yDi84v5tgXFSZLLXqHl/Mc2qy9d2CxcYhIaP192NhqTG1zA5uVtiNIzvDAXh5Vaqy8QGYkvBfbG/i55b/sXaSQ=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -975,6 +998,8 @@ "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -985,7 +1010,7 @@ "emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], - "entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="], + "entities": ["entities@7.0.0", "", {}, "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], @@ -1305,6 +1330,8 @@ "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + "launch-ide": ["launch-ide@1.3.0", "", { "dependencies": { "chalk": "^4.1.1", "dotenv": "^16.1.4" } }, "sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A=="], + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "leva": ["leva@0.10.0", "", { "dependencies": { "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-tooltip": "1.0.5", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RiNJWmeqQdKIeHuVXgshmxIHu144a2AMYtLxKf8Nm1j93pisDPexuQDHKNdQlbo37wdyDQibLjY9JKGIiD7gaw=="], @@ -1595,6 +1622,8 @@ "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], + "portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="], + "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -2081,6 +2110,8 @@ "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + "@code-inspector/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@douyinfe/semi-foundation/remark-gfm": ["remark-gfm@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA=="], "@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], @@ -2131,6 +2162,10 @@ "@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="], "antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], @@ -2155,6 +2190,8 @@ "esast-util-from-js/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2181,6 +2218,8 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "launch-ide/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -2201,6 +2240,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="], + "path-scurry/lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], "prettier-package-json/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -2269,6 +2310,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "antd/scroll-into-view-if-needed/compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -2325,6 +2368,10 @@ "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@0.7.3", "", {}, "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="], + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "simplify-geojson/concat-stream/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], "sucrase/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], diff --git a/web/package.json b/web/package.json index 5063c6073..9ac8e266e 100644 --- a/web/package.json +++ b/web/package.json @@ -78,15 +78,16 @@ "@so1ve/prettier-config": "^3.1.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.21", + "code-inspector-plugin": "^1.3.3", "eslint": "8.57.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-react-hooks": "^5.2.0", + "i18next-cli": "^1.10.3", "postcss": "^8.5.3", "prettier": "^3.0.0", "tailwindcss": "^3", "typescript": "4.4.2", - "vite": "^5.2.0", - "i18next-cli": "^1.10.3" + "vite": "^5.2.0" }, "prettier": { "singleQuote": true, diff --git a/web/vite.config.js b/web/vite.config.js index d57fd9d9b..73e46212a 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -21,6 +21,7 @@ import react from '@vitejs/plugin-react'; import { defineConfig, transformWithEsbuild } from 'vite'; import pkg from '@douyinfe/vite-plugin-semi'; import path from 'path'; +import { codeInspectorPlugin } from 'code-inspector-plugin'; const { vitePluginSemi } = pkg; // https://vitejs.dev/config/ @@ -31,6 +32,9 @@ export default defineConfig({ }, }, plugins: [ + codeInspectorPlugin({ + bundler: 'vite', + }), { name: 'treat-js-files-as-jsx', async transform(code, id) { From 531dfb2555e952275979f8cb62f4f5380b8f43d7 Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 19 Dec 2025 23:16:56 +0800 Subject: [PATCH 52/72] docs: document pyroscope env var --- .env.example | 2 ++ README.en.md | 2 ++ README.fr.md | 2 ++ README.ja.md | 2 ++ README.md | 2 ++ common/pyro.go | 9 +++++---- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index c059777d3..ea9061fb3 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ # PYROSCOPE_APP_NAME=new-api # PYROSCOPE_BASIC_AUTH_USER=your-user # PYROSCOPE_BASIC_AUTH_PASSWORD=your-password +# PYROSCOPE_MUTEX_RATE=5 +# PYROSCOPE_BLOCK_RATE=5 # HOSTNAME=your-hostname # 数据库相关配置 diff --git a/README.en.md b/README.en.md index f53c9742a..d9a924fcb 100644 --- a/README.en.md +++ b/README.en.md @@ -311,6 +311,8 @@ docker run --name new-api -d --restart always \ | `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - | | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - | +| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` | +| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` | | `HOSTNAME` | Hostname tag for Pyroscope | `new-api` | 📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables) diff --git a/README.fr.md b/README.fr.md index 362a8b652..632d1ce90 100644 --- a/README.fr.md +++ b/README.fr.md @@ -307,6 +307,8 @@ docker run --name new-api -d --restart always \ | `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - | | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - | +| `PYROSCOPE_MUTEX_RATE` | Taux d'échantillonnage mutex Pyroscope | `5` | +| `PYROSCOPE_BLOCK_RATE` | Taux d'échantillonnage block Pyroscope | `5` | | `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` | 📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) diff --git a/README.ja.md b/README.ja.md index 258280f07..77efe796e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -316,6 +316,8 @@ docker run --name new-api -d --restart always \ | `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - | | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - | +| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutexサンプリング率 | `5` | +| `PYROSCOPE_BLOCK_RATE` | Pyroscope blockサンプリング率 | `5` | | `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` | 📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables) diff --git a/README.md b/README.md index 8210babe8..e0c371ecd 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,8 @@ docker run --name new-api -d --restart always \ | `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - | | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - | +| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 采样率 | `5` | +| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 采样率 | `5` | | `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` | 📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables) diff --git a/common/pyro.go b/common/pyro.go index 739f1d116..b798f2c77 100644 --- a/common/pyro.go +++ b/common/pyro.go @@ -18,10 +18,11 @@ func StartPyroScope() error { pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "") pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api") - // These 2 lines are only required if you're using mutex or block profiling - // Read the explanation below for how to set these rates: - runtime.SetMutexProfileFraction(5) - runtime.SetBlockProfileRate(5) + mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5) + blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5) + + runtime.SetMutexProfileFraction(mutexRate) + runtime.SetBlockProfileRate(blockRate) _, err := pyroscope.Start(pyroscope.Config{ ApplicationName: pyroscopeAppName, From d9634ad2d3651cfd0e50d89f3f7791e542c10f8a Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Dec 2025 12:21:53 +0800 Subject: [PATCH 53/72] feat(channel): add error handling for SaveWithoutKey when channel ID is 0 --- model/channel.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/model/channel.go b/model/channel.go index a7f741b21..f256b54ce 100644 --- a/model/channel.go +++ b/model/channel.go @@ -254,6 +254,9 @@ func (channel *Channel) Save() error { } func (channel *Channel) SaveWithoutKey() error { + if channel.Id == 0 { + return errors.New("channel ID is 0") + } return DB.Omit("key").Save(channel).Error } From 4ee595c4486ac953668d29cc00c1eb678d29f826 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Dec 2025 13:27:55 +0800 Subject: [PATCH 54/72] feat(init): increase MaxRequestBodyMB to enhance request handling --- common/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/init.go b/common/init.go index ac27fd2c2..5c49da316 100644 --- a/common/init.go +++ b/common/init.go @@ -118,7 +118,7 @@ func initConstantEnv() { constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64) // MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨 - constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 32) + constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 64) // ForceStreamOption 覆盖请求参数,强制返回usage信息 constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true) constant.CountToken = GetEnvOrDefaultBool("CountToken", true) From cc3ba39e72a05ba21f810a34f39dc84702e2ac8c Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Dec 2025 13:34:10 +0800 Subject: [PATCH 55/72] feat(gin): improve request body handling and error reporting --- common/gin.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/common/gin.go b/common/gin.go index 95996b619..5a066bc16 100644 --- a/common/gin.go +++ b/common/gin.go @@ -2,7 +2,7 @@ package common import ( "bytes" - "errors" + "fmt" "io" "mime" "mime/multipart" @@ -12,6 +12,7 @@ import ( "time" "github.com/QuantumNous/new-api/constant" + "github.com/pkg/errors" "github.com/gin-gonic/gin" ) @@ -39,8 +40,15 @@ func GetRequestBody(c *gin.Context) ([]byte, error) { } } maxMB := constant.MaxRequestBodyMB - if maxMB <= 0 { - maxMB = 32 + if maxMB < 0 { + // no limit + body, err := io.ReadAll(c.Request.Body) + _ = c.Request.Body.Close() + if err != nil { + return nil, err + } + c.Set(KeyRequestBody, body) + return body, nil } maxBytes := int64(maxMB) << 20 @@ -49,13 +57,13 @@ func GetRequestBody(c *gin.Context) ([]byte, error) { if err != nil { _ = c.Request.Body.Close() if IsRequestBodyTooLargeError(err) { - return nil, ErrRequestBodyTooLarge + return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB)) } return nil, err } _ = c.Request.Body.Close() if int64(len(body)) > maxBytes { - return nil, ErrRequestBodyTooLarge + return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB)) } c.Set(KeyRequestBody, body) return body, nil From 0a2f12c04e7dc83379fdb663d6330062edd2ef59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E5=AE=89?= <1420970597@qq.com> Date: Sat, 20 Dec 2025 14:17:12 +0800 Subject: [PATCH 56/72] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Anthropic=20?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E7=BC=93=E5=AD=98=E8=AE=A1=E8=B4=B9=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题描述 当使用 Anthropic 渠道通过 `/v1/chat/completions` 端点调用且启用缓存功能时, 计费逻辑错误地减去了缓存 tokens,导致严重的收入损失(94.5%)。 ## 根本原因 不同 API 的 `prompt_tokens` 定义不同: - **Anthropic API**: `input_tokens` 字段已经是纯输入 tokens(不包含缓存) - **OpenAI API**: `prompt_tokens` 字段包含所有 tokens(包含缓存) - **OpenRouter API**: `prompt_tokens` 字段包含所有 tokens(包含缓存) 当前 `postConsumeQuota` 函数对所有渠道都减去缓存 tokens,这对 Anthropic 渠道是错误的,因为其 `input_tokens` 已经不包含缓存。 ## 修复方案 在 `relay/compatible_handler.go` 的 `postConsumeQuota` 函数中,添加渠道类型判断: ```go if relayInfo.ChannelType != constant.ChannelTypeAnthropic { baseTokens = baseTokens.Sub(dCacheTokens) } ``` 只对非 Anthropic 渠道减去缓存 tokens。 ## 影响分析 ### ✅ 不受影响的场景 1. **无缓存调用**(所有渠道) - cache_tokens = 0 - 减去 0 = 不减去 - 结果:完全一致 2. **OpenAI/OpenRouter 渠道 + 缓存** - 继续减去缓存(因为 ChannelType != Anthropic) - 结果:完全一致 3. **Anthropic 渠道 + /v1/messages 端点** - 使用 PostClaudeConsumeQuota(不修改) - 结果:完全不受影响 ### ✅ 修复的场景 4. **Anthropic 渠道 + /v1/chat/completions + 缓存** - 修复前:错误地减去缓存,导致 94.5% 收入损失 - 修复后:不减去缓存,计费正确 ## 验证数据 以实际记录 143509 为例: | 项目 | 修复前 | 修复后 | 差异 | |------|--------|--------|------| | Quota | 10,489 | 191,330 | +180,841 | | 费用 | ¥0.020978 | ¥0.382660 | +¥0.361682 | | 收入恢复 | - | - | **+1724.1%** | ## 测试建议 1. 测试 Anthropic 渠道 + 缓存场景 2. 测试 OpenAI 渠道 + 缓存场景(确保不受影响) 3. 测试无缓存场景(确保不受影响) ## 相关 Issue 修复 Anthropic 渠道使用 prompt caching 时的计费错误。 --- relay/compatible_handler.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index f46ff9de9..d92c990a7 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -300,14 +300,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage if !relayInfo.PriceData.UsePrice { baseTokens := dPromptTokens // 减去 cached tokens + // Anthropic API 的 input_tokens 已经不包含缓存 tokens,不需要减去 + // OpenAI/OpenRouter 等 API 的 prompt_tokens 包含缓存 tokens,需要减去 var cachedTokensWithRatio decimal.Decimal if !dCacheTokens.IsZero() { - baseTokens = baseTokens.Sub(dCacheTokens) + if relayInfo.ChannelType != constant.ChannelTypeAnthropic { + baseTokens = baseTokens.Sub(dCacheTokens) + } cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) } var dCachedCreationTokensWithRatio decimal.Decimal if !dCachedCreationTokens.IsZero() { - baseTokens = baseTokens.Sub(dCachedCreationTokens) + if relayInfo.ChannelType != constant.ChannelTypeAnthropic { + baseTokens = baseTokens.Sub(dCachedCreationTokens) + } dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio) } From 5a64ae2a290b98a8e425265b3cfe6d587340fab2 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 21 Dec 2025 17:09:49 +0800 Subject: [PATCH 57/72] =?UTF-8?q?fix:=20=E6=A8=A1=E5=9E=8B=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=A2=9E=E5=8A=A0=E9=92=88=E5=AF=B9Vertex=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E8=BF=87=E6=BB=A4content[].part[].functionResponse.id?= =?UTF-8?q?=E7=9A=84=E9=80=89=E9=A1=B9=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=90=AF?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + relay/common/relay_info.go | 45 +++++++++++++++++++ relay/gemini_handler.go | 5 +++ setting/model_setting/gemini.go | 4 +- web/src/components/settings/ModelSetting.jsx | 2 + web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/fr.json | 2 + web/src/i18n/locales/ja.json | 2 + web/src/i18n/locales/ru.json | 2 + web/src/i18n/locales/vi.json | 2 + web/src/i18n/locales/zh.json | 2 + .../Setting/Model/SettingGeminiModel.jsx | 18 ++++++++ 12 files changed, 86 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c3cde5d39..640e5ec6a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ new-api tiktoken_cache .eslintcache .gocache +.gomodcache/ .cache web/bun.lock diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 40f79463f..1b9762fec 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -634,3 +635,47 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther } return jsonDataAfter, nil } + +// RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data +// Currently supports removing functionResponse.id field which Vertex AI does not support +func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) { + if !model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled { + return jsonData, nil + } + + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + common.SysError("RemoveGeminiDisabledFields Unmarshal error: " + err.Error()) + return jsonData, nil + } + + // Process contents array + // Handle both camelCase (functionResponse) and snake_case (function_response) + if contents, ok := data["contents"].([]interface{}); ok { + for _, content := range contents { + if contentMap, ok := content.(map[string]interface{}); ok { + if parts, ok := contentMap["parts"].([]interface{}); ok { + for _, part := range parts { + if partMap, ok := part.(map[string]interface{}); ok { + // Check functionResponse (camelCase) + if funcResp, ok := partMap["functionResponse"].(map[string]interface{}); ok { + delete(funcResp, "id") + } + // Check function_response (snake_case) + if funcResp, ok := partMap["function_response"].(map[string]interface{}); ok { + delete(funcResp, "id") + } + } + } + } + } + } + } + + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveGeminiDisabledFields Marshal error: " + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil +} diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index af13341bf..6041b765a 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -162,6 +162,11 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } } + // remove disabled fields for Vertex AI + if info.ChannelType == constant.ChannelTypeVertexAi { + jsonData, _ = relaycommon.RemoveGeminiDisabledFields(jsonData) + } + logger.LogDebug(c, "Gemini request body: "+string(jsonData)) requestBody = bytes.NewReader(jsonData) diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go index 55f721e95..30d56e345 100644 --- a/setting/model_setting/gemini.go +++ b/setting/model_setting/gemini.go @@ -4,7 +4,7 @@ import ( "github.com/QuantumNous/new-api/setting/config" ) -// GeminiSettings 定义Gemini模型的配置 +// GeminiSettings defines Gemini model configuration. 注意bool要以enabled结尾才可以生效编辑 type GeminiSettings struct { SafetySettings map[string]string `json:"safety_settings"` VersionSettings map[string]string `json:"version_settings"` @@ -12,6 +12,7 @@ type GeminiSettings struct { ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"` ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"` FunctionCallThoughtSignatureEnabled bool `json:"function_call_thought_signature_enabled"` + RemoveFunctionResponseIdEnabled bool `json:"remove_function_response_id_enabled"` } // 默认配置 @@ -30,6 +31,7 @@ var defaultGeminiSettings = GeminiSettings{ ThinkingAdapterEnabled: false, ThinkingAdapterBudgetTokensPercentage: 0.6, FunctionCallThoughtSignatureEnabled: true, + RemoveFunctionResponseIdEnabled: true, } // 全局实例 diff --git a/web/src/components/settings/ModelSetting.jsx b/web/src/components/settings/ModelSetting.jsx index 768e10709..d498a3212 100644 --- a/web/src/components/settings/ModelSetting.jsx +++ b/web/src/components/settings/ModelSetting.jsx @@ -32,6 +32,7 @@ const ModelSetting = () => { 'gemini.safety_settings': '', 'gemini.version_settings': '', 'gemini.supported_imagine_models': '', + 'gemini.remove_function_response_id_enabled': true, 'claude.model_headers_settings': '', 'claude.thinking_adapter_enabled': true, 'claude.default_max_tokens': '', @@ -64,6 +65,7 @@ const ModelSetting = () => { item.value = JSON.stringify(JSON.parse(item.value), null, 2); } } + // Keep boolean config keys ending with enabled/Enabled so UI parses correctly. if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) { newInputs[item.key] = toBoolean(item.value); } else { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 188f9e693..4de684048 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -153,6 +153,7 @@ "URL链接": "URL Link", "USD (美元)": "USD (US Dollar)", "User Info Endpoint": "User Info Endpoint", + "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI does not support the functionResponse.id field. When enabled, this field will be automatically removed", "Webhook 密钥": "Webhook Secret", "Webhook 签名密钥": "Webhook Signature Key", "Webhook地址": "Webhook URL", @@ -1510,6 +1511,7 @@ "私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.", "私有部署地址": "Private Deployment Address", "秒": "Second", + "移除 functionResponse.id 字段": "Remove functionResponse.id Field", "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Removal of One API copyright mark must first be authorized. Project maintenance requires a lot of effort. If this project is meaningful to you, please actively support it.", "窗口处理": "window handling", "窗口等待": "window wait", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index b314f8608..d05cdf569 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -154,6 +154,7 @@ "URL链接": "Lien URL", "USD (美元)": "USD (Dollar US)", "User Info Endpoint": "Point de terminaison des informations utilisateur", + "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI ne prend pas en charge le champ functionResponse.id. Lorsqu'il est activé, ce champ sera automatiquement supprimé", "Webhook 密钥": "Clé Webhook", "Webhook 签名密钥": "Clé de signature Webhook", "Webhook地址": "URL du Webhook", @@ -1520,6 +1521,7 @@ "私有IP访问详细说明": "⚠️ Avertissement de sécurité : l'activation de cette option autorise l'accès aux ressources du réseau interne (localhost, réseaux privés). N'activez cette option que si vous devez accéder à des services internes et que vous comprenez les implications en matière de sécurité.", "私有部署地址": "Adresse de déploiement privée", "秒": "Seconde", + "移除 functionResponse.id 字段": "Supprimer le champ functionResponse.id", "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "La suppression de la marque de copyright de One API doit d'abord être autorisée. La maintenance du projet demande beaucoup d'efforts. Si ce projet a du sens pour vous, veuillez le soutenir activement.", "窗口处理": "gestion des fenêtres", "窗口等待": "attente de la fenêtre", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index b5767f662..2b3ea9f02 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -136,6 +136,7 @@ "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kumaの監視分類管理:サービスステータス表示用に、複数の監視分類を設定できます(最大20個)", "URL链接": "URL", "User Info Endpoint": "User Info Endpoint", + "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AIはfunctionResponse.idフィールドをサポートしていません。有効にすると、このフィールドは自動的に削除されます", "Webhook 签名密钥": "Webhook署名シークレット", "Webhook地址": "Webhook URL", "Webhook地址必须以https://开头": "Webhook URLは、https://で始まることが必須です", @@ -1440,6 +1441,7 @@ "私有IP访问详细说明": "プライベートIPアクセスの詳細説明", "私有部署地址": "プライベートデプロイ先URL", "秒": "秒", + "移除 functionResponse.id 字段": "functionResponse.idフィールドを削除", "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "One APIの著作権表示を削除するには、事前の許可が必要です。プロジェクトの維持には多大な労力がかかります。もしこのプロジェクトがあなたにとって有意義でしたら、積極的なご支援をお願いいたします", "窗口处理": "ウィンドウ処理", "窗口等待": "ウィンドウ待機中", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 046a84bff..76616cbdb 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -156,6 +156,7 @@ "URL链接": "URL ссылка", "USD (美元)": "USD (доллар США)", "User Info Endpoint": "Конечная точка информации о пользователе", + "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI не поддерживает поле functionResponse.id. При включении это поле будет автоматически удалено", "Webhook 密钥": "Секрет вебхука", "Webhook 签名密钥": "Ключ подписи Webhook", "Webhook地址": "Адрес Webhook", @@ -1531,6 +1532,7 @@ "私有IP访问详细说明": "⚠️ Предупреждение безопасности: включение этой опции позволит доступ к ресурсам внутренней сети (localhost, частные сети). Включайте только при необходимости доступа к внутренним службам и понимании рисков безопасности.", "私有部署地址": "Адрес частного развёртывания", "秒": "секунда", + "移除 functionResponse.id 字段": "Удалить поле functionResponse.id", "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Удаление авторских знаков One API требует предварительного разрешения, поддержка проекта требует больших усилий, если этот проект важен для вас, пожалуйста, поддержите его", "窗口处理": "Обработка окна", "窗口等待": "Ожидание окна", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 669cafec6..556501da2 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -136,6 +136,7 @@ "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Quản lý danh mục giám sát Uptime Kuma, bạn có thể cấu hình nhiều danh mục giám sát để hiển thị trạng thái dịch vụ (tối đa 20)", "URL链接": "Liên kết URL", "User Info Endpoint": "User Info Endpoint", + "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI không hỗ trợ trường functionResponse.id. Khi bật, trường này sẽ tự động bị xóa", "Webhook 签名密钥": "Khóa chữ ký Webhook", "Webhook地址": "URL Webhook", "Webhook地址必须以https://开头": "URL Webhook phải bắt đầu bằng https://", @@ -2648,6 +2649,7 @@ "私有IP访问详细说明": "⚠️ Cảnh báo bảo mật: Bật tính năng này cho phép truy cập vào tài nguyên mạng nội bộ (localhost, mạng riêng). Chỉ bật nếu bạn cần truy cập các dịch vụ nội bộ và hiểu rõ các rủi ro bảo mật.", "私有部署地址": "Địa chỉ triển khai riêng", "秒": "Giây", + "移除 functionResponse.id 字段": "Xóa trường functionResponse.id", "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Việc xóa dấu bản quyền One API trước tiên phải được ủy quyền. Việc bảo trì dự án đòi hỏi rất nhiều nỗ lực. Nếu dự án này có ý nghĩa với bạn, vui lòng chủ động ủng hộ dự án này.", "窗口处理": "xử lý cửa sổ", "窗口等待": "chờ cửa sổ", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 304a13b03..a8d28acca 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -150,6 +150,7 @@ "URL链接": "URL链接", "USD (美元)": "USD (美元)", "User Info Endpoint": "User Info Endpoint", + "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段", "Webhook 密钥": "Webhook 密钥", "Webhook 签名密钥": "Webhook 签名密钥", "Webhook地址": "Webhook地址", @@ -1498,6 +1499,7 @@ "私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。", "私有部署地址": "私有部署地址", "秒": "秒", + "移除 functionResponse.id 字段": "移除 functionResponse.id 字段", "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目", "窗口处理": "窗口处理", "窗口等待": "窗口等待", diff --git a/web/src/pages/Setting/Model/SettingGeminiModel.jsx b/web/src/pages/Setting/Model/SettingGeminiModel.jsx index e75a4ca91..75b0f0242 100644 --- a/web/src/pages/Setting/Model/SettingGeminiModel.jsx +++ b/web/src/pages/Setting/Model/SettingGeminiModel.jsx @@ -46,6 +46,7 @@ const DEFAULT_GEMINI_INPUTS = { 'gemini.thinking_adapter_enabled': false, 'gemini.thinking_adapter_budget_tokens_percentage': 0.6, 'gemini.function_call_thought_signature_enabled': true, + 'gemini.remove_function_response_id_enabled': true, }; export default function SettingGeminiModel(props) { @@ -186,6 +187,23 @@ export default function SettingGeminiModel(props) { /> + + + + setInputs({ + ...inputs, + 'gemini.remove_function_response_id_enabled': value, + }) + } + /> + + Date: Sun, 21 Dec 2025 17:22:04 +0800 Subject: [PATCH 58/72] =?UTF-8?q?fix:=20=E5=9C=A8Vertex=20Adapter=E8=BF=87?= =?UTF-8?q?=E6=BB=A4content[].part[].functionResponse.id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/vertex/adaptor.go | 33 +++++++++++++++++++++++++++++++++ relay/gemini_handler.go | 5 ----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index c47eeccc1..481524e49 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -51,10 +51,43 @@ type Adaptor struct { } func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + // Vertex AI does not support functionResponse.id; keep it stripped here for consistency. + if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled { + removeFunctionResponseID(request) + } geminiAdaptor := gemini.Adaptor{} return geminiAdaptor.ConvertGeminiRequest(c, info, request) } +func removeFunctionResponseID(request *dto.GeminiChatRequest) { + if request == nil { + return + } + + if len(request.Contents) > 0 { + for i := range request.Contents { + if len(request.Contents[i].Parts) == 0 { + continue + } + for j := range request.Contents[i].Parts { + part := &request.Contents[i].Parts[j] + if part.FunctionResponse == nil { + continue + } + if len(part.FunctionResponse.ID) > 0 { + part.FunctionResponse.ID = nil + } + } + } + } + + if len(request.Requests) > 0 { + for i := range request.Requests { + removeFunctionResponseID(&request.Requests[i]) + } + } +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { c.Set("request_model", v) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 6041b765a..af13341bf 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -162,11 +162,6 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } } - // remove disabled fields for Vertex AI - if info.ChannelType == constant.ChannelTypeVertexAi { - jsonData, _ = relaycommon.RemoveGeminiDisabledFields(jsonData) - } - logger.LogDebug(c, "Gemini request body: "+string(jsonData)) requestBody = bytes.NewReader(jsonData) From d6e97ab184e6be461d4cdb84c9d15b587c00db3c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 21 Dec 2025 21:00:33 +0800 Subject: [PATCH 59/72] =?UTF-8?q?=F0=9F=94=97=20docs(readme):=20update=20d?= =?UTF-8?q?ocumentation=20links=20to=20new=20site=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace legacy `docs.newapi.pro` paths with the new `/{lang}/docs/...` structure across all README translations - Point key sections (installation, env vars, API, support, features) to their new locations - Ensure language-specific links use the correct locale prefix (zh/en/ja) and keep FR aligned with English routes --- README.en.md | 70 +++++++++++++++++++++++----------------------- README.fr.md | 70 +++++++++++++++++++++++----------------------- README.ja.md | 78 ++++++++++++++++++++++++++-------------------------- README.md | 70 +++++++++++++++++++++++----------------------- 4 files changed, 144 insertions(+), 144 deletions(-) diff --git a/README.en.md b/README.en.md index 063d360b2..d2e0947d2 100644 --- a/README.en.md +++ b/README.en.md @@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \ 🎉 After deployment is complete, visit `http://localhost:3000` to start using! -📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation) +📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation) --- @@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
-### 📖 [Official Documentation](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) +### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \ | Category | Link | |------|------| -| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) | -| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) | -| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) | -| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) | +| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) | +| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) | +| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) | +| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) | +| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) | --- ## ✨ Key Features -> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) +> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction) ### 🎨 Core Functions @@ -201,11 +201,11 @@ docker run --name new-api -d --restart always \ ### 🚀 Advanced Features **API Format Support:** -- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) -- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure) -- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina) +- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure) +- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) +- ⚡ [Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina) **Intelligent Routing:** - ⚖️ Channel weighted random @@ -246,16 +246,16 @@ docker run --name new-api -d --restart always \ ## 🤖 Model Support -> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api) +> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api) | Model Type | Description | Documentation | |---------|------|------| | 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) | -| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) | -| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) | -| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/drawing-log) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/task-log) | +| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) | +| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) | +| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) | | 🔧 Dify | ChatFlow mode | - | | 🎯 Custom | Supports complete call address | - | @@ -264,16 +264,16 @@ docker run --name new-api -d --restart always \
View complete interface list -- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat) -- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses) -- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image) -- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio) -- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video) -- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) -- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) -- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat) -- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/) +- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response) +- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post) +- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription) +- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation) +- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding) +- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) +- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) +- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) +- [Google Gemini Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
@@ -309,7 +309,7 @@ docker run --name new-api -d --restart always \ | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | -📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables) +📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) @@ -411,10 +411,10 @@ docker run --name new-api -d --restart always \ | Resource | Link | |------|------| -| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) | -| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) | -| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) | +| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) | +| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) | +| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) | +| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) | ### 🤝 Contribution Guide @@ -443,7 +443,7 @@ Welcome all forms of contribution! If this project is helpful to you, welcome to give us a ⭐️ Star! -**[Official Documentation](https://docs.newapi.pro/)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)** +**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)** Built with ❤️ by QuantumNous diff --git a/README.fr.md b/README.fr.md index 0aa212d1f..1ca3de863 100644 --- a/README.fr.md +++ b/README.fr.md @@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \ 🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser! -📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/installation) +📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation) --- @@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
-### 📖 [Documentation officielle](https://docs.newapi.pro/) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) +### 📖 [Documentation officielle](https://docs.newapi.pro/en/docs) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \ | Catégorie | Lien | |------|------| -| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/installation) | -| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/installation/environment-variables) | -| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/api) | -| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) | +| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/en/docs/installation) | +| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) | +| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/en/docs/api) | +| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) | +| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) | --- ## ✨ Fonctionnalités clés -> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) | +> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction) | ### 🎨 Fonctions principales @@ -200,11 +200,11 @@ docker run --name new-api -d --restart always \ ### 🚀 Fonctionnalités avancées **Prise en charge des formats d'API:** -- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) -- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (y compris Azure) -- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -- 🔄 [Modèles Rerank](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina) +- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (y compris Azure) +- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) +- ⚡ [Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- 🔄 [Modèles Rerank](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina) **Routage intelligent:** - ⚖️ Sélection aléatoire pondérée des canaux @@ -242,16 +242,16 @@ docker run --name new-api -d --restart always \ ## 🤖 Prise en charge des modèles -> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/api) +> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/en/docs/api) | Type de modèle | Description | Documentation | |---------|------|------| | 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) | -| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) | -| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/api/anthropic-chat) | -| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/drawing-log) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/task-log) | +| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) | +| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) | +| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) | | 🔧 Dify | Mode ChatFlow | - | | 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - | @@ -260,16 +260,16 @@ docker run --name new-api -d --restart always \
Voir la liste complète des interfaces -- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat) -- [Interface de réponse (Responses)](https://docs.newapi.pro/api/openai-responses) -- [Interface d'image (Image)](https://docs.newapi.pro/api/openai-image) -- [Interface audio (Audio)](https://docs.newapi.pro/api/openai-audio) -- [Interface vidéo (Video)](https://docs.newapi.pro/api/openai-video) -- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) -- [Interface de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) -- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Discussion Claude](https://docs.newapi.pro/api/anthropic-chat) -- [Discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) +- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- [Interface de réponse (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response) +- [Interface d'image (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post) +- [Interface audio (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription) +- [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation) +- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding) +- [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) +- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) +- [Discussion Claude](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) +- [Discussion Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
@@ -305,7 +305,7 @@ docker run --name new-api -d --restart always \ | `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` | -📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) +📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) @@ -405,10 +405,10 @@ docker run --name new-api -d --restart always \ | Ressource | Lien | |------|------| -| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) | -| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) | -| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/support) | +| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) | +| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) | +| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/en/docs/support/feedback-issues) | +| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/en/docs) | ### 🤝 Guide de contribution @@ -437,7 +437,7 @@ Bienvenue à toutes les formes de contribution! Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile! -**[Documentation officielle](https://docs.newapi.pro/)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)** +**[Documentation officielle](https://docs.newapi.pro/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)** Construit avec ❤️ par QuantumNous diff --git a/README.ja.md b/README.ja.md index e76cd0ed4..68adbebec 100644 --- a/README.ja.md +++ b/README.ja.md @@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \ 🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください! -📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/installation)を参照してください。 +📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。 --- @@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
-### 📖 [公式ドキュメント](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) +### 📖 [公式ドキュメント](https://docs.newapi.pro/ja/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \ | カテゴリ | リンク | |------|------| -| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/installation) | -| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/installation/environment-variables) | -| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/api) | -| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) | +| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/ja/docs/installation) | +| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) | +| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/ja/docs/api) | +| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) | +| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) | --- ## ✨ 主な機能 -> 詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください。 +> 詳細な機能については[機能説明](https://docs.newapi.pro/ja/docs/guide/wiki/basic-concepts/features-introduction)を参照してください。 ### 🎨 コア機能 @@ -202,15 +202,15 @@ docker run --name new-api -d --restart always \ ### 🚀 高度な機能 **APIフォーマットサポート:** -- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) -- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(Azureを含む) -- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank) -- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) -- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina) +- ⚡ [OpenAI Responses](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)(Azureを含む) +- ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) +- ⚡ [Google Gemini](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) +- 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session) +- ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) +- ⚡ [Google Gemini](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) +- 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina) **インテリジェントルーティング:** - ⚖️ チャネル重み付けランダム @@ -251,16 +251,16 @@ docker run --name new-api -d --restart always \ ## 🤖 モデルサポート -> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/api) +> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/ja/docs/api) | モデルタイプ | 説明 | ドキュメント | |---------|------|------| | 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/api/suno-music) | -| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) | -| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/api/suno-music) | -| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/ja/docs/guide/console/drawing-log) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/ja/docs/guide/console/task-log) | +| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) | +| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) | +| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) | | 🔧 Dify | ChatFlowモード | - | | 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - | @@ -269,16 +269,16 @@ docker run --name new-api -d --restart always \
完全なインターフェースリストを表示 -- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/api/openai-chat) -- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/api/openai-responses) -- [イメージインターフェース (Image)](https://docs.newapi.pro/api/openai-image) -- [オーディオインターフェース (Audio)](https://docs.newapi.pro/api/openai-audio) -- [ビデオインターフェース (Video)](https://docs.newapi.pro/api/openai-video) -- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) -- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) -- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Claudeチャット](https://docs.newapi.pro/api/anthropic-chat) -- [Google Geminiチャット](https://docs.newapi.pro/api/google-gemini-chat/) +- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) +- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response) +- [イメージインターフェース (Image)](https://docs.newapi.pro/ja/docs/api/ai-model/images/openai/v1-images-generations--post) +- [オーディオインターフェース (Audio)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/create-transcription) +- [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/videos/create-video-generation) +- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/ja/docs/api/ai-model/embeddings/create-embedding) +- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) +- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session) +- [Claudeチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) +- [Google Geminiチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion)
@@ -314,7 +314,7 @@ docker run --name new-api -d --restart always \ | `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` | -📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables) +📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) @@ -414,10 +414,10 @@ docker run --name new-api -d --restart always \ | リソース | リンク | |------|------| -| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) | -| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/support/feedback-issues) | -| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/support) | +| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) | +| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) | +| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/ja/docs/support/feedback-issues) | +| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/ja/docs) | ### 🤝 貢献ガイド @@ -446,7 +446,7 @@ docker run --name new-api -d --restart always \ このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください! -**[公式ドキュメント](https://docs.newapi.pro/)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)** +**[公式ドキュメント](https://docs.newapi.pro/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)** ❤️ で構築された QuantumNous diff --git a/README.md b/README.md index f1cb37480..4f87e9e4e 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \ 🎉 部署完成后,访问 `http://localhost:3000` 即可使用! -📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation) +📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation) --- @@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
-### 📖 [官方文档](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) +### 📖 [官方文档](https://docs.newapi.pro/zh/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \ | 分类 | 链接 | |------|------| -| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) | -| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) | -| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) | -| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) | +| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/zh/docs/installation) | +| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) | +| 📡 接口文档 | [API 文档](https://docs.newapi.pro/zh/docs/api) | +| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) | +| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) | --- ## ✨ 主要特性 -> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction) +> 详细特性请参考 [特性说明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction) ### 🎨 核心功能 @@ -202,11 +202,11 @@ docker run --name new-api -d --restart always \ ### 🚀 高级功能 **API 格式支持:** -- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) -- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure) -- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina) +- ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure) +- ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) +- ⚡ [Google Gemini](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion) +- 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina) **智能路由:** - ⚖️ 渠道加权随机 @@ -247,16 +247,16 @@ docker run --name new-api -d --restart always \ ## 🤖 模型支持 -> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api) +> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/zh/docs/api) | 模型类型 | 说明 | 文档 | |---------|------|------| | 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) | -| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) | -| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) | -| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/zh/docs/guide/console/drawing-log) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/zh/docs/guide/console/task-log) | +| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) | +| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) | +| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion) | | 🔧 Dify | ChatFlow 模式 | - | | 🎯 自定义 | 支持完整调用地址 | - | @@ -265,16 +265,16 @@ docker run --name new-api -d --restart always \
查看完整接口列表 -- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat) -- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses) -- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image) -- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio) -- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video) -- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) -- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) -- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat) -- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat) +- [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion) +- [响应接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response) +- [图像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/v1-images-generations--post) +- [音频接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription) +- [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/create-video-generation) +- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/create-embedding) +- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) +- [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session) +- [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) +- [Google Gemini 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion)
@@ -310,7 +310,7 @@ docker run --name new-api -d --restart always \ | `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | -📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables) +📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) @@ -412,10 +412,10 @@ docker run --name new-api -d --restart always \ | 资源 | 链接 | |------|------| -| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) | -| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) | -| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) | -| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) | +| 📘 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) | +| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) | +| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/zh/docs/support/feedback-issues) | +| 📚 完整文档 | [官方文档](https://docs.newapi.pro/zh/docs) | ### 🤝 贡献指南 @@ -444,7 +444,7 @@ docker run --name new-api -d --restart always \ 如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star! -**[官方文档](https://docs.newapi.pro/)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)** +**[官方文档](https://docs.newapi.pro/zh/docs)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)** Built with ❤️ by QuantumNous From 470e0304d8fb4966255a43f12cd9d0f89493e110 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 21 Dec 2025 21:18:59 +0800 Subject: [PATCH 60/72] =?UTF-8?q?=F0=9F=94=97=20docs(readme):=20revert=20m?= =?UTF-8?q?issing=20docs=20links=20to=20legacy=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep new-site links (/{lang}/docs/...) where matching pages exist in the current docs repo Revert links that have no equivalent in the new docs to the legacy paths on doc.newapi.pro: Google Gemini Chat Midjourney-Proxy image docs Suno music docs Apply the same rule consistently across all README translations (zh/en/ja/fr) --- README.en.md | 10 +++++----- README.fr.md | 10 +++++----- README.ja.md | 12 ++++++------ README.md | 10 +++++----- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.en.md b/README.en.md index d2e0947d2..1e5ae9751 100644 --- a/README.en.md +++ b/README.en.md @@ -204,7 +204,7 @@ docker run --name new-api -d --restart always \ - ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response) - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure) - ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) -- ⚡ [Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat) - 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina) **Intelligent Routing:** @@ -251,11 +251,11 @@ docker run --name new-api -d --restart always \ | Model Type | Description | Documentation | |---------|------|------| | 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/drawing-log) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/task-log) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) | | 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) | | 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) | -| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) | +| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) | | 🔧 Dify | ChatFlow mode | - | | 🎯 Custom | Supports complete call address | - | @@ -273,7 +273,7 @@ docker run --name new-api -d --restart always \ - [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) - [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) - [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) -- [Google Gemini Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat) diff --git a/README.fr.md b/README.fr.md index 1ca3de863..a3a02317f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -203,7 +203,7 @@ docker run --name new-api -d --restart always \ - ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response) - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (y compris Azure) - ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) -- ⚡ [Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat) - 🔄 [Modèles Rerank](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina) **Routage intelligent:** @@ -247,11 +247,11 @@ docker run --name new-api -d --restart always \ | Type de modèle | Description | Documentation | |---------|------|------| | 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/drawing-log) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/en/docs/guide/console/task-log) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) | | 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) | | 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) | -| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) | +| 🌐 Gemini | Format Google Gemini | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) | | 🔧 Dify | Mode ChatFlow | - | | 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - | @@ -269,7 +269,7 @@ docker run --name new-api -d --restart always \ - [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) - [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) - [Discussion Claude](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) -- [Discussion Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion) +- [Discussion Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat) diff --git a/README.ja.md b/README.ja.md index 68adbebec..cae2f192d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -205,11 +205,11 @@ docker run --name new-api -d --restart always \ - ⚡ [OpenAI Responses](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response) - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)(Azureを含む) - ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) -- ⚡ [Google Gemini](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) +- ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat) - 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session) - ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) -- ⚡ [Google Gemini](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) +- ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat) - 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina) **インテリジェントルーティング:** @@ -256,11 +256,11 @@ docker run --name new-api -d --restart always \ | モデルタイプ | 説明 | ドキュメント | |---------|------|------| | 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/ja/docs/guide/console/drawing-log) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/ja/docs/guide/console/task-log) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://doc.newapi.pro/ja/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://doc.newapi.pro/ja/api/suno-music) | | 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) | | 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) | -| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) | +| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://doc.newapi.pro/ja/api/google-gemini-chat) | | 🔧 Dify | ChatFlowモード | - | | 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - | @@ -278,7 +278,7 @@ docker run --name new-api -d --restart always \ - [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) - [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session) - [Claudeチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) -- [Google Geminiチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion) +- [Google Geminiチャット](https://doc.newapi.pro/ja/api/google-gemini-chat) diff --git a/README.md b/README.md index 4f87e9e4e..3ef081bb4 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ docker run --name new-api -d --restart always \ - ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response) - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure) - ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) -- ⚡ [Google Gemini](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion) +- ⚡ [Google Gemini](https://doc.newapi.pro/api/google-gemini-chat) - 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina) **智能路由:** @@ -252,11 +252,11 @@ docker run --name new-api -d --restart always \ | 模型类型 | 说明 | 文档 | |---------|------|------| | 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - | -| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/zh/docs/guide/console/drawing-log) | -| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/zh/docs/guide/console/task-log) | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://doc.newapi.pro/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://doc.newapi.pro/api/suno-music) | | 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) | | 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) | -| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion) | +| 🌐 Gemini | Google Gemini 格式 | [文档](https://doc.newapi.pro/api/google-gemini-chat) | | 🔧 Dify | ChatFlow 模式 | - | | 🎯 自定义 | 支持完整调用地址 | - | @@ -274,7 +274,7 @@ docker run --name new-api -d --restart always \ - [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) - [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session) - [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) -- [Google Gemini 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion) +- [Google Gemini 聊天](https://doc.newapi.pro/api/google-gemini-chat) From dbaba87c39f93932e93e20f75b60d8534f8fc65a Mon Sep 17 00:00:00 2001 From: John Chen Date: Mon, 22 Dec 2025 17:05:16 +0800 Subject: [PATCH 61/72] =?UTF-8?q?=E4=B8=BAMoonshot=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BC=93=E5=AD=98tokens=E8=AF=BB=E5=8F=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为Moonshot添加缓存tokens读取逻辑。其与智普V4的逻辑相同,所以共用逻辑 --- relay/channel/openai/relay-openai.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 5819f7071..ac44312eb 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -596,7 +596,7 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 { usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens } - case constant.ChannelTypeZhipu_v4: + case constant.ChannelTypeZhipu_v4, constant.ChannelTypeMoonshot: if usage.PromptTokensDetails.CachedTokens == 0 { if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens From 42109c58407ecc43d450fa30a28c975c99baa84c Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 22 Dec 2025 18:01:38 +0800 Subject: [PATCH 62/72] feat(token): enhance error handling in ValidateUserToken for better clarity --- model/token.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/model/token.go b/model/token.go index 357d9bdd2..7c629b33c 100644 --- a/model/token.go +++ b/model/token.go @@ -112,7 +112,12 @@ func ValidateUserToken(key string) (token *Token, err error) { } return token, nil } - return nil, errors.New("无效的令牌") + common.SysLog("ValidateUserToken: failed to get token: " + err.Error()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("无效的令牌") + } else { + return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员") + } } func GetTokenByIds(id int, userId int) (*Token, error) { From 3652dfdbd5416a49c4ee6be5da20ca152ddabc95 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 24 Dec 2025 11:53:56 +0800 Subject: [PATCH 63/72] fix: check claudeResponse delta StopReason nil point --- relay/channel/claude/relay-claude.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index b815a69fb..d3986236a 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -483,9 +483,11 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse } } } else if claudeResponse.Type == "message_delta" { - finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason) - if finishReason != "null" { - choice.FinishReason = &finishReason + if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil { + finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } } //claudeUsage = &claudeResponse.Usage } else if claudeResponse.Type == "message_stop" { From 31a79620bafdb7d522cfea11d84fe8bfeb80faad Mon Sep 17 00:00:00 2001 From: Jerry Date: Wed, 24 Dec 2025 14:52:39 +0800 Subject: [PATCH 64/72] Resolving event mismatch in OpenAI2Claude add stricter validation for content_block_start corresponding to tool call and fix the crash issue when Claude Code is processing tool call --- service/convert.go | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/service/convert.go b/service/convert.go index beec76a79..7549b569d 100644 --- a/service/convert.go +++ b/service/convert.go @@ -389,25 +389,29 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } idx := blockIndex - claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &idx, - Type: "content_block_start", - ContentBlock: &dto.ClaudeMediaMessage{ - Id: toolCall.ID, - Type: "tool_use", - Name: toolCall.Function.Name, - Input: map[string]interface{}{}, - }, - }) - - claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &idx, - Type: "content_block_delta", - Delta: &dto.ClaudeMediaMessage{ - Type: "input_json_delta", - PartialJson: &toolCall.Function.Arguments, - }, - }) + if toolCall.Function.Name != "" { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &idx, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: toolCall.ID, + Type: "tool_use", + Name: toolCall.Function.Name, + Input: map[string]interface{}{}, + }, + }) + } + + if len(toolCall.Function.Arguments) > 0 { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &idx, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "input_json_delta", + PartialJson: &toolCall.Function.Arguments, + }, + }) + } info.ClaudeConvertInfo.Index = blockIndex } From 09f39573629ecf6bf962118dc6b5c81e457dd020 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 24 Dec 2025 15:35:36 +0800 Subject: [PATCH 65/72] fix: add warning for pass through body --- .../table/channels/ChannelsColumnDefs.jsx | 58 +++++++++++++++++-- web/src/components/table/channels/index.jsx | 18 ++++++ web/src/hooks/channels/useChannelsData.jsx | 23 ++++++++ web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/fr.json | 3 + web/src/i18n/locales/ja.json | 3 + web/src/i18n/locales/ru.json | 3 + web/src/i18n/locales/vi.json | 3 + web/src/i18n/locales/zh.json | 3 + 9 files changed, 113 insertions(+), 4 deletions(-) diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 5b505baed..643f3ffe6 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -39,7 +39,11 @@ import { showError, } from '../../../helpers'; import { CHANNEL_OPTIONS } from '../../../constants'; -import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons'; +import { + IconTreeTriangleDown, + IconMore, + IconAlertTriangle, +} from '@douyinfe/semi-icons'; import { FaRandom } from 'react-icons/fa'; // Render functions @@ -187,6 +191,28 @@ const renderResponseTime = (responseTime, t) => { } }; +const isRequestPassThroughEnabled = (record) => { + if (!record || record.children !== undefined) { + return false; + } + const settingValue = record.setting; + if (!settingValue) { + return false; + } + if (typeof settingValue === 'object') { + return settingValue.pass_through_body_enabled === true; + } + if (typeof settingValue !== 'string') { + return false; + } + try { + const parsed = JSON.parse(settingValue); + return parsed?.pass_through_body_enabled === true; + } catch (error) { + return false; + } +}; + export const getChannelsColumns = ({ t, COLUMN_KEYS, @@ -219,8 +245,9 @@ export const getChannelsColumns = ({ title: t('名称'), dataIndex: 'name', render: (text, record, index) => { - if (record.remark && record.remark.trim() !== '') { - return ( + const passThroughEnabled = isRequestPassThroughEnabled(record); + const nameNode = + record.remark && record.remark.trim() !== '' ? ( @@ -250,9 +277,32 @@ export const getChannelsColumns = ({ > {text} + ) : ( + {text} ); + + if (!passThroughEnabled) { + return nameNode; } - return text; + + return ( + + {nameNode} + + + + + + + ); }, }, { diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index 466f04155..fa7850959 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; +import { Banner } from '@douyinfe/semi-ui'; +import { IconAlertTriangle } from '@douyinfe/semi-icons'; import CardPro from '../../common/ui/CardPro'; import ChannelsTable from './ChannelsTable'; import ChannelsActions from './ChannelsActions'; @@ -63,6 +65,22 @@ const ChannelsPage = () => { /> {/* Main Content */} + {channelsData.globalPassThroughEnabled ? ( + + } + description={channelsData.t( + '已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。', + )} + style={{ marginBottom: 12 }} + /> + ) : null} } diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index f3f99f01e..f3df1bcca 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -26,6 +26,7 @@ import { showSuccess, loadChannelModels, copy, + toBoolean, } from '../../helpers'; import { CHANNEL_OPTIONS, @@ -85,6 +86,26 @@ export const useChannelsData = () => { const [isBatchTesting, setIsBatchTesting] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); const [selectedEndpointType, setSelectedEndpointType] = useState(''); + const [globalPassThroughEnabled, setGlobalPassThroughEnabled] = + useState(false); + + const fetchGlobalPassThroughEnabled = async () => { + try { + const res = await API.get('/api/option/'); + const { success, data } = res?.data || {}; + if (!success || !Array.isArray(data)) { + return; + } + const option = data.find( + (item) => item?.key === 'global.pass_through_request_enabled', + ); + if (option) { + setGlobalPassThroughEnabled(toBoolean(option.value)); + } + } catch (error) { + setGlobalPassThroughEnabled(false); + } + }; // 使用 ref 来避免闭包问题,类似旧版实现 const shouldStopBatchTestingRef = useRef(false); @@ -140,6 +161,7 @@ export const useChannelsData = () => { }); fetchGroups().then(); loadChannelModels().then(); + fetchGlobalPassThroughEnabled().then(); }, []); // Column visibility management @@ -1026,6 +1048,7 @@ export const useChannelsData = () => { enableBatchDelete, statusFilter, compactMode, + globalPassThroughEnabled, // UI states showEdit, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4de684048..fb34544a4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -840,6 +840,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "After enabling, free models (ratio 0 or price 0) will also pre-consume quota", "开启后,将定期发送ping数据保持连接活跃": "After enabling, ping data will be sent periodically to keep the connection active", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "When enabled, all requests will be directly forwarded to the upstream without any processing (redirects and channel adaptation will also be disabled). Please enable with caution.", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Request pass-through is enabled for this channel. Built-in NewAPI features such as parameter overrides, model redirection, and channel adaptation will be disabled. This is not a best practice. If this causes issues, please do not submit an issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Global request pass-through is enabled. Built-in NewAPI features such as parameter overrides, model redirection, and channel adaptation will be disabled. This is not a best practice. If this causes issues, please do not submit an issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Request pass-through is enabled for this channel; built-in NewAPI features such as parameter overrides and model redirection will be disabled. This is not a best practice.", "开启后不限制:必须设置模型倍率": "After enabling, no limit: must set model ratio", "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace", "开启批量操作": "Enable batch selection", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index d05cdf569..05fe5374a 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -848,6 +848,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "Après activation, les modèles gratuits (ratio 0 ou prix 0) préconsommeront également du quota", "开启后,将定期发送ping数据保持连接活跃": "Après activation, des données ping seront envoyées périodiquement pour maintenir la connexion active", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Après activation, toutes les requêtes seront directement transmises en amont sans aucun traitement (la redirection et l'adaptation de canal seront également désactivées), veuillez activer avec prudence", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "La transmission des requêtes est activée pour ce canal. Les fonctionnalités intégrées de NewAPI (surcharge des paramètres, redirection de modèle, adaptation du canal, etc.) seront désactivées. Ce n'est pas une bonne pratique. Si cela cause des problèmes, merci de ne pas ouvrir d'issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "La transmission globale des requêtes est activée. Les fonctionnalités intégrées de NewAPI (surcharge des paramètres, redirection de modèle, adaptation du canal, etc.) seront désactivées. Ce n'est pas une bonne pratique. Si cela cause des problèmes, merci de ne pas ouvrir d'issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "La transmission des requêtes est activée pour ce canal ; les fonctionnalités intégrées de NewAPI (comme la surcharge des paramètres et la redirection de modèle) seront désactivées. Ce n'est pas une bonne pratique.", "开启后不限制:必须设置模型倍率": "Après l'activation, aucune limite : le ratio de modèle doit être défini", "开启后未登录用户无法访问模型广场": "Lorsqu'il est activé, les utilisateurs non authentifiés ne peuvent pas accéder à la place du marché des modèles", "开启批量操作": "Activer la sélection par lots", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 2b3ea9f02..22e7606dd 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -796,6 +796,9 @@ "开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "有効にすると、「消費」と「エラー」のログにのみ、クライアントIPアドレスが記録されます", "开启后,将定期发送ping数据保持连接活跃": "有効にすると、接続をアクティブに保つためにpingデータが定期的に送信されます", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "有効にすると、すべてのリクエストは直接アップストリームにパススルーされ、いかなる処理も行われません(リダイレクトとチャネルの自動調整も無効になります)。有効にする際はご注意ください", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "このチャネルではリクエストのパススルーが有効です。パラメータ上書き、モデルリダイレクト、チャネル適応などの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。これにより問題が発生しても issue を投稿しないでください。", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "全体のリクエストパススルーが有効です。パラメータ上書き、モデルリダイレクト、チャネル適応などの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。これにより問題が発生しても issue を投稿しないでください。", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "このチャネルではリクエストのパススルーが有効です。パラメータ上書きやモデルリダイレクトなどの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。", "开启后不限制:必须设置模型倍率": "有効化後は制限なし:モデル倍率の設定が必須", "开启后未登录用户无法访问模型广场": "有効にすると、ログインしていないユーザーはモデルマーケットプレイスにアクセスできなくなります", "开启批量操作": "一括操作を有効にする", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 76616cbdb..d71e12d1b 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -857,6 +857,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "После включения бесплатные модели (коэффициент 0 или цена 0) тоже будут предварительно расходовать квоту", "开启后,将定期发送ping数据保持连接活跃": "После включения будет периодически отправляться ping-данные для поддержания активности соединения", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "После включения все запросы будут напрямую передаваться upstream без какой-либо обработки (перенаправление и адаптация каналов также будут отключены), включайте с осторожностью", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Для этого канала включена сквозная передача запросов. Встроенные возможности NewAPI, такие как переопределение параметров, перенаправление моделей и адаптация канала, будут отключены. Это не является лучшей практикой. Если из-за этого возникнут проблемы, пожалуйста, не создавайте issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Глобальная сквозная передача запросов включена. Встроенные возможности NewAPI, такие как переопределение параметров, перенаправление моделей и адаптация канала, будут отключены. Это не является лучшей практикой. Если из-за этого возникнут проблемы, пожалуйста, не создавайте issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Для этого канала включена сквозная передача запросов; встроенные функции NewAPI, такие как переопределение параметров и перенаправление моделей, будут отключены. Это не является лучшей практикой.", "开启后不限制:必须设置模型倍率": "После включения без ограничений: необходимо установить множители моделей", "开启后未登录用户无法访问模型广场": "После включения незарегистрированные пользователи не смогут получить доступ к площади моделей", "开启批量操作": "Включить пакетные операции", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 556501da2..51113ff44 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -796,6 +796,9 @@ "开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "Sau khi bật, chỉ nhật ký \"tiêu thụ\" và \"lỗi\" sẽ ghi lại địa chỉ IP máy khách của bạn", "开启后,将定期发送ping数据保持连接活跃": "Sau khi bật, dữ liệu ping sẽ được gửi định kỳ để giữ kết nối hoạt động", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Khi bật, tất cả các yêu cầu sẽ được chuyển tiếp trực tiếp đến thượng nguồn mà không cần xử lý (chuyển hướng và thích ứng kênh cũng sẽ bị vô hiệu hóa). Vui lòng bật một cách thận trọng.", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Kênh này đã bật truyền qua yêu cầu. Các tính năng tích hợp của NewAPI như ghi đè tham số, chuyển hướng mô hình và thích ứng kênh sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất. Nếu phát sinh vấn đề, vui lòng không gửi issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Đã bật truyền qua yêu cầu toàn cục. Các tính năng tích hợp của NewAPI như ghi đè tham số, chuyển hướng mô hình và thích ứng kênh sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất. Nếu phát sinh vấn đề, vui lòng không gửi issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Kênh này đã bật truyền qua yêu cầu; các tính năng tích hợp của NewAPI như ghi đè tham số và chuyển hướng mô hình sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất.", "开启后不限制:必须设置模型倍率": "Sau khi bật, không giới hạn: phải đặt tỷ lệ mô hình", "开启后未登录用户无法访问模型广场": "Khi bật, người dùng chưa xác thực không thể truy cập thị trường mô hình", "开启批量操作": "Bật chọn hàng loạt", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a8d28acca..35ec62ba1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -830,6 +830,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度", "开启后,将定期发送ping数据保持连接活跃": "开启后,将定期发送ping数据保持连接活跃", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。", "开启后不限制:必须设置模型倍率": "开启后不限制:必须设置模型倍率", "开启后未登录用户无法访问模型广场": "开启后未登录用户无法访问模型广场", "开启批量操作": "开启批量操作", From 14c58aea77910b2e15dfe53e856f1382491b4058 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 24 Dec 2025 15:52:56 +0800 Subject: [PATCH 66/72] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E5=B0=8F?= =?UTF-8?q?=E5=86=99bearer=E5=92=8CBearer=E5=90=8E=E5=B8=A6=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E7=A9=BA=E6=A0=BC=20&&=20=E4=BF=AE=E5=A4=8D=20WSS?= =?UTF-8?q?=E9=A2=84=E6=89=A3=E8=B4=B9=E9=94=99=E8=AF=AF=E6=8F=90=E5=8F=96?= =?UTF-8?q?key=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware/auth.go | 8 ++++++-- service/quota.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/middleware/auth.go b/middleware/auth.go index 1396b2d5a..a3b41b186 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -218,10 +218,14 @@ func TokenAuth() func(c *gin.Context) { } key := c.Request.Header.Get("Authorization") parts := make([]string, 0) - key = strings.TrimPrefix(key, "Bearer ") + if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") { + key = strings.TrimSpace(key[7:]) + } if key == "" || key == "midjourney-proxy" { key = c.Request.Header.Get("mj-api-secret") - key = strings.TrimPrefix(key, "Bearer ") + if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") { + key = strings.TrimSpace(key[7:]) + } key = strings.TrimPrefix(key, "sk-") parts = strings.Split(key, "-") key = parts[0] diff --git a/service/quota.go b/service/quota.go index 0da8dafd3..23ae60c1f 100644 --- a/service/quota.go +++ b/service/quota.go @@ -95,7 +95,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag return err } - token, err := model.GetTokenByKey(strings.TrimLeft(relayInfo.TokenKey, "sk-"), false) + token, err := model.GetTokenByKey(strings.TrimPrefix(relayInfo.TokenKey, "sk-"), false) if err != nil { return err } From 559da6362a690f77d6c695399cd847f122fe9701 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 25 Dec 2025 15:37:54 +0800 Subject: [PATCH 67/72] fix: revert model ratio --- setting/ratio_setting/model_ratio.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 00e8ccffa..df823516a 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -7,7 +7,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/reasoning" ) // from songquanpeng/one-api @@ -829,10 +828,6 @@ func FormatMatchingModelName(name string) string { name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") } - if base, _, ok := reasoning.TrimEffortSuffix(name); ok { - name = base - } - if strings.HasPrefix(name, "gpt-4-gizmo") { name = "gpt-4-gizmo-*" } From f17b3810d65798ce12b6dfb2f1b08e05cbed7c0f Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 25 Dec 2025 15:39:18 +0800 Subject: [PATCH 68/72] feat(user): simplify user response structure in JSON output --- controller/user.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/controller/user.go b/controller/user.go index ef4f0ddc0..cc3049c61 100644 --- a/controller/user.go +++ b/controller/user.go @@ -110,18 +110,17 @@ func setupLogin(user *model.User, c *gin.Context) { }) return } - cleanUser := model.User{ - Id: user.Id, - Username: user.Username, - DisplayName: user.DisplayName, - Role: user.Role, - Status: user.Status, - Group: user.Group, - } c.JSON(http.StatusOK, gin.H{ "message": "", "success": true, - "data": cleanUser, + "data": map[string]any{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "group": user.Group, + }, }) } From 83fbaba76836b8bf64fed992c8d4d5f7f8650c6d Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 25 Dec 2025 23:01:09 +0800 Subject: [PATCH 69/72] =?UTF-8?q?=F0=9F=9A=80=20fix(model-sync):=20avoid?= =?UTF-8?q?=20unnecessary=20upstream=20fetch=20while=20keeping=20overwrite?= =?UTF-8?q?=20updates=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only short-circuit when there are no missing models AND no overwrite fields requested - Preserve overwrite behavior even when the missing-model list is empty - Always return empty arrays (not null) for list fields to keep API responses stable - Clarify SyncUpstreamModels behavior in comments (create missing models + optional overwrite updates) --- controller/model_sync.go | 32 ++++++++++++++++++++++++++++---- service/convert.go | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/controller/model_sync.go b/controller/model_sync.go index 38eace06e..b2ac99da8 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -249,7 +249,9 @@ func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, v return 0 } -// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效 +// SyncUpstreamModels 同步上游模型与供应商: +// - 默认仅创建「未配置模型」 +// - 可通过 overwrite 选择性覆盖更新本地已有模型的字段(前提:sync_official <> 0) func SyncUpstreamModels(c *gin.Context) { var req syncRequest // 允许空体 @@ -261,6 +263,28 @@ func SyncUpstreamModels(c *gin.Context) { return } + // 若既无缺失模型需要创建,也未指定覆盖更新字段,则无需请求上游数据,直接返回 + if len(missing) == 0 && len(req.Overwrite) == 0 { + modelsURL, vendorsURL := getUpstreamURLs(req.Locale) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "created_models": 0, + "created_vendors": 0, + "updated_models": 0, + "skipped_models": []string{}, + "created_list": []string{}, + "updated_list": []string{}, + "source": gin.H{ + "locale": req.Locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) + return + } + // 2) 拉取上游 vendors 与 models timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second) @@ -307,9 +331,9 @@ func SyncUpstreamModels(c *gin.Context) { createdModels := 0 createdVendors := 0 updatedModels := 0 - var skipped []string - var createdList []string - var updatedList []string + skipped := make([]string, 0) + createdList := make([]string, 0) + updatedList := make([]string, 0) // 本地缓存:vendorName -> id vendorIDCache := make(map[string]int) diff --git a/service/convert.go b/service/convert.go index 7549b569d..7228db9a9 100644 --- a/service/convert.go +++ b/service/convert.go @@ -401,7 +401,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon }, }) } - + if len(toolCall.Function.Arguments) > 0 { claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Index: &idx, From f68858121cbb9b404f27af3c90ef45d7d950db5d Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 26 Dec 2025 00:10:19 +0800 Subject: [PATCH 70/72] fix(i18n): disable namespace separator to fix URL display in translations i18next uses ':' as namespace separator by default, causing URLs like 'https://api.openai.com' to be incorrectly parsed as namespace 'https' with key '//api.openai.com', resulting in truncated display. Setting nsSeparator to false fixes this issue since the project doesn't use multiple namespaces. --- web/src/i18n/i18n.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/i18n/i18n.js b/web/src/i18n/i18n.js index ac441470d..161d0a215 100644 --- a/web/src/i18n/i18n.js +++ b/web/src/i18n/i18n.js @@ -42,6 +42,7 @@ i18n vi: viTranslation, }, fallbackLng: 'zh', + nsSeparator: false, interpolation: { escapeValue: false, }, From 58db72d4590e03cec10d12fa59eec86946f44786 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:58:44 +0800 Subject: [PATCH 71/72] fix: Fix Openrouter test errors and optimize error messages (#2433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Refine openrouter error * fix: Refine openrouter error * fix: openrouter test max_output_token * fix: optimize messages * fix: maxToken unified to 16 * fix: codex系列模型使用 responses接口 * fix: codex系列模型使用 responses接口 * fix: 状态码非200打印错误信息 * fix: 日志里没有报错的响应体 --- .gitignore | 1 + controller/channel-test.go | 33 ++++++++++++++++++++++++++++----- dto/error.go | 1 + service/error.go | 14 +++++++++++++- types/error.go | 18 ++++++++++++++---- 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 640e5ec6a..133f59090 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ web/bun.lock electron/node_modules electron/dist data/ +.gomodcache/ \ No newline at end of file diff --git a/controller/channel-test.go b/controller/channel-test.go index 1c77fb030..f9657edbd 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -97,6 +97,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { requestPath = "/v1/images/generations" } + + // responses-only models + if strings.Contains(strings.ToLower(testModel), "codex") { + requestPath = "/v1/responses" + } } c.Request = &http.Request{ @@ -176,7 +181,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } - request := buildTestRequest(testModel, endpointType) + request := buildTestRequest(testModel, endpointType, channel) info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) @@ -319,6 +324,16 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) + common.SysError(fmt.Sprintf( + "channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v", + channel.Id, + channel.Name, + channel.Type, + testModel, + endpointType, + httpResp.StatusCode, + err, + )) return testResult{ context: c, localErr: err, @@ -389,7 +404,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } -func buildTestRequest(model string, endpointType string) dto.Request { +func buildTestRequest(model string, endpointType string, channel *model.Channel) dto.Request { // 根据端点类型构建不同的测试请求 if endpointType != "" { switch constant.EndpointType(endpointType) { @@ -423,7 +438,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { } case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: // 返回 GeneralOpenAIRequest - maxTokens := uint(10) + maxTokens := uint(16) if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { maxTokens = 3000 } @@ -453,6 +468,14 @@ func buildTestRequest(model string, endpointType string) dto.Request { } } + // Responses-only models (e.g. codex series) + if strings.Contains(strings.ToLower(model), "codex") { + return &dto.OpenAIResponsesRequest{ + Model: model, + Input: json.RawMessage("\"hi\""), + } + } + // Chat/Completion 请求 - 返回 GeneralOpenAIRequest testRequest := &dto.GeneralOpenAIRequest{ Model: model, @@ -466,7 +489,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { } if strings.HasPrefix(model, "o") { - testRequest.MaxCompletionTokens = 10 + testRequest.MaxCompletionTokens = 16 } else if strings.Contains(model, "thinking") { if !strings.Contains(model, "claude") { testRequest.MaxTokens = 50 @@ -474,7 +497,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { } else if strings.Contains(model, "gemini") { testRequest.MaxTokens = 3000 } else { - testRequest.MaxTokens = 10 + testRequest.MaxTokens = 16 } return testRequest diff --git a/dto/error.go b/dto/error.go index cf00d6772..78197765b 100644 --- a/dto/error.go +++ b/dto/error.go @@ -26,6 +26,7 @@ type GeneralErrorResponse struct { Msg string `json:"msg"` Err string `json:"err"` ErrorMsg string `json:"error_msg"` + Metadata json.RawMessage `json:"metadata,omitempty"` Header struct { Message string `json:"message"` } `json:"header"` diff --git a/service/error.go b/service/error.go index 9e517e85a..889964beb 100644 --- a/service/error.go +++ b/service/error.go @@ -90,11 +90,17 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai } CloseResponseBodyGracefully(resp) var errResponse dto.GeneralErrorResponse + buildErrWithBody := func(message string) error { + if message == "" { + return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) + } + return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody)) + } err = common.Unmarshal(responseBody, &errResponse) if err != nil { if showBodyWhenFail { - newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) + newApiErr.Err = buildErrWithBody("") } else { logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) @@ -107,10 +113,16 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai oaiError := errResponse.TryToOpenAIError() if oaiError != nil { newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + if showBodyWhenFail { + newApiErr.Err = buildErrWithBody(newApiErr.Error()) + } return } } newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + if showBodyWhenFail { + newApiErr.Err = buildErrWithBody(newApiErr.Error()) + } return } diff --git a/types/error.go b/types/error.go index 3bfd0399a..b060a9db6 100644 --- a/types/error.go +++ b/types/error.go @@ -1,6 +1,7 @@ package types import ( + "encoding/json" "errors" "fmt" "net/http" @@ -10,10 +11,11 @@ import ( ) type OpenAIError struct { - Message string `json:"message"` - Type string `json:"type"` - Param string `json:"param"` - Code any `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` + Metadata json.RawMessage `json:"metadata,omitempty"` } type ClaudeError struct { @@ -92,6 +94,7 @@ type NewAPIError struct { errorType ErrorType errorCode ErrorCode StatusCode int + Metadata json.RawMessage } // Unwrap enables errors.Is / errors.As to work with NewAPIError by exposing the underlying error. @@ -301,6 +304,13 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIError Err: errors.New(openAIError.Message), errorCode: ErrorCode(code), } + // OpenRouter + if len(openAIError.Metadata) > 0 { + openAIError.Message = fmt.Sprintf("%s (%s)", openAIError.Message, openAIError.Metadata) + e.Metadata = openAIError.Metadata + e.RelayError = openAIError + e.Err = errors.New(openAIError.Message) + } for _, op := range ops { op(e) } From 9aeef6abecc209d076cf357cd4dc6200524ed861 Mon Sep 17 00:00:00 2001 From: skynono Date: Fri, 26 Dec 2025 13:59:56 +0800 Subject: [PATCH 72/72] feat: support first bind update password (#2520) --- controller/user.go | 5 ++++- web/src/components/settings/PersonalSetting.jsx | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/controller/user.go b/controller/user.go index cc3049c61..1fc83c99e 100644 --- a/controller/user.go +++ b/controller/user.go @@ -763,7 +763,10 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int if err != nil { return } - if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) { + + // 密码不为空,需要验证原密码 + // 支持第一次账号绑定时原密码为空的情况 + if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) && currentUser.Password != "" { err = fmt.Errorf("原密码错误") return } diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 18d374801..6a889356d 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -314,10 +314,10 @@ const PersonalSetting = () => { }; const changePassword = async () => { - if (inputs.original_password === '') { - showError(t('请输入原密码!')); - return; - } + // if (inputs.original_password === '') { + // showError(t('请输入原密码!')); + // return; + // } if (inputs.set_new_password === '') { showError(t('请输入新密码!')); return;