Pārlūkot izejas kodu

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
Seefs 1 mēnesi atpakaļ
vecāks
revīzija
478f1871d6

+ 8 - 2
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()) {

+ 163 - 0
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
+}

+ 24 - 0
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
+}

+ 1 - 0
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"

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

+ 42 - 0
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 = ({
             </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,

+ 11 - 4
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;
 }

+ 76 - 64
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'),

+ 175 - 0
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 <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>
+  );
+}