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
This commit is contained in:
Seefs
2026-01-26 20:20:30 +08:00
committed by GitHub
parent cc1da72d10
commit 478f1871d6
9 changed files with 507 additions and 70 deletions

View File

@@ -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()) {

163
service/violation_fee.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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 = () => {
<Card style={{ marginTop: '10px' }}>
<SettingClaudeModel options={inputs} refresh={onRefresh} />
</Card>
{/* Grok */}
<Card style={{ marginTop: '10px' }}>
<SettingGrokModel options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);

View File

@@ -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 = ({
</Typography.Paragraph>
);
}
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 (
<Typography.Paragraph
ellipsis={{
rows: 2,
showTooltip: {
type: 'popover',
opts: { style: { width: 240 } },
},
}}
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
>
{summary}
</Typography.Paragraph>
);
}
let content = other?.claude
? renderModelPriceSimple(
other.model_ratio,

View File

@@ -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;
}

View File

@@ -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'),

View File

@@ -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 <https://www.gnu.org/licenses/>.
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 (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('Grok设置')}>
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
label={t('启用违规扣费')}
field={'grok.violation_deduction_enabled'}
onChange={(value) =>
setInputs({
...inputs,
'grok.violation_deduction_enabled': value,
})
}
extraText={
<span>
{t('开启后,违规请求将额外扣费。')}{' '}
<a
href={XAI_VIOLATION_FEE_DOC_URL}
target='_blank'
rel='noreferrer'
>
{t('官方说明')}
</a>
</span>
}
/>
</Col>
</Row>
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
label={t('违规扣费金额')}
field={'grok.violation_deduction_amount'}
min={0}
step={0.01}
precision={4}
disabled={!inputs['grok.violation_deduction_enabled']}
onChange={(value) =>
setInputs({
...inputs,
'grok.violation_deduction_amount': value,
})
}
extraText={
<span>
{t('这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。')}{' '}
<a
href={XAI_VIOLATION_FEE_DOC_URL}
target='_blank'
rel='noreferrer'
>
{t('官方说明')}
</a>
</span>
}
/>
</Col>
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
{t('保存')}
</Button>
</Row>
</Form.Section>
</Form>
</Spin>
);
}