diff --git a/relay/common/override.go b/relay/common/override.go index 8bfdcd74..17b0769b 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -21,10 +21,21 @@ var negativeIndexRegexp = regexp.MustCompile(`\.(-\d+)`) const ( paramOverrideContextRequestHeaders = "request_headers" paramOverrideContextHeaderOverride = "header_override" + paramOverrideContextAuditRecorder = "__param_override_audit_recorder" ) var errSourceHeaderNotFound = errors.New("source header does not exist") +var paramOverrideKeyAuditPaths = map[string]struct{}{ + "model": {}, + "service_tier": {}, + "inference_geo": {}, +} + +type paramOverrideAuditRecorder struct { + lines []string +} + type ConditionOperation struct { Path string `json:"path"` // JSON路径 Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte @@ -118,6 +129,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c if len(paramOverride) == 0 { return jsonData, nil } + auditRecorder := getParamOverrideAuditRecorder(conditionContext) // 尝试断言为操作格式 if operations, ok := tryParseOperations(paramOverride); ok { @@ -125,7 +137,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c workingJSON := jsonData var err error if len(legacyOverride) > 0 { - workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride) + workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride, auditRecorder) if err != nil { return nil, err } @@ -137,7 +149,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c } // 直接使用旧方法 - return applyOperationsLegacy(jsonData, paramOverride) + return applyOperationsLegacy(jsonData, paramOverride, auditRecorder) } func buildLegacyParamOverride(paramOverride map[string]interface{}) map[string]interface{} { @@ -161,14 +173,133 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte, } overrideCtx := BuildParamOverrideContext(info) + var recorder *paramOverrideAuditRecorder + if shouldEnableParamOverrideAudit(paramOverride) { + recorder = ¶mOverrideAuditRecorder{} + overrideCtx[paramOverrideContextAuditRecorder] = recorder + } result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx) if err != nil { return nil, err } syncRuntimeHeaderOverrideFromContext(info, overrideCtx) + if info != nil { + if recorder != nil { + info.ParamOverrideAudit = recorder.lines + } else { + info.ParamOverrideAudit = nil + } + } return result, nil } +func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool { + if common.DebugEnabled { + return true + } + if len(paramOverride) == 0 { + return false + } + if operations, ok := tryParseOperations(paramOverride); ok { + for _, operation := range operations { + if shouldAuditParamPath(strings.TrimSpace(operation.Path)) || + shouldAuditParamPath(strings.TrimSpace(operation.To)) { + return true + } + } + for key := range buildLegacyParamOverride(paramOverride) { + if shouldAuditParamPath(strings.TrimSpace(key)) { + return true + } + } + return false + } + for key := range paramOverride { + if shouldAuditParamPath(strings.TrimSpace(key)) { + return true + } + } + return false +} + +func getParamOverrideAuditRecorder(context map[string]interface{}) *paramOverrideAuditRecorder { + if context == nil { + return nil + } + recorder, _ := context[paramOverrideContextAuditRecorder].(*paramOverrideAuditRecorder) + return recorder +} + +func (r *paramOverrideAuditRecorder) record(path string, beforeExists bool, beforeValue interface{}, afterExists bool, afterValue interface{}) { + if r == nil { + return + } + path = strings.TrimSpace(path) + if path == "" { + return + } + if !shouldAuditParamPath(path) { + return + } + + beforeText := "" + if beforeExists { + beforeText = formatParamOverrideAuditValue(beforeValue) + } + afterText := "" + if afterExists { + afterText = formatParamOverrideAuditValue(afterValue) + } + + line := fmt.Sprintf("%s: %s -> %s", path, beforeText, afterText) + if lo.Contains(r.lines, line) { + return + } + r.lines = append(r.lines, line) +} + +func shouldAuditParamPath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if common.DebugEnabled { + return true + } + _, ok := paramOverrideKeyAuditPaths[path] + return ok +} + +func applyAuditedPathMutation(result, path string, auditRecorder *paramOverrideAuditRecorder, mutate func(string) (string, error)) (string, error) { + needAudit := auditRecorder != nil && shouldAuditParamPath(path) + var beforeResult gjson.Result + if needAudit { + beforeResult = gjson.Get(result, path) + } + + next, err := mutate(result) + if err != nil { + return next, err + } + + if needAudit { + afterResult := gjson.Get(next, path) + auditRecorder.record(path, beforeResult.Exists(), beforeResult.Value(), afterResult.Exists(), afterResult.Value()) + } + return next, nil +} + +func formatParamOverrideAuditValue(value interface{}) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + default: + return common.GetJsonString(typed) + } +} + func getParamOverrideMap(info *RelayInfo) map[string]interface{} { if info == nil || info.ChannelMeta == nil { return nil @@ -455,7 +586,7 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool, } // applyOperationsLegacy 原参数覆盖方法 -func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) { +func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}, auditRecorder *paramOverrideAuditRecorder) ([]byte, error) { reqMap := make(map[string]interface{}) err := common.Unmarshal(jsonData, &reqMap) if err != nil { @@ -463,7 +594,9 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{} } for key, value := range paramOverride { + beforeValue, beforeExists := reqMap[key] reqMap[key] = value + auditRecorder.record(key, beforeExists, beforeValue, true, value) } return common.Marshal(reqMap) @@ -471,6 +604,7 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{} func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) { context := ensureContextMap(conditionContext) + auditRecorder := getParamOverrideAuditRecorder(context) contextJSON, err := marshalContextJSON(context) if err != nil { return "", fmt.Errorf("failed to marshal condition context: %v", err) @@ -502,7 +636,9 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte switch op.Mode { case "delete": for _, path := range opPaths { - result, err = deleteValue(result, path) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return deleteValue(current, path) + }) if err != nil { break } @@ -512,7 +648,9 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte if op.KeepOrigin && gjson.Get(result, path).Exists() { continue } - result, err = sjson.Set(result, path, op.Value) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return sjson.Set(current, path, op.Value) + }) if err != nil { break } @@ -520,87 +658,137 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte case "move": opFrom := processNegativeIndex(result, op.From) opTo := processNegativeIndex(result, op.To) + needAuditTo := auditRecorder != nil && shouldAuditParamPath(opTo) + needAuditFrom := auditRecorder != nil && shouldAuditParamPath(opFrom) + var beforeResult gjson.Result + var fromResult gjson.Result + if needAuditTo { + beforeResult = gjson.Get(result, opTo) + } + if needAuditFrom { + fromResult = gjson.Get(result, opFrom) + } result, err = moveValue(result, opFrom, opTo) + if err == nil { + if needAuditTo { + afterResult := gjson.Get(result, opTo) + auditRecorder.record(opTo, beforeResult.Exists(), beforeResult.Value(), afterResult.Exists(), afterResult.Value()) + } + if needAuditFrom && common.DebugEnabled { + auditRecorder.record(opFrom, fromResult.Exists(), fromResult.Value(), false, nil) + } + } case "copy": if op.From == "" || op.To == "" { return "", fmt.Errorf("copy from/to is required") } opFrom := processNegativeIndex(result, op.From) opTo := processNegativeIndex(result, op.To) + needAudit := auditRecorder != nil && shouldAuditParamPath(opTo) + var beforeResult gjson.Result + if needAudit { + beforeResult = gjson.Get(result, opTo) + } result, err = copyValue(result, opFrom, opTo) + if err == nil && needAudit { + afterResult := gjson.Get(result, opTo) + auditRecorder.record(opTo, beforeResult.Exists(), beforeResult.Value(), afterResult.Exists(), afterResult.Value()) + } case "prepend": for _, path := range opPaths { - result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return modifyValue(current, path, op.Value, op.KeepOrigin, true) + }) if err != nil { break } } case "append": for _, path := range opPaths { - result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return modifyValue(current, path, op.Value, op.KeepOrigin, false) + }) if err != nil { break } } case "trim_prefix": for _, path := range opPaths { - result, err = trimStringValue(result, path, op.Value, true) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return trimStringValue(current, path, op.Value, true) + }) if err != nil { break } } case "trim_suffix": for _, path := range opPaths { - result, err = trimStringValue(result, path, op.Value, false) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return trimStringValue(current, path, op.Value, false) + }) if err != nil { break } } case "ensure_prefix": for _, path := range opPaths { - result, err = ensureStringAffix(result, path, op.Value, true) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return ensureStringAffix(current, path, op.Value, true) + }) if err != nil { break } } case "ensure_suffix": for _, path := range opPaths { - result, err = ensureStringAffix(result, path, op.Value, false) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return ensureStringAffix(current, path, op.Value, false) + }) if err != nil { break } } case "trim_space": for _, path := range opPaths { - result, err = transformStringValue(result, path, strings.TrimSpace) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return transformStringValue(current, path, strings.TrimSpace) + }) if err != nil { break } } case "to_lower": for _, path := range opPaths { - result, err = transformStringValue(result, path, strings.ToLower) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return transformStringValue(current, path, strings.ToLower) + }) if err != nil { break } } case "to_upper": for _, path := range opPaths { - result, err = transformStringValue(result, path, strings.ToUpper) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return transformStringValue(current, path, strings.ToUpper) + }) if err != nil { break } } case "replace": for _, path := range opPaths { - result, err = replaceStringValue(result, path, op.From, op.To) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return replaceStringValue(current, path, op.From, op.To) + }) if err != nil { break } } case "regex_replace": for _, path := range opPaths { - result, err = regexReplaceStringValue(result, path, op.From, op.To) + result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { + return regexReplaceStringValue(current, path, op.From, op.To) + }) if err != nil { break } @@ -797,6 +985,7 @@ func setHeaderOverrideInContext(context map[string]interface{}, headerName strin } rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) + beforeRaw, beforeExists := rawHeaders[headerName] if keepOrigin { if existing, ok := rawHeaders[headerName]; ok { existingValue := strings.TrimSpace(fmt.Sprintf("%v", existing)) @@ -812,10 +1001,12 @@ func setHeaderOverrideInContext(context map[string]interface{}, headerName strin } if !hasValue { delete(rawHeaders, headerName) + getParamOverrideAuditRecorder(context).record("header."+headerName, beforeExists, beforeRaw, false, nil) return nil } rawHeaders[headerName] = headerValue + getParamOverrideAuditRecorder(context).record("header."+headerName, beforeExists, beforeRaw, true, headerValue) return nil } @@ -987,7 +1178,9 @@ func deleteHeaderOverrideInContext(context map[string]interface{}, headerName st return fmt.Errorf("header name is required") } rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) + beforeRaw, beforeExists := rawHeaders[headerName] delete(rawHeaders, headerName) + getParamOverrideAuditRecorder(context).record("header."+headerName, beforeExists, beforeRaw, false, nil) return nil } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 8b0789c0..c90ecb44 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -149,6 +149,7 @@ type RelayInfo struct { LastError *types.NewAPIError RuntimeHeadersOverride map[string]interface{} UseRuntimeHeadersOverride bool + ParamOverrideAudit []string PriceData types.PriceData diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 1c440911..eea2ea07 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -74,9 +74,17 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m appendRequestPath(ctx, relayInfo, other) appendRequestConversionChain(relayInfo, other) appendBillingInfo(relayInfo, other) + appendParamOverrideInfo(relayInfo, other) return other } +func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { + if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 { + return + } + other["po"] = relayInfo.ParamOverrideAudit +} + func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { if relayInfo == nil || other == nil { return diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index 7d2d47c3..ce5a17f8 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -25,6 +25,7 @@ import LogsFilters from './UsageLogsFilters'; import ColumnSelectorModal from './modals/ColumnSelectorModal'; import UserInfoModal from './modals/UserInfoModal'; import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal'; +import ParamOverrideModal from './modals/ParamOverrideModal'; import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { createCardProPagination } from '../../../helpers/utils'; @@ -39,6 +40,7 @@ const LogsPage = () => { + {/* Main Content */} . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { + Modal, + Button, + Empty, + Space, + Tag, + Typography, +} from '@douyinfe/semi-ui'; +import { IconCopy } from '@douyinfe/semi-icons'; +import { copy, showError, showSuccess } from '../../../../helpers'; + +const { Text } = Typography; + +const parseAuditLine = (line) => { + if (typeof line !== 'string') { + return null; + } + const colonIndex = line.indexOf(': '); + const arrowIndex = line.indexOf(' -> ', colonIndex + 2); + if (colonIndex <= 0 || arrowIndex <= colonIndex) { + return null; + } + + return { + field: line.slice(0, colonIndex), + before: line.slice(colonIndex + 2, arrowIndex), + after: line.slice(arrowIndex + 4), + raw: line, + }; +}; + +const ValuePanel = ({ label, value, tone }) => ( +
+
+ {label} +
+ + {value} + +
+); + +const ParamOverrideModal = ({ + showParamOverrideModal, + setShowParamOverrideModal, + paramOverrideTarget, + t, +}) => { + const lines = Array.isArray(paramOverrideTarget?.lines) + ? paramOverrideTarget.lines + : []; + + const parsedLines = useMemo(() => { + return lines.map(parseAuditLine); + }, [lines]); + + const copyAll = async () => { + const content = lines.join('\n'); + if (!content) { + return; + } + if (await copy(content)) { + showSuccess(t('参数覆盖已复制')); + return; + } + showError(t('无法复制到剪贴板,请手动复制')); + }; + + return ( + setShowParamOverrideModal(false)} + footer={null} + centered + closable + maskClosable + width={760} + > +
+
+
+
+
+ {t('已应用参数覆盖')} +
+ + + {t('{{count}} 项变更', { count: lines.length })} + + {paramOverrideTarget?.modelName ? ( + + {paramOverrideTarget.modelName} + + ) : null} + {paramOverrideTarget?.requestId ? ( + + {t('Request ID')}: {paramOverrideTarget.requestId} + + ) : null} + +
+ + +
+ + {paramOverrideTarget?.requestPath ? ( +
+ + {t('请求路径')}: {paramOverrideTarget.requestPath} + +
+ ) : null} +
+ + {lines.length === 0 ? ( + + ) : ( +
+ {parsedLines.map((item, index) => { + if (!item) { + return ( +
+ + {lines[index]} + +
+ ); + } + + return ( +
+
+ + {item.field} + +
+
+ + +
+
+ ); + })} +
+ )} +
+
+ ); +}; + +export default ParamOverrideModal; diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index ebe1b882..566602df 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal } from '@douyinfe/semi-ui'; +import { Modal, Button, Tag } from '@douyinfe/semi-ui'; import { API, getTodayStartTimestamp, @@ -181,6 +181,8 @@ export const useLogsData = () => { ] = useState(false); const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] = useState(null); + const [showParamOverrideModal, setShowParamOverrideModal] = useState(false); + const [paramOverrideTarget, setParamOverrideTarget] = useState(null); // Initialize default column visibility const initDefaultColumns = () => { @@ -345,6 +347,20 @@ export const useLogsData = () => { setShowChannelAffinityUsageCacheModal(true); }; + const openParamOverrideModal = (log, other) => { + const lines = Array.isArray(other?.po) ? other.po.filter(Boolean) : []; + if (lines.length === 0) { + return; + } + setParamOverrideTarget({ + lines, + modelName: log?.model_name || '', + requestId: log?.request_id || '', + requestPath: other?.request_path || '', + }); + setShowParamOverrideModal(true); + }; + // Format logs data const setLogsFormat = (logs) => { const requestConversionDisplayValue = (conversionChain) => { @@ -584,6 +600,37 @@ export const useLogsData = () => { value: other.request_path, }); } + if (Array.isArray(other?.po) && other.po.length > 0) { + expandDataLocal.push({ + key: t('参数覆盖'), + value: ( +
+ + {t('{{count}} 项变更', { count: other.po.length })} + + +
+ ), + }); + } if (other?.billing_source === 'subscription') { const planId = other?.subscription_plan_id; const planTitle = other?.subscription_plan_title || ''; @@ -811,6 +858,9 @@ export const useLogsData = () => { setShowChannelAffinityUsageCacheModal, channelAffinityUsageCacheTarget, openChannelAffinityUsageCacheModal, + showParamOverrideModal, + setShowParamOverrideModal, + paramOverrideTarget, // Functions loadLogs, @@ -822,6 +872,7 @@ export const useLogsData = () => { setLogsFormat, hasExpandableRows, setLogType, + openParamOverrideModal, // Translation t,