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 e321ee0c..38eace06 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 f8a233e9..1049dc2d 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 55bd1402..03528897 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 bf35b0b1..130d1dde 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 4fd6956e..b11bea10 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 00000000..b1fd2c8e --- /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 c7348add..d737e3d9 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 a65da462..54f8baa3 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 6debce28..a5ac5d44 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 ff03c03d..f315e428 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 f4371797..48a607d5 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 1d0794d2..3850218c 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 fccb44af..5415e67f 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 425abb31..450c5799 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 04e31f8c..0f0ae7fd 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 a31f3416..7debb353 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 3b317bc1..cf54cb31 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 bd533db5..bef82e57 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 920041ce..c47eeccc 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 ff03c03d..11a846e2 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 f4371797..a39ab97c 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 809a2e93..b2db2b77 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 39d6d448..1b6fc692 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 489de227..8beb0f08 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 021a7803..c6b5bc18 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 b87e5a2f..6e09bf43 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 1b6fc692..096ea08c 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 ad034d61..16acc226 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 8c9f9719..86095307 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 a577cf81..f102baae 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 4e2e60e6..053ac651 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 7f8faf22..ff7606e2 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 32d5da39..eef69966 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 1bacb201..dd21fb75 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 0fa9dda4..16c6919b 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 cb6f1eeb..c77905bf 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 da4a1f8f..d6973531 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 c1bbd9d5..d0035065 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 17aec18f..214561b5 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 c4858d0c..f7c89172 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 d98ac53c..8ec77266 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 6b62f1f0..3657161c 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 61e2af52..ba9fe1e8 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 2fa9e51c..be89c73c 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 93fff238..beec76a7 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 e174fd60..ecccf4df 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 5a9deb23..3c8529d9 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 214561b5..9dc03796 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 ba9fe1e8..bac05e0e 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 87097cf8..d5fed1d7 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 530518d1..969977d1 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 57fbbbde..a142a0eb 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 3f279e13..efdb89a5 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 ed1df8a8..c10229e4 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 0e4786c6..b67f5529 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 92171a0c..eb13f610 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 8af562f7..39b80674 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 a0788563..d3441f3b 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 e27d8bcd..9f574f06 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 d2ac2f0b..a5bc896b 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 4de70461..ecc5178e 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 04e31f8c..832438e8 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 dc59df9a..b1fca471 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 c1fe2a67..a6a307ac 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 69731d4d..18cada8e 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 53f7d2c2..348b89e5 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 ebf0e243..c70c54a8 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 59a3894a..c7db40d6 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 348b89e5..b95aa025 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 4e092f9c..ce8eab80 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 3f279e13..2539c9d1 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 ed1df8a8..2e07dd1d 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 0e4786c6..f59ff604 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 92171a0c..ad85a9dd 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 8af562f7..85da47cc 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 a0788563..8215ba14 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 d737e3d9..aa43de1c 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 55bd1402..d2ac7566 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 342f47cf..d9e2ba9a 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 50ad9dab..2013b9c0 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 1882eca8..8bc47bb5 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 b95aa025..aea522d9 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 aea522d9..ab33bcd1 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 b1fca471..cefc4e06 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 8bc47bb5..40f79463 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 f25d9ebf..5f9ff7cd 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 3bb1c80c..17ce79d2 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 00000000..4fb4f7bb --- /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 87af3c22..4b5d63e4 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 1138c747..697a313d 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 481d0a60..8484257b 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 ecc5178e..833aabae 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 d9e2ba9a..501c4e15 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 2013b9c0..a0618452 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 cefc4e06..d2412004 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 3c8529d9..390dc059 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 ab33bcd1..afaf4f04 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 0f41b851..0da8dafd 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 1c92a250..f75f6819 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 c2409fc0..aa6c6e2b 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 79547671..cf00d677 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 e5edd0dd..e96feda6 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 070335ec..9e517e85 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 eca4ce00..efefea0e 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 ea51516f..c6f5b947 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 15fbb939..39eb03d3 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 00000000..b267dcfb --- /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 8c55ae7a..5819f707 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 60934505..f46ff9de 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 bef82e57..89e768a0 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 b267dcfb..877f5bb1 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 afaf4f04..a3710ef8 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 89e768a0..00e8ccff 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 1eb1079d..466cd2c7 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 466cd2c7..f0ad90db 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 f0ad90db..466cd2c7 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 bfb64ee7..0f2a41ff 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 6f7d289f..3cd5c2ea 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 0ffa128e..f63df857 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 d2412004..9bc2f042 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 a6a307ac..357d9bdd 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 c7db40d6..cc9f51b0 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 448559f8..188f9e69 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 a53d459c..b314f860 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 72d17e0b..b5767f66 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 85659818..046a84bf 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 6e8076c5..669cafec 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 273cc24f..304a13b0 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 9bc2f042..1396b2d5 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 1ee71a71..4d738c22 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 e71f5e62..063d360b 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 35051223..0aa212d1 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 0c4b91f6..e76cd0ed 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 3d5b6923..f1cb3748 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 db299f29..e927962c 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 3f3bd1df..ac27fd2c 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 975bced7..c561c207 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 41dd5980..a0865de5 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 a0618452..29fd209d 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 16acc226..244f9161 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 aab951c5..80a86967 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 390dc059..a3340472 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 7fe2f3be..e86d2fff 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 6323bb3b..888d96ee 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 8597e50e..691d4188 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 7095a8b6..2edeeee0 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 d6973531..91d3f236 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 d0035065..4c3c9d61 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 f7c89172..8ea9a1c7 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 bac05e0e..04a905c7 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 f4e763ff..f4789060 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 e927962c..95996b61 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 29fd209d..9759fa30 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 e86d2fff..5e568253 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 9c12034e..3bfd0399 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 b314f860..2487dca8 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 e8b8212d..d8616d2d 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 f75a9214..db5ea489 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 4cc74b61..da3bdc7d 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 b1d2545f..e5cf6643 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 4fb4f7bb..9891bac1 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 f43f7b21..c059777d 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 e71f5e62..f53c9742 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 35051223..362a8b65 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 0c4b91f6..258280f0 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 3d5b6923..8210babe 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 9891bac1..739f1d11 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 fdec073e..f9a60229 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 5063c607..9ac8e266 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 d57fd9d9..73e46212 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 c059777d..ea9061fb 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 f53c9742..d9a924fc 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 362a8b65..632d1ce9 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 258280f0..77efe796 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 8210babe..e0c371ec 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 739f1d11..b798f2c7 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 a7f741b2..f256b54c 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 ac27fd2c..5c49da31 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 95996b61..5a066bc1 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 f46ff9de..d92c990a 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 c3cde5d3..640e5ec6 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 40f79463..1b9762fe 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 af13341b..6041b765 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 55f721e9..30d56e34 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 768e1070..d498a321 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 188f9e69..4de68404 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 b314f860..d05cdf56 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 b5767f66..2b3ea9f0 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 046a84bf..76616cbd 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 669cafec..556501da 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 304a13b0..a8d28acc 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 e75a4ca9..75b0f024 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 c47eeccc..481524e4 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 6041b765..af13341b 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 063d360b..d2e0947d 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 0aa212d1..1ca3de86 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 e76cd0ed..68adbebe 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 f1cb3748..4f87e9e4 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 d2e0947d..1e5ae975 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 1ca3de86..a3a02317 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 68adbebe..cae2f192 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 4f87e9e4..3ef081bb 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 5819f707..ac44312e 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 357d9bdd..7c629b33 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 b815a69f..d3986236 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 beec76a7..7549b569 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 5b505bae..643f3ffe 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 466f0415..fa785095 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 f3f99f01..f3df1bcc 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 4de68404..fb34544a 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 d05cdf56..05fe5374 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 2b3ea9f0..22e7606d 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 76616cbd..d71e12d1 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 556501da..51113ff4 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 a8d28acc..35ec62ba 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 1396b2d5..a3b41b18 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 0da8dafd..23ae60c1 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 00e8ccff..df823516 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 ef4f0ddc..cc3049c6 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 38eace06..b2ac99da 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 7549b569..7228db9a 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 ac441470..161d0a21 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 640e5ec6..133f5909 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 1c77fb03..f9657edb 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 cf00d677..78197765 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 9e517e85..889964be 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 3bfd0399..b060a9db 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 cc3049c6..1fc83c99 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 18d37480..6a889356 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;