From 69420f713f9aa6f2390ac6592d39905830df0246 Mon Sep 17 00:00:00 2001 From: JoeyLearnsToCode Date: Mon, 19 May 2025 19:33:29 +0800 Subject: [PATCH 01/74] =?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 916329e72e1b..0f77dbb9fd75 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 f7fab05723e0..e19f1cd2bb86 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, 29 May 2025 00:49:21 +0800 Subject: [PATCH 02/74] 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 bda1bb179b67..9e3a41ac2746 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 f0cf073f0e53..cef958b247e8 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 cc462b409eb4..67e7790334b7 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 03/74] 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 790d4df81957..fb4aeb4c1320 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 0d11b4a0fd6e..43297b4a1847 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 c4f25a77d1af97998f66f5cc7f1c3942994135ec Mon Sep 17 00:00:00 2001 From: neotf Date: Wed, 11 Jun 2025 13:56:44 +0800 Subject: [PATCH 04/74] 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 a51dffd81a81..50dee203ce04 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 05/74] 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 50dee203ce04..10e103329917 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 296da5dbccb5367706ba4a9e8087a4d61300a3bb Mon Sep 17 00:00:00 2001 From: Papersnake Date: Mon, 16 Jun 2025 17:43:39 +0800 Subject: [PATCH 06/74] 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 cb2c75b1cce7..24fbbdb8cd30 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 a6363a502ad239610281fe078df8ec1158bfc461 Mon Sep 17 00:00:00 2001 From: neotf Date: Wed, 18 Jun 2025 15:29:19 +0800 Subject: [PATCH 07/74] Update relay/channel/openai/adaptor.go use review's suggestion Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- relay/channel/openai/adaptor.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index ea24d811998f..451ed408108b 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -159,9 +159,11 @@ 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 info.ChannelType == common.ChannelTypeOpenRouter { + if len(request.Usage) == 0 { + request.Usage = json.RawMessage(`{"include":true}`) + } +} if strings.HasPrefix(request.Model, "o") { if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { request.MaxCompletionTokens = request.MaxTokens From 37fbcb7950a122aadada77c0dfdffae928b16242 Mon Sep 17 00:00:00 2001 From: neotf <10400594+neotf@users.noreply.github.com> Date: Wed, 18 Jun 2025 19:54:20 +0800 Subject: [PATCH 08/74] format --- relay/channel/openai/adaptor.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 451ed408108b..424fd3dfaaf9 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -159,11 +159,11 @@ 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 { - if len(request.Usage) == 0 { - request.Usage = json.RawMessage(`{"include":true}`) - } -} + if info.ChannelType == common.ChannelTypeOpenRouter { + if len(request.Usage) == 0 { + request.Usage = json.RawMessage(`{"include":true}`) + } + } if strings.HasPrefix(request.Model, "o") { if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { request.MaxCompletionTokens = request.MaxTokens From 16c63b3be9a1935df7a5f0c24a238f0bd3aaa21c Mon Sep 17 00:00:00 2001 From: neotf <10400594+neotf@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:11:48 +0800 Subject: [PATCH 09/74] fix(quota): refine cache token calculation for OpenRouter channel type --- service/quota.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/service/quota.go b/service/quota.go index 8c7ed07effb1..33cc65d7255a 100644 --- a/service/quota.go +++ b/service/quota.go @@ -232,9 +232,15 @@ 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 + if relayInfo.ChannelType == common.ChannelTypeOpenRouter { + promptTokens -= cacheTokens + if cacheCreationTokens == 0 && priceData.CacheCreationRatio != 1 && usage.Cost != 0 { + maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, priceData) + if promptTokens >= maybeCacheCreationTokens { + cacheCreationTokens = maybeCacheCreationTokens + } + } + promptTokens -= cacheCreationTokens } calculateQuota := 0.0 From f7f1be9df244bce21ebe15d4e9559d508e10c6c2 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 21 Jun 2025 15:09:48 +0800 Subject: [PATCH 10/74] =?UTF-8?q?=F0=9F=8E=A8=20refactor:=20Refactor=20Rat?= =?UTF-8?q?ioSetting:=20integrate=20Group=20Ratio=20Settings=20into=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Moved `GroupRatioSettings` component inside the existing Tabs as a new **Group Ratios** tab. * Removed the standalone `Card` that previously wrapped `GroupRatioSettings`. * Re-formatted JSX props for `ModelRatioSettings` and `GroupRatioSettings` to improve readability. * Consolidates all ratio-related settings into a single tabbed view for a cleaner and more consistent UI. --- web/src/components/settings/RatioSetting.js | 15 +- web/src/i18n/locales/en.json | 2 +- .../pages/Setting/Ratio/GroupRatioSettings.js | 242 +++++++++--------- .../pages/Setting/Ratio/ModelRatioSettings.js | 200 +++++++-------- 4 files changed, 230 insertions(+), 229 deletions(-) diff --git a/web/src/components/settings/RatioSetting.js b/web/src/components/settings/RatioSetting.js index 99a6a3cf9477..b0284e1dabd0 100644 --- a/web/src/components/settings/RatioSetting.js +++ b/web/src/components/settings/RatioSetting.js @@ -84,7 +84,16 @@ const RatioSetting = () => { - + + + + { - {/* 分组倍率设置 */} - - - ); }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index cab7f8fbeb54..d9cfe1d85cf0 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1263,7 +1263,7 @@ " 吗?": "?", "修改子渠道优先级": "Modify sub-channel priority", "确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ", - "分组设置": "Group settings", + "分组倍率设置": "Group ratio settings", "用户可选分组": "User selectable groups", "保存分组倍率设置": "Save group ratio settings", "模型倍率设置": "Model ratio settings", diff --git a/web/src/pages/Setting/Ratio/GroupRatioSettings.js b/web/src/pages/Setting/Ratio/GroupRatioSettings.js index 3c7c754be323..12e634bac9ad 100644 --- a/web/src/pages/Setting/Ratio/GroupRatioSettings.js +++ b/web/src/pages/Setting/Ratio/GroupRatioSettings.js @@ -96,133 +96,131 @@ export default function GroupRatioSettings(props) { getFormApi={(formAPI) => (refForm.current = formAPI)} style={{ marginBottom: 15 }} > - - - - verifyJSON(value), - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ ...inputs, GroupRatio: value }) - } - /> - - - - - verifyJSON(value), - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ ...inputs, UserUsableGroups: value }) - } - /> - - - - - verifyJSON(value), - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ ...inputs, GroupGroupRatio: value }) - } - /> - - - - - { - if (!value || value.trim() === '') { - return true; // Allow empty values - } - - // First check if it's valid JSON - try { - const parsed = JSON.parse(value); + + + verifyJSON(value), + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ ...inputs, GroupRatio: value }) + } + /> + + + + + verifyJSON(value), + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ ...inputs, UserUsableGroups: value }) + } + /> + + + + + verifyJSON(value), + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ ...inputs, GroupGroupRatio: value }) + } + /> + + + + + { + if (!value || value.trim() === '') { + return true; // Allow empty values + } - // Check if it's an array - if (!Array.isArray(parsed)) { - return false; - } + // First check if it's valid JSON + try { + const parsed = JSON.parse(value); - // Check if every element is a string - return parsed.every(item => typeof item === 'string'); - } catch (error) { + // Check if it's an array + if (!Array.isArray(parsed)) { return false; } - }, - message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'), + + // Check if every element is a string + return parsed.every(item => typeof item === 'string'); + } catch (error) { + return false; + } }, - ]} - onChange={(value) => - setInputs({ ...inputs, AutoGroups: value }) - } - /> - - - - - - setInputs({ ...inputs, DefaultUseAutoGroup: value }) - } - /> - - - + message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'), + }, + ]} + onChange={(value) => + setInputs({ ...inputs, AutoGroups: value }) + } + /> + + + + + + setInputs({ ...inputs, DefaultUseAutoGroup: value }) + } + /> + + diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.js b/web/src/pages/Setting/Ratio/ModelRatioSettings.js index 764c69863e91..80238fc815b4 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.js +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.js @@ -118,107 +118,105 @@ export default function ModelRatioSettings(props) { getFormApi={(formAPI) => (refForm.current = formAPI)} style={{ marginBottom: 15 }} > - - - - verifyJSON(value), - message: '不是合法的 JSON 字符串', - }, - ]} - onChange={(value) => - setInputs({ ...inputs, ModelPrice: value }) - } - /> - - - - - verifyJSON(value), - message: '不是合法的 JSON 字符串', - }, - ]} - onChange={(value) => - setInputs({ ...inputs, ModelRatio: value }) - } - /> - - - - - verifyJSON(value), - message: '不是合法的 JSON 字符串', - }, - ]} - onChange={(value) => - setInputs({ ...inputs, CacheRatio: value }) - } - /> - - - - - verifyJSON(value), - message: '不是合法的 JSON 字符串', - }, - ]} - onChange={(value) => - setInputs({ ...inputs, CompletionRatio: value }) - } - /> - - - - - - setInputs({ ...inputs, ExposeRatioEnabled: value }) - } - /> - - - + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, ModelPrice: value }) + } + /> + + + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, ModelRatio: value }) + } + /> + + + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, CacheRatio: value }) + } + /> + + + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, CompletionRatio: value }) + } + /> + + + + + + setInputs({ ...inputs, ExposeRatioEnabled: value }) + } + /> + + From b43423bffcb3c28e9ace63733fab5c8c9cf5e54e Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 21 Jun 2025 20:24:52 +0800 Subject: [PATCH 11/74] =?UTF-8?q?=E2=9C=A8=20feat(ratio-sync):=20support?= =?UTF-8?q?=20/api/pricing=20parsing,=20confidence=20verification=20&=20UI?= =?UTF-8?q?=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - controller/ratio_sync.go • Parse /api/pricing response and convert to ratio / price maps. • Introduce confidence heuristic (model_ratio = 37.5 && completion_ratio = 1) to flag unreliable data. • Include confidence map when building differences and filter “same”/empty entries. - dto/ratio_sync.go • Add `ID` to UpstreamDTO, `upstreams` to UpstreamRequest, and `Confidence` to DifferenceItem. Frontend - ChannelSelectorModal.js • Re-implement with table layout, pagination, search, endpoint-type selector and mobile support. - UpstreamRatioSync.js • Send full upstream objects, add ratio-type filter, confidence badges/tooltips, retain endpoints. • Leverage ChannelSelectorModal’s pagination reset. - ChannelsTable.js – fix tag color for disabled status. - en.json – add translations for new UI labels. Motivation These changes let users sync model ratios / prices from different upstream endpoints and visually identify potentially unreliable data, improving operational safety and flexibility. --- controller/ratio_sync.go | 174 +++++++++- dto/ratio_sync.go | 17 +- .../settings/ChannelSelectorModal.js | 299 ++++++++++++------ web/src/components/table/ChannelsTable.js | 2 +- web/src/i18n/locales/en.json | 11 +- .../pages/Setting/Ratio/UpstreamRatioSync.js | 177 ++++++++--- 6 files changed, 507 insertions(+), 173 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index f749f3845e10..0453870d0070 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -3,6 +3,7 @@ package controller import ( "context" "encoding/json" + "fmt" "net/http" "strings" "sync" @@ -43,7 +44,17 @@ func FetchUpstreamRatios(c *gin.Context) { var upstreams []dto.UpstreamDTO - if len(req.ChannelIDs) > 0 { + if len(req.Upstreams) > 0 { + for _, u := range req.Upstreams { + if strings.HasPrefix(u.BaseURL, "http") { + if u.Endpoint == "" { + u.Endpoint = defaultEndpoint + } + u.BaseURL = strings.TrimRight(u.BaseURL, "/") + upstreams = append(upstreams, u) + } + } + } else if len(req.ChannelIDs) > 0 { intIds := make([]int, 0, len(req.ChannelIDs)) for _, id64 := range req.ChannelIDs { intIds = append(intIds, int(id64)) @@ -57,6 +68,7 @@ func FetchUpstreamRatios(c *gin.Context) { for _, ch := range dbChannels { if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { upstreams = append(upstreams, dto.UpstreamDTO{ + ID: ch.Id, Name: ch.Name, BaseURL: strings.TrimRight(base, "/"), Endpoint: "", @@ -93,43 +105,125 @@ func FetchUpstreamRatios(c *gin.Context) { } fullURL := chItem.BaseURL + endpoint + uniqueName := chItem.Name + if chItem.ID != 0 { + uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID) + } + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { common.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) - ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return } resp, err := client.Do(httpReq) if err != nil { common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) - ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status) - ch <- upstreamResult{Name: chItem.Name, Err: resp.Status} + ch <- upstreamResult{Name: uniqueName, Err: resp.Status} return } + // 兼容两种上游接口格式: + // type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price + // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 var body struct { - Success bool `json:"success"` - Data map[string]any `json:"data"` - Message string `json:"message"` + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Message string `json:"message"` } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) - ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return } + if !body.Success { - ch <- upstreamResult{Name: chItem.Name, Err: body.Message} + ch <- upstreamResult{Name: uniqueName, Err: body.Message} return } - ch <- upstreamResult{Name: chItem.Name, Data: body.Data} + + // 尝试按 type1 解析 + var type1Data map[string]any + if err := json.Unmarshal(body.Data, &type1Data); err == nil { + // 如果包含至少一个 ratioTypes 字段,则认为是 type1 + isType1 := false + for _, rt := range ratioTypes { + if _, ok := type1Data[rt]; ok { + isType1 = true + break + } + } + if isType1 { + ch <- upstreamResult{Name: uniqueName, Data: type1Data} + return + } + } + + // 如果不是 type1,则尝试按 type2 (/api/pricing) 解析 + var pricingItems []struct { + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + CompletionRatio float64 `json:"completion_ratio"` + } + if err := json.Unmarshal(body.Data, &pricingItems); err != nil { + common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"} + return + } + + modelRatioMap := make(map[string]float64) + completionRatioMap := make(map[string]float64) + modelPriceMap := make(map[string]float64) + + for _, item := range pricingItems { + if item.QuotaType == 1 { + modelPriceMap[item.ModelName] = item.ModelPrice + } else { + modelRatioMap[item.ModelName] = item.ModelRatio + // completionRatio 可能为 0,此时也直接赋值,保持与上游一致 + completionRatioMap[item.ModelName] = item.CompletionRatio + } + } + + converted := make(map[string]any) + + if len(modelRatioMap) > 0 { + ratioAny := make(map[string]any, len(modelRatioMap)) + for k, v := range modelRatioMap { + ratioAny[k] = v + } + converted["model_ratio"] = ratioAny + } + + if len(completionRatioMap) > 0 { + compAny := make(map[string]any, len(completionRatioMap)) + for k, v := range completionRatioMap { + compAny[k] = v + } + converted["completion_ratio"] = compAny + } + + if len(modelPriceMap) > 0 { + priceAny := make(map[string]any, len(modelPriceMap)) + for k, v := range modelPriceMap { + priceAny[k] = v + } + converted["model_price"] = priceAny + } + + ch <- upstreamResult{Name: uniqueName, Data: converted} }(chn) } @@ -202,6 +296,43 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } + confidenceMap := make(map[string]map[string]bool) + + // 预处理阶段:检查pricing接口的可信度 + for _, channel := range successfulChannels { + confidenceMap[channel.name] = make(map[string]bool) + + modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any) + completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any) + + if hasModelRatio && hasCompletionRatio { + // 遍历所有模型,检查是否满足不可信条件 + for modelName := range allModels { + // 默认为可信 + confidenceMap[channel.name][modelName] = true + + // 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1 + if modelRatioVal, ok := modelRatios[modelName]; ok { + if completionRatioVal, ok := completionRatios[modelName]; ok { + // 转换为float64进行比较 + if modelRatioFloat, ok := modelRatioVal.(float64); ok { + if completionRatioFloat, ok := completionRatioVal.(float64); ok { + if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 { + confidenceMap[channel.name][modelName] = false + } + } + } + } + } + } + } else { + // 如果不是从pricing接口获取的数据,则全部标记为可信 + for modelName := range allModels { + confidenceMap[channel.name][modelName] = true + } + } + } + for modelName := range allModels { for _, ratioType := range ratioTypes { var localValue interface{} = nil @@ -214,6 +345,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } upstreamValues := make(map[string]interface{}) + confidenceValues := make(map[string]bool) hasUpstreamValue := false hasDifference := false @@ -241,6 +373,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } upstreamValues[channel.name] = upstreamValue + + confidenceValues[channel.name] = confidenceMap[channel.name][modelName] } shouldInclude := false @@ -262,6 +396,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { differences[modelName][ratioType] = dto.DifferenceItem{ Current: localValue, Upstreams: upstreamValues, + Confidence: confidenceValues, } } } @@ -283,9 +418,26 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { for chName := range item.Upstreams { if !channelHasDiff[chName] { delete(item.Upstreams, chName) + delete(item.Confidence, chName) + } + } + + allSame := true + for _, v := range item.Upstreams { + if v != "same" { + allSame = false + break } } - differences[modelName][ratioType] = item + if len(item.Upstreams) == 0 || allSame { + delete(ratioMap, ratioType) + } else { + differences[modelName][ratioType] = item + } + } + + if len(ratioMap) == 0 { + delete(differences, modelName) } } diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go index 55a89025b085..6315f31ae6f5 100644 --- a/dto/ratio_sync.go +++ b/dto/ratio_sync.go @@ -1,18 +1,7 @@ package dto -// UpstreamDTO 提交到后端同步倍率的上游渠道信息 -// Endpoint 可以为空,后端会默认使用 /api/ratio_config -// BaseURL 必须以 http/https 开头,不要以 / 结尾 -// 例如: https://api.example.com -// Endpoint: /api/ratio_config -// 提交示例: -// { -// "name": "openai", -// "base_url": "https://api.openai.com", -// "endpoint": "/ratio_config" -// } - type UpstreamDTO struct { + ID int `json:"id,omitempty"` Name string `json:"name" binding:"required"` BaseURL string `json:"base_url" binding:"required"` Endpoint string `json:"endpoint"` @@ -20,6 +9,7 @@ type UpstreamDTO struct { type UpstreamRequest struct { ChannelIDs []int64 `json:"channel_ids"` + Upstreams []UpstreamDTO `json:"upstreams"` Timeout int `json:"timeout"` } @@ -37,10 +27,9 @@ type TestResult struct { type DifferenceItem struct { Current interface{} `json:"current"` Upstreams map[string]interface{} `json:"upstreams"` + Confidence map[string]bool `json:"confidence"` } -// SyncableChannel 可同步的渠道信息(base_url 不为空) - type SyncableChannel struct { ID int `json:"id"` Name string `json:"name"` diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index 573329b3cadf..a09eff1c6a23 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -1,115 +1,183 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { isMobile } from '../../helpers'; import { Modal, - Transfer, + Table, Input, Space, - Checkbox, - Avatar, Highlight, + Select, + Tag, } from '@douyinfe/semi-ui'; -import { IconClose } from '@douyinfe/semi-icons'; +import { IconSearch } from '@douyinfe/semi-icons'; +import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react'; -const CHANNEL_STATUS_CONFIG = { - 1: { color: 'green', text: '启用' }, - 2: { color: 'red', text: '禁用' }, - 3: { color: 'amber', text: '自禁' }, - default: { color: 'grey', text: '未知' } -}; - -const getChannelStatusConfig = (status) => { - return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default; -}; - -export default function ChannelSelectorModal({ - t, +const ChannelSelectorModal = forwardRef(({ visible, onCancel, onOk, - allChannels = [], - selectedChannelIds = [], + allChannels, + selectedChannelIds, setSelectedChannelIds, channelEndpoints, updateChannelEndpoint, -}) { + t, +}, ref) => { const [searchText, setSearchText] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); - const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => { - const channelId = item.key || item.value; - const currentEndpoint = channelEndpoints[channelId]; - const baseUrl = item._originalData?.base_url || ''; - const status = item._originalData?.status || 0; - const statusConfig = getChannelStatusConfig(status); + const [filteredData, setFilteredData] = useState([]); - return ( - <> - - {statusConfig.text} - -
-
- {isSelected ? ( - item.label - ) : ( - - )} -
-
- - {isSelected ? ( - baseUrl - ) : ( - - )} - - {showEndpoint && ( - updateChannelEndpoint(channelId, value)} - placeholder="/api/ratio_config" - className="flex-1 text-xs" - style={{ fontSize: '12px' }} - /> - )} - {isSelected && !showEndpoint && ( - - {currentEndpoint} - - )} -
-
- - ); + useImperativeHandle(ref, () => ({ + resetPagination: () => { + setCurrentPage(1); + setSearchText(''); + }, + })); + + useEffect(() => { + if (!allChannels) return; + + const searchLower = searchText.trim().toLowerCase(); + const matched = searchLower + ? allChannels.filter((item) => { + const name = (item.label || '').toLowerCase(); + const baseUrl = (item._originalData?.base_url || '').toLowerCase(); + return name.includes(searchLower) || baseUrl.includes(searchLower); + }) + : allChannels; + + setFilteredData(matched); + }, [allChannels, searchText]); + + const total = filteredData.length; + + const paginatedData = filteredData.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); + + const updateEndpoint = (channelId, endpoint) => { + if (typeof updateChannelEndpoint === 'function') { + updateChannelEndpoint(channelId, endpoint); + } }; - const renderSourceItem = (item) => { + const renderEndpointCell = (text, record) => { + const channelId = record.key || record.value; + const currentEndpoint = channelEndpoints[channelId] || ''; + + const getEndpointType = (ep) => { + if (ep === '/api/ratio_config') return 'ratio_config'; + if (ep === '/api/pricing') return 'pricing'; + return 'custom'; + }; + + const currentType = getEndpointType(currentEndpoint); + + const handleTypeChange = (val) => { + if (val === 'ratio_config') { + updateEndpoint(channelId, '/api/ratio_config'); + } else if (val === 'pricing') { + updateEndpoint(channelId, '/api/pricing'); + } else { + if (currentType !== 'custom') { + updateEndpoint(channelId, ''); + } + } + }; + return ( -
- - - +
+ updateEndpoint(channelId, val)} + placeholder="/your/endpoint" + style={{ width: 160, fontSize: 12 }} + /> + )}
); }; - const renderSelectedItem = (item) => { - return ( -
- - -
- ); + const renderStatusCell = (status) => { + switch (status) { + case 1: + return ( + }> + {t('已启用')} + + ); + case 2: + return ( + }> + {t('已禁用')} + + ); + case 3: + return ( + }> + {t('自动禁用')} + + ); + default: + return ( + }> + {t('未知状态')} + + ); + } }; - const channelFilter = (input, item) => { - const searchLower = input.toLowerCase(); - return item.label.toLowerCase().includes(searchLower) || - (item._originalData?.base_url || '').toLowerCase().includes(searchLower); + const renderNameCell = (text) => ( + + ); + + const renderBaseUrlCell = (text) => ( + + ); + + const columns = [ + { + title: t('名称'), + dataIndex: 'label', + render: renderNameCell, + }, + { + title: t('源地址'), + dataIndex: '_originalData.base_url', + render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''), + }, + { + title: t('状态'), + dataIndex: '_originalData.status', + render: (_, record) => renderStatusCell(record._originalData?.status || 0), + }, + { + title: t('同步接口'), + dataIndex: 'endpoint', + fixed: 'right', + render: renderEndpointCell, + }, + ]; + + const rowSelection = { + selectedRowKeys: selectedChannelIds, + onChange: (keys) => setSelectedChannelIds(keys), }; return ( @@ -118,26 +186,51 @@ export default function ChannelSelectorModal({ onCancel={onCancel} onOk={onOk} title={{t('选择同步渠道')}} - width={1000} + size={isMobile() ? 'full-width' : 'large'} + keepDOM + lazyRender={false} > - } + placeholder={t('搜索渠道名称或地址')} + value={searchText} + onChange={setSearchText} + showClear + className="!rounded-full" + /> + + t('第 {{start}} - {{end}} 条,共 {{total}} 条', { + start: page.currentStart, + end: page.currentEnd, + total: total, + }), + onChange: (page, size) => { + setCurrentPage(page); + setPageSize(size); + }, + onShowSizeChange: (curr, size) => { + setCurrentPage(1); + setPageSize(size); + }, }} + size="small" /> ); -} \ No newline at end of file +}); + +export default ChannelSelectorModal; \ No newline at end of file diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 9092146093cc..7aef69ce6cfd 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -114,7 +114,7 @@ const ChannelsTable = () => { ); case 2: return ( - }> + }> {t('已禁用')} ); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d9cfe1d85cf0..70ce272d2888 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1701,5 +1701,14 @@ "充值分组倍率": "Recharge group ratio", "充值方式设置": "Recharge method settings", "更新支付设置": "Update payment settings", - "通知": "Notice" + "通知": "Notice", + "源地址": "Source address", + "同步接口": "Synchronization interface", + "置信度": "Confidence", + "谨慎": "Cautious", + "该数据可能不可信,请谨慎使用": "This data may not be reliable, please use with caution", + "可信": "Reliable", + "所有上游数据均可信": "All upstream data is reliable", + "以下上游数据可能不可信:": "The following upstream data may not be reliable: ", + "按倍率类型筛选": "Filter by ratio type" } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index c246a3fe54f3..656e31aee003 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -7,11 +7,15 @@ import { Checkbox, Form, Input, + Tooltip, + Select, } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; import { RefreshCcw, CheckSquare, + AlertTriangle, + CheckCircle, } from 'lucide-react'; import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; import { DEFAULT_ENDPOINT } from '../../../constants'; @@ -49,6 +53,11 @@ export default function UpstreamRatioSync(props) { // 搜索相关状态 const [searchKeyword, setSearchKeyword] = useState(''); + // 倍率类型过滤 + const [ratioTypeFilter, setRatioTypeFilter] = useState(''); + + const channelSelectorRef = React.useRef(null); + const fetchAllChannels = async () => { setLoading(true); try { @@ -67,11 +76,16 @@ export default function UpstreamRatioSync(props) { setAllChannels(transferData); - const initialEndpoints = {}; - transferData.forEach(channel => { - initialEndpoints[channel.key] = DEFAULT_ENDPOINT; + // 合并已有 endpoints,避免每次打开弹窗都重置 + setChannelEndpoints(prev => { + const merged = { ...prev }; + transferData.forEach(channel => { + if (!merged[channel.key]) { + merged[channel.key] = DEFAULT_ENDPOINT; + } + }); + return merged; }); - setChannelEndpoints(initialEndpoints); } else { showError(res.data.message); } @@ -99,8 +113,15 @@ export default function UpstreamRatioSync(props) { const fetchRatiosFromChannels = async (channelList) => { setSyncLoading(true); + const upstreams = channelList.map(ch => ({ + id: ch.id, + name: ch.name, + base_url: ch.base_url, + endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT, + })); + const payload = { - channel_ids: channelList.map(ch => parseInt(ch.id)), + upstreams: upstreams, timeout: 10, }; @@ -215,13 +236,15 @@ export default function UpstreamRatioSync(props) { const renderHeader = () => (
-
+
@@ -268,6 +307,7 @@ export default function UpstreamRatioSync(props) { ratioType, current: diff.current, upstreams: diff.upstreams, + confidence: diff.confidence || {}, }); }); }); @@ -276,15 +316,20 @@ export default function UpstreamRatioSync(props) { }, [differences]); const filteredDataSource = useMemo(() => { - if (!searchKeyword.trim()) { + if (!searchKeyword.trim() && !ratioTypeFilter) { return dataSource; } - const keyword = searchKeyword.toLowerCase().trim(); - return dataSource.filter(item => - item.model.toLowerCase().includes(keyword) - ); - }, [dataSource, searchKeyword]); + return dataSource.filter(item => { + const matchesKeyword = !searchKeyword.trim() || + item.model.toLowerCase().includes(searchKeyword.toLowerCase().trim()); + + const matchesRatioType = !ratioTypeFilter || + item.ratioType === ratioTypeFilter; + + return matchesKeyword && matchesRatioType; + }); + }, [dataSource, searchKeyword, ratioTypeFilter]); const upstreamNames = useMemo(() => { const set = new Set(); @@ -330,6 +375,36 @@ export default function UpstreamRatioSync(props) { return {typeMap[text] || text}; }, }, + { + title: t('置信度'), + dataIndex: 'confidence', + render: (_, record) => { + const allConfident = Object.values(record.confidence || {}).every(v => v !== false); + + if (allConfident) { + return ( + + }> + {t('可信')} + + + ); + } else { + const untrustedSources = Object.entries(record.confidence || {}) + .filter(([_, isConfident]) => isConfident === false) + .map(([name]) => name) + .join(', '); + + return ( + + }> + {t('谨慎')} + + + ); + } + }, + }, { title: t('当前值'), dataIndex: 'current', @@ -404,6 +479,7 @@ export default function UpstreamRatioSync(props) { dataIndex: upName, render: (_, record) => { const upstreamVal = record.upstreams?.[upName]; + const isConfident = record.confidence?.[upName] !== false; if (upstreamVal === null || upstreamVal === undefined) { return {t('未设置')}; @@ -416,28 +492,35 @@ export default function UpstreamRatioSync(props) { const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal; return ( - { - const isChecked = e.target.checked; - if (isChecked) { - selectValue(record.model, record.ratioType, upstreamVal); - } else { - setResolutions((prev) => { - const newRes = { ...prev }; - if (newRes[record.model]) { - delete newRes[record.model][record.ratioType]; - if (Object.keys(newRes[record.model]).length === 0) { - delete newRes[record.model]; +
+ { + const isChecked = e.target.checked; + if (isChecked) { + selectValue(record.model, record.ratioType, upstreamVal); + } else { + setResolutions((prev) => { + const newRes = { ...prev }; + if (newRes[record.model]) { + delete newRes[record.model][record.ratioType]; + if (Object.keys(newRes[record.model]).length === 0) { + delete newRes[record.model]; + } } - } - return newRes; - }); - } - }} - > - {upstreamVal} - + return newRes; + }); + } + }} + > + {upstreamVal} + + {!isConfident && ( + + + + )} +
); }, }; @@ -481,6 +564,13 @@ export default function UpstreamRatioSync(props) { setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint })); }, []); + const handleModalClose = () => { + setModalVisible(false); + if (channelSelectorRef.current) { + channelSelectorRef.current.resetPagination(); + } + }; + return ( <> @@ -488,9 +578,10 @@ export default function UpstreamRatioSync(props) { setModalVisible(false)} + onCancel={handleModalClose} onOk={confirmChannelSelection} allChannels={allChannels} selectedChannelIds={selectedChannelIds} From 44d20de251d91d83cc084254b498b2326f60abc0 Mon Sep 17 00:00:00 2001 From: skynono Date: Sat, 21 Jun 2025 20:36:52 +0800 Subject: [PATCH 12/74] feat: add placeholder for kling AccessKey and SecretKey --- web/src/pages/Channel/EditChannel.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index ca38e6b969a9..1ef8af8c1d7f 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -64,6 +64,8 @@ function type2secretPrompt(type) { return '按照如下格式输入:AppId|SecretId|SecretKey'; case 33: return '按照如下格式输入:Ak|Sk|Region'; + case 50: + return '按照如下格式输入: AccessKey|SecretKey'; default: return '请输入渠道对应的鉴权密钥'; } From e4def0625b3984118fd70229e696164150b9c202 Mon Sep 17 00:00:00 2001 From: skynono Date: Sat, 21 Jun 2025 20:50:53 +0800 Subject: [PATCH 13/74] feat: kling apiKey format to use `|` delimiter --- relay/channel/task/kling/adaptor.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index 9ea58728234d..2995a07bfe3b 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -69,8 +69,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.BaseUrl - // apiKey format: "access_key,secret_key" - keyParts := strings.Split(info.ApiKey, ",") + // apiKey format: "access_key|secret_key" + keyParts := strings.Split(info.ApiKey, "|") if len(keyParts) == 2 { a.accessKey = strings.TrimSpace(keyParts[0]) a.secretKey = strings.TrimSpace(keyParts[1]) @@ -264,7 +264,7 @@ func (a *TaskAdaptor) createJWTToken() (string, error) { } func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) { - parts := strings.Split(apiKey, ",") + parts := strings.Split(apiKey, "|") if len(parts) != 2 { return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'") } From 384fadf227ab1b7457e01bbd7ec799d2bfa40c67 Mon Sep 17 00:00:00 2001 From: CaIon <1808837298@qq.com> Date: Sat, 21 Jun 2025 21:50:03 +0800 Subject: [PATCH 14/74] =?UTF-8?q?=E2=9C=A8=20feat(gemini):=20enhance=20Thi?= =?UTF-8?q?nkingAdapter=20and=20model=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `isNoThinkingRequest` and `trimModelThinking` functions to manage model names and thinking configurations. - Updated `GeminiHelper` to conditionally adjust the model name based on the thinking budget and request settings. - Refactored `ThinkingAdaptor` to streamline the integration of thinking capabilities into Gemini requests. - Cleaned up commented-out code in `FetchUpstreamModels` for clarity. These changes improve the handling of model configurations and enhance the adaptability of the Gemini relay system. --- controller/channel.go | 7 ---- relay/channel/gemini/relay-gemini.go | 44 +++++++++++++------------ relay/gemini_handler.go | 49 ++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 13ed72b3dea0..70410295d3bb 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -134,13 +134,6 @@ func FetchUpstreamModels(c *gin.Context) { return } - //if channel.Type != common.ChannelTypeOpenAI { - // c.JSON(http.StatusOK, gin.H{ - // "success": false, - // "message": "仅支持 OpenAI 类型渠道", - // }) - // return - //} baseURL := common.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 18edfd04d229..a65216ad0c27 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -78,26 +78,7 @@ func clampThinkingBudget(modelName string, budget int) int { return budget } -// Setting safety to the lowest possible values since Gemini is already powerless enough -func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { - - geminiRequest := GeminiChatRequest{ - Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), - GenerationConfig: GeminiChatGenerationConfig{ - Temperature: textRequest.Temperature, - TopP: textRequest.TopP, - MaxOutputTokens: textRequest.MaxTokens, - Seed: int64(textRequest.Seed), - }, - } - - if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { - geminiRequest.GenerationConfig.ResponseModalities = []string{ - "TEXT", - "IMAGE", - } - } - +func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && @@ -150,6 +131,29 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } } } +} + +// Setting safety to the lowest possible values since Gemini is already powerless enough +func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { + + geminiRequest := GeminiChatRequest{ + Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: GeminiChatGenerationConfig{ + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + MaxOutputTokens: textRequest.MaxTokens, + Seed: int64(textRequest.Seed), + }, + } + + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + geminiRequest.GenerationConfig.ResponseModalities = []string{ + "TEXT", + "IMAGE", + } + } + + ThinkingAdaptor(&geminiRequest, info) safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList)) for _, category := range SafetySettingList { diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 14d58cc581e9..9185ce624e81 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -13,6 +13,7 @@ import ( "one-api/relay/helper" "one-api/service" "one-api/setting" + "one-api/setting/model_setting" "strings" "github.com/gin-gonic/gin" @@ -76,6 +77,33 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay return inputTokens } +func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool { + if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { + return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0 + } + return false +} + +func trimModelThinking(modelName string) string { + // 去除模型名称中的 -nothinking 后缀 + if strings.HasSuffix(modelName, "-nothinking") { + return strings.TrimSuffix(modelName, "-nothinking") + } + // 去除模型名称中的 -thinking 后缀 + if strings.HasSuffix(modelName, "-thinking") { + return strings.TrimSuffix(modelName, "-thinking") + } + + // 去除模型名称中的 -thinking-number + if strings.Contains(modelName, "-thinking-") { + parts := strings.Split(modelName, "-thinking-") + if len(parts) > 1 { + return parts[0] + "-thinking" + } + } + return modelName +} + func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { req, err := getAndValidateGeminiRequest(c) if err != nil { @@ -107,12 +135,27 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { relayInfo.SetPromptTokens(promptTokens) } else { promptTokens := getGeminiInputTokens(req, relayInfo) - if err != nil { - return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest) - } c.Set("prompt_tokens", promptTokens) } + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + if isNoThinkingRequest(req) { + // check is thinking + if !strings.Contains(relayInfo.OriginModelName, "-nothinking") { + // try to get no thinking model price + noThinkingModelName := relayInfo.OriginModelName + "-nothinking" + containPrice := helper.ContainPriceOrRatio(noThinkingModelName) + if containPrice { + relayInfo.OriginModelName = noThinkingModelName + relayInfo.UpstreamModelName = noThinkingModelName + } + } + } + if req.GenerationConfig.ThinkingConfig == nil { + gemini.ThinkingAdaptor(req, relayInfo) + } + } + priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens)) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError) From 58c9c7d5dd28d9e5cd039c84f789a78f661506b5 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 21 Jun 2025 21:59:38 +0800 Subject: [PATCH 15/74] =?UTF-8?q?=E2=9C=A8=20feat(settings-announcements):?= =?UTF-8?q?=20improve=20editor=20UX=20with=20modal=20&=20tooltips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added “Expand Edit” button with `Maximize2` icon to open a large modal editor * Introduced full-screen `TextArea` modal; content syncs back to main form via Form API * Switched to correct `TextArea` import from Semi UI to fix invalid element error * Implemented ellipsis & `Tooltip` for “content” and “extra” columns to keep table concise * Added state management (`showContentModal`, `formApiRef`) and related handlers * Updated success messages & punctuation for consistency --- web/src/i18n/locales/en.json | 5 +- .../Dashboard/SettingsAnnouncements.js | 91 +++++++++++++++---- web/src/pages/Setting/index.js | 20 ++-- 3 files changed, 87 insertions(+), 29 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 70ce272d2888..4b5dfc653ac2 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1710,5 +1710,8 @@ "可信": "Reliable", "所有上游数据均可信": "All upstream data is reliable", "以下上游数据可能不可信:": "The following upstream data may not be reliable: ", - "按倍率类型筛选": "Filter by ratio type" + "按倍率类型筛选": "Filter by ratio type", + "内容": "Content", + "放大编辑": "Expand editor", + "编辑公告内容": "Edit announcement content" } \ No newline at end of file diff --git a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js index c15e2885fe6b..dfbad8eee1d0 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js +++ b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Button, Space, @@ -9,7 +9,9 @@ import { Divider, Modal, Tag, - Switch + Switch, + TextArea, + Tooltip } from '@douyinfe/semi-ui'; import { IllustrationNoResult, @@ -20,7 +22,8 @@ import { Edit, Trash2, Save, - Bell + Bell, + Maximize2 } from 'lucide-react'; import { API, showError, showSuccess, getRelativeTime, formatDateTimeString } from '../../../helpers'; import { useTranslation } from 'react-i18next'; @@ -33,6 +36,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { const [announcementsList, setAnnouncementsList] = useState([]); const [showAnnouncementModal, setShowAnnouncementModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showContentModal, setShowContentModal] = useState(false); const [deletingAnnouncement, setDeletingAnnouncement] = useState(null); const [editingAnnouncement, setEditingAnnouncement] = useState(null); const [modalLoading, setModalLoading] = useState(false); @@ -51,6 +55,8 @@ const SettingsAnnouncements = ({ options, refresh }) => { // 面板启用状态 const [panelEnabled, setPanelEnabled] = useState(true); + const formApiRef = useRef(null); + const typeOptions = [ { value: 'default', label: t('默认') }, { value: 'ongoing', label: t('进行中') }, @@ -76,13 +82,16 @@ const SettingsAnnouncements = ({ options, refresh }) => { dataIndex: 'content', key: 'content', render: (text) => ( -
- {text} -
+ +
+ {text} +
+
) }, { @@ -121,13 +130,17 @@ const SettingsAnnouncements = ({ options, refresh }) => { dataIndex: 'extra', key: 'extra', render: (text) => ( -
- {text || '-'} -
+ +
+ {text || '-'} +
+
) }, { @@ -472,7 +485,12 @@ const SettingsAnnouncements = ({ options, refresh }) => { className="rounded-xl" confirmLoading={modalLoading} > -
+ (formApiRef.current = api)} + > { rules={[{ required: true, message: t('请输入公告内容') }]} onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })} /> + { > {t('确定要删除此公告吗?')} + + {/* 公告内容放大编辑 Modal */} + { + // 将内容同步到表单 + if (formApiRef.current) { + formApiRef.current.setValue('content', announcementForm.content); + } + setShowContentModal(false); + }} + onCancel={() => setShowContentModal(false)} + okText={t('确定')} + cancelText={t('取消')} + className="rounded-xl" + width={800} + > +