From 5f5ad45d8c6f1202b531c1df0a1af3d6514274f8 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 e321ee0c57..38eace06eb 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 ac8b40a2769711e37693cb583222d01c89d56c67 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 f8a233e995..1049dc2de8 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 601d257b807c3708d729418c065e237bc522189a 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 55bd1402ca..035288978c 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 634651b463bbc81808dd876fd270c0f68bc6ffb8 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 bf35b0b120..130d1dde81 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 4fd6956ebf..b11bea105f 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 0000000000..b1fd2c8e34 --- /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 2430a8e036ac0a8e6d6e6efa4c5ae9c34bc00043 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 c7348add80..d737e3d93d 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 1dd8c0f569ad6eba371cf06e2871350f61409a6d 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 a65da462e2..54f8baa342 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 6debce28b7..a5ac5d447e 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 ff03c03d3b..f315e428ff 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 f437179733..48a607d54e 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 1d0794d263..3850218c31 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 41b1499d41d365d146ccbcd3669d89e5e13c9af8 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 fccb44af08..5415e67f3d 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 e030ac827a96865b5c206971eba9bc1b3e33dbea 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 425abb318a..450c5799b4 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 6680517aeff9253722e834829796fa12c642b21e 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 04e31f8c14..0f0ae7fdff 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 a6558010178608fbe3c681d2efc9df6e74f7bd98 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 a31f341620..7debb353ea 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 3b317bc18d..cf54cb3139 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 bd533db5c8..bef82e57ee 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 06c23ea562fb4408e080a15b3abab055d7d7a2f5 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 920041ce6f..c47eeccc11 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 0be0b3650349084d2777d5f4cc3effdfb1a5423f 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 ff03c03d3b..11a846e2f1 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 f437179733..a39ab97ce6 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 0d23d599b8ebc060a8279d2beb8774bdfc0be245 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 809a2e932a..b2db2b777b 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 063597c19114800d03ed9fd9aba7a14d4764701f 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 39d6d44894..1b6fc69247 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 489de22762..8beb0f0853 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 021a7803d0..c6b5bc183a 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 b87e5a2f82..6e09bf43ca 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 1ebbf5171f5eaf5238bdde602d1bb85d9f559af8 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 1b6fc69247..096ea08ccc 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 ad034d61e4..16acc22692 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 8c9f9719e6..86095307d0 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 a577cf8199..f102baae4a 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 4e2e60e62f..053ac65155 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 7f8faf22d8..ff7606e2e2 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 32d5da3980..eef699665f 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 1bacb20192..dd21fb75a5 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 0fa9dda4b5..16c6919b7c 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 cb6f1eebda..c77905bfb2 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 da4a1f8fe0..d6973531ff 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 c1bbd9d590..d003506527 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 17aec18f08..214561b5b0 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 c4858d0c0f..f7c8917236 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 d98ac53cfc..8ec77266e9 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 6b62f1f018..3657161c02 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 61e2af5230..ba9fe1e8f4 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 2fa9e51cf6..be89c73c09 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 ede2e0e94e2bb5d66ad242879faaf16bac308e43 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 93fff2386d..beec76a794 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 ee53a7b6bf875778595b0437a5208d65e7e0905c 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 e174fd60e0..ecccf4dfe1 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 5a9deb23cd..3c8529d96b 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 214561b5b0..9dc03796c9 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 ba9fe1e8f4..bac05e0ee7 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 87097cf865..d5fed1d786 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 530518d182..969977d174 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 57fbbbde59..a142a0eb57 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 3f279e13a7..efdb89a597 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 ed1df8a838..c10229e480 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 0e4786c680..b67f5529e7 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 92171a0c32..eb13f61063 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 8af562f7ae..39b80674d8 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 a07885638a..d3441f3b53 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -543,6 +543,7 @@ "参数值": "参数值", "参数覆盖": "参数覆盖", "参照生视频": "参照生视频", + "视频Remix": "视频 Remix", "友情链接": "友情链接", "发布日期": "发布日期", "发布时间": "发布时间", From 8279be2380c54df0d6a4f3c22e656636b379caea 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 e27d8bcd17..9f574f06e5 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 85ecad90a75b0df8ccd0fa3b64158e226efaeb82 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 d2ac2f0bb6..a5bc896b25 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 c87deaa7d9093250d2b8aa12d8c4d28051e8d466 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 4de7046196..ecc5178ee4 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 04e31f8c14..832438e83f 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 dc59df9aff..b1fca4712a 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 c1fe2a6700..a6a307ac26 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 69731d4d21..18cada8e00 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 53f7d2c2ae..348b89e55a 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 ebf0e243dd..c70c54a88a 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 59a3894aff..c7db40d668 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 348b89e55a..b95aa025bd 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 4e092f9cca..ce8eab807e 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 3f279e13a7..2539c9d1cd 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 ed1df8a838..2e07dd1d32 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 0e4786c680..f59ff6042d 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 92171a0c32..ad85a9dd90 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 8af562f7ae..85da47cc16 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 a07885638a..8215ba145e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2202,6 +2202,9 @@ "默认用户消息": "你好", "默认助手消息": "你好!有什么我可以帮助你的吗?", "可选,用于复现结果": "可选,用于复现结果", - "随机种子 (留空为随机)": "随机种子 (留空为随机)" + "随机种子 (留空为随机)": "随机种子 (留空为随机)", + "跨分组重试": "跨分组重试", + "跨分组": "跨分组", + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道" } } From ae30b4d15fda36af2334f1220fef50c056ae03a9 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 d737e3d93d..aa43de1c96 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 27dd42718b35cd5383508d17b69f1f339875e8c5 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 55bd1402ca..d2ac756641 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 413968a0fd55411fc122684f99f58b220d015d7c 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 342f47cf0e..d9e2ba9a18 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 50ad9dabb9..2013b9c0f1 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 1882eca894..8bc47bb52c 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 b95aa025bd..aea522d960 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 51f3a936e46c62061f717bd0163522fa087e895a 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 aea522d960..ab33bcd19a 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 e0a79e853d012fbad3ac3be2df92b3e0d64d5998 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 b1fca4712a..cefc4e0689 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 8bc47bb52c..40f79463f9 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 7d586ef507c5b184e63014ca836e4b8e81c1d4ae 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 f25d9ebf03..5f9ff7cdfe 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 3bb1c80c91..17ce79d2a1 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 bdfc87577518cb88f7e392c56fefe1370d791270 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 0000000000..4fb4f7bb79 --- /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 87af3c22eb..4b5d63e493 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 1138c747aa..697a313d8b 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 481d0a6002..8484257bf1 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 6175f254a2a6f285afa2b61c4b9725a6e95f5c52 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 ecc5178ee4..833aabae10 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 d9e2ba9a18..501c4e1565 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 2013b9c0f1..a0618452cc 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 cefc4e0689..d241200420 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 3c8529d96b..390dc059ff 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 ab33bcd19a..afaf4f04ef 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 0f41b851bf..0da8dafd36 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 a1299114a65c2bff55d958782bcd8ac162daef8e 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 1c92a25070..f75f681984 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 c2409fc007..aa6c6e2b9d 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 79547671bf..cf00d6772a 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 e5edd0ddf7..e96feda6bc 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 070335ec6a..9e517e85ac 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 29565c837fa8d1b84ac89af6f197ed4f458f6d8a 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 eca4ce002a..efefea0eba 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 be2a863b9b1b0dcb013c2a585b0304b1a030bb8d 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 ea51516f84..c6f5b94797 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 15fbb9390a..39eb03d39c 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 0000000000..b267dcfbb2 --- /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 8c55ae7a72..5819f70710 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 60934505df..f46ff9de9a 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 bef82e57ee..89e768a055 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 3822f4577c810afbc1332dfb187bd62d63925ecd 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 b267dcfbb2..877f5bb1cc 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 284ce42c88b86a7f495b6743ffb9b926dab7a75c 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 afaf4f04ef..a3710ef8ce 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 06d1bd404b2e0a42b4db9bf1f9a26a26b971acb7 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 89e768a055..00e8ccffab 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 da0d3ea93c048f2c459db29747261a3e2a64e6e2 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 1eb1079de0..466cd2c797 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 2a980bbcf579901c8eead829a8afa1d493eb70a3 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 466cd2c797..f0ad90db1f 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 1bd0d8de02d207311a89e4c49375dcf2c21ac4d3 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 2a980bbcf579901c8eead829a8afa1d493eb70a3. --- common/audio.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/audio.go b/common/audio.go index f0ad90db1f..466cd2c797 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 947a763a1a52f0da53e22b6d85492e8c1182b51b 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 bfb64ee7f8..0f2a41ffdf 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 6f7d289f1a..3cd5c2ea1f 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 0ffa128e79..f63df857b5 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 d241200420..9bc2f04280 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 a6a307ac26..357d9bdd23 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 c7db40d668..cc9f51b0ed 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 448559f82d..188f9e6937 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 a53d459c98..b314f86083 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 72d17e0b99..b5767f6628 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 85659818ee..046a84bffe 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 6e8076c56a..669cafec69 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 273cc24f24..304a13b034 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 692b5ff5ac334d90dc43250fd7496a27f6ea8d79 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 9bc2f04280..1396b2d5a5 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 3eee8c7a21e012cde776e768fd71511fd8ed0a87 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 1ee71a719c..4d738c22a5 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 c2ed76ddfda1b0a32c48bc281f449f252214d566 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 e71f5e6232..063d360b25 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 35051223ec..0aa212d1fe 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 0c4b91f66a..e76cd0ed4f 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 3d5b6923c4..f1cb37480d 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 db299f2938..e927962cfb 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 3f3bd1df4e..ac27fd2c2a 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 975bced7c1..c561c207df 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 41dd59808a..a0865de510 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 a0618452cc..29fd209d2a 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 16acc22692..244f9161c2 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 aab951c54a..80a869673b 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 390dc059ff..a33404726b 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 7fe2f3be30..e86d2fffca 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 6323bb3b13..888d96eefa 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 8597e50efd..691d418886 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 7095a8b6d1..2edeeee0d9 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 d6973531ff..91d3f23617 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 d003506527..4c3c9d61bb 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 f7c8917236..8ea9a1c7fe 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 bac05e0ee7..04a905c7f1 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 f4e763ffad..f4789060b4 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 fa814b80fe62ad269dc2132bce766219ea4260b7 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 e927962cfb..95996b6199 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 29fd209d2a..9759fa30ce 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 e86d2fffca..5e5682532f 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 9c12034e1e..3bfd0399a6 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 5aaf00664200d29438e55b836af2b17b6ac43037 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 b314f86083..2487dca836 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 39df47486c547ecad4b09da339e0240e56ab9816 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 e8b8212d68..d8616d2d9d 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 f75a921404..db5ea489c6 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 4cc74b612a..da3bdc7d3e 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 530b3eff110206b2560f69e1b04d794c57df6068 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 b1d2545f28..e5cf66434c 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 b49bb48ed1adca1dfcbbb5693a50ac1b85915b82 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 4fb4f7bb79..9891bac1c6 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 fb0ffe8c95004bb167d54f56dfa9febf051f1d8c 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 f43f7b2112..c059777d37 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 e71f5e6232..f53c9742a7 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 35051223ec..362a8b6527 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 0c4b91f66a..258280f072 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 3d5b6923c4..8210babe86 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 9891bac1c6..739f1d116b 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 c06a216a14954e9c74ab2a8b85d934eac74703c7 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 fdec073ec7..f9a60229b3 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 5063c60732..9ac8e266eb 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 d57fd9d9b7..73e46212a5 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 a78fd2dae66c60edcae8f433bc4f8fb94531b045 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 c059777d37..ea9061fb3f 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 f53c9742a7..d9a924fcbf 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 362a8b6527..632d1ce908 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 258280f072..77efe796ee 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 8210babe86..e0c371ecd0 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 739f1d116b..b798f2c774 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 f2d2b6e7fcd10bf6ea7bcd415dfc0c78fc53b17c 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 a7f741b21f..f256b54ce3 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 3523acfc2c8ba2c6825df47561e442e59a94bb10 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 ac27fd2c2a..5c49da3169 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 c2a619349730296bc7ee2847250c2b50d224bce2 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 95996b6199..5a066bc162 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 6e3bc06fa655a1802326e1ed6df4f3daa54efb9e 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 f46ff9de9a..d92c990a78 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 219b13af70e7242a3c821e54590d055505cefcb3 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 c3cde5d390..640e5ec6a0 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 40f79463f9..1b9762fec5 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 af13341bf0..6041b765a5 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 55f721e954..30d56e345d 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 768e107095..d498a32126 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 188f9e6937..4de684048c 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 b314f86083..d05cdf5698 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 b5767f6628..2b3ea9f025 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 046a84bffe..76616cbdbb 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 669cafec69..556501da2f 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 304a13b034..a8d28acca1 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 e75a4ca910..75b0f0242a 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 c47eeccc11..481524e49f 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 6041b765a5..af13341bf0 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 93b3cfc0f780a45495da379683c9c1a11942eaeb 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 063d360b25..d2e0947d2a 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 0aa212d1fe..1ca3de8639 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 e76cd0ed4f..68adbebec7 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 f1cb37480d..4f87e9e4e8 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 1f6527e91a635e7a5599e1bd8b6fe2172a29f626 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 d2e0947d2a..1e5ae97516 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 1ca3de8639..a3a02317f8 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 68adbebec7..cae2f192d3 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 4f87e9e4e8..3ef081bb4e 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 6dbe89f1cf5440b7eb6f35e37c196754d04a3b28 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 5819f70710..ac44312eb0 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 d488a19ed78c03283463bf3beef278b6d2d67f69 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 357d9bdd23..7c629b33ce 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 1dc7ab9a97098804b3fe4d72dbe5f97817d6d373 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 b815a69fbb..d3986236aa 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 2504e9ad0439111d20cc5c35fe57a24cdee258e9 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 beec76a794..7549b569dd 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 783e7877c289b80982cc3b261e3ca41a8f053c63 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 5b505baede..643f3ffe69 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 466f041554..fa7850959f 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 f3f99f01eb..f3df1bcca5 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 4de684048c..fb34544a44 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 d05cdf5698..05fe5374af 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 2b3ea9f025..22e7606dd3 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 76616cbdbb..d71e12d1b2 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 556501da2f..51113ff445 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 a8d28acca1..35ec62ba1c 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 7e1ad4bdffef1280456040d98e762f5575294ce5 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 1396b2d5a5..a3b41b186c 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 0da8dafd36..23ae60c1f0 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 0edef974134d94b3765bab1cf25da874b7e04cc7 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 00e8ccffab..df823516a1 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 ceb7ebe5cde8fb5d554feec98f32b16cd5ac85f6 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 ef4f0ddc0b..cc3049c61d 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 f45e707d8edd4efa9d2570e0a41ebd8699d196f7 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 38eace06eb..b2ac99da87 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 7549b569dd..7228db9a92 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 4a8bdb148323103a4893aabf36906a924771692e 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 ac441470d3..161d0a2156 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 2570788b46b56ef70df36672239c4524f0656318 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 640e5ec6a0..133f59090a 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 1c77fb030e..f9657edbd9 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 cf00d6772a..78197765b4 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 9e517e85ac..889964beb7 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 3bfd0399a6..b060a9db6e 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 7379b68f9f4b1c7f476fca3a155079d97cb8bd0e 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 cc3049c61d..1fc83c99e7 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 18d3748019..6a889356de 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;