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 (
+
+
+
+
+
+ setInputs({
+ ...inputs,
+ 'grok.violation_deduction_enabled': value,
+ })
+ }
+ extraText={
+
+ {t('开启后,违规请求将额外扣费。')}{' '}
+
+ {t('官方说明')}
+
+
+ }
+ />
+
+
+
+
+
+
+ setInputs({
+ ...inputs,
+ 'grok.violation_deduction_amount': value,
+ })
+ }
+ extraText={
+
+ {t('这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。')}{' '}
+
+ {t('官方说明')}
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+}