Skip to content
2 changes: 2 additions & 0 deletions controller/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"upgrade_group": req.Plan.UpgradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"model_limits_enabled": req.Plan.ModelLimitsEnabled,
"model_limits": req.Plan.ModelLimits,
"updated_at": common.GetTimestamp(),
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
Expand Down
4 changes: 4 additions & 0 deletions model/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never',
` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0,
` + "`model_limits_enabled`" + ` numeric DEFAULT 0,
` + "`model_limits`" + ` text DEFAULT '',
` + "`created_at`" + ` bigint,
` + "`updated_at`" + ` bigint,
PRIMARY KEY (` + "`id`" + `)
Expand Down Expand Up @@ -435,6 +437,8 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
{Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"},
{Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"},
{Name: "model_limits_enabled", DDL: "`model_limits_enabled` numeric DEFAULT 0"},
{Name: "model_limits", DDL: "`model_limits` text DEFAULT ''"},
{Name: "created_at", DDL: "`created_at` bigint"},
{Name: "updated_at", DDL: "`updated_at` bigint"},
}
Expand Down
51 changes: 51 additions & 0 deletions model/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,52 @@ type SubscriptionPlan struct {
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"`
QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"`

// Model limits: restrict which models can be used with this plan
ModelLimitsEnabled bool `json:"model_limits_enabled" gorm:"default:false"`
ModelLimits string `json:"model_limits" gorm:"type:text"`

CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
}

// GetModelLimits returns the model limits as a slice.
func (p *SubscriptionPlan) GetModelLimits() []string {
if p.ModelLimits == "" {
return []string{}
}
parts := strings.Split(p.ModelLimits, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}

// GetModelLimitsMap returns the model limits as a map for fast lookup.
func (p *SubscriptionPlan) GetModelLimitsMap() map[string]bool {
limits := p.GetModelLimits()
limitsMap := make(map[string]bool, len(limits))
for _, limit := range limits {
if limit != "" {
limitsMap[limit] = true
}
}
return limitsMap
}

// IsModelAllowed checks if a model is allowed by this plan's model limits.
// Returns true if model limits are disabled or if the model is in the allowed list.
func (p *SubscriptionPlan) IsModelAllowed(modelName string) bool {
if !p.ModelLimitsEnabled || p.ModelLimits == "" {
return true
}
limitsMap := p.GetModelLimitsMap()
return limitsMap[modelName]
}

func (p *SubscriptionPlan) BeforeCreate(tx *gorm.DB) error {
now := common.GetTimestamp()
p.CreatedAt = now
Expand Down Expand Up @@ -1013,12 +1055,18 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string,
if len(subs) == 0 {
return errors.New("no active subscription")
}
modelNotAllowed := false
for _, candidate := range subs {
sub := candidate
plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId)
if err != nil {
return err
}
// Check model limits: skip subscriptions whose plan doesn't allow the requested model
if modelName != "" && !plan.IsModelAllowed(modelName) {
modelNotAllowed = true
continue
}
if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, now); err != nil {
return err
}
Expand Down Expand Up @@ -1062,6 +1110,9 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string,
returnValue.AmountUsedAfter = sub.AmountUsed
return nil
}
if modelNotAllowed {
return fmt.Errorf("no subscription allows model %s", modelName)
}
return fmt.Errorf("subscription quota insufficient, need=%d", amount)
})
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ const renderPlanTitle = (text, record, t) => {
<Text>{formatDuration(plan, t)}</Text>
<Text type='tertiary'>{t('重置')}</Text>
<Text>{formatResetPeriod(plan, t)}</Text>
<Text type='tertiary'>{t('模型限制')}</Text>
<Text>
{plan?.model_limits_enabled && plan?.model_limits
? `${new Set(
plan.model_limits
.split(',')
.map((item) => item.trim())
.filter(Boolean),
).size} ${t('个模型')}`
: t('不限')}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Text>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,17 @@ import {
IconCalendarClock,
IconClose,
IconCreditCard,
IconLink,
IconSave,
} from '@douyinfe/semi-icons';
import { Clock, RefreshCw } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import {
API,
showError,
showSuccess,
getModelCategories,
selectFilter,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
Expand Down Expand Up @@ -75,6 +82,7 @@ const AddEditSubscriptionModal = ({
const [loading, setLoading] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
const [groupLoading, setGroupLoading] = useState(false);
const [models, setModels] = useState([]);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const isEdit = editingPlan?.plan?.id !== undefined;
Expand All @@ -97,12 +105,20 @@ const AddEditSubscriptionModal = ({
upgrade_group: '',
stripe_price_id: '',
creem_product_id: '',
model_limits: [],
});

const buildFormValues = () => {
const base = getInitValues();
if (editingPlan?.plan?.id === undefined) return base;
const p = editingPlan.plan || {};
let modelLimits = [];
if (p.model_limits && p.model_limits !== '') {
modelLimits = p.model_limits
.split(',')
.map((m) => m.trim())
.filter(Boolean);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return {
...base,
title: p.title || '',
Expand All @@ -123,7 +139,42 @@ const AddEditSubscriptionModal = ({
upgrade_group: p.upgrade_group || '',
stripe_price_id: p.stripe_price_id || '',
creem_product_id: p.creem_product_id || '',
model_limits: modelLimits,
};
};

const loadModels = async () => {
try {
const res = await API.get('/api/user/models');
const { success, data } = res.data;
if (success) {
const categories = getModelCategories(t);
const localModelOptions = data.map((model) => {
let icon = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
icon = category.icon;
break;
}
}
return {
label: (
<span className='flex items-center gap-1'>
{icon}
{model}
</span>
),
value: model,
};
});
setModels(localModelOptions);
} else {
setModels([]);
}
} catch (error) {
console.error('Failed to load models:', error);
setModels([]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
};

useEffect(() => {
Expand All @@ -139,6 +190,7 @@ const AddEditSubscriptionModal = ({
})
.catch(() => setGroupOptions([]))
.finally(() => setGroupLoading(false));
loadModels();
}, [visible]);

const submit = async (values) => {
Expand All @@ -148,6 +200,7 @@ const AddEditSubscriptionModal = ({
}
setLoading(true);
try {
const modelLimitsStr = (values.model_limits || []).join(',');
const payload = {
plan: {
...values,
Expand All @@ -164,6 +217,8 @@ const AddEditSubscriptionModal = ({
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
total_amount: displayAmountToQuota(values.total_amount),
upgrade_group: values.upgrade_group || '',
model_limits_enabled: modelLimitsStr.length > 0,
model_limits: modelLimitsStr,
},
};
if (editingPlan?.plan?.id) {
Expand Down Expand Up @@ -501,6 +556,49 @@ const AddEditSubscriptionModal = ({
</Row>
</Card>

{/* 模型限制 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='cyan'
className='mr-2 shadow-md'
>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('模型限制')}
</Text>
<div className='text-xs text-gray-600'>
{t('限制该订阅套餐可使用的模型范围')}
</div>
</div>
</div>

<Row gutter={12}>
<Col span={24}>
<Form.Select
field='model_limits'
label={t('模型限制列表')}
placeholder={t(
'请选择该套餐支持的模型,留空支持所有模型',
)}
multiple
optionList={models}
extraText={t(
'选择后,通过该订阅套餐计费的请求只能使用选中的模型;留空则不限制',
)}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
showClear
style={{ width: '100%' }}
/>
</Col>
</Row>
</Card>

{/* 第三方支付配置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
Expand Down
13 changes: 13 additions & 0 deletions web/classic/src/components/topup/SubscriptionPlansCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,10 @@ const SubscriptionPlansCard = ({
formatSubscriptionResetPeriod(plan, t) === t('不重置')
? null
: `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
const modelLimitsLabel =
plan?.model_limits_enabled && plan?.model_limits
? `${t('可用模型')}: ${plan.model_limits.split(',').filter(Boolean).length} ${t('个模型')}`
: null;
const planBenefits = [
{
label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
Expand All @@ -523,6 +527,15 @@ const SubscriptionPlansCard = ({
: { label: totalLabel },
limitLabel ? { label: limitLabel } : null,
upgradeLabel ? { label: upgradeLabel } : null,
modelLimitsLabel
? {
label: modelLimitsLabel,
tooltip: plan.model_limits
.split(',')
.filter(Boolean)
.join(', '),
}
: null,
].filter(Boolean);

return (
Expand Down
4 changes: 4 additions & 0 deletions web/classic/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2151,6 +2151,7 @@
"模型配置": "Model Configuration",
"模型重定向": "Model mapping",
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:",
"模型限制": "Model Restrictions",
"模型限制列表": "Model restrictions list",
"模式": "Mode",
"模板": "Template",
Expand Down Expand Up @@ -3231,6 +3232,7 @@
"请选择至少一个部署位置": "Please select at least one deployment location",
"请选择订阅套餐": "Please select a subscription plan",
"请选择该令牌支持的模型,留空支持所有模型": "Select models supported by the token, leave blank to support all models",
"请选择该套餐支持的模型,留空支持所有模型": "Select models allowed by this plan, leave empty for all models",
"请选择该渠道所支持的模型": "Please select the model supported by this channel",
"请选择该渠道所支持的模型,留空则不更改": "Please select the models supported by the channel, leaving blank will not change",
"请选择过期时间": "Please select expiration time",
Expand Down Expand Up @@ -3388,6 +3390,7 @@
"选择同步来源": "Select sync source",
"选择同步渠道": "Select synchronization channel",
"选择同步语言": "Select sync language",
"选择后,通过该订阅套餐计费的请求只能使用选中的模型;留空则不限制": "When set, requests billed via this subscription can only use selected models; leave empty for no restriction",
"选择容器": "Select Container",
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "Select your preferred interface language. Settings will be saved automatically and synced across all devices",
"选择成功": "Selection successful",
Expand Down Expand Up @@ -3563,6 +3566,7 @@
"降级": "Demote",
"限制周期": "Limit period",
"限制周期统一使用上方配置的“限制周期”值。": "The limit period uniformly uses the \"limit period\" value configured above.",
"限制该订阅套餐可使用的模型范围": "Restrict models available for this subscription plan",
"限流": "Rate Limiting",
"限购": "Limit",
"隐私政策": "Privacy Policy",
Expand Down
4 changes: 4 additions & 0 deletions web/classic/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,7 @@
"模型配置": "Configuration du modèle",
"模型重定向": "Redirection de modèle",
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "Les modèles suivants provenant de la redirection n'ont pas été ajoutés à la liste « Modèles », l'appel échouera faute de modèle disponible :",
"模型限制": "Restrictions de modèle",
"模型限制列表": "Liste des restrictions de modèle",
"模式": "Mode",
"模板": "Modèle",
Expand Down Expand Up @@ -3216,6 +3217,7 @@
"请选择至少一个部署位置": "Please select at least one deployment location",
"请选择订阅套餐": "Veuillez sélectionner un forfait d'abonnement",
"请选择该令牌支持的模型,留空支持所有模型": "Sélectionnez les modèles pris en charge par le jeton, laissez vide pour prendre en charge tous les modèles",
"请选择该套餐支持的模型,留空支持所有模型": "Veuillez sélectionner les modèles pris en charge par ce plan, laissez vide pour tous les modèles",
"请选择该渠道所支持的模型": "Veuillez sélectionner le modèle pris en charge par ce canal",
"请选择该渠道所支持的模型,留空则不更改": "Veuillez sélectionner les modèles pris en charge par le canal, laisser vide ne changera rien",
"请选择过期时间": "Veuillez sélectionner une date d'expiration",
Expand Down Expand Up @@ -3368,6 +3370,7 @@
"选择同步来源": "Sélectionner la source de synchronisation",
"选择同步渠道": "Sélectionner le canal de synchronisation",
"选择同步语言": "Sélectionner la langue de synchronisation",
"选择后,通过该订阅套餐计费的请求只能使用选中的模型;留空则不限制": "Une fois sélectionnés, les modèles facturés via ce forfait d’abonnement ne peuvent être que ceux sélectionnés ; laissez vide pour ne pas imposer de restriction",
"选择容器": "Select Container",
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "Sélectionnez votre langue d'interface préférée, les paramètres seront automatiquement enregistrés et synchronisés sur tous les appareils",
"选择成功": "Sélection réussie",
Expand Down Expand Up @@ -3541,6 +3544,7 @@
"降级": "Rétrograder",
"限制周期": "Période de limite",
"限制周期统一使用上方配置的“限制周期”值。": "La période de limite utilise uniformément la valeur \"période de limite\" configurée ci-dessus.",
"限制该订阅套餐可使用的模型范围": "Restreindre les modèles disponibles pour ce plan d'abonnement",
"限流": "Limitation de débit",
"限购": "Limite",
"隐私政策": "Politique de confidentialité",
Expand Down
Loading