diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser
index 2242c162..419994b9 100644
--- a/Dockerfile.goreleaser
+++ b/Dockerfile.goreleaser
@@ -5,7 +5,12 @@
# It only packages the pre-built binary, no compilation needed.
# =============================================================================
-FROM alpine:3.19
+ARG ALPINE_IMAGE=alpine:3.21
+ARG POSTGRES_IMAGE=postgres:18-alpine
+
+FROM ${POSTGRES_IMAGE} AS pg-client
+
+FROM ${ALPINE_IMAGE}
LABEL maintainer="Wei-Shaw "
LABEL description="Sub2API - AI API Gateway Platform"
@@ -16,8 +21,20 @@ RUN apk add --no-cache \
ca-certificates \
tzdata \
curl \
+ libpq \
+ zstd-libs \
+ lz4-libs \
+ krb5-libs \
+ libldap \
+ libedit \
&& rm -rf /var/cache/apk/*
+# Copy pg_dump and psql from a version-matched PostgreSQL image so backup and
+# restore work in the runtime container without requiring Docker socket access.
+COPY --from=pg-client /usr/local/bin/pg_dump /usr/local/bin/pg_dump
+COPY --from=pg-client /usr/local/bin/psql /usr/local/bin/psql
+COPY --from=pg-client /usr/local/lib/libpq.so.5* /usr/local/lib/
+
# Create non-root user
RUN addgroup -g 1000 sub2api && \
adduser -u 1000 -G sub2api -s /bin/sh -D sub2api
diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go
index 5be66768..4ffe64ee 100644
--- a/backend/internal/handler/admin/group_handler.go
+++ b/backend/internal/handler/admin/group_handler.go
@@ -1,6 +1,9 @@
package admin
import (
+ "bytes"
+ "encoding/json"
+ "fmt"
"strconv"
"strings"
@@ -16,6 +19,55 @@ type GroupHandler struct {
adminService service.AdminService
}
+type optionalLimitField struct {
+ set bool
+ value *float64
+}
+
+func (f *optionalLimitField) UnmarshalJSON(data []byte) error {
+ f.set = true
+
+ trimmed := bytes.TrimSpace(data)
+ if bytes.Equal(trimmed, []byte("null")) {
+ f.value = nil
+ return nil
+ }
+
+ var number float64
+ if err := json.Unmarshal(trimmed, &number); err == nil {
+ f.value = &number
+ return nil
+ }
+
+ var text string
+ if err := json.Unmarshal(trimmed, &text); err == nil {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ f.value = nil
+ return nil
+ }
+ number, err = strconv.ParseFloat(text, 64)
+ if err != nil {
+ return fmt.Errorf("invalid numeric limit value %q: %w", text, err)
+ }
+ f.value = &number
+ return nil
+ }
+
+ return fmt.Errorf("invalid limit value: %s", string(trimmed))
+}
+
+func (f optionalLimitField) ToServiceInput() *float64 {
+ if !f.set {
+ return nil
+ }
+ if f.value != nil {
+ return f.value
+ }
+ zero := 0.0
+ return &zero
+}
+
// NewGroupHandler creates a new admin group handler
func NewGroupHandler(adminService service.AdminService) *GroupHandler {
return &GroupHandler{
@@ -25,15 +77,15 @@ func NewGroupHandler(adminService service.AdminService) *GroupHandler {
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
- Name string `json:"name" binding:"required"`
- Description string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"`
- RateMultiplier float64 `json:"rate_multiplier"`
- IsExclusive bool `json:"is_exclusive"`
- SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
- DailyLimitUSD *float64 `json:"daily_limit_usd"`
- WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
- MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
+ Name string `json:"name" binding:"required"`
+ Description string `json:"description"`
+ Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"`
+ RateMultiplier float64 `json:"rate_multiplier"`
+ IsExclusive bool `json:"is_exclusive"`
+ SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
+ DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
+ WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
+ MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
@@ -62,16 +114,16 @@ type CreateGroupRequest struct {
// UpdateGroupRequest represents update group request
type UpdateGroupRequest struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"`
- RateMultiplier *float64 `json:"rate_multiplier"`
- IsExclusive *bool `json:"is_exclusive"`
- Status string `json:"status" binding:"omitempty,oneof=active inactive"`
- SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
- DailyLimitUSD *float64 `json:"daily_limit_usd"`
- WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
- MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"`
+ RateMultiplier *float64 `json:"rate_multiplier"`
+ IsExclusive *bool `json:"is_exclusive"`
+ Status string `json:"status" binding:"omitempty,oneof=active inactive"`
+ SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
+ DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
+ WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
+ MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
@@ -191,9 +243,9 @@ func (h *GroupHandler) Create(c *gin.Context) {
RateMultiplier: req.RateMultiplier,
IsExclusive: req.IsExclusive,
SubscriptionType: req.SubscriptionType,
- DailyLimitUSD: req.DailyLimitUSD,
- WeeklyLimitUSD: req.WeeklyLimitUSD,
- MonthlyLimitUSD: req.MonthlyLimitUSD,
+ DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
+ WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
+ MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -244,9 +296,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
IsExclusive: req.IsExclusive,
Status: req.Status,
SubscriptionType: req.SubscriptionType,
- DailyLimitUSD: req.DailyLimitUSD,
- WeeklyLimitUSD: req.WeeklyLimitUSD,
- MonthlyLimitUSD: req.MonthlyLimitUSD,
+ DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
+ WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
+ MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go
index a003f636..e65c838d 100644
--- a/backend/internal/service/gemini_messages_compat_service.go
+++ b/backend/internal/service/gemini_messages_compat_service.go
@@ -3235,7 +3235,7 @@ func cleanToolSchema(schema any) any {
for key, value := range v {
// 跳过不支持的字段
if key == "$schema" || key == "$id" || key == "$ref" ||
- key == "additionalProperties" || key == "minLength" ||
+ key == "additionalProperties" || key == "patternProperties" || key == "minLength" ||
key == "maxLength" || key == "minItems" || key == "maxItems" {
continue
}
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index c6e08684..64524d51 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -164,27 +164,10 @@
-
-
-
-
+
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
@@ -832,8 +815,12 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
+import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import Icon from '@/components/icons/Icon.vue'
-import { buildModelMappingObject as buildModelMappingPayload } from '@/composables/useModelWhitelist'
+import {
+ buildModelMappingObject as buildModelMappingPayload,
+ getPresetMappingsByPlatform
+} from '@/composables/useModelWhitelist'
interface Props {
show: boolean
@@ -865,26 +852,20 @@ const allAnthropicOAuthOrSetupToken = computed(() => {
)
})
-const platformModelPrefix: Record = {
- anthropic: ['claude-'],
- antigravity: ['claude-', 'gemini-', 'gpt-oss-', 'tab_'],
- openai: ['gpt-'],
- gemini: ['gemini-'],
- sora: []
-}
-
-const filteredModels = computed(() => {
- if (props.selectedPlatforms.length === 0) return allModels
- const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
- if (prefixes.length === 0) return allModels
- return allModels.filter(m => prefixes.some(prefix => m.value.startsWith(prefix)))
-})
-
const filteredPresets = computed(() => {
- if (props.selectedPlatforms.length === 0) return presetMappings
- const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
- if (prefixes.length === 0) return presetMappings
- return presetMappings.filter(m => prefixes.some(prefix => m.from.startsWith(prefix)))
+ if (props.selectedPlatforms.length === 0) return []
+
+ const dedupedPresets = new Map[number]>()
+ for (const platform of props.selectedPlatforms) {
+ for (const preset of getPresetMappingsByPlatform(platform)) {
+ const key = `${preset.from}=>${preset.to}`
+ if (!dedupedPresets.has(key)) {
+ dedupedPresets.set(key, preset)
+ }
+ }
+ }
+
+ return Array.from(dedupedPresets.values())
})
// Model mapping type
@@ -937,204 +918,6 @@ const umqModeOptions = computed(() => [
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
])
-// All models list (combined Anthropic + OpenAI + Gemini)
-const allModels = [
- { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
- { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
- { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
- { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
- { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
- { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
- { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
- { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
- { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
- { value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
- { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
- { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
- { value: 'gpt-5.4', label: 'GPT-5.4' },
- { value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
- { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
- { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
- { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
- { value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
- { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
- { value: 'gpt-5-2025-08-07', label: 'GPT-5' },
- { value: 'gemini-3.1-flash-image', label: 'Gemini 3.1 Flash Image' },
- { value: 'gemini-2.5-flash-image', label: 'Gemini 2.5 Flash Image' },
- { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
- { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
- { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
- { value: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image (Legacy)' },
- { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
- { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
-]
-
-// Preset mappings (combined Anthropic + OpenAI + Gemini)
-const presetMappings = [
- {
- label: 'Sonnet 4',
- from: 'claude-sonnet-4-20250514',
- to: 'claude-sonnet-4-20250514',
- color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
- },
- {
- label: 'Sonnet 4.5',
- from: 'claude-sonnet-4-5-20250929',
- to: 'claude-sonnet-4-5-20250929',
- color:
- 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
- },
- {
- label: 'Opus 4.5',
- from: 'claude-opus-4-5-20251101',
- to: 'claude-opus-4-5-20251101',
- color:
- 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
- },
- {
- label: 'Opus 4.6',
- from: 'claude-opus-4-6',
- to: 'claude-opus-4-6-thinking',
- color:
- 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
- },
- {
- label: 'Opus 4.6-thinking',
- from: 'claude-opus-4-6-thinking',
- to: 'claude-opus-4-6-thinking',
- color:
- 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
- },
- {
- label: 'Sonnet 4.6',
- from: 'claude-sonnet-4-6',
- to: 'claude-sonnet-4-6',
- color:
- 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
- },
- {
- label: 'Sonnet4→4.6',
- from: 'claude-sonnet-4-20250514',
- to: 'claude-sonnet-4-6',
- color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
- },
- {
- label: 'Sonnet4.5→4.6',
- from: 'claude-sonnet-4-5-20250929',
- to: 'claude-sonnet-4-6',
- color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400'
- },
- {
- label: 'Sonnet3.5→4.6',
- from: 'claude-3-5-sonnet-20241022',
- to: 'claude-sonnet-4-6',
- color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
- },
- {
- label: 'Opus4.5→4.6',
- from: 'claude-opus-4-5-20251101',
- to: 'claude-opus-4-6-thinking',
- color:
- 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400'
- },
- {
- label: 'Opus->Sonnet',
- from: 'claude-opus-4-5-20251101',
- to: 'claude-sonnet-4-5-20250929',
- color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
- },
- {
- label: 'Gemini 2.5 Image',
- from: 'gemini-2.5-flash-image',
- to: 'gemini-2.5-flash-image',
- color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
- },
- {
- label: 'Gemini 3.1 Image',
- from: 'gemini-3.1-flash-image',
- to: 'gemini-3.1-flash-image',
- color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
- },
- {
- label: 'G3 Image→3.1',
- from: 'gemini-3-pro-image',
- to: 'gemini-3.1-flash-image',
- color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
- },
- {
- label: 'GPT-5.3 Codex',
- from: 'gpt-5.3-codex',
- to: 'gpt-5.3-codex',
- color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
- },
- {
- label: 'GPT-5.3 Spark',
- from: 'gpt-5.3-codex-spark',
- to: 'gpt-5.3-codex-spark',
- color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
- },
- {
- label: 'GPT-5.4',
- from: 'gpt-5.4',
- to: 'gpt-5.4',
- color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400'
- },
- {
- label: '5.2→5.3',
- from: 'gpt-5.2-codex',
- to: 'gpt-5.3-codex',
- color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
- },
- {
- label: 'GPT-5.2',
- from: 'gpt-5.2-2025-12-11',
- to: 'gpt-5.2-2025-12-11',
- color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
- },
- {
- label: 'GPT-5.2 Codex',
- from: 'gpt-5.2-codex',
- to: 'gpt-5.2-codex',
- color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
- },
- {
- label: 'Max->Codex',
- from: 'gpt-5.1-codex-max',
- to: 'gpt-5.1-codex',
- color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
- },
- {
- label: '3-Pro-Preview→3.1-Pro-High',
- from: 'gemini-3-pro-preview',
- to: 'gemini-3.1-pro-high',
- color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
- },
- {
- label: '3-Pro-High→3.1-Pro-High',
- from: 'gemini-3-pro-high',
- to: 'gemini-3.1-pro-high',
- color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400'
- },
- {
- label: '3-Pro-Low→3.1-Pro-Low',
- from: 'gemini-3-pro-low',
- to: 'gemini-3.1-pro-low',
- color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400'
- },
- {
- label: '3-Flash透传',
- from: 'gemini-3-flash',
- to: 'gemini-3-flash',
- color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
- },
- {
- label: '2.5-Flash-Lite透传',
- from: 'gemini-2.5-flash-lite',
- to: 'gemini-2.5-flash-lite',
- color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
- }
-]
-
// Common HTTP error codes
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue
index 16ffa225..ebce3740 100644
--- a/frontend/src/components/account/ModelWhitelistSelector.vue
+++ b/frontend/src/components/account/ModelWhitelistSelector.vue
@@ -131,7 +131,8 @@ const { t } = useI18n()
const props = defineProps<{
modelValue: string[]
- platform: string
+ platform?: string
+ platforms?: string[]
}>()
const emit = defineEmits<{
@@ -144,11 +145,36 @@ const showDropdown = ref(false)
const searchQuery = ref('')
const customModel = ref('')
const isComposing = ref(false)
+const normalizedPlatforms = computed(() => {
+ const rawPlatforms =
+ props.platforms && props.platforms.length > 0
+ ? props.platforms
+ : props.platform
+ ? [props.platform]
+ : []
+
+ return Array.from(
+ new Set(
+ rawPlatforms
+ .map(platform => platform?.trim())
+ .filter((platform): platform is string => Boolean(platform))
+ )
+ )
+})
+
const availableOptions = computed(() => {
- if (props.platform === 'sora') {
- return getModelsByPlatform('sora').map(m => ({ value: m, label: m }))
+ if (normalizedPlatforms.value.length === 0) {
+ return allModels
}
- return allModels
+
+ const allowedModels = new Set()
+ for (const platform of normalizedPlatforms.value) {
+ for (const model of getModelsByPlatform(platform)) {
+ allowedModels.add(model)
+ }
+ }
+
+ return allModels.filter(model => allowedModels.has(model.value))
})
const filteredModels = computed(() => {
@@ -192,10 +218,13 @@ const handleEnter = () => {
}
const fillRelated = () => {
- const models = getModelsByPlatform(props.platform)
const newModels = [...props.modelValue]
- for (const model of models) {
- if (!newModels.includes(model)) newModels.push(model)
+ for (const platform of normalizedPlatforms.value) {
+ for (const model of getModelsByPlatform(platform)) {
+ if (!newModels.includes(model)) {
+ newModels.push(model)
+ }
+ }
}
emit('update:modelValue', newModels)
}
diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue
index 498342d0..f8ee39e9 100644
--- a/frontend/src/views/admin/GroupsView.vue
+++ b/frontend/src/views/admin/GroupsView.vue
@@ -2368,6 +2368,23 @@ const closeCreateModal = () => {
createModelRoutingRules.value = []
}
+const normalizeOptionalLimit = (value: number | string | null | undefined): number | null => {
+ if (value === null || value === undefined) {
+ return null
+ }
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ if (!trimmed) {
+ return null
+ }
+ const parsed = Number(trimmed)
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
+ }
+
+ return Number.isFinite(value) && value > 0 ? value : null
+}
+
const handleCreateGroup = async () => {
if (!createForm.name.trim()) {
appStore.showError(t('admin.groups.nameRequired'))
@@ -2379,6 +2396,9 @@ const handleCreateGroup = async () => {
const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm
const requestData = {
...createRest,
+ daily_limit_usd: normalizeOptionalLimit(createForm.daily_limit_usd as number | string | null),
+ weekly_limit_usd: normalizeOptionalLimit(createForm.weekly_limit_usd as number | string | null),
+ monthly_limit_usd: normalizeOptionalLimit(createForm.monthly_limit_usd as number | string | null),
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
}
@@ -2462,6 +2482,9 @@ const handleUpdateGroup = async () => {
const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm
const payload = {
...editRest,
+ daily_limit_usd: normalizeOptionalLimit(editForm.daily_limit_usd as number | string | null),
+ weekly_limit_usd: normalizeOptionalLimit(editForm.weekly_limit_usd as number | string | null),
+ monthly_limit_usd: normalizeOptionalLimit(editForm.monthly_limit_usd as number | string | null),
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request: