Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ var TelegramBotName = ""
var QuotaForNewUser = 0
var QuotaForInviter = 0
var QuotaForInvitee = 0
var AffiliateRewardsEnabled = true
var ChannelDisableThreshold = 5.0
var AutomaticDisableChannelEnabled = false
var AutomaticEnableChannelEnabled = false
Expand Down
1 change: 1 addition & 0 deletions controller/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func GetStatus(c *gin.Context) {
"register_enabled": common.RegisterEnabled,
"password_login_enabled": common.PasswordLoginEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
"affiliate_rewards_enabled": common.AffiliateRewardsEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,

"usd_exchange_rate": operation_setting.USDExchangeRate,
Expand Down
38 changes: 32 additions & 6 deletions controller/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,15 @@ func ListModels(c *gin.Context, modelType int) {
switch modelType {
case constant.ChannelTypeAnthropic:
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
for i, m := range userOpenAiModels {
id := m.Id
if !strings.HasPrefix(id, "claude-") {
id = "claude-" + id
}
useranthropicModels[i] = dto.AnthropicModel{
ID: model.Id,
CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339),
DisplayName: model.Id,
ID: id,
CreatedAt: time.Unix(int64(m.Created), 0).UTC().Format(time.RFC3339),
DisplayName: m.Id,
Type: "model",
}
Comment on lines +268 to 278

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make Anthropic aliases reversible and requester-scoped.

foo and a literal claude-foo both serialize as claude-foo. Retrieval and routing then prefer the literal model; moreover, an ability in another group can make routing preserve an ID the current user cannot access. This yields duplicate listings and makes the original foo unrouteable through /v1/messages.

  • controller/model.go#L271-L281: prevent emitting colliding IDs; enforce a reserved-prefix invariant or expose a per-user reversible alias map.
  • controller/model.go#L339-L351: resolve against that caller-visible mapping rather than global exact-key precedence.
  • model/ability.go#L57-L70: provide a group/requester-scoped lookup if existence checks remain part of resolution.
  • middleware/distributor.go#L41-L43: perform resolution with the effective user/group context.
  • middleware/distributor.go#L179-L190: do not use global ability existence to disambiguate aliases.
📍 Affects 3 files
  • controller/model.go#L271-L281 (this comment)
  • controller/model.go#L339-L351
  • model/ability.go#L57-L70
  • middleware/distributor.go#L41-L43
  • middleware/distributor.go#L179-L190
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/model.go` around lines 271 - 281, Make Anthropic model aliases
reversible and scoped to the requesting user/group: in
controller/model.go:271-281, prevent collisions between foo and literal
claude-foo by enforcing a reserved-prefix invariant or maintaining a per-user
alias map; in controller/model.go:339-351, resolve IDs only through the
caller-visible mapping; in model/ability.go:57-70, provide
group/requester-scoped lookup where existence checks remain; in
middleware/distributor.go:41-43, pass the effective user/group context; and in
middleware/distributor.go:179-190, remove global ability-based alias
disambiguation so inaccessible abilities cannot affect routing.

}
Expand Down Expand Up @@ -329,11 +333,19 @@ func EnabledListModels(c *gin.Context) {

func RetrieveModel(c *gin.Context, modelType int) {
modelId := c.Param("model")
if aiModel, ok := openAIModelsMap[modelId]; ok {
lookupId := modelId
if modelType == constant.ChannelTypeAnthropic {
lookupId = resolveAnthropicModelID(modelId)
}
if aiModel, ok := openAIModelsMap[lookupId]; ok {
switch modelType {
case constant.ChannelTypeAnthropic:
id := aiModel.Id
if !strings.HasPrefix(id, "claude-") {
id = "claude-" + id
}
c.JSON(200, dto.AnthropicModel{
ID: aiModel.Id,
ID: id,
CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339),
DisplayName: aiModel.Id,
Type: "model",
Expand All @@ -353,3 +365,17 @@ func RetrieveModel(c *gin.Context, modelType int) {
})
}
}

func resolveAnthropicModelID(id string) string {
if !strings.HasPrefix(id, "claude-") {
return id
}
if _, ok := openAIModelsMap[id]; ok {
return id
}
stripped := id[len("claude-"):]
if stripped == "" {
return id
}
return stripped
}
74 changes: 74 additions & 0 deletions controller/status_affiliate_rewards_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package controller

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetStatusIncludesAffiliateRewardsEnabled(t *testing.T) {
originalEnabled := common.AffiliateRewardsEnabled
originalOptionMap := common.OptionMap
t.Cleanup(func() {
common.AffiliateRewardsEnabled = originalEnabled
common.OptionMap = originalOptionMap
})

common.AffiliateRewardsEnabled = false
common.OptionMap = map[string]string{
"HeaderNavModules": "[]",
"SidebarModulesAdmin": "[]",
}

gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil)

GetStatus(context)

require.Equal(t, http.StatusOK, recorder.Code)

var payload struct {
Success bool `json:"success"`
Data struct {
AffiliateRewardsEnabled bool `json:"affiliate_rewards_enabled"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.True(t, payload.Success)
assert.False(t, payload.Data.AffiliateRewardsEnabled)
}

func TestGetTopUpInfoIncludesAffiliateRewardsEnabled(t *testing.T) {
originalEnabled := common.AffiliateRewardsEnabled
t.Cleanup(func() {
common.AffiliateRewardsEnabled = originalEnabled
})

common.AffiliateRewardsEnabled = false

gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/user/topup/info", nil)

GetTopUpInfo(context)

require.Equal(t, http.StatusOK, recorder.Code)

var payload struct {
Success bool `json:"success"`
Data struct {
AffiliateRewardsEnabled bool `json:"affiliate_rewards_enabled"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.True(t, payload.Success)
assert.False(t, payload.Data.AffiliateRewardsEnabled)
}
1 change: 1 addition & 0 deletions controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func GetTopUpInfo(c *gin.Context) {
"enable_waffo_topup": enableWaffo,
"enable_waffo_pancake_topup": enableWaffoPancake,
"enable_redemption": complianceConfirmed,
"affiliate_rewards_enabled": common.AffiliateRewardsEnabled,

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.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 'AffiliateRewardsEnabled|OptionMapRWMutex' controller/topup.go model/option.go controller/misc.go

Repository: QuantumNous/new-api

Length of output: 5469


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== common files with AffiliateRewardsEnabled/OptionMapRWMutex declarations =="
rg -n -C2 'var OptionMapRWMutex|type OptionMapRWMutex|AffiliateRewardsEnabled' -S --glob '*.go' .

echo
echo "== controller/topup.go relevant sections =="
fd -a 'topup\.go$' . | sed 's#^\./##' | while read -r f; do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  rg -n -C8 'GetTopUpInfo|affiliate_rewards_enabled|OptionMapRWMutex' "$f" || true
done

echo
echo "== direct reads/writes of AffiliateRewardsEnabled in Go sources =="
python3 - <<'PY'
import re, pathlib
for p in pathlib.Path('.').rglob('*.go'):
    s=p.read_text(errors='ignore').splitlines()
    for i,line in enumerate(s,1):
        if 'common.AffiliateRewardsEnabled' in line or 'AffiliateRewardsEnabled = ' in line:
            print(f"{p}:{i}: {line.strip()}")
PY

Repository: QuantumNous/new-api

Length of output: 9925


Protect the feature-flag read with the configuration lock.

model/option.go writes common.AffiliateRewardsEnabled while holding common.OptionMapRWMutex, but controller/topup.go reads it directly. This unprotected read from GetTopUpInfo can race with an option update and expose undefined or stale top-up visibility.

Proposed fix
+	common.OptionMapRWMutex.RLock()
+	affiliateRewardsEnabled := common.AffiliateRewardsEnabled
+	common.OptionMapRWMutex.RUnlock()
+
	data := gin.H{
-		"affiliate_rewards_enabled":        common.AffiliateRewardsEnabled,
+		"affiliate_rewards_enabled":        affiliateRewardsEnabled,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"affiliate_rewards_enabled": common.AffiliateRewardsEnabled,
common.OptionMapRWMutex.RLock()
affiliateRewardsEnabled := common.AffiliateRewardsEnabled
common.OptionMapRWMutex.RUnlock()
data := gin.H{
"affiliate_rewards_enabled": affiliateRewardsEnabled,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/topup.go` at line 105, Update GetTopUpInfo’s read of
common.AffiliateRewardsEnabled to use common.OptionMapRWMutex for
synchronization, acquiring the appropriate read lock before accessing the
feature flag and releasing it afterward; preserve the existing
affiliate_rewards_enabled response value.

"payment_compliance_confirmed": complianceConfirmed,
"payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
"waffo_pay_methods": func() interface{} {
Expand Down
21 changes: 21 additions & 0 deletions middleware/distributor.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ func Distribute() func(c *gin.Context) {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
return
}
if shouldSelectChannel && strings.HasPrefix(c.Request.URL.Path, "/v1/messages") {
modelRequest.Model = resolveAnthropicModelForRouting(modelRequest.Model)
}
if ok {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
Expand Down Expand Up @@ -170,6 +173,24 @@ func Distribute() func(c *gin.Context) {
}
}

// resolveAnthropicModelForRouting strips the `claude-` prefix from model names
// that were disguised for Claude Code Desktop compatibility. Real Claude models
// (e.g. claude-sonnet-4-6) are returned unchanged because they exist in the
// ability system; disguised models (e.g. claude-gpt-4o) have their prefix stripped.
func resolveAnthropicModelForRouting(modelName string) string {
if !strings.HasPrefix(modelName, "claude-") {
return modelName
}
if model.IsModelInAnyAbility(modelName) {
return modelName
}
stripped := modelName[len("claude-"):]
if stripped == "" {
return modelName
}
return stripped
}

// channelSupportsRequestPath reports whether a channel can serve the request path.
// Only Advanced Custom (type 58) channels are path-checked; all other channel types
// always pass. A type-58 channel is usable only when one of its routes matches.
Expand Down
16 changes: 16 additions & 0 deletions model/ability.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ func GetEnabledModels() []string {
return models
}

func IsModelInAnyAbility(modelName string) bool {
if !common.MemoryCacheEnabled {
var count int64
DB.Model(&Ability{}).Where("model = ? and enabled = ?", modelName, true).Limit(1).Count(&count)
return count > 0
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
for _, modelMap := range group2model2channels {
if channels, ok := modelMap[modelName]; ok && len(channels) > 0 {
return true
}
}
return false
}

func GetAllEnableAbilities() []Ability {
var abilities []Ability
DB.Find(&abilities, "enabled = ?", true)
Expand Down
3 changes: 3 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func InitOptionMap() {
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter)
common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee)
common.OptionMap["AffiliateRewardsEnabled"] = strconv.FormatBool(common.AffiliateRewardsEnabled)
common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold)
common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota)
common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount)
Expand Down Expand Up @@ -351,6 +352,8 @@ func updateOptionMap(key string, value string) (err error) {
common.TaskEnabled = boolValue
case "DataExportEnabled":
common.DataExportEnabled = boolValue
case "AffiliateRewardsEnabled":
common.AffiliateRewardsEnabled = boolValue
case "DefaultCollapseSidebar":
common.DefaultCollapseSidebar = boolValue
case "MjNotifyEnabled":
Expand Down
26 changes: 26 additions & 0 deletions model/option_affiliate_rewards_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUpdateOptionMapUpdatesAffiliateRewardsEnabled(t *testing.T) {
originalEnabled := common.AffiliateRewardsEnabled
originalOptionMap := common.OptionMap
t.Cleanup(func() {
common.AffiliateRewardsEnabled = originalEnabled
common.OptionMap = originalOptionMap
})

common.OptionMap = map[string]string{}
common.AffiliateRewardsEnabled = true

require.NoError(t, updateOptionMap("AffiliateRewardsEnabled", "false"))

assert.False(t, common.AffiliateRewardsEnabled)
assert.Equal(t, "false", common.OptionMap["AffiliateRewardsEnabled"])
}
2 changes: 2 additions & 0 deletions web/src/features/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export interface SystemStatus {
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
demo_site_enabled?: boolean
affiliate_rewards_enabled?: boolean
user_agreement_enabled?: boolean
privacy_policy_enabled?: boolean
oauth_register_enabled?: boolean
Expand Down Expand Up @@ -173,6 +174,7 @@ export interface SystemStatus {
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
demo_site_enabled?: boolean
affiliate_rewards_enabled?: boolean
user_agreement_enabled?: boolean
privacy_policy_enabled?: boolean
oauth_register_enabled?: boolean
Expand Down
1 change: 1 addition & 0 deletions web/src/features/system-settings/billing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const defaultBillingSettings: BillingSettings = {
PreConsumedQuota: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
AffiliateRewardsEnabled: true,
TopUpLink: '',
'general_setting.docs_link': '',
'quota_setting.enable_free_model_pre_consume': true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const BILLING_SECTIONS = [
PreConsumedQuota: settings.PreConsumedQuota,
QuotaForInviter: settings.QuotaForInviter,
QuotaForInvitee: settings.QuotaForInvitee,
AffiliateRewardsEnabled: settings.AffiliateRewardsEnabled,
TopUpLink: settings.TopUpLink,
general_setting: {
docs_link: settings['general_setting.docs_link'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const quotaSchema = z.object({
PreConsumedQuota: z.coerce.number().min(0),
QuotaForInviter: z.coerce.number().min(0),
QuotaForInvitee: z.coerce.number().min(0),
AffiliateRewardsEnabled: z.boolean(),
TopUpLink: z.string(),
general_setting: z.object({
docs_link: z.string(),
Expand Down Expand Up @@ -237,6 +238,32 @@ export function QuotaSettingsSection({
)}
/>

<SettingsFormGridItem span='full'>
<FormField
control={form.control}
name='AffiliateRewardsEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Invitation Rewards')}</FormLabel>
<FormDescription>
{t(
'When disabled, users will not see the invitation rewards panel.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={updateOption.isPending}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</SettingsFormGridItem>

<SettingsFormGridItem span='full'>
<FormField
control={form.control}
Expand Down
1 change: 1 addition & 0 deletions web/src/features/system-settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export type BillingSettings = {
PreConsumedQuota: number
QuotaForInviter: number
QuotaForInvitee: number
AffiliateRewardsEnabled: boolean
TopUpLink: string
'general_setting.docs_link': string
'quota_setting.enable_free_model_pre_consume': boolean
Expand Down
29 changes: 24 additions & 5 deletions web/src/features/wallet/hooks/use-affiliate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,27 @@ import { generateAffiliateLink } from '../lib'
// Affiliate Hook
// ============================================================================

export function useAffiliate() {
interface UseAffiliateOptions {
enabled?: boolean
}

export function useAffiliate(options: UseAffiliateOptions = {}) {
const { enabled = true } = options
const [affiliateCode, setAffiliateCode] = useState<string>('')
const [affiliateLink, setAffiliateLink] = useState<string>('')
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(enabled)
const [transferring, setTransferring] = useState(false)
const { copyToClipboard } = useCopyToClipboard()

// Fetch affiliate code
const fetchAffiliateCode = useCallback(async () => {
if (!enabled) {
setAffiliateCode('')
setAffiliateLink('')
setLoading(false)
return
}

try {
setLoading(true)
const response = await getAffiliateCode()
Expand All @@ -54,7 +66,7 @@ export function useAffiliate() {
} finally {
setLoading(false)
}
}, [])
}, [enabled])

// Copy affiliate link
const copyAffiliateLink = useCallback(() => {
Expand Down Expand Up @@ -84,8 +96,15 @@ export function useAffiliate() {
}, [])

useEffect(() => {
fetchAffiliateCode()
}, [fetchAffiliateCode])
if (enabled) {
fetchAffiliateCode()
return
}

setAffiliateCode('')
setAffiliateLink('')
setLoading(false)
}, [enabled, fetchAffiliateCode])

return {
affiliateCode,
Expand Down
Loading