From 478f1871d67b38207fd638dded763d9de8eb7992 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:20:30 +0800 Subject: [PATCH] feat: grok Usage Guidelines Violation Fee (#2753) * feat: grok Usage Guidelines Violation Fee ui setting * feat: grok Usage Guidelines Violation Fee consume log * fix: grok Usage Guidelines Violation Fee log detail --- controller/relay.go | 10 +- service/violation_fee.go | 163 ++++++++++++++++ setting/model_setting/grok.go | 24 +++ types/error.go | 1 + web/src/components/settings/ModelSetting.jsx | 7 + .../table/usage-logs/UsageLogsColumnDefs.jsx | 42 +++++ web/src/helpers/log.js | 15 +- web/src/hooks/usage-logs/useUsageLogsData.jsx | 140 +++++++------- .../pages/Setting/Model/SettingGrokModel.jsx | 175 ++++++++++++++++++ 9 files changed, 507 insertions(+), 70 deletions(-) create mode 100644 service/violation_fee.go create mode 100644 setting/model_setting/grok.go create mode 100644 web/src/pages/Setting/Model/SettingGrokModel.jsx diff --git a/controller/relay.go b/controller/relay.go index 9197847e4..387fe47f8 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -167,8 +167,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { defer func() { // Only return quota if downstream failed and quota was actually pre-consumed - if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 { - service.ReturnPreConsumedQuota(c, relayInfo) + if newAPIError != nil { + newAPIError = service.NormalizeViolationFeeError(newAPIError) + if relayInfo.FinalPreConsumedQuota != 0 { + service.ReturnPreConsumedQuota(c, relayInfo) + } + service.ChargeViolationFeeIfNeeded(c, relayInfo, newAPIError) } }() @@ -215,6 +219,8 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { return } + newAPIError = service.NormalizeViolationFeeError(newAPIError) + processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { diff --git a/service/violation_fee.go b/service/violation_fee.go new file mode 100644 index 000000000..400c10dd5 --- /dev/null +++ b/service/violation_fee.go @@ -0,0 +1,163 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" +) + +const ( + ViolationFeeCodePrefix = "violation_fee." + CSAMViolationMarker = "Failed check: SAFETY_CHECK_TYPE_CSAM" +) + +func IsViolationFeeCode(code types.ErrorCode) bool { + return strings.HasPrefix(string(code), ViolationFeeCodePrefix) +} + +func HasCSAMViolationMarker(err *types.NewAPIError) bool { + if err == nil { + return false + } + if strings.Contains(err.Error(), CSAMViolationMarker) { + return true + } + msg := err.ToOpenAIError().Message + return strings.Contains(msg, CSAMViolationMarker) +} + +func WrapAsViolationFeeGrokCSAM(err *types.NewAPIError) *types.NewAPIError { + if err == nil { + return nil + } + oai := err.ToOpenAIError() + oai.Type = string(types.ErrorCodeViolationFeeGrokCSAM) + oai.Code = string(types.ErrorCodeViolationFeeGrokCSAM) + return types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry()) +} + +// NormalizeViolationFeeError ensures: +// - if the CSAM marker is present, error.code is set to a stable violation-fee code and skip-retry is enabled. +// - if error.code already has the violation-fee prefix, skip-retry is enabled. +// +// It must be called before retry decision logic. +func NormalizeViolationFeeError(err *types.NewAPIError) *types.NewAPIError { + if err == nil { + return nil + } + + if HasCSAMViolationMarker(err) { + return WrapAsViolationFeeGrokCSAM(err) + } + + if IsViolationFeeCode(err.GetErrorCode()) { + oai := err.ToOpenAIError() + return types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry()) + } + + return err +} + +func shouldChargeViolationFee(err *types.NewAPIError) bool { + if err == nil { + return false + } + if err.GetErrorCode() == types.ErrorCodeViolationFeeGrokCSAM { + return true + } + // In case some callers didn't normalize, keep a safety net. + return HasCSAMViolationMarker(err) +} + +func calcViolationFeeQuota(amount, groupRatio float64) int { + if amount <= 0 { + return 0 + } + if groupRatio <= 0 { + return 0 + } + quota := decimal.NewFromFloat(amount). + Mul(decimal.NewFromFloat(common.QuotaPerUnit)). + Mul(decimal.NewFromFloat(groupRatio)). + Round(0). + IntPart() + if quota <= 0 { + return 0 + } + return int(quota) +} + +// ChargeViolationFeeIfNeeded charges an additional fee after the normal flow finishes (including refund). +// It uses Grok fee settings as the fee policy. +func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, apiErr *types.NewAPIError) bool { + if ctx == nil || relayInfo == nil || apiErr == nil { + return false + } + //if relayInfo.IsPlayground { + // return false + //} + if !shouldChargeViolationFee(apiErr) { + return false + } + + settings := model_setting.GetGrokSettings() + if settings == nil || !settings.ViolationDeductionEnabled { + return false + } + + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + feeQuota := calcViolationFeeQuota(settings.ViolationDeductionAmount, groupRatio) + if feeQuota <= 0 { + return false + } + + if err := PostConsumeQuota(relayInfo, feeQuota, 0, true); err != nil { + logger.LogError(ctx, fmt.Sprintf("failed to charge violation fee: %s", err.Error())) + return false + } + + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, feeQuota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, feeQuota) + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + tokenName := ctx.GetString("token_name") + oai := apiErr.ToOpenAIError() + + other := map[string]any{ + "violation_fee": true, + "violation_fee_code": string(types.ErrorCodeViolationFeeGrokCSAM), + "fee_quota": feeQuota, + "base_amount": settings.ViolationDeductionAmount, + "group_ratio": groupRatio, + "status_code": apiErr.StatusCode, + "upstream_error_type": oai.Type, + "upstream_error_code": fmt.Sprintf("%v", oai.Code), + "violation_fee_marker": CSAMViolationMarker, + } + + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + ModelName: relayInfo.OriginModelName, + TokenName: tokenName, + Quota: feeQuota, + Content: "Violation fee charged", + TokenId: relayInfo.TokenId, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) + + return true +} diff --git a/setting/model_setting/grok.go b/setting/model_setting/grok.go new file mode 100644 index 000000000..d558679bf --- /dev/null +++ b/setting/model_setting/grok.go @@ -0,0 +1,24 @@ +package model_setting + +import "github.com/QuantumNous/new-api/setting/config" + +// GrokSettings defines Grok model configuration. +type GrokSettings struct { + ViolationDeductionEnabled bool `json:"violation_deduction_enabled"` + ViolationDeductionAmount float64 `json:"violation_deduction_amount"` +} + +var defaultGrokSettings = GrokSettings{ + ViolationDeductionEnabled: true, + ViolationDeductionAmount: 0.05, +} + +var grokSettings = defaultGrokSettings + +func init() { + config.GlobalConfig.Register("grok", &grokSettings) +} + +func GetGrokSettings() *GrokSettings { + return &grokSettings +} diff --git a/types/error.go b/types/error.go index e112eeefb..6af39f7e9 100644 --- a/types/error.go +++ b/types/error.go @@ -40,6 +40,7 @@ type ErrorCode string const ( ErrorCodeInvalidRequest ErrorCode = "invalid_request" ErrorCodeSensitiveWordsDetected ErrorCode = "sensitive_words_detected" + ErrorCodeViolationFeeGrokCSAM ErrorCode = "violation_fee.grok.csam" // new api error ErrorCodeCountTokenFailed ErrorCode = "count_token_failed" diff --git a/web/src/components/settings/ModelSetting.jsx b/web/src/components/settings/ModelSetting.jsx index 69ece885a..fb0b844a0 100644 --- a/web/src/components/settings/ModelSetting.jsx +++ b/web/src/components/settings/ModelSetting.jsx @@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'; import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel'; import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel'; import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel'; +import SettingGrokModel from '../../pages/Setting/Model/SettingGrokModel'; import SettingsChannelAffinity from '../../pages/Setting/Operation/SettingsChannelAffinity'; const ModelSetting = () => { @@ -45,6 +46,8 @@ const ModelSetting = () => { 'general_setting.ping_interval_seconds': 60, 'gemini.thinking_adapter_enabled': false, 'gemini.thinking_adapter_budget_tokens_percentage': 0.6, + 'grok.violation_deduction_enabled': true, + 'grok.violation_deduction_amount': 0.05, }); let [loading, setLoading] = useState(false); @@ -122,6 +125,10 @@ const ModelSetting = () => { + {/* Grok */} + + + ); diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 4c7fb1d84..2fb0cde8b 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -61,6 +61,16 @@ const colors = [ 'yellow', ]; +function formatRatio(ratio) { + if (ratio === undefined || ratio === null) { + return '-'; + } + if (typeof ratio === 'number') { + return ratio.toFixed(4); + } + return String(ratio); +} + // Render functions function renderType(type, t) { switch (type) { @@ -588,6 +598,38 @@ export const getLogsColumns = ({ ); } + + if ( + other?.violation_fee === true || + Boolean(other?.violation_fee_code) || + Boolean(other?.violation_fee_marker) + ) { + const feeQuota = other?.fee_quota ?? record?.quota; + const ratioText = formatRatio(other?.group_ratio); + const summary = [ + t('违规扣费'), + `${t('分组倍率')}:${ratioText}`, + `${t('扣费')}:${renderQuota(feeQuota, 6)}`, + text ? `${t('详情')}:${text}` : null, + ] + .filter(Boolean) + .join('\n'); + return ( + + {summary} + + ); + } + let content = other?.claude ? renderModelPriceSimple( other.model_ratio, diff --git a/web/src/helpers/log.js b/web/src/helpers/log.js index 298dff189..0f2190a65 100644 --- a/web/src/helpers/log.js +++ b/web/src/helpers/log.js @@ -18,9 +18,16 @@ For commercial licensing, please contact support@quantumnous.com */ export function getLogOther(otherStr) { - if (otherStr === undefined || otherStr === '') { - otherStr = '{}'; + if (otherStr === undefined || otherStr === null || otherStr === '') { + return {}; + } + if (typeof otherStr === 'object') { + return otherStr; + } + try { + return JSON.parse(otherStr); + } catch (e) { + console.error(`Failed to parse record.other: "${otherStr}".`, e); + return null; } - let other = JSON.parse(otherStr); - return other; } diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 27950cde7..4c7fa147c 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -419,72 +419,84 @@ export const useLogsData = () => { value: other.upstream_model_name, }); } + + const isViolationFeeLog = + other?.violation_fee === true || + Boolean(other?.violation_fee_code) || + Boolean(other?.violation_fee_marker); + let content = ''; - if (other?.ws || other?.audio) { - content = renderAudioModelPrice( - other?.text_input, - other?.text_output, - other?.model_ratio, - other?.model_price, - other?.completion_ratio, - other?.audio_input, - other?.audio_output, - other?.audio_ratio, - other?.audio_completion_ratio, - other?.group_ratio, - other?.user_group_ratio, - other?.cache_tokens || 0, - other?.cache_ratio || 1.0, - ); - } else if (other?.claude) { - content = renderClaudeModelPrice( - logs[i].prompt_tokens, - logs[i].completion_tokens, - other.model_ratio, - other.model_price, - other.completion_ratio, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - other.cache_creation_tokens || 0, - other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_5m || 0, - other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_1h || 0, - other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, - ); - } else { - content = renderModelPrice( - logs[i].prompt_tokens, - logs[i].completion_tokens, - other?.model_ratio, - other?.model_price, - other?.completion_ratio, - other?.group_ratio, - other?.user_group_ratio, - other?.cache_tokens || 0, - other?.cache_ratio || 1.0, - other?.image || false, - other?.image_ratio || 0, - other?.image_output || 0, - other?.web_search || false, - other?.web_search_call_count || 0, - other?.web_search_price || 0, - other?.file_search || false, - other?.file_search_call_count || 0, - other?.file_search_price || 0, - other?.audio_input_seperate_price || false, - other?.audio_input_token_count || 0, - other?.audio_input_price || 0, - other?.image_generation_call || false, - other?.image_generation_call_price || 0, - ); + if (!isViolationFeeLog) { + if (other?.ws || other?.audio) { + content = renderAudioModelPrice( + other?.text_input, + other?.text_output, + other?.model_ratio, + other?.model_price, + other?.completion_ratio, + other?.audio_input, + other?.audio_output, + other?.audio_ratio, + other?.audio_completion_ratio, + other?.group_ratio, + other?.user_group_ratio, + other?.cache_tokens || 0, + other?.cache_ratio || 1.0, + ); + } else if (other?.claude) { + content = renderClaudeModelPrice( + logs[i].prompt_tokens, + logs[i].completion_tokens, + other.model_ratio, + other.model_price, + other.completion_ratio, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + other.cache_creation_tokens || 0, + other.cache_creation_ratio || 1.0, + other.cache_creation_tokens_5m || 0, + other.cache_creation_ratio_5m || + other.cache_creation_ratio || + 1.0, + other.cache_creation_tokens_1h || 0, + other.cache_creation_ratio_1h || + other.cache_creation_ratio || + 1.0, + ); + } else { + content = renderModelPrice( + logs[i].prompt_tokens, + logs[i].completion_tokens, + other?.model_ratio, + other?.model_price, + other?.completion_ratio, + other?.group_ratio, + other?.user_group_ratio, + other?.cache_tokens || 0, + other?.cache_ratio || 1.0, + other?.image || false, + other?.image_ratio || 0, + other?.image_output || 0, + other?.web_search || false, + other?.web_search_call_count || 0, + other?.web_search_price || 0, + other?.file_search || false, + other?.file_search_call_count || 0, + other?.file_search_price || 0, + other?.audio_input_seperate_price || false, + other?.audio_input_token_count || 0, + other?.audio_input_price || 0, + other?.image_generation_call || false, + other?.image_generation_call_price || 0, + ); + } + expandDataLocal.push({ + key: t('计费过程'), + value: content, + }); } - expandDataLocal.push({ - key: t('计费过程'), - value: content, - }); if (other?.reasoning_effort) { expandDataLocal.push({ key: t('Reasoning Effort'), diff --git a/web/src/pages/Setting/Model/SettingGrokModel.jsx b/web/src/pages/Setting/Model/SettingGrokModel.jsx new file mode 100644 index 000000000..3fdf2ca09 --- /dev/null +++ b/web/src/pages/Setting/Model/SettingGrokModel.jsx @@ -0,0 +1,175 @@ +/* +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, { useEffect, useRef, useState } from 'react'; +import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; +import { + API, + compareObjects, + showError, + showSuccess, + showWarning, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const XAI_VIOLATION_FEE_DOC_URL = + 'https://docs.x.ai/docs/models#usage-guidelines-violation-fee'; + +const DEFAULT_GROK_INPUTS = { + 'grok.violation_deduction_enabled': true, + 'grok.violation_deduction_amount': 0.05, +}; + +export default function SettingGrokModel(props) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState(DEFAULT_GROK_INPUTS); + const [inputsRow, setInputsRow] = useState(DEFAULT_GROK_INPUTS); + const refForm = useRef(); + + async function onSubmit() { + await refForm.current + .validate() + .then(() => { + const updateArray = compareObjects(inputs, inputsRow); + if (!updateArray.length) + return showWarning(t('你似乎并没有修改什么')); + + const requestQueue = updateArray.map((item) => { + const value = String(inputs[item.key]); + return API.put('/api/option/', { key: item.key, value }); + }); + + setLoading(true); + Promise.all(requestQueue) + .then((res) => { + if (requestQueue.length === 1) { + if (res.includes(undefined)) return; + } else if (requestQueue.length > 1) { + if (res.includes(undefined)) + return showError(t('部分保存失败,请重试')); + } + showSuccess(t('保存成功')); + props.refresh(); + }) + .catch(() => { + showError(t('保存失败,请重试')); + }) + .finally(() => { + setLoading(false); + }); + }) + .catch((error) => { + console.error('Validation failed:', error); + showError(t('请检查输入')); + }); + } + + useEffect(() => { + const currentInputs = { ...DEFAULT_GROK_INPUTS }; + for (const key of Object.keys(DEFAULT_GROK_INPUTS)) { + if (props.options[key] !== undefined) { + currentInputs[key] = props.options[key]; + } + } + + setInputs(currentInputs); + setInputsRow(structuredClone(currentInputs)); + if (refForm.current) { + refForm.current.setValues(currentInputs); + } + }, [props.options]); + + return ( + +
(refForm.current = formAPI)} + style={{ marginBottom: 15 }} + > + + + + + setInputs({ + ...inputs, + 'grok.violation_deduction_enabled': value, + }) + } + extraText={ + + {t('开启后,违规请求将额外扣费。')}{' '} + + {t('官方说明')} + + + } + /> + + + + + + + setInputs({ + ...inputs, + 'grok.violation_deduction_amount': value, + }) + } + extraText={ + + {t('这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。')}{' '} + + {t('官方说明')} + + + } + /> + + + + + + + +
+
+ ); +}