- UserRatioSection 渠道 ID 输入改为带搜索的下拉选择器 - 使用日志详情、计费过程、展开行均显示用户倍率 - renderLogContent suffix 统一追加,消除 4 处重复 - 提取 dumpUpstreamRequest 辅助函数,debug 日志走 SysLog Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -4,6 +4,7 @@ import ( | |||||
| "context" | "context" | ||||
| "errors" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "bytes" | |||||
| "io" | "io" | ||||
| "net/http" | "net/http" | ||||
| "regexp" | "regexp" | ||||
| @@ -293,13 +294,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody | |||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("setup request header failed: %w", err) | return nil, fmt.Errorf("setup request header failed: %w", err) | ||||
| } | } | ||||
| // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 | |||||
| // 这样可以覆盖默认的 Authorization header 设置 | |||||
| headerOverride, err := processHeaderOverride(info, c) | headerOverride, err := processHeaderOverride(info, c) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| applyHeaderOverrideToRequest(req, headerOverride) | applyHeaderOverrideToRequest(req, headerOverride) | ||||
| dumpUpstreamRequest(req) | |||||
| resp, err := doRequest(c, req, info) | resp, err := doRequest(c, req, info) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("do request failed: %w", err) | return nil, fmt.Errorf("do request failed: %w", err) | ||||
| @@ -319,20 +319,18 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod | |||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("new request failed: %w", err) | return nil, fmt.Errorf("new request failed: %w", err) | ||||
| } | } | ||||
| // set form data | |||||
| req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) | req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) | ||||
| headers := req.Header | headers := req.Header | ||||
| err = a.SetupRequestHeader(c, &headers, info) | err = a.SetupRequestHeader(c, &headers, info) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("setup request header failed: %w", err) | return nil, fmt.Errorf("setup request header failed: %w", err) | ||||
| } | } | ||||
| // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 | |||||
| // 这样可以覆盖默认的 Authorization header 设置 | |||||
| headerOverride, err := processHeaderOverride(info, c) | headerOverride, err := processHeaderOverride(info, c) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| applyHeaderOverrideToRequest(req, headerOverride) | applyHeaderOverrideToRequest(req, headerOverride) | ||||
| dumpUpstreamRequest(req) | |||||
| resp, err := doRequest(c, req, info) | resp, err := doRequest(c, req, info) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("do request failed: %w", err) | return nil, fmt.Errorf("do request failed: %w", err) | ||||
| @@ -340,6 +338,15 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod | |||||
| return resp, nil | return resp, nil | ||||
| } | } | ||||
| func dumpUpstreamRequest(req *http.Request) { | |||||
| if !common2.DebugEnabled || req == nil { | |||||
| return | |||||
| } | |||||
| bodyBytes, _ := io.ReadAll(req.Body) | |||||
| req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) | |||||
| common2.SysLog(fmt.Sprintf("[UpstreamRequest] URL: %s\nHeaders: %v\nBody: %s", req.URL.String(), req.Header, string(bodyBytes))) | |||||
| } | |||||
| func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) { | func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) { | ||||
| fullRequestURL, err := a.GetRequestURL(info) | fullRequestURL, err := a.GetRequestURL(info) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -347,11 +354,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody | |||||
| } | } | ||||
| targetHeader := http.Header{} | targetHeader := http.Header{} | ||||
| err = a.SetupRequestHeader(c, &targetHeader, info) | err = a.SetupRequestHeader(c, &targetHeader, info) | ||||
| if err != nil { | |||||
| return nil, fmt.Errorf("setup request header failed: %w", err) | |||||
| } | |||||
| // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 | |||||
| // 这样可以覆盖默认的 Authorization header 设置 | |||||
| headerOverride, err := processHeaderOverride(info, c) | headerOverride, err := processHeaderOverride(info, c) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| @@ -41,6 +41,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m | |||||
| other["cache_ratio"] = cacheRatio | other["cache_ratio"] = cacheRatio | ||||
| other["model_price"] = modelPrice | other["model_price"] = modelPrice | ||||
| other["user_group_ratio"] = userGroupRatio | other["user_group_ratio"] = userGroupRatio | ||||
| if relayInfo != nil && relayInfo.PriceData.UserChannelRatio != 1.0 { | |||||
| other["user_channel_ratio"] = relayInfo.PriceData.UserChannelRatio | |||||
| } | |||||
| other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) | other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) | ||||
| if relayInfo.ReasoningEffort != "" { | if relayInfo.ReasoningEffort != "" { | ||||
| other["reasoning_effort"] = relayInfo.ReasoningEffort | other["reasoning_effort"] = relayInfo.ReasoningEffort | ||||
| @@ -20,6 +20,7 @@ const UserRatioSection = ({ userId }) => { | |||||
| const [ratios, setRatios] = useState([]); | const [ratios, setRatios] = useState([]); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [addModalVisible, setAddModalVisible] = useState(false); | const [addModalVisible, setAddModalVisible] = useState(false); | ||||
| const [channelOptions, setChannelOptions] = useState([]); | |||||
| const formApiRef = useRef(null); | const formApiRef = useRef(null); | ||||
| const loadRatios = async () => { | const loadRatios = async () => { | ||||
| @@ -39,8 +40,29 @@ const UserRatioSection = ({ userId }) => { | |||||
| setLoading(false); | setLoading(false); | ||||
| }; | }; | ||||
| const loadChannels = async () => { | |||||
| try { | |||||
| const res = await API.get('/api/channel/?p=0&page_size=500'); | |||||
| const { success, data, message } = res.data; | |||||
| if (success) { | |||||
| const options = (data?.items || []).map((ch) => ({ | |||||
| label: ch.remark | |||||
| ? `${ch.name} (${ch.remark})` | |||||
| : `${ch.name} (ID: ${ch.id})`, | |||||
| value: ch.id, | |||||
| })); | |||||
| setChannelOptions(options); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch (e) { | |||||
| showError(e.message); | |||||
| } | |||||
| }; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadRatios(); | loadRatios(); | ||||
| loadChannels(); | |||||
| }, [userId]); | }, [userId]); | ||||
| const handleAdd = async (values) => { | const handleAdd = async (values) => { | ||||
| @@ -86,7 +108,15 @@ const UserRatioSection = ({ userId }) => { | |||||
| const columns = [ | const columns = [ | ||||
| { title: t('模型'), dataIndex: 'model_name', key: 'model_name' }, | { title: t('模型'), dataIndex: 'model_name', key: 'model_name' }, | ||||
| { title: t('渠道 ID'), dataIndex: 'channel_id', key: 'channel_id' }, | |||||
| { | |||||
| title: t('渠道'), | |||||
| dataIndex: 'channel_id', | |||||
| key: 'channel_id', | |||||
| render: (channelId) => { | |||||
| const ch = channelOptions.find((c) => c.value === channelId); | |||||
| return ch ? ch.label : channelId; | |||||
| }, | |||||
| }, | |||||
| { | { | ||||
| title: t('倍率'), | title: t('倍率'), | ||||
| dataIndex: 'ratio', | dataIndex: 'ratio', | ||||
| @@ -166,11 +196,14 @@ const UserRatioSection = ({ userId }) => { | |||||
| placeholder='gpt-4o' | placeholder='gpt-4o' | ||||
| rules={[{ required: true, message: t('请输入模型名称') }]} | rules={[{ required: true, message: t('请输入模型名称') }]} | ||||
| /> | /> | ||||
| <Form.InputNumber | |||||
| <Form.Select | |||||
| field='channel_id' | field='channel_id' | ||||
| label={t('渠道 ID')} | |||||
| placeholder={t('请输入渠道 ID')} | |||||
| rules={[{ required: true, message: t('请输入渠道 ID') }]} | |||||
| label={t('渠道')} | |||||
| placeholder={t('请选择渠道')} | |||||
| optionList={channelOptions} | |||||
| filter | |||||
| showClear | |||||
| rules={[{ required: true, message: t('请选择渠道') }]} | |||||
| style={{ width: '100%' }} | style={{ width: '100%' }} | ||||
| /> | /> | ||||
| <Form.InputNumber | <Form.InputNumber | ||||
| @@ -1312,6 +1312,7 @@ export function renderModelPrice( | |||||
| audioInputPrice = 0, | audioInputPrice = 0, | ||||
| imageGenerationCall = false, | imageGenerationCall = false, | ||||
| imageGenerationCallPrice = 0, | imageGenerationCallPrice = 0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | ||||
| groupRatio, | groupRatio, | ||||
| @@ -1322,17 +1323,19 @@ export function renderModelPrice( | |||||
| // 获取货币配置 | // 获取货币配置 | ||||
| const { symbol, rate } = getCurrencyConfig(); | const { symbol, rate } = getCurrencyConfig(); | ||||
| const ucr = (userChannelRatio != null && userChannelRatio !== 1.0) ? userChannelRatio : null; | |||||
| if (modelPrice !== -1) { | if (modelPrice !== -1) { | ||||
| const displayPrice = (modelPrice * rate).toFixed(6); | const displayPrice = (modelPrice * rate).toFixed(6); | ||||
| const displayTotal = (modelPrice * groupRatio * rate).toFixed(6); | |||||
| const displayTotal = (modelPrice * groupRatio * (ucr || 1) * rate).toFixed(6); | |||||
| const ratioParts = `${ratioLabel}:${groupRatio}` + (ucr ? ` * 用户倍率:${ucr}` : ''); | |||||
| return i18next.t( | return i18next.t( | ||||
| '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', | |||||
| '模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}', | |||||
| { | { | ||||
| symbol: symbol, | symbol: symbol, | ||||
| price: displayPrice, | price: displayPrice, | ||||
| ratio: groupRatio, | |||||
| ratioParts, | |||||
| total: displayTotal, | total: displayTotal, | ||||
| ratioType: ratioLabel, | |||||
| }, | }, | ||||
| ); | ); | ||||
| } else { | } else { | ||||
| @@ -1363,6 +1366,10 @@ export function renderModelPrice( | |||||
| (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + | (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + | ||||
| imageGenerationCallPrice * groupRatio; | imageGenerationCallPrice * groupRatio; | ||||
| if (ucr) { | |||||
| price *= ucr; | |||||
| } | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <article> | <article> | ||||
| @@ -1490,13 +1497,14 @@ export function renderModelPrice( | |||||
| // 构建输出部分描述 | // 构建输出部分描述 | ||||
| const outputDesc = i18next.t( | const outputDesc = i18next.t( | ||||
| '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}', | |||||
| '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}{{userRatio}}', | |||||
| { | { | ||||
| completion: completionTokens, | completion: completionTokens, | ||||
| symbol: symbol, | symbol: symbol, | ||||
| compPrice: (completionRatioPrice * rate).toFixed(6), | compPrice: (completionRatioPrice * rate).toFixed(6), | ||||
| ratio: groupRatio, | ratio: groupRatio, | ||||
| ratioType: ratioLabel, | ratioType: ratioLabel, | ||||
| userRatio: ucr ? ` * 用户倍率 ${ucr}` : '', | |||||
| }, | }, | ||||
| ); | ); | ||||
| @@ -1571,6 +1579,7 @@ export function renderLogContent( | |||||
| webSearchCallCount = 0, | webSearchCallCount = 0, | ||||
| fileSearch = false, | fileSearch = false, | ||||
| fileSearchCallCount = 0, | fileSearchCallCount = 0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { | const { | ||||
| ratio, | ratio, | ||||
| @@ -1581,51 +1590,55 @@ export function renderLogContent( | |||||
| // 获取货币配置 | // 获取货币配置 | ||||
| const { symbol, rate } = getCurrencyConfig(); | const { symbol, rate } = getCurrencyConfig(); | ||||
| const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0) | |||||
| ? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio }) | |||||
| : ''; | |||||
| let result; | |||||
| if (modelPrice !== -1) { | if (modelPrice !== -1) { | ||||
| return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | |||||
| result = i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | |||||
| symbol: symbol, | symbol: symbol, | ||||
| price: (modelPrice * rate).toFixed(6), | price: (modelPrice * rate).toFixed(6), | ||||
| ratioType: ratioLabel, | ratioType: ratioLabel, | ||||
| ratio, | ratio, | ||||
| }); | }); | ||||
| } else if (image) { | |||||
| result = i18next.t( | |||||
| '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', | |||||
| { | |||||
| modelRatio: modelRatio, | |||||
| cacheRatio: cacheRatio, | |||||
| completionRatio: completionRatio, | |||||
| imageRatio: imageRatio, | |||||
| ratioType: ratioLabel, | |||||
| ratio, | |||||
| }, | |||||
| ); | |||||
| } else if (webSearch) { | |||||
| result = i18next.t( | |||||
| '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次', | |||||
| { | |||||
| modelRatio: modelRatio, | |||||
| cacheRatio: cacheRatio, | |||||
| completionRatio: completionRatio, | |||||
| ratioType: ratioLabel, | |||||
| ratio, | |||||
| webSearchCallCount, | |||||
| }, | |||||
| ); | |||||
| } else { | } else { | ||||
| if (image) { | |||||
| return i18next.t( | |||||
| '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', | |||||
| { | |||||
| modelRatio: modelRatio, | |||||
| cacheRatio: cacheRatio, | |||||
| completionRatio: completionRatio, | |||||
| imageRatio: imageRatio, | |||||
| ratioType: ratioLabel, | |||||
| ratio, | |||||
| }, | |||||
| ); | |||||
| } else if (webSearch) { | |||||
| return i18next.t( | |||||
| '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次', | |||||
| { | |||||
| modelRatio: modelRatio, | |||||
| cacheRatio: cacheRatio, | |||||
| completionRatio: completionRatio, | |||||
| ratioType: ratioLabel, | |||||
| ratio, | |||||
| webSearchCallCount, | |||||
| }, | |||||
| ); | |||||
| } else { | |||||
| return i18next.t( | |||||
| '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', | |||||
| { | |||||
| modelRatio: modelRatio, | |||||
| cacheRatio: cacheRatio, | |||||
| completionRatio: completionRatio, | |||||
| ratioType: ratioLabel, | |||||
| ratio, | |||||
| }, | |||||
| ); | |||||
| } | |||||
| result = i18next.t( | |||||
| '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', | |||||
| { | |||||
| modelRatio: modelRatio, | |||||
| cacheRatio: cacheRatio, | |||||
| completionRatio: completionRatio, | |||||
| ratioType: ratioLabel, | |||||
| ratio, | |||||
| }, | |||||
| ); | |||||
| } | } | ||||
| return result + userRatioSuffix; | |||||
| } | } | ||||
| export function renderModelPriceSimple( | export function renderModelPriceSimple( | ||||
| @@ -2148,6 +2161,7 @@ export function renderClaudeLogContent( | |||||
| cacheCreationRatio5m = 1.0, | cacheCreationRatio5m = 1.0, | ||||
| cacheCreationTokens1h = 0, | cacheCreationTokens1h = 0, | ||||
| cacheCreationRatio1h = 1.0, | cacheCreationRatio1h = 1.0, | ||||
| userChannelRatio, | |||||
| ) { | ) { | ||||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | ||||
| groupRatio, | groupRatio, | ||||
| @@ -2158,13 +2172,17 @@ export function renderClaudeLogContent( | |||||
| // 获取货币配置 | // 获取货币配置 | ||||
| const { symbol, rate } = getCurrencyConfig(); | const { symbol, rate } = getCurrencyConfig(); | ||||
| const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0) | |||||
| ? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio }) | |||||
| : ''; | |||||
| if (modelPrice !== -1) { | if (modelPrice !== -1) { | ||||
| return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | ||||
| symbol: symbol, | symbol: symbol, | ||||
| price: (modelPrice * rate).toFixed(6), | price: (modelPrice * rate).toFixed(6), | ||||
| ratioType: ratioLabel, | ratioType: ratioLabel, | ||||
| ratio: groupRatio, | ratio: groupRatio, | ||||
| }); | |||||
| }) + userRatioSuffix; | |||||
| } else { | } else { | ||||
| const hasSplitCacheCreation = | const hasSplitCacheCreation = | ||||
| cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; | cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; | ||||
| @@ -2217,7 +2235,7 @@ export function renderClaudeLogContent( | |||||
| }), | }), | ||||
| ]; | ]; | ||||
| return parts.join(','); | |||||
| return parts.join(',') + userRatioSuffix; | |||||
| } | } | ||||
| } | } | ||||
| @@ -422,6 +422,7 @@ export const useLogsData = () => { | |||||
| other.cache_creation_ratio_1h || | other.cache_creation_ratio_1h || | ||||
| other.cache_creation_ratio || | other.cache_creation_ratio || | ||||
| 1.0, | 1.0, | ||||
| other?.user_channel_ratio, | |||||
| ) | ) | ||||
| : renderLogContent( | : renderLogContent( | ||||
| other?.model_ratio, | other?.model_ratio, | ||||
| @@ -436,6 +437,7 @@ export const useLogsData = () => { | |||||
| other.web_search_call_count || 0, | other.web_search_call_count || 0, | ||||
| other.file_search || false, | other.file_search || false, | ||||
| other.file_search_call_count || 0, | other.file_search_call_count || 0, | ||||
| other?.user_channel_ratio, | |||||
| ), | ), | ||||
| }); | }); | ||||
| if (logs[i]?.content) { | if (logs[i]?.content) { | ||||
| @@ -537,6 +539,7 @@ export const useLogsData = () => { | |||||
| other?.audio_input_price || 0, | other?.audio_input_price || 0, | ||||
| other?.image_generation_call || false, | other?.image_generation_call || false, | ||||
| other?.image_generation_call_price || 0, | other?.image_generation_call_price || 0, | ||||
| other?.user_channel_ratio, | |||||
| ); | ); | ||||
| } | } | ||||
| expandDataLocal.push({ | expandDataLocal.push({ | ||||
| @@ -550,6 +553,12 @@ export const useLogsData = () => { | |||||
| value: other.reasoning_effort, | value: other.reasoning_effort, | ||||
| }); | }); | ||||
| } | } | ||||
| if (other?.user_channel_ratio) { | |||||
| expandDataLocal.push({ | |||||
| key: t('用户倍率'), | |||||
| value: other.user_channel_ratio, | |||||
| }); | |||||
| } | |||||
| } | } | ||||
| if (logs[i].type === 6) { | if (logs[i].type === 6) { | ||||
| if (other?.task_id) { | if (other?.task_id) { | ||||