Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}()

Expand Down Expand Up @@ -215,6 +219,8 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
return
}

newAPIError = service.NormalizeViolationFeeError(newAPIError)

Comment on lines +222 to +223

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the relay.go file to see the context at lines 222-223 and 171
fd -t f "relay.go" | head -5

Repository: QuantumNous/new-api

Length of output: 82


🏁 Script executed:

# Get the content around line 222 and the defer block around line 171
cat -n controller/relay.go | sed -n '160,230p'

Repository: QuantumNous/new-api

Length of output: 3017


🏁 Script executed:

# Search for the normalization function definitions
rg -n "func.*NormalizeViolationFeeError|func.*WrapAsViolationFeeGrokCSAM" -A 15

Repository: QuantumNous/new-api

Length of output: 1916


🏁 Script executed:

# Check for idempotency documentation or markers
rg -n "idempotent|violation_fee_marker|ErrorCodeViolationFee" -B 2 -A 5

Repository: QuantumNous/new-api

Length of output: 4255


🏁 Script executed:

# Find complete IsViolationFeeCode function
rg -n "func.*IsViolationFeeCode" -A 10

Repository: QuantumNous/new-api

Length of output: 658


🏁 Script executed:

# Find HasCSAMViolationMarker function
rg -n "func.*HasCSAMViolationMarker" -A 10

Repository: QuantumNous/new-api

Length of output: 628


🏁 Script executed:

# Check all violation fee related error codes
rg -n "ErrorCode.*[Vv]iolation" -B 1 -A 1

Repository: QuantumNous/new-api

Length of output: 995


🏁 Script executed:

# Find ViolationFeeCodePrefix definition
rg -n "ViolationFeeCodePrefix" -B 2 -A 2

Repository: QuantumNous/new-api

Length of output: 582


🏁 Script executed:

# Check ToOpenAIError implementation to understand behavior
rg -n "func.*ToOpenAIError" -A 15

Repository: QuantumNous/new-api

Length of output: 1489


🏁 Script executed:

# Check the shouldRetry and processChannelError calls to understand flow
rg -n "func shouldRetry|func processChannelError" -A 5

Repository: QuantumNous/new-api

Length of output: 1365


🏁 Script executed:

# Check if there are other paths to line 171 that bypass line 222
rg -n "break" controller/relay.go | grep -A 2 -B 2 "191\|203"

Repository: QuantumNous/new-api

Length of output: 114


Line 222 unnecessarily normalizes errors that will be normalized again in the defer block.

In the relay handler path (line 216 success/failure that passes shouldRetry check), NormalizeViolationFeeError is called at line 222, then again at line 171 in the defer block. While the function is idempotent by design—IsViolationFeeCode uses a prefix check that handles already-wrapped errors, and HasCSAMViolationMarker persists in the message—the redundant call is unnecessary. Either normalize once in the defer (removing line 222) for all error paths, or skip the defer normalization if already normalized in the loop.

🤖 Prompt for AI Agents
In `@controller/relay.go` around lines 222 - 223, Remove the redundant early
normalization call to service.NormalizeViolationFeeError in the relay handler's
success/failure path and rely on the single normalize call in the defer block;
specifically delete the invocation of service.NormalizeViolationFeeError that
occurs after the shouldRetry check so errors are only normalized once in the
defer, leaving IsViolationFeeCode and HasCSAMViolationMarker logic unchanged.

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()) {
Expand Down
163 changes: 163 additions & 0 deletions service/violation_fee.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +125 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Quota updates lack atomicity.

PostConsumeQuota, UpdateUserUsedQuotaAndRequestCount, and UpdateChannelUsedQuota are called sequentially without a transaction. If any call fails after another succeeds, quota data could become inconsistent.

Consider wrapping these operations in a transaction or implementing compensation logic if a later operation fails.

🤖 Prompt for AI Agents
In `@service/violation_fee.go` around lines 125 - 131, The sequential calls to
PostConsumeQuota, model.UpdateUserUsedQuotaAndRequestCount, and
model.UpdateChannelUsedQuota can leave quotas inconsistent if one succeeds and a
later one fails; wrap these operations in a single atomic transaction (or
implement explicit compensation/rollback) so that either all three succeed or
all are reverted, e.g., begin a DB transaction around PostConsumeQuota and the
two model updates, check errors on each call, rollback the transaction and log
the error if any step fails, and commit only after all succeed; ensure
transaction handling is added around PostConsumeQuota,
UpdateUserUsedQuotaAndRequestCount, and UpdateChannelUsedQuota and that errors
propagate to calling code.


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 changes: 24 additions & 0 deletions setting/model_setting/grok.go
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions types/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/settings/ModelSetting.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

const ModelSetting = () => {
const { t } = useTranslation();
Expand All @@ -44,6 +45,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);
Expand Down Expand Up @@ -117,6 +120,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>
</>
);
Expand Down
42 changes: 42 additions & 0 deletions web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -541,6 +551,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,
Expand Down
15 changes: 11 additions & 4 deletions web/src/helpers/log.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading