From bc322ddac4bfbebc055bae600ceed156c86357a4 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Tue, 29 Apr 2025 22:54:43 +0800 Subject: [PATCH 001/191] refactor: optimize Dockerfile apk usage --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 214ceaa3..3b42089b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,7 @@ RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one- FROM alpine -RUN apk update \ - && apk upgrade \ +RUN apk upgrade --no-cache \ && apk add --no-cache ca-certificates tzdata ffmpeg \ && update-ca-certificates From 69420f713f9aa6f2390ac6592d39905830df0246 Mon Sep 17 00:00:00 2001 From: JoeyLearnsToCode Date: Mon, 19 May 2025 19:33:29 +0800 Subject: [PATCH 002/191] =?UTF-8?q?feat:=20=E6=B8=A0=E9=81=93=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5=E5=A2=9E=E5=8A=A0=E5=A4=8D=E5=88=B6=E6=89=80?= =?UTF-8?q?=E6=9C=89=E6=A8=A1=E5=9E=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/locales/en.json | 1 + web/src/pages/Channel/EditChannel.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 916329e7..0f77dbb9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -408,6 +408,7 @@ "填入基础模型": "Fill in the basic model", "填入所有模型": "Fill in all models", "清除所有模型": "Clear all models", + "复制所有模型": "Copy all models", "密钥": "Key", "请输入密钥": "Please enter the key", "批量创建": "Batch Create", diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index f7fab057..e19f1cd2 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -29,6 +29,7 @@ import { } from '@douyinfe/semi-ui'; import { getChannelModels, loadChannelModels } from '../../components/utils.js'; import { IconHelpCircle } from '@douyinfe/semi-icons'; +import { copy } from '../../helpers'; const MODEL_MAPPING_EXAMPLE = { 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125', @@ -873,7 +874,7 @@ const EditChannel = (props) => { optionList={modelOptions} />
- + + Date: Thu, 22 May 2025 13:58:05 +0800 Subject: [PATCH 003/191] =?UTF-8?q?feat:=20=E7=81=AB=E5=B1=B1=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=A2=9E=E5=8A=A0=E6=96=87=E7=94=9F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/dalle.go | 1 + relay/relay-image.go | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/dto/dalle.go b/dto/dalle.go index a1309b6c..ce2f6361 100644 --- a/dto/dalle.go +++ b/dto/dalle.go @@ -15,6 +15,7 @@ type ImageRequest struct { Background string `json:"background,omitempty"` Moderation string `json:"moderation,omitempty"` OutputFormat string `json:"output_format,omitempty"` + Watermark *bool `json:"watermark,omitempty"` } type ImageResponse struct { diff --git a/relay/relay-image.go b/relay/relay-image.go index daed3d80..9b1515c4 100644 --- a/relay/relay-image.go +++ b/relay/relay-image.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "one-api/relay/constant" ) func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.ImageRequest, error) { @@ -41,6 +42,11 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. imageRequest.Quality = "standard" } } + + if info.ApiType == constant.APITypeVolcEngine { + watermark := formData.Has("watermark") + imageRequest.Watermark = &watermark + } default: err := common.UnmarshalBodyReusable(c, imageRequest) if err != nil { From 6be78ff283310f2bf5e7c5c9a2879a6afd40754d Mon Sep 17 00:00:00 2001 From: "Adam.Wang" Date: Fri, 23 May 2025 16:42:53 +0800 Subject: [PATCH 004/191] =?UTF-8?q?feat:=20=E7=81=AB=E5=B1=B1=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=A2=9E=E5=8A=A0=E6=96=87=E7=94=9F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/volcengine/adaptor.go | 150 +++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 2 deletions(-) diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index a4a48ee9..78233934 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -1,15 +1,19 @@ package volcengine import ( + "bytes" "errors" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "one-api/dto" "one-api/relay/channel" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" "one-api/relay/constant" + "path/filepath" "strings" "github.com/gin-gonic/gin" @@ -30,8 +34,146 @@ 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") + switch info.RelayMode { + case constant.RelayModeImagesEdits: + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + // 获取所有表单字段 + formData := c.Request.PostForm + // 遍历表单字段并打印输出 + for key, values := range formData { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + + // Parse the multipart form to handle both single image and multiple images + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory + return nil, errors.New("failed to parse multipart form") + } + + if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil { + // Check if "image" field exists in any form, including array notation + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 { + // If still not found, iterate through all fields to find any that start with "image[" + foundArrayImages := false + for fieldName, files := range c.Request.MultipartForm.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + for _, file := range files { + imageFiles = append(imageFiles, file) + } + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + // Process all image files + for i, fileHeader := range imageFiles { + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + } + defer file.Close() + + // If multiple images, use image[] as the field name + fieldName := "image" + if len(imageFiles) > 1 { + fieldName = "image[]" + } + + // Determine MIME type based on file extension + mimeType := detectImageMimeType(fileHeader.Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + } + + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) + } + } + + // Handle mask file if present + if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 { + maskFile, err := maskFiles[0].Open() + if err != nil { + return nil, errors.New("failed to open mask file") + } + defer maskFile.Close() + + // Determine MIME type for mask file + mimeType := detectImageMimeType(maskFiles[0].Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) + h.Set("Content-Type", mimeType) + + maskPart, err := writer.CreatePart(h) + if err != nil { + return nil, errors.New("create form file failed for mask") + } + + if _, err := io.Copy(maskPart, maskFile); err != nil { + return nil, errors.New("copy mask file failed") + } + } + } else { + return nil, errors.New("no multipart form data found") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return bytes.NewReader(requestBody.Bytes()), nil + + default: + return request, nil + } +} + +// detectImageMimeType determines the MIME type based on the file extension +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + default: + // Try to detect from extension if possible + if strings.HasPrefix(ext, ".jp") { + return "image/jpeg" + } + // Default to png as a fallback + return "image/png" + } } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -46,6 +188,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/api/v3/chat/completions", info.BaseUrl), nil case constant.RelayModeEmbeddings: return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil default: } return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) @@ -91,6 +235,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom } case constant.RelayModeEmbeddings: err, usage = openai.OpenaiHandler(c, resp, info) + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + err, usage = openai.OpenaiHandlerWithUsage(c, resp, info) } return } From 66778efcc5599191c200ee723f49ba0be2af5b83 Mon Sep 17 00:00:00 2001 From: neotf <10400594+neotf@users.noreply.github.com> Date: Thu, 29 May 2025 00:49:21 +0800 Subject: [PATCH 005/191] feat: enhance token usage details for upstream OpenRouter --- dto/openai_request.go | 76 +++++++++++++++++---------------- relay/channel/openai/adaptor.go | 3 ++ service/convert.go | 9 ++-- 3 files changed, 48 insertions(+), 40 deletions(-) diff --git a/dto/openai_request.go b/dto/openai_request.go index bda1bb17..9e3a41ac 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -18,43 +18,45 @@ type FormatJsonSchema struct { } type GeneralOpenAIRequest struct { - Model string `json:"model,omitempty"` - Messages []Message `json:"messages,omitempty"` - Prompt any `json:"prompt,omitempty"` - Prefix any `json:"prefix,omitempty"` - Suffix any `json:"suffix,omitempty"` - Stream bool `json:"stream,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - MaxTokens uint `json:"max_tokens,omitempty"` - MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - //Reasoning json.RawMessage `json:"reasoning,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` - TopK int `json:"top_k,omitempty"` - Stop any `json:"stop,omitempty"` - N int `json:"n,omitempty"` - Input any `json:"input,omitempty"` - Instruction string `json:"instruction,omitempty"` - Size string `json:"size,omitempty"` - Functions any `json:"functions,omitempty"` - FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` - PresencePenalty float64 `json:"presence_penalty,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - EncodingFormat any `json:"encoding_format,omitempty"` - Seed float64 `json:"seed,omitempty"` - ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` - Tools []ToolCallRequest `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - User string `json:"user,omitempty"` - LogProbs bool `json:"logprobs,omitempty"` - TopLogProbs int `json:"top_logprobs,omitempty"` - Dimensions int `json:"dimensions,omitempty"` - Modalities any `json:"modalities,omitempty"` - Audio any `json:"audio,omitempty"` - EnableThinking any `json:"enable_thinking,omitempty"` // ali - ExtraBody any `json:"extra_body,omitempty"` - WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Prompt any `json:"prompt,omitempty"` + Prefix any `json:"prefix,omitempty"` + Suffix any `json:"suffix,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stop any `json:"stop,omitempty"` + N int `json:"n,omitempty"` + Input any `json:"input,omitempty"` + Instruction string `json:"instruction,omitempty"` + Size string `json:"size,omitempty"` + Functions any `json:"functions,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + EncodingFormat any `json:"encoding_format,omitempty"` + Seed float64 `json:"seed,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` + Tools []ToolCallRequest `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + User string `json:"user,omitempty"` + LogProbs bool `json:"logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + Modalities any `json:"modalities,omitempty"` + Audio any `json:"audio,omitempty"` + EnableThinking any `json:"enable_thinking,omitempty"` // ali + ExtraBody any `json:"extra_body,omitempty"` + WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` + // OpenRouter Params + Usage json.RawMessage `json:"usage,omitempty"` + Reasoning json.RawMessage `json:"reasoning,omitempty"` } type ToolCallRequest struct { diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f0cf073f..cef958b2 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -152,6 +152,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if info.ChannelType != common.ChannelTypeOpenAI && info.ChannelType != common.ChannelTypeAzure { request.StreamOptions = nil } + if info.ChannelType == common.ChannelTypeOpenRouter { + request.Usage = json.RawMessage("{\"include\": true}") + } if strings.HasPrefix(request.Model, "o") { if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { request.MaxCompletionTokens = request.MaxTokens diff --git a/service/convert.go b/service/convert.go index cc462b40..67e77903 100644 --- a/service/convert.go +++ b/service/convert.go @@ -246,12 +246,15 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } if info.Done { claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - if info.ClaudeConvertInfo.Usage != nil { + oaiUsage := info.ClaudeConvertInfo.Usage + if oaiUsage != nil { claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Type: "message_delta", Usage: &dto.ClaudeUsage{ - InputTokens: info.ClaudeConvertInfo.Usage.PromptTokens, - OutputTokens: info.ClaudeConvertInfo.Usage.CompletionTokens, + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, }, Delta: &dto.ClaudeMediaMessage{ StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), From 3d9587f128a20b786c464a9f77ace143f4f426d8 Mon Sep 17 00:00:00 2001 From: neotf <10400594+neotf@users.noreply.github.com> Date: Thu, 29 May 2025 22:24:29 +0800 Subject: [PATCH 006/191] feat: enhance cache_create_tokens calculation for OpenRouter --- dto/openai_response.go | 2 ++ service/quota.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/dto/openai_response.go b/dto/openai_response.go index 790d4df8..fb4aeb4c 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -178,6 +178,8 @@ type Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + // OpenRouter Params + Cost float64 `json:"cost,omitempty"` } type InputTokenDetails struct { diff --git a/service/quota.go b/service/quota.go index 0d11b4a0..43297b4a 100644 --- a/service/quota.go +++ b/service/quota.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "math" "one-api/common" constant2 "one-api/constant" "one-api/dto" @@ -214,6 +215,11 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, cacheCreationRatio := priceData.CacheCreationRatio cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + if relayInfo.ChannelType == common.ChannelTypeOpenRouter && priceData.CacheCreationRatio != 1 { + cacheCreationTokens = CalcOpenRouterCacheCreateTokens(*usage, priceData) + promptTokens = promptTokens - cacheCreationTokens - cacheTokens + } + calculateQuota := 0.0 if !priceData.UsePrice { calculateQuota = float64(promptTokens) @@ -261,6 +267,27 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) } +func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData helper.PriceData) int { + if priceData.CacheCreationRatio == 1 { + return 0 + } + quotaPrice := priceData.ModelRatio / common.QuotaPerUnit + promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio + promptCacheReadPrice := quotaPrice * priceData.CacheRatio + completionPrice := quotaPrice * priceData.CompletionRatio + + cost := usage.Cost + totalPromptTokens := float64(usage.PromptTokens) + completionTokens := float64(usage.CompletionTokens) + promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens) + + return int(math.Round((cost - + totalPromptTokens*quotaPrice + + promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) - + completionTokens*completionPrice) / + (promptCacheCreatePrice - quotaPrice))) +} + func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) { From 4a313a5f93d09698768f3eeaeee4998147682b6e Mon Sep 17 00:00:00 2001 From: skynono <6811626@qq.com> Date: Thu, 5 Jun 2025 17:31:15 +0800 Subject: [PATCH 007/191] feat: add moonshot(kimi) update balance --- controller/channel-billing.go | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 2bda0fd2..9bf5d1fe 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -4,11 +4,13 @@ import ( "encoding/json" "errors" "fmt" + "github.com/shopspring/decimal" "io" "net/http" "one-api/common" "one-api/model" "one-api/service" + "one-api/setting" "strconv" "time" @@ -304,6 +306,40 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { return balance, nil } +func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { + url := "https://api.moonshot.cn/v1/users/me/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + + type MoonshotBalanceData struct { + AvailableBalance float64 `json:"available_balance"` + VoucherBalance float64 `json:"voucher_balance"` + CashBalance float64 `json:"cash_balance"` + } + + type MoonshotBalanceResponse struct { + Code int `json:"code"` + Data MoonshotBalanceData `json:"data"` + Scode string `json:"scode"` + Status bool `json:"status"` + } + + response := MoonshotBalanceResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Status || response.Code != 0 { + return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) + } + availableBalanceCny := response.Data.AvailableBalance + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64() + channel.UpdateBalance(availableBalanceUsd) + return availableBalanceUsd, nil +} + func updateChannelBalance(channel *model.Channel) (float64, error) { baseURL := common.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() == "" { @@ -332,6 +368,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { return updateChannelDeepSeekBalance(channel) case common.ChannelTypeOpenRouter: return updateChannelOpenRouterBalance(channel) + case common.ChannelTypeMoonshot: + return updateChannelMoonshotBalance(channel) default: return 0, errors.New("尚未实现") } From c4f25a77d1af97998f66f5cc7f1c3942994135ec Mon Sep 17 00:00:00 2001 From: neotf Date: Wed, 11 Jun 2025 13:56:44 +0800 Subject: [PATCH 008/191] format --- dto/openai_request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dto/openai_request.go b/dto/openai_request.go index a51dffd8..50dee203 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -56,7 +56,7 @@ type GeneralOpenAIRequest struct { ExtraBody json.RawMessage `json:"extra_body,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` // OpenRouter Params - Usage json.RawMessage `json:"usage,omitempty"` + Usage json.RawMessage `json:"usage,omitempty"`  Reasoning json.RawMessage `json:"reasoning,omitempty"` } From d67d5d800671c9087e245383cab7c180a2b3c821 Mon Sep 17 00:00:00 2001 From: neotf Date: Wed, 11 Jun 2025 14:00:32 +0800 Subject: [PATCH 009/191] format --- dto/openai_request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dto/openai_request.go b/dto/openai_request.go index 50dee203..10e10332 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -56,7 +56,7 @@ type GeneralOpenAIRequest struct { ExtraBody json.RawMessage `json:"extra_body,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` // OpenRouter Params - Usage json.RawMessage `json:"usage,omitempty"`  + Usage json.RawMessage `json:"usage,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"` } From 856465ae59a8c1b19e39db211e53036b3eddc9b0 Mon Sep 17 00:00:00 2001 From: a37836323 <37836323@qq.com> Date: Wed, 11 Jun 2025 22:11:47 +0800 Subject: [PATCH 010/191] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAzure=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E5=AF=B9responses=20API=E7=9A=84=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E6=94=AF=E6=8C=81=20-=20=E4=B8=BAAzure=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=B7=BB=E5=8A=A0=E5=AF=B9responses=20API=E7=9A=84?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E5=A4=84=E7=90=86=20-=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=BE=AE=E8=BD=AF=E6=96=B0=E7=9A=84API=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8preview=E7=89=88=E6=9C=AC=E7=9A=84ap?= =?UTF-8?q?i-version=20-=20=E4=BF=AE=E5=A4=8D=E4=BA=86Azure=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=97=A0=E6=B3=95=E6=AD=A3=E7=A1=AE=E5=A4=84=E7=90=86?= =?UTF-8?q?responses=E8=AF=B7=E6=B1=82=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/openai/adaptor.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f0cf073f..8358f3e2 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -88,6 +88,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { requestURL := strings.Split(info.RequestURLPath, "?")[0] requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion) task := strings.TrimPrefix(requestURL, "/v1/") + + // 特殊处理 responses API + if info.RelayMode == constant.RelayModeResponses { + requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview") + return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil + } + model_ := info.UpstreamModelName // 2025年5月10日后创建的渠道不移除. if info.ChannelCreateTime < constant2.AzureNoRemoveDotTime { From 21edb750810cf9c5073e7b1dcd16938e5dcf937e Mon Sep 17 00:00:00 2001 From: skynono Date: Thu, 12 Jun 2025 17:12:54 +0800 Subject: [PATCH 011/191] fix: enabled hot reload SyncOptions --- main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index c286650f..30ba8092 100644 --- a/main.go +++ b/main.go @@ -105,10 +105,12 @@ func main() { model.InitChannelCache() }() - go model.SyncOptions(common.SyncFrequency) go model.SyncChannelCache(common.SyncFrequency) } + // 热更新配置 + go model.SyncOptions(common.SyncFrequency) + // 数据看板 go model.UpdateQuotaData() From 1ec2bbd533f8f4d95c700c358aff2d613b291bac Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 12 Jun 2025 18:20:58 +0800 Subject: [PATCH 012/191] update input range and description for thinking adapter budget tokens --- web/src/pages/Setting/Model/SettingGeminiModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Setting/Model/SettingGeminiModel.js b/web/src/pages/Setting/Model/SettingGeminiModel.js index b802af1a..6a4d941a 100644 --- a/web/src/pages/Setting/Model/SettingGeminiModel.js +++ b/web/src/pages/Setting/Model/SettingGeminiModel.js @@ -208,8 +208,8 @@ export default function SettingGeminiModel(props) { label={t('请求模型带-thinking后缀的BudgetTokens数(超出24576的部分将被忽略)')} field={'gemini.thinking_adapter_budget_tokens_percentage'} initValue={''} - extraText={t('0.1-1之间的小数')} - min={0.1} + extraText={t('0.002-1之间的小数')} + min={0.002} max={1} onChange={(value) => setInputs({ From e7353772184b31d13758504574758f0e8dead6f6 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sun, 15 Jun 2025 21:12:56 +0800 Subject: [PATCH 013/191] feat: implement thinking budget control in model name --- relay/channel/gemini/adaptor.go | 7 ++- relay/channel/gemini/relay-gemini.go | 43 ++++++++++++++++++- setting/operation_setting/model-ratio.go | 18 ++++++-- web/src/i18n/locales/en.json | 3 ++ .../pages/Setting/Model/SettingGeminiModel.js | 7 +-- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index e6f66d5f..a81eb3a9 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -72,8 +72,11 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - // suffix -thinking and -nothinking - if strings.HasSuffix(info.OriginModelName, "-thinking") { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.OriginModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.OriginModelName, "-thinking") { // 旧的适配 info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") } else if strings.HasSuffix(info.OriginModelName, "-nothinking") { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index e2288faf..b65d5af7 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -12,6 +12,7 @@ import ( "one-api/relay/helper" "one-api/service" "one-api/setting/model_setting" + "strconv" "strings" "unicode/utf8" @@ -36,6 +37,13 @@ var geminiSupportedMimeTypes = map[string]bool{ "video/flv": true, } +// Gemini 允许的思考预算范围 +const ( + pro25MinBudget = 128 + pro25MaxBudget = 32768 + flash25MaxBudget = 24576 +) + // Setting safety to the lowest possible values since Gemini is already powerless enough func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { @@ -57,7 +65,40 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - if strings.HasSuffix(info.OriginModelName, "-thinking") { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.OriginModelName, "-thinking-") { + parts := strings.SplitN(info.OriginModelName, "-thinking-", 2) + if len(parts) == 2 && parts[1] != "" { + if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { + // 从模型名称成功解析预算 + isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") && + !strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25") + + if isNew25Pro { + // 新的2.5pro模型:ThinkingBudget范围为128-32768 + if budgetTokens < pro25MinBudget { + budgetTokens = pro25MinBudget + } else if budgetTokens > pro25MaxBudget { + budgetTokens = pro25MaxBudget + } + } else { + // 其他模型:ThinkingBudget范围为0-24576 + if budgetTokens < 0 { + budgetTokens = 0 + } else if budgetTokens > flash25MaxBudget { + budgetTokens = flash25MaxBudget + } + } + + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(budgetTokens), + IncludeThoughts: true, + } + } + // 如果解析失败,则不设置ThinkingConfig,静默处理 + } + } else if strings.HasSuffix(info.OriginModelName, "-thinking") { // 保留旧逻辑以兼容 // 硬编码不支持 ThinkingBudget 的旧模型 unsupportedModels := []string{ "gemini-2.5-pro-preview-05-06", diff --git a/setting/operation_setting/model-ratio.go b/setting/operation_setting/model-ratio.go index 700a7c4e..fa6f9560 100644 --- a/setting/operation_setting/model-ratio.go +++ b/setting/operation_setting/model-ratio.go @@ -142,6 +142,11 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-preview-04-17": 0.075, "gemini-2.5-flash-preview-04-17-thinking": 0.075, "gemini-2.5-flash-preview-04-17-nothinking": 0.075, + "gemini-2.5-flash-preview-05-20": 0.075, + "gemini-2.5-flash-preview-05-20-thinking": 0.075, + "gemini-2.5-flash-preview-05-20-nothinking": 0.075, + "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率 + "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率 "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens @@ -345,7 +350,14 @@ func UpdateModelRatioByJSONString(jsonStr string) error { func GetModelRatio(name string) (float64, bool) { modelRatioMapMutex.RLock() defer modelRatioMapMutex.RUnlock() - + // 处理带有思考预算的模型名称,方便统一定价 + handleThinkingBudgetModel := func(prefix, wildcard string) { + if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") { + name = wildcard + } + } + handleThinkingBudgetModel("gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + handleThinkingBudgetModel("gemini-2.5-pro", "gemini-2.5-pro-thinking-*") if strings.HasPrefix(name, "gpt-4-gizmo") { name = "gpt-4-gizmo-*" } @@ -470,9 +482,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { return 4, true } else if strings.HasPrefix(name, "gemini-2.0") { return 4, true - } else if strings.HasPrefix(name, "gemini-2.5-pro-preview") { + } else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致 return 8, true - } else if strings.HasPrefix(name, "gemini-2.5-flash-preview") { + } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 同上 if strings.HasSuffix(name, "-nothinking") { return 4, false } else { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ba23ca5c..d563aaad 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1373,6 +1373,9 @@ "示例": "Example", "缺省 MaxTokens": "Default MaxTokens", "启用Claude思考适配(-thinking后缀)": "Enable Claude thinking adaptation (-thinking suffix)", + "启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation", + "适配-thinking、-thinking-预算数字和-nothinking后缀": "Adapt -thinking, -thinking-budgetNumber, and -nothinking suffixes", + "思考预算占比": "Thinking budget ratio", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage", "思考适配 BudgetTokens 百分比": "Thinking adaptation BudgetTokens percentage", "0.1-1之间的小数": "Decimal between 0.1 and 1", diff --git a/web/src/pages/Setting/Model/SettingGeminiModel.js b/web/src/pages/Setting/Model/SettingGeminiModel.js index b802af1a..1d28ae92 100644 --- a/web/src/pages/Setting/Model/SettingGeminiModel.js +++ b/web/src/pages/Setting/Model/SettingGeminiModel.js @@ -173,7 +173,8 @@ export default function SettingGeminiModel(props) { {t( "和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用," + - "如果您需要计费,推荐设置无后缀模型价格按思考价格设置" + "如果您需要计费,推荐设置无后缀模型价格按思考价格设置。" + + "支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。" )} @@ -183,7 +184,7 @@ export default function SettingGeminiModel(props) { setInputs({ ...inputs, @@ -205,7 +206,7 @@ export default function SettingGeminiModel(props) { Date: Sun, 15 Jun 2025 23:40:58 +0800 Subject: [PATCH 014/191] update i18n --- web/src/i18n/locales/en.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d563aaad..8316f8a2 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1373,6 +1373,9 @@ "示例": "Example", "缺省 MaxTokens": "Default MaxTokens", "启用Claude思考适配(-thinking后缀)": "Enable Claude thinking adaptation (-thinking suffix)", + "和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,": "Unlike Claude, Gemini's thinking model automatically decides whether to think by default, and can be used normally even without enabling the adaptation model.", + "如果您需要计费,推荐设置无后缀模型价格按思考价格设置。": "If you need billing, it is recommended to set the no-suffix model price according to the thinking price.", + "支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Supports using gemini-2.5-pro-preview-06-05-thinking-128 format to precisely pass thinking budget.", "启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation", "适配-thinking、-thinking-预算数字和-nothinking后缀": "Adapt -thinking, -thinking-budgetNumber, and -nothinking suffixes", "思考预算占比": "Thinking budget ratio", From a9160804a3fd30634717ef4467bc205b49da5ead Mon Sep 17 00:00:00 2001 From: RedwindA Date: Mon, 16 Jun 2025 01:12:18 +0800 Subject: [PATCH 015/191] =?UTF-8?q?=F0=9F=90=9B=20fix(api):=20include=20gr?= =?UTF-8?q?oup=20in=20payload=20for=20playground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/helpers/api.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index aef01287..e00a5bdb 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -83,6 +83,7 @@ export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled const payload = { model: inputs.model, messages: processedMessages, + group: inputs.group, stream: inputs.stream, }; From 3ac02879dedd591d13e52068c68de8ddd9238212 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Mon, 16 Jun 2025 03:20:54 +0800 Subject: [PATCH 016/191] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20admin-only=20?= =?UTF-8?q?"remark"=20support=20for=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * backend - model: add `Remark` field (varchar 255, `json:"remark,omitempty"`); AutoMigrate handles schema change automatically - controller: * accept `remark` on user create/update endpoints * hide remark from regular users (`GetSelf`) by zero-ing the field before JSON marshalling * clarify inline comment explaining the omitempty behaviour * frontend (React / Semi UI) - AddUser.js & EditUser.js: add “Remark” input for admins - UsersTable.js: * remove standalone “Remark” column * show remark as a truncated Tag next to username with Tooltip for full text * import Tooltip component - i18n: reuse existing translations where applicable This commit enables administrators to label users with private notes while ensuring those notes are never exposed to the users themselves. --- controller/user.go | 3 +++ model/user.go | 2 ++ web/src/components/table/UsersTable.js | 22 ++++++++++++++++++++++ web/src/i18n/locales/en.json | 3 ++- web/src/pages/User/AddUser.js | 18 +++++++++++++++++- web/src/pages/User/EditUser.js | 17 +++++++++++++++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/controller/user.go b/controller/user.go index d7eb42d7..ecaf2583 100644 --- a/controller/user.go +++ b/controller/user.go @@ -459,6 +459,9 @@ func GetSelf(c *gin.Context) { }) return } + // Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users + user.Remark = "" + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/model/user.go b/model/user.go index 1b3a04b6..6a695457 100644 --- a/model/user.go +++ b/model/user.go @@ -41,6 +41,7 @@ type User struct { DeletedAt gorm.DeletedAt `gorm:"index"` LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` } func (user *User) ToBaseUser() *UserBase { @@ -366,6 +367,7 @@ func (user *User) Edit(updatePassword bool) error { "display_name": newUser.DisplayName, "group": newUser.Group, "quota": newUser.Quota, + "remark": newUser.Remark, } if updatePassword { updates["password"] = newUser.Password diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js index a027af59..d245c56f 100644 --- a/web/src/components/table/UsersTable.js +++ b/web/src/components/table/UsersTable.js @@ -26,6 +26,7 @@ import { Space, Table, Tag, + Tooltip, Typography } from '@douyinfe/semi-ui'; import { @@ -110,6 +111,27 @@ const UsersTable = () => { { title: t('用户名'), dataIndex: 'username', + render: (text, record) => { + const remark = record.remark; + if (!remark) { + return {text}; + } + const maxLen = 10; + const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark; + return ( + + {text} + + +
+
+ {displayRemark} +
+ + + + ); + }, }, { title: t('分组'), diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ba23ca5c..358c86bb 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1658,5 +1658,6 @@ "清除失效兑换码": "Clear invalid redemption codes", "确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?", "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.", - "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)" + "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)", + "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)" } \ No newline at end of file diff --git a/web/src/pages/User/AddUser.js b/web/src/pages/User/AddUser.js index 99620cfe..259a7750 100644 --- a/web/src/pages/User/AddUser.js +++ b/web/src/pages/User/AddUser.js @@ -16,6 +16,7 @@ import { IconClose, IconKey, IconUserAdd, + IconEdit, } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; @@ -27,10 +28,11 @@ const AddUser = (props) => { username: '', display_name: '', password: '', + remark: '', }; const [inputs, setInputs] = useState(originInputs); const [loading, setLoading] = useState(false); - const { username, display_name, password } = inputs; + const { username, display_name, password, remark } = inputs; const handleInputChange = (name, value) => { setInputs((inputs) => ({ ...inputs, [name]: value })); @@ -175,6 +177,20 @@ const AddUser = (props) => { required />
+ +
+ {t('备注')} + handleInputChange('remark', value)} + value={remark} + autoComplete="off" + size="large" + className="!rounded-lg" + prefix={} + showClear + /> +
diff --git a/web/src/pages/User/EditUser.js b/web/src/pages/User/EditUser.js index dceb670a..8c028d74 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/pages/User/EditUser.js @@ -22,6 +22,7 @@ import { IconLink, IconUserGroup, IconPlus, + IconEdit, } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; @@ -42,6 +43,7 @@ const EditUser = (props) => { email: '', quota: 0, group: 'default', + remark: '', }); const [groupOptions, setGroupOptions] = useState([]); const { @@ -55,6 +57,7 @@ const EditUser = (props) => { email, quota, group, + remark, } = inputs; const handleInputChange = (name, value) => { setInputs((inputs) => ({ ...inputs, [name]: value })); @@ -247,6 +250,20 @@ const EditUser = (props) => { showClear /> + +
+ {t('备注')} + handleInputChange('remark', value)} + value={remark} + autoComplete="off" + size="large" + className="!rounded-lg" + prefix={} + showClear + /> +
From 296da5dbccb5367706ba4a9e8087a4d61300a3bb Mon Sep 17 00:00:00 2001 From: Papersnake Date: Mon, 16 Jun 2025 17:43:39 +0800 Subject: [PATCH 017/191] feat: openrouter format for claude request --- relay/channel/claude/relay-claude.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index cb2c75b1..24fbbdb8 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -7,6 +7,7 @@ import ( "net/http" "one-api/common" "one-api/dto" + "one-api/relay/channel/openrouter" relaycommon "one-api/relay/common" "one-api/relay/helper" "one-api/service" @@ -122,6 +123,21 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") } + if textRequest.Reasoning != nil { + var reasoning openrouter.RequestReasoning + if err := json.Unmarshal(textRequest.Reasoning, &reasoning); err != nil { + return nil, err + } + + budgetTokens := reasoning.MaxTokens + if budgetTokens > 0 { + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: budgetTokens, + } + } + } + if textRequest.Stop != nil { // stop maybe string/array string, convert to array string switch textRequest.Stop.(type) { From b77574dad5fb730049b0f1d1de414fdf0a4899c4 Mon Sep 17 00:00:00 2001 From: CaIon <1808837298@qq.com> Date: Mon, 16 Jun 2025 18:29:49 +0800 Subject: [PATCH 018/191] =?UTF-8?q?=F0=9F=94=A7=20refactor(dto):=20update?= =?UTF-8?q?=20BudgetTokens=20handling=20in=20Thinking=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/claude.go | 9 ++++++++- relay/channel/claude/relay-claude.go | 2 +- relay/claude_handler.go | 2 +- service/convert.go | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/dto/claude.go b/dto/claude.go index 4d24bc70..98e09c78 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -178,7 +178,14 @@ type ClaudeRequest struct { type Thinking struct { Type string `json:"type"` - BudgetTokens int `json:"budget_tokens"` + BudgetTokens *int `json:"budget_tokens,omitempty"` +} + +func (c *Thinking) GetBudgetTokens() int { + if c.BudgetTokens == nil { + return 0 + } + return *c.BudgetTokens } func (c *ClaudeRequest) IsStringSystem() bool { diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index cb2c75b1..8c74af08 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -113,7 +113,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla // BudgetTokens 为 max_tokens 的 80% claudeRequest.Thinking = &dto.Thinking{ Type: "enabled", - BudgetTokens: int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage), + BudgetTokens: common.GetPointer[int](int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), } // TODO: 临时处理 // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking diff --git a/relay/claude_handler.go b/relay/claude_handler.go index fb68a88a..e8805255 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -98,7 +98,7 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) { // BudgetTokens 为 max_tokens 的 80% textRequest.Thinking = &dto.Thinking{ Type: "enabled", - BudgetTokens: int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage), + BudgetTokens: common.GetPointer[int](int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), } // TODO: 临时处理 // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking diff --git a/service/convert.go b/service/convert.go index cb964a46..7a9e8403 100644 --- a/service/convert.go +++ b/service/convert.go @@ -21,10 +21,10 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re isOpenRouter := info.ChannelType == common.ChannelTypeOpenRouter - if claudeRequest.Thinking != nil { + if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" { if isOpenRouter { reasoning := openrouter.RequestReasoning{ - MaxTokens: claudeRequest.Thinking.BudgetTokens, + MaxTokens: claudeRequest.Thinking.GetBudgetTokens(), } reasoningJSON, err := json.Marshal(reasoning) if err != nil { From 1294d286ee0decfd73e14bc907cdeb736bc4b4de Mon Sep 17 00:00:00 2001 From: RedwindA Date: Mon, 16 Jun 2025 19:41:42 +0800 Subject: [PATCH 019/191] refactor: replace inline closure with a helper function --- setting/operation_setting/model-ratio.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/setting/operation_setting/model-ratio.go b/setting/operation_setting/model-ratio.go index fa6f9560..5155b2fc 100644 --- a/setting/operation_setting/model-ratio.go +++ b/setting/operation_setting/model-ratio.go @@ -347,17 +347,20 @@ func UpdateModelRatioByJSONString(jsonStr string) error { return json.Unmarshal([]byte(jsonStr), &modelRatioMap) } +// 处理带有思考预算的模型名称,方便统一定价 +func handleThinkingBudgetModel(name, prefix, wildcard string) string { + if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") { + return wildcard + } + return name +} + func GetModelRatio(name string) (float64, bool) { modelRatioMapMutex.RLock() defer modelRatioMapMutex.RUnlock() - // 处理带有思考预算的模型名称,方便统一定价 - handleThinkingBudgetModel := func(prefix, wildcard string) { - if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") { - name = wildcard - } - } - handleThinkingBudgetModel("gemini-2.5-flash", "gemini-2.5-flash-thinking-*") - handleThinkingBudgetModel("gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") if strings.HasPrefix(name, "gpt-4-gizmo") { name = "gpt-4-gizmo-*" } From d5c96cb036514fb1f0b504da31a47da79a2403df Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Mon, 16 Jun 2025 20:05:54 +0800 Subject: [PATCH 020/191] =?UTF-8?q?=F0=9F=90=9B=20fix(console-setting):=20?= =?UTF-8?q?ensure=20announcements=20are=20returned=20in=20newest-first=20o?= =?UTF-8?q?rder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Added stable, descending sort to `GetAnnouncements()` so that the API always returns the latest announcements first. • Introduced helper `getPublishTime()` to safely parse `publishDate` (RFC 3339) and fall back to zero value on failure. • Switched to `sort.SliceStable` for deterministic ordering when timestamps are identical. • Imported the standard `sort` package and removed redundant, duplicate date parsing. Impact Front-end no longer needs to perform client-side sorting; the latest announcement is guaranteed to appear at the top on all platforms and clients. --- setting/console_setting/validation.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index 51a84849..fda6453d 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "time" + "sort" ) var ( @@ -210,8 +211,23 @@ func validateFAQ(faqStr string) error { return nil } +func getPublishTime(item map[string]interface{}) time.Time { + if v, ok := item["publishDate"]; ok { + if s, ok2 := v.(string); ok2 { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} +} + func GetAnnouncements() []map[string]interface{} { - return getJSONList(GetConsoleSetting().Announcements) + list := getJSONList(GetConsoleSetting().Announcements) + sort.SliceStable(list, func(i, j int) bool { + return getPublishTime(list[i]).After(getPublishTime(list[j])) + }) + return list } func GetFAQ() []map[string]interface{} { From 6b7295bbdf2e48fad0ddbe286e06b5e51d0ce35a Mon Sep 17 00:00:00 2001 From: CaIon <1808837298@qq.com> Date: Mon, 16 Jun 2025 21:02:27 +0800 Subject: [PATCH 021/191] =?UTF-8?q?=F0=9F=94=A7=20refactor(relay):=20repla?= =?UTF-8?q?ce=20UUID=20generation=20with=20helper=20function=20for=20respo?= =?UTF-8?q?nse=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/cohere/relay-cohere.go | 3 +-- relay/channel/gemini/relay-gemini.go | 8 ++++---- relay/channel/palm/relay-palm.go | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go index 10c4328b..8a044bf2 100644 --- a/relay/channel/cohere/relay-cohere.go +++ b/relay/channel/cohere/relay-cohere.go @@ -3,7 +3,6 @@ package cohere import ( "bufio" "encoding/json" - "fmt" "github.com/gin-gonic/gin" "io" "net/http" @@ -78,7 +77,7 @@ func stopReasonCohere2OpenAI(reason string) string { } func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { - responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + responseId := helper.GetResponseID(c) createdTime := common.GetTimestamp() usage := &dto.Usage{} responseText := "" diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index e2288faf..e0b70805 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -611,9 +611,9 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { } } -func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResponse { +func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse { fullTextResponse := dto.OpenAITextResponse{ - Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()), + Id: helper.GetResponseID(c), Object: "chat.completion", Created: common.GetTimestamp(), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), @@ -754,7 +754,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { // responseText := "" - id := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + id := helper.GetResponseID(c) createAt := common.GetTimestamp() var usage = &dto.Usage{} var imageCount int @@ -849,7 +849,7 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re StatusCode: resp.StatusCode, }, nil } - fullTextResponse := responseGeminiChat2OpenAI(&geminiResponse) + fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) fullTextResponse.Model = info.UpstreamModelName usage := dto.Usage{ PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index 5c398b5e..1f301009 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -73,7 +73,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompleti func palmStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, string) { responseText := "" - responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + responseId := helper.GetResponseID(c) createdTime := common.GetTimestamp() dataChan := make(chan string) stopChan := make(chan bool) From 7fa21ce95fc412abc975388454ca86ae0e6fe439 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Mon, 16 Jun 2025 22:15:12 +0800 Subject: [PATCH 022/191] =?UTF-8?q?feat:=20auto=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/group.go | 9 +- controller/misc.go | 79 ++-- controller/model.go | 18 +- controller/playground.go | 7 +- controller/relay.go | 4 +- controller/user.go | 3 + middleware/distributor.go | 15 +- model/cache.go | 38 +- model/option.go | 6 + relay/helper/price.go | 12 +- service/quota.go | 30 +- setting/auto_group.go | 31 ++ setting/user_usable_group.go | 7 + .../components/settings/OperationSetting.js | 6 +- .../Setting/Operation/GroupRatioSettings.js | 36 ++ web/src/pages/Token/EditToken.js | 362 +++++++++++------- 16 files changed, 477 insertions(+), 186 deletions(-) create mode 100644 setting/auto_group.go diff --git a/controller/group.go b/controller/group.go index 2c725a4d..632b6cd5 100644 --- a/controller/group.go +++ b/controller/group.go @@ -1,10 +1,11 @@ package controller import ( - "github.com/gin-gonic/gin" "net/http" "one-api/model" "one-api/setting" + + "github.com/gin-gonic/gin" ) func GetGroups(c *gin.Context) { @@ -34,6 +35,12 @@ func GetUserGroups(c *gin.Context) { } } } + if setting.GroupInUserUsableGroups("auto") { + usableGroups["auto"] = map[string]interface{}{ + "ratio": "自动", + "desc": setting.GetUsableGroupDescription("auto"), + } + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/controller/misc.go b/controller/misc.go index 33a41302..1caaf640 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -9,9 +9,9 @@ import ( "one-api/middleware" "one-api/model" "one-api/setting" + "one-api/setting/console_setting" "one-api/setting/operation_setting" "one-api/setting/system_setting" - "one-api/setting/console_setting" "strings" "github.com/gin-gonic/gin" @@ -41,46 +41,47 @@ func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() data := gin.H{ - "version": common.Version, - "start_time": common.StartTime, - "email_verification": common.EmailVerificationEnabled, - "github_oauth": common.GitHubOAuthEnabled, - "github_client_id": common.GitHubClientId, - "linuxdo_oauth": common.LinuxDOOAuthEnabled, - "linuxdo_client_id": common.LinuxDOClientId, - "telegram_oauth": common.TelegramOAuthEnabled, - "telegram_bot_name": common.TelegramBotName, - "system_name": common.SystemName, - "logo": common.Logo, - "footer_html": common.Footer, - "wechat_qrcode": common.WeChatAccountQRCodeImageURL, - "wechat_login": common.WeChatAuthEnabled, - "server_address": setting.ServerAddress, - "price": setting.Price, - "min_topup": setting.MinTopUp, - "turnstile_check": common.TurnstileCheckEnabled, - "turnstile_site_key": common.TurnstileSiteKey, - "top_up_link": common.TopUpLink, - "docs_link": operation_setting.GetGeneralSetting().DocsLink, - "quota_per_unit": common.QuotaPerUnit, - "display_in_currency": common.DisplayInCurrencyEnabled, - "enable_batch_update": common.BatchUpdateEnabled, - "enable_drawing": common.DrawingEnabled, - "enable_task": common.TaskEnabled, - "enable_data_export": common.DataExportEnabled, - "data_export_default_time": common.DataExportDefaultTime, - "default_collapse_sidebar": common.DefaultCollapseSidebar, - "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", - "mj_notify_enabled": setting.MjNotifyEnabled, - "chats": setting.Chats, - "demo_site_enabled": operation_setting.DemoSiteEnabled, - "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "version": common.Version, + "start_time": common.StartTime, + "email_verification": common.EmailVerificationEnabled, + "github_oauth": common.GitHubOAuthEnabled, + "github_client_id": common.GitHubClientId, + "linuxdo_oauth": common.LinuxDOOAuthEnabled, + "linuxdo_client_id": common.LinuxDOClientId, + "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, + "system_name": common.SystemName, + "logo": common.Logo, + "footer_html": common.Footer, + "wechat_qrcode": common.WeChatAccountQRCodeImageURL, + "wechat_login": common.WeChatAuthEnabled, + "server_address": setting.ServerAddress, + "price": setting.Price, + "min_topup": setting.MinTopUp, + "turnstile_check": common.TurnstileCheckEnabled, + "turnstile_site_key": common.TurnstileSiteKey, + "top_up_link": common.TopUpLink, + "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "quota_per_unit": common.QuotaPerUnit, + "display_in_currency": common.DisplayInCurrencyEnabled, + "enable_batch_update": common.BatchUpdateEnabled, + "enable_drawing": common.DrawingEnabled, + "enable_task": common.TaskEnabled, + "enable_data_export": common.DataExportEnabled, + "data_export_default_time": common.DataExportDefaultTime, + "default_collapse_sidebar": common.DefaultCollapseSidebar, + "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", + "mj_notify_enabled": setting.MjNotifyEnabled, + "chats": setting.Chats, + "demo_site_enabled": operation_setting.DemoSiteEnabled, + "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "default_use_auto_group": setting.DefaultUseAutoGroup, // 面板启用开关 - "api_info_enabled": cs.ApiInfoEnabled, - "uptime_kuma_enabled": cs.UptimeKumaEnabled, - "announcements_enabled": cs.AnnouncementsEnabled, - "faq_enabled": cs.FAQEnabled, + "api_info_enabled": cs.ApiInfoEnabled, + "uptime_kuma_enabled": cs.UptimeKumaEnabled, + "announcements_enabled": cs.AnnouncementsEnabled, + "faq_enabled": cs.FAQEnabled, "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, diff --git a/controller/model.go b/controller/model.go index df7e59a6..134217a3 100644 --- a/controller/model.go +++ b/controller/model.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/constant" @@ -15,6 +14,9 @@ import ( "one-api/relay/channel/moonshot" relaycommon "one-api/relay/common" relayconstant "one-api/relay/constant" + "one-api/setting" + + "github.com/gin-gonic/gin" ) // https://platform.openai.com/docs/api-reference/models/list @@ -179,7 +181,19 @@ func ListModels(c *gin.Context) { if tokenGroup != "" { group = tokenGroup } - models := model.GetGroupModels(group) + var models []string + if tokenGroup == "auto" { + for _, autoGroup := range setting.AutoGroups { + groupModels := model.GetGroupModels(autoGroup) + for _, g := range groupModels { + if !common.StringsContains(models, g) { + models = append(models, g) + } + } + } + } else { + models = model.GetGroupModels(group) + } for _, s := range models { if _, ok := openAIModelsMap[s]; ok { userOpenAiModels = append(userOpenAiModels, openAIModelsMap[s]) diff --git a/controller/playground.go b/controller/playground.go index a2b54790..37a5c7b0 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -3,7 +3,6 @@ package controller import ( "errors" "fmt" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/constant" @@ -13,6 +12,8 @@ import ( "one-api/service" "one-api/setting" "time" + + "github.com/gin-gonic/gin" ) func Playground(c *gin.Context) { @@ -57,9 +58,9 @@ func Playground(c *gin.Context) { c.Set("group", group) } c.Set("token_name", "playground-"+group) - channel, err := model.CacheGetRandomSatisfiedChannel(group, playgroundRequest.Model, 0) + channel, finalGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, playgroundRequest.Model, 0) if err != nil { - message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", group, playgroundRequest.Model) + message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", finalGroup, playgroundRequest.Model) openaiErr = service.OpenAIErrorWrapperLocal(errors.New(message), "get_playground_channel_failed", http.StatusInternalServerError) return } diff --git a/controller/relay.go b/controller/relay.go index 1a875dbc..c1c45114 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -259,7 +259,7 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m AutoBan: &autoBanInt, }, nil } - channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, retryCount) + channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) if err != nil { return nil, errors.New(fmt.Sprintf("获取重试渠道失败: %s", err.Error())) } @@ -388,7 +388,7 @@ func RelayTask(c *gin.Context) { retryTimes = 0 } for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { - channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, i) + channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, i) if err != nil { common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error())) break diff --git a/controller/user.go b/controller/user.go index ecaf2583..e8ce3c3d 100644 --- a/controller/user.go +++ b/controller/user.go @@ -226,6 +226,9 @@ func Register(c *gin.Context) { UnlimitedQuota: true, ModelLimitsEnabled: false, } + if setting.DefaultUseAutoGroup { + token.Group = "auto" + } if err := token.Insert(); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/middleware/distributor.go b/middleware/distributor.go index 1bfe1821..5d1c3641 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -49,8 +49,10 @@ func Distribute() func(c *gin.Context) { } // check group in common.GroupRatio if !setting.ContainsGroupRatio(tokenGroup) { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) - return + if tokenGroup != "auto" { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) + return + } } userGroup = tokenGroup } @@ -95,9 +97,14 @@ func Distribute() func(c *gin.Context) { } if shouldSelectChannel { - channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model, 0) + var selectGroup string + channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0) if err != nil { - message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model) + showGroup := userGroup + if userGroup == "auto" { + showGroup = fmt.Sprintf("auto(%s)", selectGroup) + } + message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model) // 如果错误,但是渠道不为空,说明是数据库一致性问题 if channel != nil { common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) diff --git a/model/cache.go b/model/cache.go index e2f83e22..1d7d2f25 100644 --- a/model/cache.go +++ b/model/cache.go @@ -3,12 +3,16 @@ package model import ( "errors" "fmt" + "log" "math/rand" "one-api/common" + "one-api/setting" "sort" "strings" "sync" "time" + + "github.com/gin-gonic/gin" ) var group2model2channels map[string]map[string][]*Channel @@ -75,7 +79,39 @@ func SyncChannelCache(frequency int) { } } -func CacheGetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { +func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) { + var channel *Channel + var err error + selectGroup := group + if group == "auto" { + if len(setting.AutoGroups) == 0 { + return nil, selectGroup, errors.New("auto groups is not enabled") + } + for _, autoGroup := range setting.AutoGroups { + log.Printf("autoGroup: %s", autoGroup) + channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry) + if channel == nil { + continue + } else { + c.Set("auto_group", autoGroup) + selectGroup = autoGroup + log.Printf("selectGroup: %s", selectGroup) + break + } + } + } else { + channel, err = getRandomSatisfiedChannel(group, model, retry) + if err != nil { + return nil, group, err + } + } + if channel == nil { + return nil, group, errors.New("channel not found") + } + return channel, selectGroup, nil +} + +func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { if strings.HasPrefix(model, "gpt-4-gizmo") { model = "gpt-4-gizmo-*" } diff --git a/model/option.go b/model/option.go index d1689cb7..89ab8506 100644 --- a/model/option.go +++ b/model/option.go @@ -76,6 +76,8 @@ func InitOptionMap() { common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() + common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientSecret"] = "" common.OptionMap["TelegramBotToken"] = "" @@ -287,6 +289,10 @@ func updateOptionMap(key string, value string) (err error) { setting.PayAddress = value case "Chats": err = setting.UpdateChatsByJsonString(value) + case "AutoGroups": + err = setting.UpdateAutoGroupsByJsonString(value) + case "DefaultUseAutoGroup": + setting.DefaultUseAutoGroup = value == "true" case "CustomCallbackAddress": setting.CustomCallbackAddress = value case "EpayId": diff --git a/relay/helper/price.go b/relay/helper/price.go index 1b52bf37..6ecebac5 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,6 +2,7 @@ package helper import ( "fmt" + "log" "one-api/common" constant2 "one-api/constant" relaycommon "one-api/relay/common" @@ -31,10 +32,19 @@ func (p PriceData) ToSetting() string { func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) { modelPrice, usePrice := operation_setting.GetModelPrice(info.OriginModelName, false) groupRatio := setting.GetGroupRatio(info.Group) + var userGroupRatio float64 + autoGroup, exists := c.Get("auto_group") + if exists { + groupRatio = setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + info.Group = autoGroup.(string) + } + actualGroupRatio := groupRatio userGroupRatio, ok := setting.GetGroupGroupRatio(info.UserGroup, info.Group) if ok { - groupRatio = userGroupRatio + actualGroupRatio = userGroupRatio } + groupRatio = actualGroupRatio var preConsumedQuota int var modelRatio float64 var completionRatio float64 diff --git a/service/quota.go b/service/quota.go index da3dd9b9..75b186ae 100644 --- a/service/quota.go +++ b/service/quota.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "log" "one-api/common" constant2 "one-api/constant" "one-api/dto" @@ -94,11 +95,20 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag audioInputTokens := usage.InputTokenDetails.AudioTokens audioOutTokens := usage.OutputTokenDetails.AudioTokens groupRatio := setting.GetGroupRatio(relayInfo.Group) + modelRatio, _ := operation_setting.GetModelRatio(modelName) + + autoGroup, exists := ctx.Get("auto_group") + if exists { + groupRatio = setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + relayInfo.Group = autoGroup.(string) + } + + actualGroupRatio := groupRatio userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) if ok { - groupRatio = userGroupRatio + actualGroupRatio = userGroupRatio } - modelRatio, _ := operation_setting.GetModelRatio(modelName) quotaInfo := QuotaInfo{ InputDetails: TokenDetails{ @@ -112,7 +122,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag ModelName: modelName, UsePrice: relayInfo.UsePrice, ModelRatio: modelRatio, - GroupRatio: groupRatio, + GroupRatio: actualGroupRatio, } quota := calculateAudioQuota(quotaInfo) @@ -149,6 +159,13 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName)) audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName)) + autoGroup, exists := ctx.Get("auto_group") + if exists { + groupRatio = setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + relayInfo.Group = autoGroup.(string) + } + actualGroupRatio := groupRatio userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) if ok { @@ -290,6 +307,13 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelPrice := priceData.ModelPrice usePrice := priceData.UsePrice + autoGroup, exists := ctx.Get("auto_group") + if exists { + groupRatio = setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + relayInfo.Group = autoGroup.(string) + } + actualGroupRatio := groupRatio userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) if ok { diff --git a/setting/auto_group.go b/setting/auto_group.go new file mode 100644 index 00000000..5a87ae56 --- /dev/null +++ b/setting/auto_group.go @@ -0,0 +1,31 @@ +package setting + +import "encoding/json" + +var AutoGroups = []string{ + "default", +} + +var DefaultUseAutoGroup = false + +func ContainsAutoGroup(group string) bool { + for _, autoGroup := range AutoGroups { + if autoGroup == group { + return true + } + } + return false +} + +func UpdateAutoGroupsByJsonString(jsonString string) error { + AutoGroups = make([]string, 0) + return json.Unmarshal([]byte(jsonString), &AutoGroups) +} + +func AutoGroups2JsonString() string { + jsonBytes, err := json.Marshal(AutoGroups) + if err != nil { + return "[]" + } + return string(jsonBytes) +} diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go index 7082b683..fdf2f723 100644 --- a/setting/user_usable_group.go +++ b/setting/user_usable_group.go @@ -50,3 +50,10 @@ func GroupInUserUsableGroups(groupName string) bool { _, ok := userUsableGroups[groupName] return ok } + +func GetUsableGroupDescription(groupName string) string { + if desc, ok := userUsableGroups[groupName]; ok { + return desc + } + return groupName +} diff --git a/web/src/components/settings/OperationSetting.js b/web/src/components/settings/OperationSetting.js index 55e328a3..7bd9bf62 100644 --- a/web/src/components/settings/OperationSetting.js +++ b/web/src/components/settings/OperationSetting.js @@ -31,6 +31,8 @@ const OperationSetting = () => { ModelPrice: '', GroupRatio: '', GroupGroupRatio: '', + AutoGroups: '', + DefaultUseAutoGroup: false, UserUsableGroups: '', TopUpLink: '', 'general_setting.docs_link': '', @@ -76,6 +78,7 @@ const OperationSetting = () => { item.key === 'ModelRatio' || item.key === 'GroupRatio' || item.key === 'GroupGroupRatio' || + item.key === 'AutoGroups' || item.key === 'UserUsableGroups' || item.key === 'CompletionRatio' || item.key === 'ModelPrice' || @@ -85,7 +88,8 @@ const OperationSetting = () => { } if ( item.key.endsWith('Enabled') || - ['DefaultCollapseSidebar'].includes(item.key) + ['DefaultCollapseSidebar'].includes(item.key) || + ['DefaultUseAutoGroup'].includes(item.key) ) { newInputs[item.key] = item.value === 'true' ? true : false; } else { diff --git a/web/src/pages/Setting/Operation/GroupRatioSettings.js b/web/src/pages/Setting/Operation/GroupRatioSettings.js index 6d212746..c0e1ed24 100644 --- a/web/src/pages/Setting/Operation/GroupRatioSettings.js +++ b/web/src/pages/Setting/Operation/GroupRatioSettings.js @@ -17,6 +17,8 @@ export default function GroupRatioSettings(props) { GroupRatio: '', UserUsableGroups: '', GroupGroupRatio: '', + AutoGroups: '', + DefaultUseAutoGroup: false, }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); @@ -167,6 +169,40 @@ export default function GroupRatioSettings(props) { /> + + + verifyJSON(value), + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ ...inputs, AutoGroups: value }) + } + /> + + + + + + setInputs({ ...inputs, DefaultUseAutoGroup: value }) + } + /> + + diff --git a/web/src/pages/Token/EditToken.js b/web/src/pages/Token/EditToken.js index 71f611bd..782562a3 100644 --- a/web/src/pages/Token/EditToken.js +++ b/web/src/pages/Token/EditToken.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; import { API, @@ -7,7 +7,7 @@ import { showSuccess, timestamp2string, renderGroupOption, - renderQuotaWithPrompt + renderQuotaWithPrompt, } from '../../helpers'; import { AutoComplete, @@ -37,11 +37,13 @@ import { IconPlusCircle, } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; +import { StatusContext } from '../../context/Status'; const { Text, Title } = Typography; const EditToken = (props) => { const { t } = useTranslation(); + const [statusState, statusDispatch] = useContext(StatusContext); const [isEdit, setIsEdit] = useState(false); const [loading, setLoading] = useState(isEdit); const originInputs = { @@ -119,7 +121,19 @@ const EditToken = (props) => { value: group, ratio: info.ratio, })); + if (statusState?.status?.default_use_auto_group) { + // if contain auto, add it to the first position + if (localGroupOptions.some((group) => group.value === 'auto')) { + // 排序 + localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1)); + } else { + localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' }); + } + } setGroups(localGroupOptions); + if (statusState?.status?.default_use_auto_group) { + setInputs({ ...inputs, group: 'auto' }); + } } else { showError(t(message)); } @@ -268,32 +282,37 @@ const EditToken = (props) => { placement={isEdit ? 'right' : 'left'} title={ - {isEdit ? - {t('更新')} : - {t('新建')} - } - + {isEdit ? ( + <Tag color='blue' shape='circle'> + {t('更新')} + </Tag> + ) : ( + <Tag color='green' shape='circle'> + {t('新建')} + </Tag> + )} + <Title heading={4} className='m-0'> {isEdit ? t('更新令牌信息') : t('创建新的令牌')} } headerStyle={{ borderBottom: '1px solid var(--semi-color-border)', - padding: '24px' + padding: '24px', }} bodyStyle={{ backgroundColor: 'var(--semi-color-bg-0)', - padding: '0' + padding: '0', }} visible={props.visiable} width={isMobile() ? '100%' : 600} footer={ -
+
- -
-
-
-
+ +
+
+
+
-
- +
+
-
- {t('额度设置')} -
{t('设置令牌可用额度和数量')}
+
+ + {t('额度设置')} + +
+ {t('设置令牌可用额度和数量')} +
-
+
-
+
{t('额度')} - {renderQuotaWithPrompt(remain_quota)} + + {renderQuotaWithPrompt(remain_quota)} +
handleInputChange('remain_quota', value)} value={remain_quota} - autoComplete="new-password" - type="number" - size="large" - className="w-full !rounded-lg" + autoComplete='new-password' + type='number' + size='large' + className='w-full !rounded-lg' prefix={} data={[ { value: 500000, label: '1$' }, @@ -460,16 +517,18 @@ const EditToken = (props) => { {!isEdit && (
- {t('新建数量')} + + {t('新建数量')} + handleTokenCountChange(value)} onSelect={(value) => handleTokenCountChange(value)} value={tokenCount.toString()} - autoComplete="off" - type="number" - className="w-full !rounded-lg" - size="large" + autoComplete='off' + type='number' + className='w-full !rounded-lg' + size='large' prefix={} data={[ { value: 10, label: t('10个') }, @@ -482,12 +541,12 @@ const EditToken = (props) => {
)} -
+
@@ -495,92 +554,137 @@ const EditToken = (props) => {
- -
-
-
-
+ +
+
+
+
-
- +
+
-
- {t('访问限制')} -
{t('设置令牌的访问限制')}
+
+ + {t('访问限制')} + +
+ {t('设置令牌的访问限制')} +
-
+
- {t('IP白名单')} + + {t('IP白名单')} +