diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go
index f749f384..0453870d 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)
}
}
- differences[modelName][ratioType] = item
+
+ allSame := true
+ for _, v := range item.Upstreams {
+ if v != "same" {
+ allSame = false
+ break
+ }
+ }
+ 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 55a89025..6315f31a 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 573329b3..a09eff1c 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 (
- <>
-