- UserRatioSection 渠道 ID 输入改为带搜索的下拉选择器 - 使用日志详情、计费过程、展开行均显示用户倍率 - renderLogContent suffix 统一追加,消除 4 处重复 - 提取 dumpUpstreamRequest 辅助函数,debug 日志走 SysLog Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -4,6 +4,7 @@ import ( | |||
| "context" | |||
| "errors" | |||
| "fmt" | |||
| "bytes" | |||
| "io" | |||
| "net/http" | |||
| "regexp" | |||
| @@ -293,13 +294,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody | |||
| if err != nil { | |||
| return nil, fmt.Errorf("setup request header failed: %w", err) | |||
| } | |||
| // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 | |||
| // 这样可以覆盖默认的 Authorization header 设置 | |||
| headerOverride, err := processHeaderOverride(info, c) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| applyHeaderOverrideToRequest(req, headerOverride) | |||
| dumpUpstreamRequest(req) | |||
| resp, err := doRequest(c, req, info) | |||
| if err != nil { | |||
| 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 { | |||
| return nil, fmt.Errorf("new request failed: %w", err) | |||
| } | |||
| // set form data | |||
| req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) | |||
| headers := req.Header | |||
| err = a.SetupRequestHeader(c, &headers, info) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("setup request header failed: %w", err) | |||
| } | |||
| // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 | |||
| // 这样可以覆盖默认的 Authorization header 设置 | |||
| headerOverride, err := processHeaderOverride(info, c) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| applyHeaderOverrideToRequest(req, headerOverride) | |||
| dumpUpstreamRequest(req) | |||
| resp, err := doRequest(c, req, info) | |||
| if err != nil { | |||
| 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 | |||
| } | |||
| 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) { | |||
| fullRequestURL, err := a.GetRequestURL(info) | |||
| if err != nil { | |||
| @@ -347,11 +354,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody | |||
| } | |||
| targetHeader := http.Header{} | |||
| 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) | |||
| if err != nil { | |||
| return nil, err | |||
| @@ -41,6 +41,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m | |||
| other["cache_ratio"] = cacheRatio | |||
| other["model_price"] = modelPrice | |||
| 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()) | |||
| if relayInfo.ReasoningEffort != "" { | |||
| other["reasoning_effort"] = relayInfo.ReasoningEffort | |||
| @@ -20,6 +20,7 @@ const UserRatioSection = ({ userId }) => { | |||
| const [ratios, setRatios] = useState([]); | |||
| const [loading, setLoading] = useState(false); | |||
| const [addModalVisible, setAddModalVisible] = useState(false); | |||
| const [channelOptions, setChannelOptions] = useState([]); | |||
| const formApiRef = useRef(null); | |||
| const loadRatios = async () => { | |||
| @@ -39,8 +40,29 @@ const UserRatioSection = ({ userId }) => { | |||
| 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(() => { | |||
| loadRatios(); | |||
| loadChannels(); | |||
| }, [userId]); | |||
| const handleAdd = async (values) => { | |||
| @@ -86,7 +108,15 @@ const UserRatioSection = ({ userId }) => { | |||
| const columns = [ | |||
| { 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('倍率'), | |||
| dataIndex: 'ratio', | |||
| @@ -166,11 +196,14 @@ const UserRatioSection = ({ userId }) => { | |||
| placeholder='gpt-4o' | |||
| rules={[{ required: true, message: t('请输入模型名称') }]} | |||
| /> | |||
| <Form.InputNumber | |||
| <Form.Select | |||
| 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%' }} | |||
| /> | |||
| <Form.InputNumber | |||
| @@ -1312,6 +1312,7 @@ export function renderModelPrice( | |||
| audioInputPrice = 0, | |||
| imageGenerationCall = false, | |||
| imageGenerationCallPrice = 0, | |||
| userChannelRatio, | |||
| ) { | |||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | |||
| groupRatio, | |||
| @@ -1322,17 +1323,19 @@ export function renderModelPrice( | |||
| // 获取货币配置 | |||
| const { symbol, rate } = getCurrencyConfig(); | |||
| const ucr = (userChannelRatio != null && userChannelRatio !== 1.0) ? userChannelRatio : null; | |||
| if (modelPrice !== -1) { | |||
| 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( | |||
| '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', | |||
| '模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}', | |||
| { | |||
| symbol: symbol, | |||
| price: displayPrice, | |||
| ratio: groupRatio, | |||
| ratioParts, | |||
| total: displayTotal, | |||
| ratioType: ratioLabel, | |||
| }, | |||
| ); | |||
| } else { | |||
| @@ -1363,6 +1366,10 @@ export function renderModelPrice( | |||
| (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + | |||
| imageGenerationCallPrice * groupRatio; | |||
| if (ucr) { | |||
| price *= ucr; | |||
| } | |||
| return ( | |||
| <> | |||
| <article> | |||
| @@ -1490,13 +1497,14 @@ export function renderModelPrice( | |||
| // 构建输出部分描述 | |||
| const outputDesc = i18next.t( | |||
| '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}', | |||
| '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}{{userRatio}}', | |||
| { | |||
| completion: completionTokens, | |||
| symbol: symbol, | |||
| compPrice: (completionRatioPrice * rate).toFixed(6), | |||
| ratio: groupRatio, | |||
| ratioType: ratioLabel, | |||
| userRatio: ucr ? ` * 用户倍率 ${ucr}` : '', | |||
| }, | |||
| ); | |||
| @@ -1571,6 +1579,7 @@ export function renderLogContent( | |||
| webSearchCallCount = 0, | |||
| fileSearch = false, | |||
| fileSearchCallCount = 0, | |||
| userChannelRatio, | |||
| ) { | |||
| const { | |||
| ratio, | |||
| @@ -1581,51 +1590,55 @@ export function renderLogContent( | |||
| // 获取货币配置 | |||
| const { symbol, rate } = getCurrencyConfig(); | |||
| const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0) | |||
| ? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio }) | |||
| : ''; | |||
| let result; | |||
| if (modelPrice !== -1) { | |||
| return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | |||
| result = i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | |||
| symbol: symbol, | |||
| price: (modelPrice * rate).toFixed(6), | |||
| ratioType: ratioLabel, | |||
| 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 { | |||
| 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( | |||
| @@ -2148,6 +2161,7 @@ export function renderClaudeLogContent( | |||
| cacheCreationRatio5m = 1.0, | |||
| cacheCreationTokens1h = 0, | |||
| cacheCreationRatio1h = 1.0, | |||
| userChannelRatio, | |||
| ) { | |||
| const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( | |||
| groupRatio, | |||
| @@ -2158,13 +2172,17 @@ export function renderClaudeLogContent( | |||
| // 获取货币配置 | |||
| const { symbol, rate } = getCurrencyConfig(); | |||
| const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0) | |||
| ? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio }) | |||
| : ''; | |||
| if (modelPrice !== -1) { | |||
| return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { | |||
| symbol: symbol, | |||
| price: (modelPrice * rate).toFixed(6), | |||
| ratioType: ratioLabel, | |||
| ratio: groupRatio, | |||
| }); | |||
| }) + userRatioSuffix; | |||
| } else { | |||
| const hasSplitCacheCreation = | |||
| 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 || | |||
| 1.0, | |||
| other?.user_channel_ratio, | |||
| ) | |||
| : renderLogContent( | |||
| other?.model_ratio, | |||
| @@ -436,6 +437,7 @@ export const useLogsData = () => { | |||
| other.web_search_call_count || 0, | |||
| other.file_search || false, | |||
| other.file_search_call_count || 0, | |||
| other?.user_channel_ratio, | |||
| ), | |||
| }); | |||
| if (logs[i]?.content) { | |||
| @@ -537,6 +539,7 @@ export const useLogsData = () => { | |||
| other?.audio_input_price || 0, | |||
| other?.image_generation_call || false, | |||
| other?.image_generation_call_price || 0, | |||
| other?.user_channel_ratio, | |||
| ); | |||
| } | |||
| expandDataLocal.push({ | |||
| @@ -550,6 +553,12 @@ export const useLogsData = () => { | |||
| value: other.reasoning_effort, | |||
| }); | |||
| } | |||
| if (other?.user_channel_ratio) { | |||
| expandDataLocal.push({ | |||
| key: t('用户倍率'), | |||
| value: other.user_channel_ratio, | |||
| }); | |||
| } | |||
| } | |||
| if (logs[i].type === 6) { | |||
| if (other?.task_id) { | |||