diff --git a/common/utils.go b/common/utils.go
index d9db67d0..17aecd95 100644
--- a/common/utils.go
+++ b/common/utils.go
@@ -13,6 +13,7 @@ import (
"math/big"
"math/rand"
"net"
+ "net/url"
"os"
"os/exec"
"runtime"
@@ -284,3 +285,20 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
}
return strconv.ParseFloat(durationStr, 64)
}
+
+// BuildURL concatenates base and endpoint, returns the complete url string
+func BuildURL(base string, endpoint string) string {
+ u, err := url.Parse(base)
+ if err != nil {
+ return base + endpoint
+ }
+ end := endpoint
+ if end == "" {
+ end = "/"
+ }
+ ref, err := url.Parse(end)
+ if err != nil {
+ return base + endpoint
+ }
+ return u.ResolveReference(ref).String()
+}
diff --git a/controller/ratio_config.go b/controller/ratio_config.go
new file mode 100644
index 00000000..6ddc3d9e
--- /dev/null
+++ b/controller/ratio_config.go
@@ -0,0 +1,24 @@
+package controller
+
+import (
+ "net/http"
+ "one-api/setting/ratio_setting"
+
+ "github.com/gin-gonic/gin"
+)
+
+func GetRatioConfig(c *gin.Context) {
+ if !ratio_setting.IsExposeRatioEnabled() {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": "倍率配置接口未启用",
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": ratio_setting.GetExposedData(),
+ })
+}
\ No newline at end of file
diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go
new file mode 100644
index 00000000..f749f384
--- /dev/null
+++ b/controller/ratio_sync.go
@@ -0,0 +1,322 @@
+package controller
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "one-api/common"
+ "one-api/dto"
+ "one-api/model"
+ "one-api/setting/ratio_setting"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ defaultTimeoutSeconds = 10
+ defaultEndpoint = "/api/ratio_config"
+ maxConcurrentFetches = 8
+)
+
+var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
+
+type upstreamResult struct {
+ Name string `json:"name"`
+ Data map[string]any `json:"data,omitempty"`
+ Err string `json:"err,omitempty"`
+}
+
+func FetchUpstreamRatios(c *gin.Context) {
+ var req dto.UpstreamRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ if req.Timeout <= 0 {
+ req.Timeout = defaultTimeoutSeconds
+ }
+
+ var upstreams []dto.UpstreamDTO
+
+ if len(req.ChannelIDs) > 0 {
+ intIds := make([]int, 0, len(req.ChannelIDs))
+ for _, id64 := range req.ChannelIDs {
+ intIds = append(intIds, int(id64))
+ }
+ dbChannels, err := model.GetChannelsByIds(intIds)
+ if err != nil {
+ common.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
+ return
+ }
+ for _, ch := range dbChannels {
+ if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
+ upstreams = append(upstreams, dto.UpstreamDTO{
+ Name: ch.Name,
+ BaseURL: strings.TrimRight(base, "/"),
+ Endpoint: "",
+ })
+ }
+ }
+ }
+
+ if len(upstreams) == 0 {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
+ return
+ }
+
+ var wg sync.WaitGroup
+ ch := make(chan upstreamResult, len(upstreams))
+
+ sem := make(chan struct{}, maxConcurrentFetches)
+
+ client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
+
+ for _, chn := range upstreams {
+ wg.Add(1)
+ go func(chItem dto.UpstreamDTO) {
+ defer wg.Done()
+
+ sem <- struct{}{}
+ defer func() { <-sem }()
+
+ endpoint := chItem.Endpoint
+ if endpoint == "" {
+ endpoint = defaultEndpoint
+ } else if !strings.HasPrefix(endpoint, "/") {
+ endpoint = "/" + endpoint
+ }
+ fullURL := chItem.BaseURL + endpoint
+
+ 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()}
+ 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()}
+ 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}
+ return
+ }
+ var body struct {
+ Success bool `json:"success"`
+ Data map[string]any `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()}
+ return
+ }
+ if !body.Success {
+ ch <- upstreamResult{Name: chItem.Name, Err: body.Message}
+ return
+ }
+ ch <- upstreamResult{Name: chItem.Name, Data: body.Data}
+ }(chn)
+ }
+
+ wg.Wait()
+ close(ch)
+
+ localData := ratio_setting.GetExposedData()
+
+ var testResults []dto.TestResult
+ var successfulChannels []struct {
+ name string
+ data map[string]any
+ }
+
+ for r := range ch {
+ if r.Err != "" {
+ testResults = append(testResults, dto.TestResult{
+ Name: r.Name,
+ Status: "error",
+ Error: r.Err,
+ })
+ } else {
+ testResults = append(testResults, dto.TestResult{
+ Name: r.Name,
+ Status: "success",
+ })
+ successfulChannels = append(successfulChannels, struct {
+ name string
+ data map[string]any
+ }{name: r.Name, data: r.Data})
+ }
+ }
+
+ differences := buildDifferences(localData, successfulChannels)
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": gin.H{
+ "differences": differences,
+ "test_results": testResults,
+ },
+ })
+}
+
+func buildDifferences(localData map[string]any, successfulChannels []struct {
+ name string
+ data map[string]any
+}) map[string]map[string]dto.DifferenceItem {
+ differences := make(map[string]map[string]dto.DifferenceItem)
+
+ allModels := make(map[string]struct{})
+
+ for _, ratioType := range ratioTypes {
+ if localRatioAny, ok := localData[ratioType]; ok {
+ if localRatio, ok := localRatioAny.(map[string]float64); ok {
+ for modelName := range localRatio {
+ allModels[modelName] = struct{}{}
+ }
+ }
+ }
+ }
+
+ for _, channel := range successfulChannels {
+ for _, ratioType := range ratioTypes {
+ if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
+ for modelName := range upstreamRatio {
+ allModels[modelName] = struct{}{}
+ }
+ }
+ }
+ }
+
+ for modelName := range allModels {
+ for _, ratioType := range ratioTypes {
+ var localValue interface{} = nil
+ if localRatioAny, ok := localData[ratioType]; ok {
+ if localRatio, ok := localRatioAny.(map[string]float64); ok {
+ if val, exists := localRatio[modelName]; exists {
+ localValue = val
+ }
+ }
+ }
+
+ upstreamValues := make(map[string]interface{})
+ hasUpstreamValue := false
+ hasDifference := false
+
+ for _, channel := range successfulChannels {
+ var upstreamValue interface{} = nil
+
+ if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
+ if val, exists := upstreamRatio[modelName]; exists {
+ upstreamValue = val
+ hasUpstreamValue = true
+
+ if localValue != nil && localValue != val {
+ hasDifference = true
+ } else if localValue == val {
+ upstreamValue = "same"
+ }
+ }
+ }
+ if upstreamValue == nil && localValue == nil {
+ upstreamValue = "same"
+ }
+
+ if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
+ hasDifference = true
+ }
+
+ upstreamValues[channel.name] = upstreamValue
+ }
+
+ shouldInclude := false
+
+ if localValue != nil {
+ if hasDifference {
+ shouldInclude = true
+ }
+ } else {
+ if hasUpstreamValue {
+ shouldInclude = true
+ }
+ }
+
+ if shouldInclude {
+ if differences[modelName] == nil {
+ differences[modelName] = make(map[string]dto.DifferenceItem)
+ }
+ differences[modelName][ratioType] = dto.DifferenceItem{
+ Current: localValue,
+ Upstreams: upstreamValues,
+ }
+ }
+ }
+ }
+
+ channelHasDiff := make(map[string]bool)
+ for _, ratioMap := range differences {
+ for _, item := range ratioMap {
+ for chName, val := range item.Upstreams {
+ if val != nil && val != "same" {
+ channelHasDiff[chName] = true
+ }
+ }
+ }
+ }
+
+ for modelName, ratioMap := range differences {
+ for ratioType, item := range ratioMap {
+ for chName := range item.Upstreams {
+ if !channelHasDiff[chName] {
+ delete(item.Upstreams, chName)
+ }
+ }
+ differences[modelName][ratioType] = item
+ }
+ }
+
+ return differences
+}
+
+func GetSyncableChannels(c *gin.Context) {
+ channels, err := model.GetAllChannels(0, 0, true, false)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
+ var syncableChannels []dto.SyncableChannel
+ for _, channel := range channels {
+ if channel.GetBaseURL() != "" {
+ syncableChannels = append(syncableChannels, dto.SyncableChannel{
+ ID: channel.Id,
+ Name: channel.Name,
+ BaseURL: channel.GetBaseURL(),
+ Status: channel.Status,
+ })
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": syncableChannels,
+ })
+}
\ No newline at end of file
diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go
new file mode 100644
index 00000000..55a89025
--- /dev/null
+++ b/dto/ratio_sync.go
@@ -0,0 +1,49 @@
+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 {
+ Name string `json:"name" binding:"required"`
+ BaseURL string `json:"base_url" binding:"required"`
+ Endpoint string `json:"endpoint"`
+}
+
+type UpstreamRequest struct {
+ ChannelIDs []int64 `json:"channel_ids"`
+ Timeout int `json:"timeout"`
+}
+
+// TestResult 上游测试连通性结果
+type TestResult struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Error string `json:"error,omitempty"`
+}
+
+// DifferenceItem 差异项
+// Current 为本地值,可能为 nil
+// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
+
+type DifferenceItem struct {
+ Current interface{} `json:"current"`
+ Upstreams map[string]interface{} `json:"upstreams"`
+}
+
+// SyncableChannel 可同步的渠道信息(base_url 不为空)
+
+type SyncableChannel struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ BaseURL string `json:"base_url"`
+ Status int `json:"status"`
+}
\ No newline at end of file
diff --git a/model/option.go b/model/option.go
index ec386b29..ea72e5ee 100644
--- a/model/option.go
+++ b/model/option.go
@@ -127,6 +127,7 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
+ common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled())
// 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs()
@@ -267,6 +268,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
+ case "ExposeRatioEnabled":
+ ratio_setting.SetExposeRatioEnabled(boolValue)
}
}
switch key {
diff --git a/router/api-router.go b/router/api-router.go
index 45930246..badfa7bf 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -36,6 +36,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
+ apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
userRoute := apiRouter.Group("/user")
{
@@ -83,6 +84,12 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
}
+ ratioSyncRoute := apiRouter.Group("/ratio_sync")
+ ratioSyncRoute.Use(middleware.RootAuth())
+ {
+ ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
+ ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
+ }
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
{
diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go
index aa934b22..51d473a8 100644
--- a/setting/ratio_setting/cache_ratio.go
+++ b/setting/ratio_setting/cache_ratio.go
@@ -85,7 +85,11 @@ func UpdateCacheRatioByJSONString(jsonStr string) error {
cacheRatioMapMutex.Lock()
defer cacheRatioMapMutex.Unlock()
cacheRatioMap = make(map[string]float64)
- return json.Unmarshal([]byte(jsonStr), &cacheRatioMap)
+ err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
}
// GetCacheRatio returns the cache ratio for a model
@@ -106,3 +110,13 @@ func GetCreateCacheRatio(name string) (float64, bool) {
}
return ratio, true
}
+
+func GetCacheRatioCopy() map[string]float64 {
+ cacheRatioMapMutex.RLock()
+ defer cacheRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(cacheRatioMap))
+ for k, v := range cacheRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go
new file mode 100644
index 00000000..8fca0bcb
--- /dev/null
+++ b/setting/ratio_setting/expose_ratio.go
@@ -0,0 +1,17 @@
+package ratio_setting
+
+import "sync/atomic"
+
+var exposeRatioEnabled atomic.Bool
+
+func init() {
+ exposeRatioEnabled.Store(false)
+}
+
+func SetExposeRatioEnabled(enabled bool) {
+ exposeRatioEnabled.Store(enabled)
+}
+
+func IsExposeRatioEnabled() bool {
+ return exposeRatioEnabled.Load()
+}
\ No newline at end of file
diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go
new file mode 100644
index 00000000..9e5b6c30
--- /dev/null
+++ b/setting/ratio_setting/exposed_cache.go
@@ -0,0 +1,55 @@
+package ratio_setting
+
+import (
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+const exposedDataTTL = 30 * time.Second
+
+type exposedCache struct {
+ data gin.H
+ expiresAt time.Time
+}
+
+var (
+ exposedData atomic.Value
+ rebuildMu sync.Mutex
+)
+
+func InvalidateExposedDataCache() {
+ exposedData.Store((*exposedCache)(nil))
+}
+
+func cloneGinH(src gin.H) gin.H {
+ dst := make(gin.H, len(src))
+ for k, v := range src {
+ dst[k] = v
+ }
+ return dst
+}
+
+func GetExposedData() gin.H {
+ if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
+ return cloneGinH(c.data)
+ }
+ rebuildMu.Lock()
+ defer rebuildMu.Unlock()
+ if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
+ return cloneGinH(c.data)
+ }
+ newData := gin.H{
+ "model_ratio": GetModelRatioCopy(),
+ "completion_ratio": GetCompletionRatioCopy(),
+ "cache_ratio": GetCacheRatioCopy(),
+ "model_price": GetModelPriceCopy(),
+ }
+ exposedData.Store(&exposedCache{
+ data: newData,
+ expiresAt: time.Now().Add(exposedDataTTL),
+ })
+ return cloneGinH(newData)
+}
\ No newline at end of file
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 3102dfe9..1eaf25b1 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -317,7 +317,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
modelPriceMapMutex.Lock()
defer modelPriceMapMutex.Unlock()
modelPriceMap = make(map[string]float64)
- return json.Unmarshal([]byte(jsonStr), &modelPriceMap)
+ err := json.Unmarshal([]byte(jsonStr), &modelPriceMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
}
// GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false
@@ -345,7 +349,11 @@ func UpdateModelRatioByJSONString(jsonStr string) error {
modelRatioMapMutex.Lock()
defer modelRatioMapMutex.Unlock()
modelRatioMap = make(map[string]float64)
- return json.Unmarshal([]byte(jsonStr), &modelRatioMap)
+ err := json.Unmarshal([]byte(jsonStr), &modelRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
}
// 处理带有思考预算的模型名称,方便统一定价
@@ -405,7 +413,11 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error {
CompletionRatioMutex.Lock()
defer CompletionRatioMutex.Unlock()
CompletionRatio = make(map[string]float64)
- return json.Unmarshal([]byte(jsonStr), &CompletionRatio)
+ err := json.Unmarshal([]byte(jsonStr), &CompletionRatio)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
}
func GetCompletionRatio(name string) float64 {
@@ -609,3 +621,33 @@ func GetImageRatio(name string) (float64, bool) {
}
return ratio, true
}
+
+func GetModelRatioCopy() map[string]float64 {
+ modelRatioMapMutex.RLock()
+ defer modelRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(modelRatioMap))
+ for k, v := range modelRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
+func GetModelPriceCopy() map[string]float64 {
+ modelPriceMapMutex.RLock()
+ defer modelPriceMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(modelPriceMap))
+ for k, v := range modelPriceMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
+func GetCompletionRatioCopy() map[string]float64 {
+ CompletionRatioMutex.RLock()
+ defer CompletionRatioMutex.RUnlock()
+ copyMap := make(map[string]float64, len(CompletionRatio))
+ for k, v := range CompletionRatio {
+ copyMap[k] = v
+ }
+ return copyMap
+}
diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js
new file mode 100644
index 00000000..573329b3
--- /dev/null
+++ b/web/src/components/settings/ChannelSelectorModal.js
@@ -0,0 +1,143 @@
+import React, { useState } from 'react';
+import {
+ Modal,
+ Transfer,
+ Input,
+ Space,
+ Checkbox,
+ Avatar,
+ Highlight,
+} from '@douyinfe/semi-ui';
+import { IconClose } from '@douyinfe/semi-icons';
+
+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,
+ visible,
+ onCancel,
+ onOk,
+ allChannels = [],
+ selectedChannelIds = [],
+ setSelectedChannelIds,
+ channelEndpoints,
+ updateChannelEndpoint,
+}) {
+ const [searchText, setSearchText] = useState('');
+
+ 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);
+
+ return (
+ <>
+