From 5db25f47f1b11fef349c183d918e5461114cc818 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 17 Mar 2026 15:47:05 +0800 Subject: [PATCH 1/3] feat: add param override audit modal for usage logs --- relay/common/override.go | 225 +++++++++++++- relay/common/relay_info.go | 1 + service/log_info_generate.go | 8 + web/src/components/table/usage-logs/index.jsx | 2 + .../usage-logs/modals/ParamOverrideModal.jsx | 278 ++++++++++++++++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 53 +++- 6 files changed, 550 insertions(+), 17 deletions(-) create mode 100644 web/src/components/table/usage-logs/modals/ParamOverrideModal.jsx 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, From bc80477b1a643adeceb211274109eab6fd568016 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 17 Mar 2026 16:22:26 +0800 Subject: [PATCH 2/3] feat: simplify param override audit UI and operation labels --- relay/common/override.go | 239 ++++++++------ relay/common/override_test.go | 100 ++++++ .../components/ParamOverrideEntry.jsx | 54 ++++ .../usage-logs/modals/ParamOverrideModal.jsx | 304 +++++++++--------- web/src/hooks/usage-logs/useUsageLogsData.jsx | 33 +- 5 files changed, 448 insertions(+), 282 deletions(-) create mode 100644 web/src/components/table/usage-logs/components/ParamOverrideEntry.jsx diff --git a/relay/common/override.go b/relay/common/override.go index 17b0769b..d75216d1 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -230,28 +230,14 @@ func getParamOverrideAuditRecorder(context map[string]interface{}) *paramOverrid return recorder } -func (r *paramOverrideAuditRecorder) record(path string, beforeExists bool, beforeValue interface{}, afterExists bool, afterValue interface{}) { +func (r *paramOverrideAuditRecorder) recordOperation(mode, path, from, to string, value interface{}) { if r == nil { return } - path = strings.TrimSpace(path) - if path == "" { + line := buildParamOverrideAuditLine(mode, path, from, to, value) + if line == "" { 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 } @@ -270,23 +256,16 @@ func shouldAuditParamPath(path string) bool { 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) +func shouldAuditOperation(mode, path, from, to string) bool { + if common.DebugEnabled { + return true } - - next, err := mutate(result) - if err != nil { - return next, err + for _, candidate := range []string{path, to} { + if shouldAuditParamPath(candidate) { + return true + } } - - if needAudit { - afterResult := gjson.Get(next, path) - auditRecorder.record(path, beforeResult.Exists(), beforeResult.Value(), afterResult.Exists(), afterResult.Value()) - } - return next, nil + return false } func formatParamOverrideAuditValue(value interface{}) string { @@ -300,6 +279,94 @@ func formatParamOverrideAuditValue(value interface{}) string { } } +func buildParamOverrideAuditLine(mode, path, from, to string, value interface{}) string { + mode = strings.TrimSpace(mode) + path = strings.TrimSpace(path) + from = strings.TrimSpace(from) + to = strings.TrimSpace(to) + + if !shouldAuditOperation(mode, path, from, to) { + return "" + } + + switch mode { + case "set": + if path == "" { + return "" + } + return fmt.Sprintf("set %s = %s", path, formatParamOverrideAuditValue(value)) + case "delete": + if path == "" { + return "" + } + return fmt.Sprintf("delete %s", path) + case "copy": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("copy %s -> %s", from, to) + case "move": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("move %s -> %s", from, to) + case "prepend": + if path == "" { + return "" + } + return fmt.Sprintf("prepend %s with %s", path, formatParamOverrideAuditValue(value)) + case "append": + if path == "" { + return "" + } + return fmt.Sprintf("append %s with %s", path, formatParamOverrideAuditValue(value)) + case "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix": + if path == "" { + return "" + } + return fmt.Sprintf("%s %s with %s", mode, path, formatParamOverrideAuditValue(value)) + case "trim_space", "to_lower", "to_upper": + if path == "" { + return "" + } + return fmt.Sprintf("%s %s", mode, path) + case "replace", "regex_replace": + if path == "" { + return "" + } + return fmt.Sprintf("%s %s from %s to %s", mode, path, from, to) + case "set_header": + if path == "" { + return "" + } + return fmt.Sprintf("set_header %s = %s", path, formatParamOverrideAuditValue(value)) + case "delete_header": + if path == "" { + return "" + } + return fmt.Sprintf("delete_header %s", path) + case "copy_header", "move_header": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("%s %s -> %s", mode, from, to) + case "pass_headers": + return fmt.Sprintf("pass_headers %s", formatParamOverrideAuditValue(value)) + case "sync_fields": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("sync_fields %s -> %s", from, to) + case "return_error": + return fmt.Sprintf("return_error %s", formatParamOverrideAuditValue(value)) + default: + if path == "" { + return mode + } + return fmt.Sprintf("%s %s", mode, path) + } +} + func getParamOverrideMap(info *RelayInfo) map[string]interface{} { if info == nil || info.ChannelMeta == nil { return nil @@ -594,9 +661,8 @@ 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) + auditRecorder.recordOperation("set", key, "", "", value) } return common.Marshal(reqMap) @@ -636,47 +702,29 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte switch op.Mode { case "delete": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return deleteValue(current, path) - }) + result, err = deleteValue(result, path) if err != nil { break } + auditRecorder.recordOperation("delete", path, "", "", nil) } case "set": for _, path := range opPaths { if op.KeepOrigin && gjson.Get(result, path).Exists() { continue } - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return sjson.Set(current, path, op.Value) - }) + result, err = sjson.Set(result, path, op.Value) if err != nil { break } + auditRecorder.recordOperation("set", path, "", "", op.Value) } 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) - } + auditRecorder.recordOperation("move", "", opFrom, opTo, nil) } case "copy": if op.From == "" || op.To == "" { @@ -684,116 +732,100 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } 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()) + if err == nil { + auditRecorder.recordOperation("copy", "", opFrom, opTo, nil) } case "prepend": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return modifyValue(current, path, op.Value, op.KeepOrigin, true) - }) + result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true) if err != nil { break } + auditRecorder.recordOperation("prepend", path, "", "", op.Value) } case "append": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return modifyValue(current, path, op.Value, op.KeepOrigin, false) - }) + result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false) if err != nil { break } + auditRecorder.recordOperation("append", path, "", "", op.Value) } case "trim_prefix": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return trimStringValue(current, path, op.Value, true) - }) + result, err = trimStringValue(result, path, op.Value, true) if err != nil { break } + auditRecorder.recordOperation("trim_prefix", path, "", "", op.Value) } case "trim_suffix": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return trimStringValue(current, path, op.Value, false) - }) + result, err = trimStringValue(result, path, op.Value, false) if err != nil { break } + auditRecorder.recordOperation("trim_suffix", path, "", "", op.Value) } case "ensure_prefix": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return ensureStringAffix(current, path, op.Value, true) - }) + result, err = ensureStringAffix(result, path, op.Value, true) if err != nil { break } + auditRecorder.recordOperation("ensure_prefix", path, "", "", op.Value) } case "ensure_suffix": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return ensureStringAffix(current, path, op.Value, false) - }) + result, err = ensureStringAffix(result, path, op.Value, false) if err != nil { break } + auditRecorder.recordOperation("ensure_suffix", path, "", "", op.Value) } case "trim_space": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return transformStringValue(current, path, strings.TrimSpace) - }) + result, err = transformStringValue(result, path, strings.TrimSpace) if err != nil { break } + auditRecorder.recordOperation("trim_space", path, "", "", nil) } case "to_lower": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return transformStringValue(current, path, strings.ToLower) - }) + result, err = transformStringValue(result, path, strings.ToLower) if err != nil { break } + auditRecorder.recordOperation("to_lower", path, "", "", nil) } case "to_upper": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return transformStringValue(current, path, strings.ToUpper) - }) + result, err = transformStringValue(result, path, strings.ToUpper) if err != nil { break } + auditRecorder.recordOperation("to_upper", path, "", "", nil) } case "replace": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return replaceStringValue(current, path, op.From, op.To) - }) + result, err = replaceStringValue(result, path, op.From, op.To) if err != nil { break } + auditRecorder.recordOperation("replace", path, op.From, op.To, nil) } case "regex_replace": for _, path := range opPaths { - result, err = applyAuditedPathMutation(result, path, auditRecorder, func(current string) (string, error) { - return regexReplaceStringValue(current, path, op.From, op.To) - }) + result, err = regexReplaceStringValue(result, path, op.From, op.To) if err != nil { break } + auditRecorder.recordOperation("regex_replace", path, op.From, op.To, nil) } case "return_error": + auditRecorder.recordOperation("return_error", op.Path, "", "", op.Value) returnErr, parseErr := parseParamOverrideReturnError(op.Value) if parseErr != nil { return "", parseErr @@ -809,11 +841,13 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte case "set_header": err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin) if err == nil { + auditRecorder.recordOperation("set_header", op.Path, "", "", op.Value) contextJSON, err = marshalContextJSON(context) } case "delete_header": err = deleteHeaderOverrideInContext(context, op.Path) if err == nil { + auditRecorder.recordOperation("delete_header", op.Path, "", "", nil) contextJSON, err = marshalContextJSON(context) } case "copy_header": @@ -830,6 +864,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte err = nil } if err == nil { + auditRecorder.recordOperation("copy_header", "", sourceHeader, targetHeader, nil) contextJSON, err = marshalContextJSON(context) } case "move_header": @@ -846,6 +881,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte err = nil } if err == nil { + auditRecorder.recordOperation("move_header", "", sourceHeader, targetHeader, nil) contextJSON, err = marshalContextJSON(context) } case "pass_headers": @@ -863,11 +899,13 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } } if err == nil { + auditRecorder.recordOperation("pass_headers", "", "", "", headerNames) contextJSON, err = marshalContextJSON(context) } case "sync_fields": result, err = syncFieldsBetweenTargets(result, context, op.From, op.To) if err == nil { + auditRecorder.recordOperation("sync_fields", "", op.From, op.To, nil) contextJSON, err = marshalContextJSON(context) } default: @@ -985,7 +1023,6 @@ 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)) @@ -1001,12 +1038,10 @@ 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 } @@ -1178,9 +1213,7 @@ 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/override_test.go b/relay/common/override_test.go index c41be219..1a7793ba 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -6,6 +6,7 @@ import ( "reflect" "testing" + common2 "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/dto" @@ -2066,6 +2067,105 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) { assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out)) } +func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = true + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + "from": "metadata.target_model", + "to": "model", + }, + map[string]interface{}{ + "mode": "set", + "path": "service_tier", + "value": "flex", + }, + map[string]interface{}{ + "mode": "set", + "path": "temperature", + "value": 0.1, + }, + }, + }, + }, + } + + out, err := ApplyParamOverrideWithRelayInfo([]byte(`{ + "model":"gpt-4.1", + "temperature":0.7, + "metadata":{"target_model":"gpt-4.1-mini"} + }`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + assertJSONEqual(t, `{ + "model":"gpt-4.1-mini", + "temperature":0.1, + "service_tier":"flex", + "metadata":{"target_model":"gpt-4.1-mini"} + }`, string(out)) + + expected := []string{ + "copy metadata.target_model -> model", + "set service_tier = flex", + "set temperature = 0.1", + } + if !reflect.DeepEqual(info.ParamOverrideAudit, expected) { + t.Fatalf("unexpected param override audit, got %#v", info.ParamOverrideAudit) + } +} + +func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisabled(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = false + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + "from": "metadata.target_model", + "to": "model", + }, + map[string]interface{}{ + "mode": "set", + "path": "temperature", + "value": 0.1, + }, + }, + }, + }, + } + + _, err := ApplyParamOverrideWithRelayInfo([]byte(`{ + "model":"gpt-4.1", + "temperature":0.7, + "metadata":{"target_model":"gpt-4.1-mini"} + }`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + + expected := []string{ + "copy metadata.target_model -> model", + } + if !reflect.DeepEqual(info.ParamOverrideAudit, expected) { + t.Fatalf("unexpected param override audit, got %#v", info.ParamOverrideAudit) + } +} + func assertJSONEqual(t *testing.T, want, got string) { t.Helper() diff --git a/web/src/components/table/usage-logs/components/ParamOverrideEntry.jsx b/web/src/components/table/usage-logs/components/ParamOverrideEntry.jsx new file mode 100644 index 00000000..aa7c7499 --- /dev/null +++ b/web/src/components/table/usage-logs/components/ParamOverrideEntry.jsx @@ -0,0 +1,54 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Typography } from '@douyinfe/semi-ui'; + +const { Text } = Typography; + +const ParamOverrideEntry = ({ count, onOpen, t }) => { + return ( +
+ + {t('{{count}} 项操作', { count })} + + + {t('查看详情')} + +
+ ); +}; + +export default React.memo(ParamOverrideEntry); diff --git a/web/src/components/table/usage-logs/modals/ParamOverrideModal.jsx b/web/src/components/table/usage-logs/modals/ParamOverrideModal.jsx index 4d14d1ea..0b0c6ec8 100644 --- a/web/src/components/table/usage-logs/modals/ParamOverrideModal.jsx +++ b/web/src/components/table/usage-logs/modals/ParamOverrideModal.jsx @@ -22,8 +22,7 @@ import { Modal, Button, Empty, - Space, - Tag, + Divider, Typography, } from '@douyinfe/semi-ui'; import { IconCopy } from '@douyinfe/semi-icons'; @@ -35,59 +34,66 @@ 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; + const firstSpaceIndex = line.indexOf(' '); + if (firstSpaceIndex <= 0) { + return { action: line, content: line }; } - return { - field: line.slice(0, colonIndex), - before: line.slice(colonIndex + 2, arrowIndex), - after: line.slice(arrowIndex + 4), - raw: line, + action: line.slice(0, firstSpaceIndex), + content: line.slice(firstSpaceIndex + 1), }; }; -const ValuePanel = ({ label, value, tone }) => ( -
-
- {label} -
- - {value} - -
-); +const getActionLabel = (action, t) => { + switch ((action || '').toLowerCase()) { + case 'set': + return t('设置'); + case 'delete': + return t('删除'); + case 'copy': + return t('复制'); + case 'move': + return t('移动'); + case 'append': + return t('追加'); + case 'prepend': + return t('前置'); + case 'trim_prefix': + return t('去前缀'); + case 'trim_suffix': + return t('去后缀'); + case 'ensure_prefix': + return t('保前缀'); + case 'ensure_suffix': + return t('保后缀'); + case 'trim_space': + return t('去空格'); + case 'to_lower': + return t('转小写'); + case 'to_upper': + return t('转大写'); + case 'replace': + return t('替换'); + case 'regex_replace': + return t('正则替换'); + case 'set_header': + return t('设请求头'); + case 'delete_header': + return t('删请求头'); + case 'copy_header': + return t('复制请求头'); + case 'move_header': + return t('移动请求头'); + case 'pass_headers': + return t('透传请求头'); + case 'sync_fields': + return t('同步字段'); + case 'return_error': + return t('返回错误'); + default: + return action; + } +}; const ParamOverrideModal = ({ showParamOverrideModal, @@ -124,147 +130,135 @@ const ParamOverrideModal = ({ centered closable maskClosable - width={760} + width={640} > -
+
-
-
-
- {t('已应用参数覆盖')} -
- - - {t('{{count}} 项变更', { count: lines.length })} - - {paramOverrideTarget?.modelName ? ( - - {paramOverrideTarget.modelName} - - ) : null} - {paramOverrideTarget?.requestId ? ( - - {t('Request ID')}: {paramOverrideTarget.requestId} - - ) : null} - -
- - -
- - {paramOverrideTarget?.requestPath ? ( -
- - {t('请求路径')}: {paramOverrideTarget.requestPath} +
+
+ + {t('{{count}} 项操作', { count: lines.length })}
- ) : null} +
+ {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 null; } return (
-
- - {item.field} - -
- - + + {getActionLabel(item.action, t)} +
+ + {item.content} +
); })} diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 566602df..d4ac9df4 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, Button, Tag } from '@douyinfe/semi-ui'; +import { Modal } from '@douyinfe/semi-ui'; import { API, getTodayStartTimestamp, @@ -39,6 +39,7 @@ import { } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; import { useTableCompactMode } from '../common/useTableCompactMode'; +import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry'; export const useLogsData = () => { const { t } = useTranslation(); @@ -604,30 +605,14 @@ export const useLogsData = () => { expandDataLocal.push({ key: t('参数覆盖'), value: ( -
{ + event.stopPropagation(); + openParamOverrideModal(logs[i], other); }} - > - - {t('{{count}} 项变更', { count: other.po.length })} - - -
+ /> ), }); } From 8aa8b81e03522f306d24134725378228e64b03ab Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 17 Mar 2026 16:44:13 +0800 Subject: [PATCH 3/3] fix: original_model && upstream_model paramOverrideKeyAuditPaths --- relay/common/override.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index d75216d1..af0b4361 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -27,9 +27,11 @@ const ( var errSourceHeaderNotFound = errors.New("source header does not exist") var paramOverrideKeyAuditPaths = map[string]struct{}{ - "model": {}, - "service_tier": {}, - "inference_geo": {}, + "model": {}, + "original_model": {}, + "upstream_model": {}, + "service_tier": {}, + "inference_geo": {}, } type paramOverrideAuditRecorder struct {