Jelajahi Sumber

feat: add param override audit modal for usage logs

Seefs 1 bulan lalu
induk
melakukan
5db25f47f1

+ 209 - 16
relay/common/override.go

@@ -21,10 +21,21 @@ var negativeIndexRegexp = regexp.MustCompile(`\.(-\d+)`)
 const (
 const (
 	paramOverrideContextRequestHeaders = "request_headers"
 	paramOverrideContextRequestHeaders = "request_headers"
 	paramOverrideContextHeaderOverride = "header_override"
 	paramOverrideContextHeaderOverride = "header_override"
+	paramOverrideContextAuditRecorder  = "__param_override_audit_recorder"
 )
 )
 
 
 var errSourceHeaderNotFound = errors.New("source header does not exist")
 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 {
 type ConditionOperation struct {
 	Path           string      `json:"path"`             // JSON路径
 	Path           string      `json:"path"`             // JSON路径
 	Mode           string      `json:"mode"`             // full, prefix, suffix, contains, gt, gte, lt, lte
 	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 {
 	if len(paramOverride) == 0 {
 		return jsonData, nil
 		return jsonData, nil
 	}
 	}
+	auditRecorder := getParamOverrideAuditRecorder(conditionContext)
 
 
 	// 尝试断言为操作格式
 	// 尝试断言为操作格式
 	if operations, ok := tryParseOperations(paramOverride); ok {
 	if operations, ok := tryParseOperations(paramOverride); ok {
@@ -125,7 +137,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c
 		workingJSON := jsonData
 		workingJSON := jsonData
 		var err error
 		var err error
 		if len(legacyOverride) > 0 {
 		if len(legacyOverride) > 0 {
-			workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride)
+			workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride, auditRecorder)
 			if err != nil {
 			if err != nil {
 				return nil, err
 				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{} {
 func buildLegacyParamOverride(paramOverride map[string]interface{}) map[string]interface{} {
@@ -161,14 +173,133 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte,
 	}
 	}
 
 
 	overrideCtx := BuildParamOverrideContext(info)
 	overrideCtx := BuildParamOverrideContext(info)
+	var recorder *paramOverrideAuditRecorder
+	if shouldEnableParamOverrideAudit(paramOverride) {
+		recorder = &paramOverrideAuditRecorder{}
+		overrideCtx[paramOverrideContextAuditRecorder] = recorder
+	}
 	result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx)
 	result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 	syncRuntimeHeaderOverrideFromContext(info, overrideCtx)
 	syncRuntimeHeaderOverrideFromContext(info, overrideCtx)
+	if info != nil {
+		if recorder != nil {
+			info.ParamOverrideAudit = recorder.lines
+		} else {
+			info.ParamOverrideAudit = nil
+		}
+	}
 	return result, 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 := "<empty>"
+	if beforeExists {
+		beforeText = formatParamOverrideAuditValue(beforeValue)
+	}
+	afterText := "<deleted>"
+	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 "<empty>"
+	case string:
+		return typed
+	default:
+		return common.GetJsonString(typed)
+	}
+}
+
 func getParamOverrideMap(info *RelayInfo) map[string]interface{} {
 func getParamOverrideMap(info *RelayInfo) map[string]interface{} {
 	if info == nil || info.ChannelMeta == nil {
 	if info == nil || info.ChannelMeta == nil {
 		return nil
 		return nil
@@ -455,7 +586,7 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool,
 }
 }
 
 
 // applyOperationsLegacy 原参数覆盖方法
 // 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{})
 	reqMap := make(map[string]interface{})
 	err := common.Unmarshal(jsonData, &reqMap)
 	err := common.Unmarshal(jsonData, &reqMap)
 	if err != nil {
 	if err != nil {
@@ -463,7 +594,9 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}
 	}
 	}
 
 
 	for key, value := range paramOverride {
 	for key, value := range paramOverride {
+		beforeValue, beforeExists := reqMap[key]
 		reqMap[key] = value
 		reqMap[key] = value
+		auditRecorder.record(key, beforeExists, beforeValue, true, value)
 	}
 	}
 
 
 	return common.Marshal(reqMap)
 	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) {
 func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) {
 	context := ensureContextMap(conditionContext)
 	context := ensureContextMap(conditionContext)
+	auditRecorder := getParamOverrideAuditRecorder(context)
 	contextJSON, err := marshalContextJSON(context)
 	contextJSON, err := marshalContextJSON(context)
 	if err != nil {
 	if err != nil {
 		return "", fmt.Errorf("failed to marshal condition context: %v", err)
 		return "", fmt.Errorf("failed to marshal condition context: %v", err)
@@ -502,7 +636,9 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
 		switch op.Mode {
 		switch op.Mode {
 		case "delete":
 		case "delete":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
@@ -512,7 +648,9 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
 				if op.KeepOrigin && gjson.Get(result, path).Exists() {
 				if op.KeepOrigin && gjson.Get(result, path).Exists() {
 					continue
 					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 {
 				if err != nil {
 					break
 					break
 				}
 				}
@@ -520,87 +658,137 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
 		case "move":
 		case "move":
 			opFrom := processNegativeIndex(result, op.From)
 			opFrom := processNegativeIndex(result, op.From)
 			opTo := processNegativeIndex(result, op.To)
 			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)
 			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":
 		case "copy":
 			if op.From == "" || op.To == "" {
 			if op.From == "" || op.To == "" {
 				return "", fmt.Errorf("copy from/to is required")
 				return "", fmt.Errorf("copy from/to is required")
 			}
 			}
 			opFrom := processNegativeIndex(result, op.From)
 			opFrom := processNegativeIndex(result, op.From)
 			opTo := processNegativeIndex(result, op.To)
 			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)
 			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":
 		case "prepend":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "append":
 		case "append":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "trim_prefix":
 		case "trim_prefix":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "trim_suffix":
 		case "trim_suffix":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "ensure_prefix":
 		case "ensure_prefix":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "ensure_suffix":
 		case "ensure_suffix":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "trim_space":
 		case "trim_space":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "to_lower":
 		case "to_lower":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "to_upper":
 		case "to_upper":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "replace":
 		case "replace":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
 			}
 			}
 		case "regex_replace":
 		case "regex_replace":
 			for _, path := range opPaths {
 			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 {
 				if err != nil {
 					break
 					break
 				}
 				}
@@ -797,6 +985,7 @@ func setHeaderOverrideInContext(context map[string]interface{}, headerName strin
 	}
 	}
 
 
 	rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride)
 	rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride)
+	beforeRaw, beforeExists := rawHeaders[headerName]
 	if keepOrigin {
 	if keepOrigin {
 		if existing, ok := rawHeaders[headerName]; ok {
 		if existing, ok := rawHeaders[headerName]; ok {
 			existingValue := strings.TrimSpace(fmt.Sprintf("%v", existing))
 			existingValue := strings.TrimSpace(fmt.Sprintf("%v", existing))
@@ -812,10 +1001,12 @@ func setHeaderOverrideInContext(context map[string]interface{}, headerName strin
 	}
 	}
 	if !hasValue {
 	if !hasValue {
 		delete(rawHeaders, headerName)
 		delete(rawHeaders, headerName)
+		getParamOverrideAuditRecorder(context).record("header."+headerName, beforeExists, beforeRaw, false, nil)
 		return nil
 		return nil
 	}
 	}
 
 
 	rawHeaders[headerName] = headerValue
 	rawHeaders[headerName] = headerValue
+	getParamOverrideAuditRecorder(context).record("header."+headerName, beforeExists, beforeRaw, true, headerValue)
 	return nil
 	return nil
 }
 }
 
 
@@ -987,7 +1178,9 @@ func deleteHeaderOverrideInContext(context map[string]interface{}, headerName st
 		return fmt.Errorf("header name is required")
 		return fmt.Errorf("header name is required")
 	}
 	}
 	rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride)
 	rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride)
+	beforeRaw, beforeExists := rawHeaders[headerName]
 	delete(rawHeaders, headerName)
 	delete(rawHeaders, headerName)
+	getParamOverrideAuditRecorder(context).record("header."+headerName, beforeExists, beforeRaw, false, nil)
 	return nil
 	return nil
 }
 }
 
 

+ 1 - 0
relay/common/relay_info.go

@@ -149,6 +149,7 @@ type RelayInfo struct {
 	LastError                             *types.NewAPIError
 	LastError                             *types.NewAPIError
 	RuntimeHeadersOverride                map[string]interface{}
 	RuntimeHeadersOverride                map[string]interface{}
 	UseRuntimeHeadersOverride             bool
 	UseRuntimeHeadersOverride             bool
+	ParamOverrideAudit                    []string
 
 
 	PriceData types.PriceData
 	PriceData types.PriceData
 
 

+ 8 - 0
service/log_info_generate.go

@@ -74,9 +74,17 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
 	appendRequestPath(ctx, relayInfo, other)
 	appendRequestPath(ctx, relayInfo, other)
 	appendRequestConversionChain(relayInfo, other)
 	appendRequestConversionChain(relayInfo, other)
 	appendBillingInfo(relayInfo, other)
 	appendBillingInfo(relayInfo, other)
+	appendParamOverrideInfo(relayInfo, other)
 	return 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{}) {
 func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
 	if relayInfo == nil || other == nil {
 	if relayInfo == nil || other == nil {
 		return
 		return

+ 2 - 0
web/src/components/table/usage-logs/index.jsx

@@ -25,6 +25,7 @@ import LogsFilters from './UsageLogsFilters';
 import ColumnSelectorModal from './modals/ColumnSelectorModal';
 import ColumnSelectorModal from './modals/ColumnSelectorModal';
 import UserInfoModal from './modals/UserInfoModal';
 import UserInfoModal from './modals/UserInfoModal';
 import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
 import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
+import ParamOverrideModal from './modals/ParamOverrideModal';
 import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
 import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
 import { useIsMobile } from '../../../hooks/common/useIsMobile';
 import { useIsMobile } from '../../../hooks/common/useIsMobile';
 import { createCardProPagination } from '../../../helpers/utils';
 import { createCardProPagination } from '../../../helpers/utils';
@@ -39,6 +40,7 @@ const LogsPage = () => {
       <ColumnSelectorModal {...logsData} />
       <ColumnSelectorModal {...logsData} />
       <UserInfoModal {...logsData} />
       <UserInfoModal {...logsData} />
       <ChannelAffinityUsageCacheModal {...logsData} />
       <ChannelAffinityUsageCacheModal {...logsData} />
+      <ParamOverrideModal {...logsData} />
 
 
       {/* Main Content */}
       {/* Main Content */}
       <CardPro
       <CardPro

+ 278 - 0
web/src/components/table/usage-logs/modals/ParamOverrideModal.jsx

@@ -0,0 +1,278 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+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 }) => (
+  <div
+    style={{
+      flex: 1,
+      minWidth: 0,
+      padding: 12,
+      borderRadius: 12,
+      border: '1px solid var(--semi-color-border)',
+      background:
+        tone === 'after'
+          ? 'rgba(var(--semi-blue-5), 0.08)'
+          : 'var(--semi-color-fill-0)',
+    }}
+  >
+    <div
+      style={{
+        marginBottom: 6,
+        fontSize: 12,
+        fontWeight: 600,
+        color: 'var(--semi-color-text-2)',
+      }}
+    >
+      {label}
+    </div>
+    <Text
+      style={{
+        display: 'block',
+        fontFamily:
+          'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace',
+        fontSize: 13,
+        lineHeight: 1.65,
+        whiteSpace: 'pre-wrap',
+        wordBreak: 'break-word',
+      }}
+    >
+      {value}
+    </Text>
+  </div>
+);
+
+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 (
+    <Modal
+      title={t('参数覆盖详情')}
+      visible={showParamOverrideModal}
+      onCancel={() => setShowParamOverrideModal(false)}
+      footer={null}
+      centered
+      closable
+      maskClosable
+      width={760}
+    >
+      <div style={{ padding: 20 }}>
+        <div
+          style={{
+            marginBottom: 16,
+            padding: 16,
+            borderRadius: 16,
+            background:
+              'linear-gradient(135deg, rgba(var(--semi-blue-5), 0.08), rgba(var(--semi-teal-5), 0.12))',
+            border: '1px solid rgba(var(--semi-blue-5), 0.16)',
+          }}
+        >
+          <div
+            style={{
+              display: 'flex',
+              justifyContent: 'space-between',
+              gap: 12,
+              flexWrap: 'wrap',
+              alignItems: 'flex-start',
+            }}
+          >
+            <div>
+              <div
+                style={{
+                  fontSize: 18,
+                  fontWeight: 700,
+                  color: 'var(--semi-color-text-0)',
+                  marginBottom: 8,
+                }}
+              >
+                {t('已应用参数覆盖')}
+              </div>
+              <Space wrap spacing={8}>
+                <Tag color='blue' size='large'>
+                  {t('{{count}} 项变更', { count: lines.length })}
+                </Tag>
+                {paramOverrideTarget?.modelName ? (
+                  <Tag color='cyan' size='large'>
+                    {paramOverrideTarget.modelName}
+                  </Tag>
+                ) : null}
+                {paramOverrideTarget?.requestId ? (
+                  <Tag color='grey' size='large'>
+                    {t('Request ID')}: {paramOverrideTarget.requestId}
+                  </Tag>
+                ) : null}
+              </Space>
+            </div>
+
+            <Button
+              icon={<IconCopy />}
+              theme='solid'
+              type='tertiary'
+              onClick={copyAll}
+              disabled={lines.length === 0}
+            >
+              {t('复制全部')}
+            </Button>
+          </div>
+
+          {paramOverrideTarget?.requestPath ? (
+            <div style={{ marginTop: 12 }}>
+              <Text type='tertiary' size='small'>
+                {t('请求路径')}: {paramOverrideTarget.requestPath}
+              </Text>
+            </div>
+          ) : null}
+        </div>
+
+        {lines.length === 0 ? (
+          <Empty
+            description={t('暂无参数覆盖记录')}
+            style={{ padding: '32px 0 12px' }}
+          />
+        ) : (
+          <div
+            style={{
+              display: 'flex',
+              flexDirection: 'column',
+              gap: 12,
+              maxHeight: '60vh',
+              overflowY: 'auto',
+              paddingRight: 4,
+            }}
+          >
+            {parsedLines.map((item, index) => {
+              if (!item) {
+                return (
+                  <div
+                    key={`raw-${index}`}
+                    style={{
+                      padding: 14,
+                      borderRadius: 14,
+                      border: '1px solid var(--semi-color-border)',
+                      background: 'var(--semi-color-fill-0)',
+                    }}
+                  >
+                    <Text
+                      style={{
+                        fontFamily:
+                          'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace',
+                        fontSize: 13,
+                        lineHeight: 1.65,
+                        whiteSpace: 'pre-wrap',
+                        wordBreak: 'break-word',
+                      }}
+                    >
+                      {lines[index]}
+                    </Text>
+                  </div>
+                );
+              }
+
+              return (
+                <div
+                  key={`${item.field}-${index}`}
+                  style={{
+                    padding: 14,
+                    borderRadius: 16,
+                    border: '1px solid var(--semi-color-border)',
+                    background: 'var(--semi-color-bg-0)',
+                    boxShadow: '0 8px 24px rgba(15, 23, 42, 0.04)',
+                  }}
+                >
+                  <div style={{ marginBottom: 12 }}>
+                    <Tag color='blue' shape='circle' size='large'>
+                      {item.field}
+                    </Tag>
+                  </div>
+                  <div
+                    style={{
+                      display: 'flex',
+                      gap: 12,
+                      flexWrap: 'wrap',
+                      alignItems: 'stretch',
+                    }}
+                  >
+                    <ValuePanel label={t('变更前')} value={item.before} />
+                    <ValuePanel label={t('变更后')} value={item.after} tone='after' />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default ParamOverrideModal;

+ 52 - 1
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 { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Modal } from '@douyinfe/semi-ui';
+import { Modal, Button, Tag } from '@douyinfe/semi-ui';
 import {
 import {
   API,
   API,
   getTodayStartTimestamp,
   getTodayStartTimestamp,
@@ -181,6 +181,8 @@ export const useLogsData = () => {
   ] = useState(false);
   ] = useState(false);
   const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
   const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
     useState(null);
     useState(null);
+  const [showParamOverrideModal, setShowParamOverrideModal] = useState(false);
+  const [paramOverrideTarget, setParamOverrideTarget] = useState(null);
 
 
   // Initialize default column visibility
   // Initialize default column visibility
   const initDefaultColumns = () => {
   const initDefaultColumns = () => {
@@ -345,6 +347,20 @@ export const useLogsData = () => {
     setShowChannelAffinityUsageCacheModal(true);
     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
   // Format logs data
   const setLogsFormat = (logs) => {
   const setLogsFormat = (logs) => {
     const requestConversionDisplayValue = (conversionChain) => {
     const requestConversionDisplayValue = (conversionChain) => {
@@ -584,6 +600,37 @@ export const useLogsData = () => {
           value: other.request_path,
           value: other.request_path,
         });
         });
       }
       }
+      if (Array.isArray(other?.po) && other.po.length > 0) {
+        expandDataLocal.push({
+          key: t('参数覆盖'),
+          value: (
+            <div
+              style={{
+                display: 'flex',
+                alignItems: 'center',
+                gap: 10,
+                flexWrap: 'wrap',
+              }}
+            >
+              <Tag color='blue' shape='circle'>
+                {t('{{count}} 项变更', { count: other.po.length })}
+              </Tag>
+              <Button
+                theme='borderless'
+                type='primary'
+                size='small'
+                style={{ paddingLeft: 0 }}
+                onClick={(event) => {
+                  event.stopPropagation();
+                  openParamOverrideModal(logs[i], other);
+                }}
+              >
+                {t('查看详情')}
+              </Button>
+            </div>
+          ),
+        });
+      }
       if (other?.billing_source === 'subscription') {
       if (other?.billing_source === 'subscription') {
         const planId = other?.subscription_plan_id;
         const planId = other?.subscription_plan_id;
         const planTitle = other?.subscription_plan_title || '';
         const planTitle = other?.subscription_plan_title || '';
@@ -811,6 +858,9 @@ export const useLogsData = () => {
     setShowChannelAffinityUsageCacheModal,
     setShowChannelAffinityUsageCacheModal,
     channelAffinityUsageCacheTarget,
     channelAffinityUsageCacheTarget,
     openChannelAffinityUsageCacheModal,
     openChannelAffinityUsageCacheModal,
+    showParamOverrideModal,
+    setShowParamOverrideModal,
+    paramOverrideTarget,
 
 
     // Functions
     // Functions
     loadLogs,
     loadLogs,
@@ -822,6 +872,7 @@ export const useLogsData = () => {
     setLogsFormat,
     setLogsFormat,
     hasExpandableRows,
     hasExpandableRows,
     setLogType,
     setLogType,
+    openParamOverrideModal,
 
 
     // Translation
     // Translation
     t,
     t,