const platformDefaultUrl =
- newAccount.platform === 'openai' || newAccount.platform === 'sora'
+ newAccount.platform === 'openai'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
@@ -2253,7 +2253,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editBaseUrl.value = (credentials.base_url as string) || ''
} else {
const platformDefaultUrl =
- newAccount.platform === 'openai' || newAccount.platform === 'sora'
+ newAccount.platform === 'openai'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue
index b4c299db..08c67494 100644
--- a/frontend/src/components/account/OAuthAuthorizationFlow.vue
+++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue
@@ -168,217 +168,6 @@
-
-
-
-
- {{ t(getOAuthKey('sessionTokenDesc')) }}
-
-
-
-
-
- {{ t(getOAuthKey('sessionTokenRawLabel')) }}
-
- {{ t('admin.accounts.oauth.keysCount', { count: parsedSessionTokenCount }) }}
-
-
-
-
- {{ t(getOAuthKey('sessionTokenRawHint')) }}
-
-
-
- {{ t(getOAuthKey('openSessionUrl')) }}
-
-
- {{ t(getOAuthKey('copySessionUrl')) }}
-
-
-
- {{ soraSessionUrl }}
-
-
- {{ t(getOAuthKey('sessionUrlHint')) }}
-
-
- {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedSessionTokenCount }) }}
-
-
-
-
-
-
- {{ t(getOAuthKey('parsedSessionTokensLabel')) }}
-
- {{ parsedSessionTokenCount }}
-
-
-
-
- {{ t(getOAuthKey('parsedSessionTokensEmpty')) }}
-
-
-
-
-
- {{ t(getOAuthKey('parsedAccessTokensLabel')) }}
-
- {{ parsedAccessTokenFromSessionInputCount }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- loading
- ? t(getOAuthKey('validating'))
- : t(getOAuthKey('validateAndCreate'))
- }}
-
-
-
-
-
-
-
-
- {{ t('admin.accounts.oauth.openai.accessTokenDesc', '直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。') }}
-
-
-
-
-
- Access Token
-
- {{ t('admin.accounts.oauth.keysCount', { count: parsedAccessTokenCount }) }}
-
-
-
-
- {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedAccessTokenCount }) }}
-
-
-
-
-
-
-
- {{ t('admin.accounts.oauth.openai.importAccessToken', '导入 Access Token') }}
-
-
-
-
props.platform === 'openai' || props.platform === 'sora')
+const isOpenAI = computed(() => props.platform === 'openai')
// Get translation key based on platform
const getOAuthKey = (key: string) => {
- if (props.platform === 'openai' || props.platform === 'sora') return `admin.accounts.oauth.openai.${key}`
+ if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
return `admin.accounts.oauth.${key}`
@@ -831,7 +619,7 @@ const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
const oauthImportantNotice = computed(() => {
- if (props.platform === 'openai' || props.platform === 'sora') return t('admin.accounts.oauth.openai.importantNotice')
+ if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
return ''
})
@@ -842,7 +630,6 @@ const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
-const accessTokenInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
@@ -869,33 +656,6 @@ const parsedRefreshTokenCount = computed(() => {
.filter((rt) => rt).length
})
-const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
-
-const parsedSessionTokenCount = computed(() => {
- return parsedSoraRawTokens.value.sessionTokens.length
-})
-
-const parsedSessionTokensText = computed(() => {
- return parsedSoraRawTokens.value.sessionTokens.join('\n')
-})
-
-const parsedAccessTokenFromSessionInputCount = computed(() => {
- return parsedSoraRawTokens.value.accessTokens.length
-})
-
-const parsedAccessTokensText = computed(() => {
- return parsedSoraRawTokens.value.accessTokens.join('\n')
-})
-
-const soraSessionUrl = 'https://sora.chatgpt.com/api/auth/session'
-
-const parsedAccessTokenCount = computed(() => {
- return accessTokenInput.value
- .split('\n')
- .map((at) => at.trim())
- .filter((at) => at).length
-})
-
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
@@ -904,7 +664,7 @@ watch(inputMethod, (newVal) => {
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
// e.g., http://localhost:8085/callback?code=xxx...&state=...
watch(authCodeInput, (newVal) => {
- if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'sora') return
+ if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter
@@ -914,7 +674,7 @@ watch(authCodeInput, (newVal) => {
const url = new URL(trimmed)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
- if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
+ if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) {
@@ -925,7 +685,7 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
- if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
+ if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1]
}
if (match && match[1] && match[1] !== trimmed) {
@@ -967,26 +727,6 @@ const handleValidateRefreshToken = () => {
}
}
-const handleValidateSessionToken = () => {
- if (parsedSessionTokenCount.value > 0) {
- emit('validate-session-token', parsedSessionTokensText.value)
- }
-}
-
-const handleOpenSoraSessionUrl = () => {
- window.open(soraSessionUrl, '_blank', 'noopener,noreferrer')
-}
-
-const handleCopySoraSessionUrl = () => {
- copyToClipboard(soraSessionUrl, 'URL copied to clipboard')
-}
-
-const handleImportAccessToken = () => {
- if (accessTokenInput.value.trim()) {
- emit('import-access-token', accessTokenInput.value.trim())
- }
-}
-
// Expose methods and state
defineExpose({
authCode: authCodeInput,
diff --git a/frontend/src/components/account/ReAuthAccountModal.vue b/frontend/src/components/account/ReAuthAccountModal.vue
index aab0fe7d..c9c6d2db 100644
--- a/frontend/src/components/account/ReAuthAccountModal.vue
+++ b/frontend/src/components/account/ReAuthAccountModal.vue
@@ -33,8 +33,6 @@
{{
isOpenAI
? t('admin.accounts.openaiAccount')
- : isSora
- ? t('admin.accounts.soraAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: isAntigravity
@@ -130,7 +128,7 @@
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
- :platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
+ :platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@@ -226,8 +224,7 @@ const { t } = useI18n()
// OAuth composables
const claudeOAuth = useAccountOAuth()
-const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
-const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
+const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
@@ -240,34 +237,32 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
-const isSora = computed(() => props.account?.platform === 'sora')
-const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
+const isOpenAILike = computed(() => isOpenAI.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
-const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
+ if (isOpenAILike.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
+ if (isOpenAILike.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
+ if (isOpenAILike.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
+ if (isOpenAILike.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
@@ -275,7 +270,7 @@ const currentError = computed(() => {
// Computed
const isManualInputMethod = computed(() => {
- // OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
+ // OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
@@ -319,7 +314,6 @@ const resetState = () => {
geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState()
openaiOAuth.resetState()
- soraOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
@@ -333,7 +327,7 @@ const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAILike.value) {
- await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
+ await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
@@ -354,7 +348,7 @@ const handleExchangeCode = async () => {
if (isOpenAILike.value) {
// OpenAI OAuth flow
- const oauthClient = activeOpenAIOAuth.value
+ const oauthClient = openaiOAuth
const sessionId = oauthClient.sessionId.value
if (!sessionId) return
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue
index 43e703ec..6b474183 100644
--- a/frontend/src/components/admin/account/AccountTableFilters.vue
+++ b/frontend/src/components/admin/account/AccountTableFilters.vue
@@ -25,7 +25,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
-const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
+const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
const privacyOpts = computed(() => [
diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue
index dcaef15d..87b7ff0a 100644
--- a/frontend/src/components/admin/account/AccountTestModal.vue
+++ b/frontend/src/components/admin/account/AccountTestModal.vue
@@ -41,7 +41,7 @@
-
+
{{ t('admin.accounts.selectTestModel') }}
@@ -54,12 +54,6 @@
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
-
- {{ t('admin.accounts.soraTestHint') }}
-
{{
- isSoraAccount
- ? t('admin.accounts.soraTestMode')
- : supportsGeminiImageTest
- ? t('admin.accounts.geminiImageTestMode')
- : t('admin.accounts.testPrompt')
+ supportsGeminiImageTest
+ ? t('admin.accounts.geminiImageTestMode')
+ : t('admin.accounts.testPrompt')
}}
@@ -179,10 +171,10 @@
('code_as
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
-const isSora = computed(() => props.account?.platform === 'sora')
-const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
+const isOpenAILike = computed(() => isOpenAI.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
-const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
+ if (isOpenAILike.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
+ if (isOpenAILike.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
+ if (isOpenAILike.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
- if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
+ if (isOpenAILike.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
@@ -275,7 +270,7 @@ const currentError = computed(() => {
// Computed
const isManualInputMethod = computed(() => {
- // OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
+ // OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
@@ -319,7 +314,6 @@ const resetState = () => {
geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState()
openaiOAuth.resetState()
- soraOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
@@ -333,7 +327,7 @@ const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAILike.value) {
- await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
+ await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
@@ -354,7 +348,7 @@ const handleExchangeCode = async () => {
if (isOpenAILike.value) {
// OpenAI OAuth flow
- const oauthClient = activeOpenAIOAuth.value
+ const oauthClient = openaiOAuth
const sessionId = oauthClient.sessionId.value
if (!sessionId) return
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
diff --git a/frontend/src/components/admin/channel/IntervalRow.vue b/frontend/src/components/admin/channel/IntervalRow.vue
new file mode 100644
index 00000000..21dcc90d
--- /dev/null
+++ b/frontend/src/components/admin/channel/IntervalRow.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
diff --git a/frontend/src/components/admin/channel/ModelTagInput.vue b/frontend/src/components/admin/channel/ModelTagInput.vue
new file mode 100644
index 00000000..a1ce4022
--- /dev/null
+++ b/frontend/src/components/admin/channel/ModelTagInput.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+ {{ model }}
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.modelInputHint', 'Press Enter to add, supports paste for batch import.') }}
+
+
+
+
+
diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue
new file mode 100644
index 00000000..e98853c3
--- /dev/null
+++ b/frontend/src/components/admin/channel/PricingEntryCard.vue
@@ -0,0 +1,354 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ m }}
+
+
+ +{{ entry.models.length - 3 }}
+
+
+ {{ t('admin.channels.form.noModels', '未添加模型') }}
+
+
+
+
+
+ {{ billingModeLabel }}
+
+
+
+
+
+ {{ t('admin.channels.form.pricingEntry', '定价配置') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.models', '模型列表') }} *
+
+
+
+
+
+ {{ t('admin.channels.form.billingMode', '计费模式') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.defaultPrices', '默认价格(未命中区间时使用)') }}
+ $/MTok
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.intervals', '上下文区间定价(可选)') }}
+ (min, max]
+
+
+ + {{ t('admin.channels.form.addInterval', '添加区间') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.defaultPerRequestPrice', '默认单次价格(未命中层级时使用)') }}
+ $
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.requestTiers', '按次计费层级') }}
+
+
+ + {{ t('admin.channels.form.addTier', '添加层级') }}
+
+
+
+
+
+
+ {{ t('admin.channels.form.noTiersYet', '暂无层级,点击添加配置按次计费价格') }}
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.defaultImagePrice', '默认图片价格(未命中层级时使用)') }}
+ $
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.imageTiers', '图片计费层级(按次)') }}
+
+
+ + {{ t('admin.channels.form.addTier', '添加层级') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts
new file mode 100644
index 00000000..b3966289
--- /dev/null
+++ b/frontend/src/components/admin/channel/types.ts
@@ -0,0 +1,189 @@
+import type { BillingMode, PricingInterval } from '@/api/admin/channels'
+
+export interface IntervalFormEntry {
+ min_tokens: number
+ max_tokens: number | null
+ tier_label: string
+ input_price: number | string | null
+ output_price: number | string | null
+ cache_write_price: number | string | null
+ cache_read_price: number | string | null
+ per_request_price: number | string | null
+ sort_order: number
+}
+
+export interface PricingFormEntry {
+ models: string[]
+ billing_mode: BillingMode
+ input_price: number | string | null
+ output_price: number | string | null
+ cache_write_price: number | string | null
+ cache_read_price: number | string | null
+ image_output_price: number | string | null
+ per_request_price: number | string | null
+ intervals: IntervalFormEntry[]
+}
+
+// 价格转换:后端存 per-token,前端显示 per-MTok ($/1M tokens)
+const MTOK = 1_000_000
+
+export function toNullableNumber(val: number | string | null | undefined): number | null {
+ if (val === null || val === undefined || val === '') return null
+ const num = Number(val)
+ return isNaN(num) ? null : num
+}
+
+/** 前端显示值($/MTok) → 后端存储值(per-token) */
+export function mTokToPerToken(val: number | string | null | undefined): number | null {
+ const num = toNullableNumber(val)
+ return num === null ? null : parseFloat((num / MTOK).toPrecision(10))
+}
+
+/** 后端存储值(per-token) → 前端显示值($/MTok) */
+export function perTokenToMTok(val: number | null | undefined): number | null {
+ if (val === null || val === undefined) return null
+ // toPrecision(10) 消除 IEEE 754 浮点乘法精度误差,如 5e-8 * 1e6 = 0.04999...96 → 0.05
+ return parseFloat((val * MTOK).toPrecision(10))
+}
+
+export function apiIntervalsToForm(intervals: PricingInterval[]): IntervalFormEntry[] {
+ return (intervals || []).map(iv => ({
+ min_tokens: iv.min_tokens,
+ max_tokens: iv.max_tokens,
+ tier_label: iv.tier_label || '',
+ input_price: perTokenToMTok(iv.input_price),
+ output_price: perTokenToMTok(iv.output_price),
+ cache_write_price: perTokenToMTok(iv.cache_write_price),
+ cache_read_price: perTokenToMTok(iv.cache_read_price),
+ per_request_price: iv.per_request_price,
+ sort_order: iv.sort_order
+ }))
+}
+
+export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInterval[] {
+ return (intervals || []).map(iv => ({
+ min_tokens: iv.min_tokens,
+ max_tokens: iv.max_tokens,
+ tier_label: iv.tier_label,
+ input_price: mTokToPerToken(iv.input_price),
+ output_price: mTokToPerToken(iv.output_price),
+ cache_write_price: mTokToPerToken(iv.cache_write_price),
+ cache_read_price: mTokToPerToken(iv.cache_read_price),
+ per_request_price: toNullableNumber(iv.per_request_price),
+ sort_order: iv.sort_order
+ }))
+}
+
+// ── 模型模式冲突检测 ──────────────────────────────────────
+
+interface ModelPattern {
+ pattern: string
+ prefix: string // lowercase, 通配符去掉尾部 *
+ wildcard: boolean
+}
+
+function toModelPattern(model: string): ModelPattern {
+ const lower = model.toLowerCase()
+ const wildcard = lower.endsWith('*')
+ return {
+ pattern: model,
+ prefix: wildcard ? lower.slice(0, -1) : lower,
+ wildcard,
+ }
+}
+
+function patternsConflict(a: ModelPattern, b: ModelPattern): boolean {
+ if (!a.wildcard && !b.wildcard) return a.prefix === b.prefix
+ if (a.wildcard && !b.wildcard) return b.prefix.startsWith(a.prefix)
+ if (!a.wildcard && b.wildcard) return a.prefix.startsWith(b.prefix)
+ // 双通配符:任一前缀是另一前缀的前缀即冲突
+ return a.prefix.startsWith(b.prefix) || b.prefix.startsWith(a.prefix)
+}
+
+/** 检测模型模式列表中的冲突,返回冲突的两个模式名;无冲突返回 null */
+export function findModelConflict(models: string[]): [string, string] | null {
+ const patterns = models.map(toModelPattern)
+ for (let i = 0; i < patterns.length; i++) {
+ for (let j = i + 1; j < patterns.length; j++) {
+ if (patternsConflict(patterns[i], patterns[j])) {
+ return [patterns[i].pattern, patterns[j].pattern]
+ }
+ }
+ }
+ return null
+}
+
+// ── 区间校验 ──────────────────────────────────────────────
+
+/** 校验区间列表的合法性,返回错误消息;通过则返回 null */
+export function validateIntervals(intervals: IntervalFormEntry[]): string | null {
+ if (!intervals || intervals.length === 0) return null
+
+ // 按 min_tokens 排序(不修改原数组)
+ const sorted = [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
+
+ for (let i = 0; i < sorted.length; i++) {
+ const err = validateSingleInterval(sorted[i], i)
+ if (err) return err
+ }
+ return checkIntervalOverlap(sorted)
+}
+
+function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null {
+ if (iv.min_tokens < 0) {
+ return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数`
+ }
+ if (iv.max_tokens != null) {
+ if (iv.max_tokens <= 0) {
+ return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于 0`
+ }
+ if (iv.max_tokens <= iv.min_tokens) {
+ return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于最小 token 数 (${iv.min_tokens})`
+ }
+ }
+ return validateIntervalPrices(iv, idx)
+}
+
+function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
+ const prices: [string, number | string | null][] = [
+ ['输入价格', iv.input_price],
+ ['输出价格', iv.output_price],
+ ['缓存写入价格', iv.cache_write_price],
+ ['缓存读取价格', iv.cache_read_price],
+ ['单次价格', iv.per_request_price],
+ ]
+ for (const [name, val] of prices) {
+ if (val != null && val !== '' && Number(val) < 0) {
+ return `区间 #${idx + 1}: ${name}不能为负数`
+ }
+ }
+ return null
+}
+
+function checkIntervalOverlap(sorted: IntervalFormEntry[]): string | null {
+ for (let i = 0; i < sorted.length; i++) {
+ // 无上限区间必须是最后一个
+ if (sorted[i].max_tokens == null && i < sorted.length - 1) {
+ return `区间 #${i + 1}: 无上限区间(最大 token 数为空)只能是最后一个`
+ }
+ if (i === 0) continue
+ const prev = sorted[i - 1]
+ // (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
+ if (prev.max_tokens == null || prev.max_tokens > sorted[i].min_tokens) {
+ const prevMax = prev.max_tokens == null ? '∞' : String(prev.max_tokens)
+ return `区间 #${i} 和 #${i + 1} 重叠:前一个区间上界 (${prevMax}) 大于当前区间下界 (${sorted[i].min_tokens})`
+ }
+ }
+ return null
+}
+
+/** 平台对应的模型 tag 样式(背景+文字) */
+export function getPlatformTagClass(platform: string): string {
+ switch (platform) {
+ case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
+ case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
+ case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
+ case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
+ default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
+ }
+}
diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue
index ee5020e7..66c2b4fa 100644
--- a/frontend/src/components/admin/usage/UsageFilters.vue
+++ b/frontend/src/components/admin/usage/UsageFilters.vue
@@ -133,6 +133,12 @@
+
+
+ {{ t('admin.usage.billingMode') }}
+
+
+
{{ t('admin.usage.group') }}
@@ -232,6 +238,13 @@ const billingTypeOptions = ref
([
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
])
+const billingModeOptions = ref([
+ { value: null, label: t('admin.usage.allBillingModes') },
+ { value: 'token', label: t('admin.usage.billingModeToken') },
+ { value: 'per_request', label: t('admin.usage.billingModePerRequest') },
+ { value: 'image', label: t('admin.usage.billingModeImage') }
+])
+
const emitChange = () => emit('change')
const debounceUserSearch = () => {
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue
index 4a42ab05..9bbdb380 100644
--- a/frontend/src/components/admin/usage/UsageTable.vue
+++ b/frontend/src/components/admin/usage/UsageTable.vue
@@ -26,7 +26,15 @@
-
+
+
{{ row.model }}
@@ -69,9 +77,15 @@
+
+
+ {{ getBillingModeLabel(row.billing_mode) }}
+
+
+
-
-
+
+
{{ t('usage.rate') }}
- {{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x
+ {{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x
{{ t('usage.accountMultiplier') }}
- {{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x
+ {{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x
{{ t('usage.original') }}
@@ -312,6 +326,7 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
+import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
@@ -350,12 +365,19 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
-const formatCacheTokens = (tokens: number): string => {
- if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
- return tokens.toString()
+const getBillingModeLabel = (mode: string | null | undefined): string => {
+ if (mode === 'per_request') return t('admin.usage.billingModePerRequest')
+ if (mode === 'image') return t('admin.usage.billingModeImage')
+ return t('admin.usage.billingModeToken')
}
+const getBillingModeBadgeClass = (mode: string | null | undefined): string => {
+ if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
+ if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
+}
+
+
const formatUserAgent = (ua: string): string => {
return ua
}
diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue
index e537dbf6..70ebd2d3 100644
--- a/frontend/src/components/admin/user/UserEditModal.vue
+++ b/frontend/src/components/admin/user/UserEditModal.vue
@@ -37,14 +37,6 @@
{{ t('admin.users.columns.concurrency') }}
-
-
{{ t('admin.users.soraStorageQuota') }}
-
-
- GB
-
-
{{ t('admin.users.soraStorageQuotaHint') }}
-
@@ -74,11 +66,11 @@ const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
-const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, sora_storage_quota_gb: 0, customAttributes: {} as UserAttributeValuesMap })
+const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
- Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, sora_storage_quota_gb: Number(((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024)).toFixed(2)), customAttributes: {} })
+ Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
@@ -105,7 +97,7 @@ const handleUpdateUser = async () => {
}
submitting.value = true
try {
- const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, sora_storage_quota_bytes: Math.round((form.sora_storage_quota_gb || 0) * 1024 * 1024 * 1024) }
+ const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
diff --git a/frontend/src/components/charts/EndpointDistributionChart.vue b/frontend/src/components/charts/EndpointDistributionChart.vue
index 5e3fc23b..32e05a93 100644
--- a/frontend/src/components/charts/EndpointDistributionChart.vue
+++ b/frontend/src/components/charts/EndpointDistributionChart.vue
@@ -161,6 +161,7 @@ const props = withDefaults(
showSourceToggle?: boolean
startDate?: string
endDate?: string
+ filters?: Record
}>(),
{
upstreamEndpointStats: () => [],
@@ -193,6 +194,7 @@ const toggleBreakdown = async (endpoint: string) => {
breakdownItems.value = []
try {
const res = await getUserBreakdown({
+ ...props.filters,
start_date: props.startDate,
end_date: props.endDate,
endpoint,
diff --git a/frontend/src/components/charts/GroupDistributionChart.vue b/frontend/src/components/charts/GroupDistributionChart.vue
index f2be366f..560529b1 100644
--- a/frontend/src/components/charts/GroupDistributionChart.vue
+++ b/frontend/src/components/charts/GroupDistributionChart.vue
@@ -125,6 +125,7 @@ const props = withDefaults(defineProps<{
showMetricToggle?: boolean
startDate?: string
endDate?: string
+ filters?: Record
}>(), {
loading: false,
metric: 'tokens',
@@ -150,6 +151,7 @@ const toggleBreakdown = async (type: string, id: number | string) => {
breakdownItems.value = []
try {
const res = await getUserBreakdown({
+ ...props.filters,
start_date: props.startDate,
end_date: props.endDate,
group_id: Number(id),
diff --git a/frontend/src/components/charts/ModelDistributionChart.vue b/frontend/src/components/charts/ModelDistributionChart.vue
index a88da0c4..820eada3 100644
--- a/frontend/src/components/charts/ModelDistributionChart.vue
+++ b/frontend/src/components/charts/ModelDistributionChart.vue
@@ -270,6 +270,7 @@ const props = withDefaults(defineProps<{
rankingError?: boolean
startDate?: string
endDate?: string
+ filters?: Record
}>(), {
upstreamModelStats: () => [],
mappingModelStats: () => [],
@@ -302,6 +303,7 @@ const toggleBreakdown = async (type: string, id: string) => {
breakdownItems.value = []
try {
const res = await getUserBreakdown({
+ ...props.filters,
start_date: props.startDate,
end_date: props.endDate,
model: id,
diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue
index 5012285f..83f4b8aa 100644
--- a/frontend/src/components/common/GroupBadge.vue
+++ b/frontend/src/components/common/GroupBadge.vue
@@ -116,9 +116,6 @@ const labelClass = computed(() => {
if (props.platform === 'gemini') {
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
- if (props.platform === 'sora') {
- return `${base} bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
- }
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
@@ -140,11 +137,6 @@ const badgeClass = computed(() => {
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
}
- if (props.platform === 'sora') {
- return isSubscription.value
- ? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
- : 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
- }
// Fallback: original colors
return isSubscription.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
diff --git a/frontend/src/components/common/GroupOptionItem.vue b/frontend/src/components/common/GroupOptionItem.vue
index 10c431a1..28b5d6e3 100644
--- a/frontend/src/components/common/GroupOptionItem.vue
+++ b/frontend/src/components/common/GroupOptionItem.vue
@@ -91,8 +91,6 @@ const ratePillClass = computed(() => {
return 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
case 'gemini':
return 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
- case 'sora':
- return 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
default: // antigravity and others
return 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400'
}
diff --git a/frontend/src/components/common/PlatformIcon.vue b/frontend/src/components/common/PlatformIcon.vue
index 04ecb830..1e137ae5 100644
--- a/frontend/src/components/common/PlatformIcon.vue
+++ b/frontend/src/components/common/PlatformIcon.vue
@@ -19,12 +19,6 @@
-
-
-
-
{{ privacyBadge.label }}
+
+
+ {{ expiresLabel }}
+
@@ -62,6 +66,7 @@ interface Props {
type: AccountType
planType?: string
privacyMode?: string
+ subscriptionExpiresAt?: string
}
const props = defineProps
()
@@ -70,7 +75,6 @@ const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
- if (props.platform === 'sora') return 'Sora'
return 'Gemini'
})
@@ -119,9 +123,6 @@ const platformClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
- if (props.platform === 'sora') {
- return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
- }
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -135,9 +136,6 @@ const typeClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
}
- if (props.platform === 'sora') {
- return 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400'
- }
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -148,6 +146,22 @@ const planBadgeClass = computed(() => {
return typeClass.value
})
+// Subscription expiration label (non-free only)
+const expiresLabel = computed(() => {
+ if (!props.subscriptionExpiresAt || !props.planType) return ''
+ if (props.planType.toLowerCase() === 'free') return ''
+ try {
+ const d = new Date(props.subscriptionExpiresAt)
+ if (isNaN(d.getTime())) return ''
+ const yyyy = d.getFullYear()
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
+ const dd = String(d.getDate()).padStart(2, '0')
+ return `${t('admin.accounts.subscriptionExpires')} ${yyyy}-${mm}-${dd}`
+ } catch {
+ return ''
+ }
+})
+
// Privacy badge — shows different states for OpenAI/Antigravity OAuth privacy setting
const privacyBadge = computed(() => {
if (props.type !== 'oauth' || !props.privacyMode) return null
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 2e5babeb..09b83f6f 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -287,6 +287,21 @@ const FolderIcon = {
)
}
+const ChannelIcon = {
+ render: () =>
+ h(
+ 'svg',
+ { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
+ [
+ h('path', {
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ d: 'M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0l4.179 2.25L12 17.25 2.25 12m15.321-2.25l4.179 2.25L12 17.25l-9.75-5.25'
+ })
+ ]
+ )
+}
+
const CreditCardIcon = {
render: () =>
h(
@@ -452,21 +467,6 @@ const ChevronDoubleLeftIcon = {
)
}
-const SoraIcon = {
- render: () =>
- h(
- 'svg',
- { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
- [
- h('path', {
- 'stroke-linecap': 'round',
- 'stroke-linejoin': 'round',
- d: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z'
- })
- ]
- )
-}
-
const ChevronDoubleRightIcon = {
render: () =>
h(
@@ -489,9 +489,6 @@ const userNavItems = computed((): NavItem[] => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
- ...(appStore.cachedPublicSettings?.sora_client_enabled
- ? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
- : []),
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
@@ -520,9 +517,6 @@ const personalNavItems = computed((): NavItem[] => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
- ...(appStore.cachedPublicSettings?.sora_client_enabled
- ? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
- : []),
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
@@ -568,6 +562,7 @@ const adminNavItems = computed((): NavItem[] => {
: []),
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
+ { path: '/admin/channels', label: t('nav.channels', '渠道管理'), icon: ChannelIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
diff --git a/frontend/src/components/sora/SoraDownloadDialog.vue b/frontend/src/components/sora/SoraDownloadDialog.vue
deleted file mode 100644
index 5f39980f..00000000
--- a/frontend/src/components/sora/SoraDownloadDialog.vue
+++ /dev/null
@@ -1,217 +0,0 @@
-
-
-
-
-
-
-
📥
-
{{ t('sora.downloadTitle') }}
-
{{ t('sora.downloadExpirationWarning') }}
-
-
-
-
-
-
-
- {{ isExpired ? t('sora.upstreamExpired') : t('sora.upstreamCountdown', { time: remainingText }) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraGeneratePage.vue b/frontend/src/components/sora/SoraGeneratePage.vue
deleted file mode 100644
index 1f77edc4..00000000
--- a/frontend/src/components/sora/SoraGeneratePage.vue
+++ /dev/null
@@ -1,430 +0,0 @@
-
-
-
-
-
-
{{ t('sora.welcomeTitle') }}
-
{{ t('sora.welcomeSubtitle') }}
-
-
-
-
-
- {{ example }}
-
-
-
-
-
-
-
-
-
-
- ⚠️
- {{ t('sora.noStorageToastMessage') }}
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraLibraryPage.vue b/frontend/src/components/sora/SoraLibraryPage.vue
deleted file mode 100644
index 1d49fe60..00000000
--- a/frontend/src/components/sora/SoraLibraryPage.vue
+++ /dev/null
@@ -1,577 +0,0 @@
-
-
-
-
-
-
- {{ f.label }}
-
-
-
- {{ t('sora.galleryCount', { count: filteredItems.length }) }}
-
-
-
-
-
-
-
-
-
-
-
- {{ item.media_type === 'video' ? '🎬' : '🎨' }}
-
-
-
-
- {{ item.media_type === 'video' ? 'VIDEO' : 'IMAGE' }}
-
-
-
-
-
- 📥
-
-
- 🗑
-
-
-
-
-
▶
-
-
-
- {{ formatDuration(item) }}
-
-
-
-
-
-
{{ item.model }}
-
{{ formatTime(item.created_at) }}
-
-
-
-
-
-
-
🎬
-
{{ t('sora.galleryEmptyTitle') }}
-
{{ t('sora.galleryEmptyDesc') }}
-
- {{ t('sora.startCreating') }}
-
-
-
-
-
-
- {{ loading ? t('sora.loading') : t('sora.loadMore') }}
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraMediaPreview.vue b/frontend/src/components/sora/SoraMediaPreview.vue
deleted file mode 100644
index 09a3aea1..00000000
--- a/frontend/src/components/sora/SoraMediaPreview.vue
+++ /dev/null
@@ -1,282 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraNoStorageWarning.vue b/frontend/src/components/sora/SoraNoStorageWarning.vue
deleted file mode 100644
index c5ede271..00000000
--- a/frontend/src/components/sora/SoraNoStorageWarning.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
⚠️
-
-
{{ t('sora.noStorageWarningTitle') }}
-
{{ t('sora.noStorageWarningDesc') }}
-
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraProgressCard.vue b/frontend/src/components/sora/SoraProgressCard.vue
deleted file mode 100644
index 69b28ef9..00000000
--- a/frontend/src/components/sora/SoraProgressCard.vue
+++ /dev/null
@@ -1,609 +0,0 @@
-
-
-
-
-
-
-
- {{ generation.prompt }}
-
-
-
-
- ⛔ {{ t('sora.errorCategory') }}
-
-
- {{ generation.error_message }}
-
-
-
-
-
-
- {{ progressInfoText }}
- {{ progressInfoRight }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ✓ {{ t('sora.savedToCloud') }}
-
-
-
- ☁️ {{ t('sora.save') }}
-
-
-
- 📥 {{ t('sora.downloadLocal') }}
-
-
-
- ⏱ {{ t('sora.upstreamCountdown', { time: countdownText }) }} {{ t('sora.canDownload') }}
-
-
- {{ t('sora.upstreamExpired') }}
-
-
-
-
-
-
- 🔄 {{ generation.status === 'cancelled' ? t('sora.regenrate') : t('sora.retry') }}
-
-
- 🗑 {{ t('sora.delete') }}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraPromptBar.vue b/frontend/src/components/sora/SoraPromptBar.vue
deleted file mode 100644
index f5f1bfc9..00000000
--- a/frontend/src/components/sora/SoraPromptBar.vue
+++ /dev/null
@@ -1,738 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ f.name }}
-
-
- {{ f.name }}
-
-
- ▼
-
-
-
-
- {{ t('sora.selectCredential') }}
-
-
- {{ k.name }}{{ k.group ? ' · ' + k.group.name : '' }}
-
-
-
-
- {{ s.group?.name || t('sora.subscription') }}
-
-
-
- ▼
-
-
-
- ⚠ {{ t('sora.noCredentialHint') }}
-
-
-
- ⚠ {{ t('sora.noStorageConfigured') }}
-
-
-
-
-
-
-
-
✕
-
-
{{ t('sora.referenceImage') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ imageError }}
-
-
-
-
-
-
diff --git a/frontend/src/components/sora/SoraQuotaBar.vue b/frontend/src/components/sora/SoraQuotaBar.vue
deleted file mode 100644
index 4a3af027..00000000
--- a/frontend/src/components/sora/SoraQuotaBar.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
-
- {{ formatBytes(quota.used_bytes) }} / {{ quota.quota_bytes === 0 ? '∞' : formatBytes(quota.quota_bytes) }}
-
-
-
-
-
-
-
diff --git a/frontend/src/components/user/dashboard/UserDashboardCharts.vue b/frontend/src/components/user/dashboard/UserDashboardCharts.vue
index 22148592..73e88c3b 100644
--- a/frontend/src/components/user/dashboard/UserDashboardCharts.vue
+++ b/frontend/src/components/user/dashboard/UserDashboardCharts.vue
@@ -7,6 +7,9 @@
{{ t('dashboard.timeRange') }}:
+
+ {{ t('common.refresh') }}
+
{{ t('dashboard.granularity') }}:
@@ -74,7 +77,7 @@ import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler)
const props = defineProps<{ loading: boolean, startDate: string, endDate: string, granularity: string, trend: TrendDataPoint[], models: ModelStat[] }>()
-defineEmits(['update:startDate', 'update:endDate', 'update:granularity', 'dateRangeChange', 'granularityChange'])
+defineEmits(['update:startDate', 'update:endDate', 'update:granularity', 'dateRangeChange', 'granularityChange', 'refresh'])
const { t } = useI18n()
const modelData = computed(() => !props.models?.length ? null : {
diff --git a/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts b/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
index ee3f7990..3058819c 100644
--- a/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
+++ b/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
@@ -11,8 +11,7 @@ vi.mock('@/api/admin', () => ({
accounts: {
generateAuthUrl: vi.fn(),
exchangeCode: vi.fn(),
- refreshOpenAIToken: vi.fn(),
- validateSoraSessionToken: vi.fn()
+ refreshOpenAIToken: vi.fn()
}
}
}))
@@ -21,21 +20,21 @@ import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
describe('useOpenAIOAuth.buildCredentials', () => {
it('should keep client_id when token response contains it', () => {
- const oauth = useOpenAIOAuth({ platform: 'sora' })
+ const oauth = useOpenAIOAuth()
const creds = oauth.buildCredentials({
access_token: 'at',
refresh_token: 'rt',
- client_id: 'app_sora_client',
+ client_id: 'app_test_client',
expires_at: 1700000000
})
- expect(creds.client_id).toBe('app_sora_client')
+ expect(creds.client_id).toBe('app_test_client')
expect(creds.access_token).toBe('at')
expect(creds.refresh_token).toBe('rt')
})
it('should keep legacy behavior when client_id is missing', () => {
- const oauth = useOpenAIOAuth({ platform: 'openai' })
+ const oauth = useOpenAIOAuth()
const creds = oauth.buildCredentials({
access_token: 'at',
refresh_token: 'rt',
diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts
index 9e7cb036..d16ed977 100644
--- a/frontend/src/composables/useModelWhitelist.ts
+++ b/frontend/src/composables/useModelWhitelist.ts
@@ -60,22 +60,6 @@ const geminiModels = [
'gemini-3-pro-preview'
]
-// Sora
-const soraModels = [
- 'gpt-image', 'gpt-image-landscape', 'gpt-image-portrait',
- 'sora2-landscape-10s', 'sora2-portrait-10s',
- 'sora2-landscape-15s', 'sora2-portrait-15s',
- 'sora2-landscape-25s', 'sora2-portrait-25s',
- 'sora2pro-landscape-10s', 'sora2pro-portrait-10s',
- 'sora2pro-landscape-15s', 'sora2pro-portrait-15s',
- 'sora2pro-landscape-25s', 'sora2pro-portrait-25s',
- 'sora2pro-hd-landscape-10s', 'sora2pro-hd-portrait-10s',
- 'sora2pro-hd-landscape-15s', 'sora2pro-hd-portrait-15s',
- 'prompt-enhance-short-10s', 'prompt-enhance-short-15s', 'prompt-enhance-short-20s',
- 'prompt-enhance-medium-10s', 'prompt-enhance-medium-15s', 'prompt-enhance-medium-20s',
- 'prompt-enhance-long-10s', 'prompt-enhance-long-15s', 'prompt-enhance-long-20s'
-]
-
// Antigravity 官方支持的模型(精确匹配)
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
const antigravityModels = [
@@ -236,7 +220,6 @@ const allModelsList: string[] = [
...openaiModels,
...claudeModels,
...geminiModels,
- ...soraModels,
...zhipuModels,
...qwenModels,
...deepseekModels,
@@ -289,8 +272,6 @@ const openaiPresetMappings = [
{ label: 'Sonnet→5.4', from: 'claude-sonnet-4-6', to: 'gpt-5.4', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }
]
-const soraPresetMappings: { label: string; from: string; to: string; color: string }[] = []
-
const geminiPresetMappings = [
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
@@ -385,7 +366,6 @@ export function getModelsByPlatform(platform: string): string[] {
case 'anthropic':
case 'claude': return claudeModels
case 'gemini': return geminiModels
- case 'sora': return soraModels
case 'antigravity': return antigravityModels
case 'zhipu': return zhipuModels
case 'qwen': return qwenModels
@@ -410,7 +390,6 @@ export function getModelsByPlatform(platform: string): string[] {
export function getPresetMappingsByPlatform(platform: string) {
if (platform === 'openai') return openaiPresetMappings
if (platform === 'gemini') return geminiPresetMappings
- if (platform === 'sora') return soraPresetMappings
if (platform === 'antigravity') return antigravityPresetMappings
if (platform === 'bedrock') return bedrockPresetMappings
return anthropicPresetMappings
diff --git a/frontend/src/composables/useOpenAIOAuth.ts b/frontend/src/composables/useOpenAIOAuth.ts
index adea5646..060ddbd2 100644
--- a/frontend/src/composables/useOpenAIOAuth.ts
+++ b/frontend/src/composables/useOpenAIOAuth.ts
@@ -22,16 +22,11 @@ export interface OpenAITokenInfo {
[key: string]: unknown
}
-export type OpenAIOAuthPlatform = 'openai' | 'sora'
+export type OpenAIOAuthPlatform = 'openai'
-interface UseOpenAIOAuthOptions {
- platform?: OpenAIOAuthPlatform
-}
-
-export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
+export function useOpenAIOAuth() {
const appStore = useAppStore()
- const oauthPlatform = options?.platform ?? 'openai'
- const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
+ const endpointPrefix = '/admin/openai'
// State
const authUrl = ref('')
@@ -160,33 +155,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
}
}
- // Validate Sora session token and get access token
- const validateSessionToken = async (
- sessionToken: string,
- proxyId?: number | null
- ): Promise
=> {
- if (!sessionToken.trim()) {
- error.value = 'Missing session token'
- return null
- }
- loading.value = true
- error.value = ''
- try {
- const tokenInfo = await adminAPI.accounts.validateSoraSessionToken(
- sessionToken.trim(),
- proxyId,
- `${endpointPrefix}/st2at`
- )
- return tokenInfo as OpenAITokenInfo
- } catch (err: any) {
- error.value = err.response?.data?.detail || 'Failed to validate session token'
- appStore.showError(error.value)
- return null
- } finally {
- loading.value = false
- }
- }
-
// Build credentials for OpenAI OAuth account (aligned with backend BuildAccountCredentials)
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record => {
const creds: Record = {
@@ -250,7 +218,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
- validateSessionToken,
buildCredentials,
buildExtraInfo
}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index f5267d6a..fc9297fd 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -335,6 +335,7 @@ export default {
profile: 'Profile',
users: 'Users',
groups: 'Groups',
+ channels: 'Channels',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
proxies: 'Proxies',
@@ -1610,7 +1611,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
- sora: 'Sora'
},
deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
@@ -1635,16 +1635,6 @@ export default {
title: 'Image Generation Pricing',
description: 'Configure pricing for image generation models. Leave empty to use default prices.'
},
- soraPricing: {
- title: 'Sora Per-Request Pricing',
- description: 'Configure per-request pricing for Sora image/video generation. Leave empty to disable billing.',
- image360: 'Image 360px ($)',
- image540: 'Image 540px ($)',
- video: 'Video (standard) ($)',
- videoHd: 'Video (Pro-HD) ($)',
- storageQuota: 'Storage Quota',
- storageQuotaHint: 'In GB, set the Sora storage quota for users in this group. 0 means use system default'
- },
claudeCode: {
title: 'Claude Code Client Restriction',
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
@@ -1719,6 +1709,107 @@ export default {
}
},
+ // Channel Management
+ channels: {
+ title: 'Channel Management',
+ description: 'Manage channels and custom model pricing',
+ searchChannels: 'Search channels...',
+ createChannel: 'Create Channel',
+ editChannel: 'Edit Channel',
+ deleteChannel: 'Delete Channel',
+ statusActive: 'Active',
+ statusDisabled: 'Disabled',
+ allStatus: 'All Status',
+ groupsUnit: 'groups',
+ pricingUnit: 'pricing rules',
+ noChannelsYet: 'No Channels Yet',
+ createFirstChannel: 'Create your first channel to manage model pricing',
+ loadError: 'Failed to load channels',
+ createSuccess: 'Channel created',
+ updateSuccess: 'Channel updated',
+ deleteSuccess: 'Channel deleted',
+ createError: 'Failed to create channel',
+ updateError: 'Failed to update channel',
+ deleteError: 'Failed to delete channel',
+ nameRequired: 'Please enter a channel name',
+ duplicateModels: 'Model "{0}" appears in multiple pricing entries',
+ modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range",
+ mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range",
+ deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
+ columns: {
+ name: 'Name',
+ description: 'Description',
+ status: 'Status',
+ groups: 'Groups',
+ pricing: 'Pricing',
+ createdAt: 'Created',
+ actions: 'Actions'
+ },
+ billingMode: {
+ token: 'Token',
+ perRequest: 'Per Request',
+ image: 'Image (Per Request)'
+ },
+ form: {
+ name: 'Name',
+ namePlaceholder: 'Enter channel name',
+ description: 'Description',
+ descriptionPlaceholder: 'Optional description',
+ status: 'Status',
+ groups: 'Associated Groups',
+ noGroupsAvailable: 'No groups available',
+ inOtherChannel: 'In "{name}"',
+ modelPricing: 'Model Pricing',
+ models: 'Models',
+ modelsPlaceholder: 'Type full model name and press Enter',
+ modelInputHint: 'Press Enter to add, supports paste for batch import.',
+ billingMode: 'Billing Mode',
+ defaultPrices: 'Default prices (fallback when no interval matches)',
+ inputPrice: 'Input',
+ outputPrice: 'Output',
+ cacheWritePrice: 'Cache Write',
+ cacheReadPrice: 'Cache Read',
+ imageTokenPrice: 'Image Output',
+ imageOutputPrice: 'Image Output Price',
+ pricePlaceholder: 'Default',
+ intervals: 'Context Intervals (optional)',
+ addInterval: 'Add Interval',
+ requestTiers: 'Request Tiers',
+ imageTiers: 'Image Tiers (Per Request)',
+ addTier: 'Add Tier',
+ noTiersYet: 'No tiers yet. Click add to configure per-request pricing.',
+ noPricingRules: 'No pricing rules yet. Click "Add" to create one.',
+ perRequestPrice: 'Price per Request',
+ perRequestPriceRequired: 'Per-request price or billing tiers required for per-request/image billing mode',
+ tierLabel: 'Tier',
+ resolution: 'Resolution',
+ modelMapping: 'Model Mapping',
+ modelMappingHint: 'Map request model names to actual model names. Runs before account-level mapping.',
+ noMappingRules: 'No mapping rules. Click "Add" to create one.',
+ mappingSource: 'Source model',
+ mappingTarget: 'Target model',
+ billingModelSource: 'Billing Model',
+ billingModelSourceChannelMapped: 'Bill by channel-mapped model',
+ billingModelSourceRequested: 'Bill by requested model',
+ billingModelSourceUpstream: 'Bill by final upstream model',
+ billingModelSourceHint: 'Controls which model name is used for pricing lookup',
+ selectedCount: '{count} selected',
+ searchGroups: 'Search groups...',
+ noGroupsMatch: 'No groups match your search',
+ restrictModels: 'Restrict Models',
+ restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.',
+ defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)',
+ defaultImagePrice: 'Default image price (fallback when no tier matches)',
+ platformConfig: 'Platform Configuration',
+ basicSettings: 'Basic Settings',
+ addPlatform: 'Add Platform',
+ noPlatforms: 'Click "Add Platform" to start configuring the channel',
+ mappingCount: 'mappings',
+ pricingEntry: 'Pricing Entry',
+ noModels: 'No models added'
+ }
+ },
+
// Subscriptions
subscriptions: {
title: 'Subscription Management',
@@ -1923,7 +2014,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
- sora: 'Sora'
},
types: {
oauth: 'OAuth',
@@ -1933,10 +2023,6 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: 'Connect via Base URL + API Key',
- soraApiKey: 'API Key / Upstream',
- soraApiKeyHint: 'Connect to another Sub2API or compatible API',
- soraBaseUrlRequired: 'Sora API Key account requires a Base URL',
- soraBaseUrlInvalidScheme: 'Base URL must start with http:// or https://',
upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key'
},
@@ -1988,6 +2074,7 @@ export default {
privacyAntigravityFailed: 'Privacy setting failed',
setPrivacy: 'Set Privacy',
subscriptionAbnormal: 'Abnormal',
+ subscriptionExpires: 'Expires',
// Capacity status tooltips
capacity: {
windowCost: {
@@ -2198,8 +2285,6 @@ export default {
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
- enableSora: 'Enable Sora simultaneously',
- enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
},
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
@@ -2214,9 +2299,6 @@ export default {
'Map request models to actual models. Left is the requested model, right is the actual model sent to API.',
selectedModels: 'Selected {count} model(s)',
supportsAllModels: '(supports all models)',
- soraModelsLoadFailed: 'Failed to load Sora models, fallback to default list',
- soraModelsLoading: 'Loading Sora models...',
- soraModelsRetry: 'Load failed, click to retry',
requestModel: 'Request model',
actualModel: 'Actual model',
addMapping: 'Add Mapping',
@@ -2366,8 +2448,6 @@ export default {
creating: 'Creating...',
updating: 'Updating...',
accountCreated: 'Account created successfully',
- soraAccountCreated: 'Sora account created simultaneously',
- soraAccountFailed: 'Failed to create Sora account, please add manually later',
accountUpdated: 'Account updated successfully',
failedToCreate: 'Failed to create account',
failedToUpdate: 'Failed to update account',
@@ -2481,8 +2561,8 @@ export default {
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
sessionTokenAuth: 'Manual ST Input',
- sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
- sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
+ sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
+ sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
sessionTokenRawLabel: 'Raw Input',
sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...',
sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.',
@@ -2716,7 +2796,6 @@ export default {
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account',
- soraAccount: 'Sora Account',
geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account',
inputMethod: 'Input Method',
@@ -2750,11 +2829,6 @@ export default {
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}',
- soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
- soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
- soraTestTarget: 'Target: Sora account capability',
- soraTestMode: 'Mode: Connectivity + Capability checks',
- soraTestingFlow: 'Running Sora connectivity and capability checks...',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
@@ -3257,6 +3331,11 @@ export default {
allBillingTypes: 'All Billing Types',
billingTypeBalance: 'Balance',
billingTypeSubscription: 'Subscription',
+ billingMode: 'Billing Mode',
+ billingModeToken: 'Token',
+ billingModePerRequest: 'Per Request',
+ billingModeImage: 'Image',
+ allBillingModes: 'All Billing Modes',
ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history',
failedToLoadUser: 'Failed to load user info',
@@ -4508,7 +4587,19 @@ export default {
errorMessagePlaceholder: 'Custom error message when blocked',
errorMessageHint: 'Leave empty for default message',
saved: 'Beta policy settings saved',
- saveFailed: 'Failed to save beta policy settings'
+ saveFailed: 'Failed to save beta policy settings',
+ modelWhitelist: 'Model Whitelist',
+ modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., claude-opus-*)',
+ modelPatternPlaceholder: 'e.g., claude-opus-* or claude-opus-4-6',
+ addModelPattern: 'Add model pattern',
+ removePattern: 'Remove',
+ fallbackAction: 'Fallback Action',
+ fallbackActionHint: 'Action for models not matching the whitelist',
+ fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked',
+ quickPresets: 'Quick Presets',
+ presetOpusOnly: 'Opus only for 1M',
+ presetOpusOnlyDesc: 'Pass for Opus, filter others',
+ commonPatterns: 'Common patterns'
},
saveSettings: 'Save Settings',
saving: 'Saving...',
@@ -4915,99 +5006,4 @@ export default {
}
},
- // Sora Studio
- sora: {
- title: 'Sora Studio',
- description: 'Generate videos and images with Sora AI',
- notEnabled: 'Feature Not Available',
- notEnabledDesc: 'The Sora Studio feature has not been enabled by the administrator. Please contact your admin.',
- tabGenerate: 'Generate',
- tabLibrary: 'Library',
- noActiveGenerations: 'No active generations',
- startGenerating: 'Enter a prompt below to start creating',
- storage: 'Storage',
- promptPlaceholder: 'Describe what you want to create...',
- generate: 'Generate',
- generating: 'Generating...',
- selectModel: 'Select Model',
- statusPending: 'Pending',
- statusGenerating: 'Generating',
- statusCompleted: 'Completed',
- statusFailed: 'Failed',
- statusCancelled: 'Cancelled',
- cancel: 'Cancel',
- delete: 'Delete',
- save: 'Save to Cloud',
- saved: 'Saved',
- retry: 'Retry',
- download: 'Download',
- justNow: 'Just now',
- minutesAgo: '{n} min ago',
- hoursAgo: '{n} hr ago',
- noSavedWorks: 'No saved works',
- saveWorksHint: 'Save your completed generations to the library',
- filterAll: 'All',
- filterVideo: 'Video',
- filterImage: 'Image',
- confirmDelete: 'Are you sure you want to delete this work?',
- loading: 'Loading...',
- loadMore: 'Load More',
- noStorageWarningTitle: 'No Storage Configured',
- noStorageWarningDesc: 'Generated content is only available via temporary upstream links that expire in ~15 minutes. Consider configuring S3 storage.',
- mediaTypeVideo: 'Video',
- mediaTypeImage: 'Image',
- notificationCompleted: 'Generation Complete',
- notificationFailed: 'Generation Failed',
- notificationCompletedBody: 'Your {model} task has completed',
- notificationFailedBody: 'Your {model} task has failed',
- upstreamExpiresSoon: 'Expiring soon',
- upstreamExpired: 'Link expired',
- upstreamCountdown: '{time} remaining',
- previewTitle: 'Preview',
- closePreview: 'Close',
- beforeUnloadWarning: 'You have unsaved generated content. Are you sure you want to leave?',
- downloadTitle: 'Download Generated Content',
- downloadExpirationWarning: 'This link expires in approximately 15 minutes. Please download and save promptly.',
- downloadNow: 'Download Now',
- referenceImage: 'Reference Image',
- removeImage: 'Remove',
- imageTooLarge: 'Image size cannot exceed 20MB',
- // Sora dark theme additions
- welcomeTitle: 'Turn your imagination into video',
- welcomeSubtitle: 'Enter a description and Sora will create realistic videos or images for you. Try the examples below to get started.',
- queueTasks: 'tasks',
- queueWaiting: 'Queued',
- waiting: 'Waiting',
- waited: 'Waited',
- errorCategory: 'Content Policy Violation',
- savedToCloud: 'Saved to Cloud',
- downloadLocal: 'Download',
- canDownload: 'to download',
- regenrate: 'Regenerate',
- regenerate: 'Regenerate',
- creatorPlaceholder: 'Describe the video or image you want to create...',
- videoModels: 'Video Models',
- imageModels: 'Image Models',
- noStorageConfigured: 'No Storage',
- selectCredential: 'Select Credential',
- apiKeys: 'API Keys',
- subscriptions: 'Subscriptions',
- subscription: 'Subscription',
- noCredentialHint: 'Please create an API Key or contact admin for subscription',
- uploadReference: 'Upload reference image',
- generatingCount: 'Generating {current}/{max}',
- noStorageToastMessage: 'Cloud storage is not configured. Please use "Download" to save files after generation, otherwise they will be lost.',
- galleryCount: '{count} works',
- galleryEmptyTitle: 'No works yet',
- galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
- startCreating: 'Start Creating',
- yesterday: 'Yesterday',
- landscape: 'Landscape',
- portrait: 'Portrait',
- square: 'Square',
- examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
- examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
- examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
- examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
- }
}
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 9581206e..57bfefdc 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -335,6 +335,7 @@ export default {
profile: '个人资料',
users: '用户管理',
groups: '分组管理',
+ channels: '渠道管理',
subscriptions: '订阅管理',
accounts: '账号管理',
proxies: 'IP管理',
@@ -1647,7 +1648,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
- sora: 'Sora'
},
saving: '保存中...',
noGroups: '暂无分组',
@@ -1721,16 +1721,6 @@ export default {
title: '图片生成计费',
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
},
- soraPricing: {
- title: 'Sora 按次计费',
- description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
- image360: '图片 360px ($)',
- image540: '图片 540px ($)',
- video: '视频(标准)($)',
- videoHd: '视频(Pro-HD)($)',
- storageQuota: '存储配额',
- storageQuotaHint: '单位 GB,设置该分组用户的 Sora 存储配额上限,0 表示使用系统默认'
- },
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip:
@@ -1799,6 +1789,107 @@ export default {
}
},
+ // Channel Management
+ channels: {
+ title: '渠道管理',
+ description: '管理渠道和自定义模型定价',
+ searchChannels: '搜索渠道...',
+ createChannel: '创建渠道',
+ editChannel: '编辑渠道',
+ deleteChannel: '删除渠道',
+ statusActive: '启用',
+ statusDisabled: '停用',
+ allStatus: '全部状态',
+ groupsUnit: '个分组',
+ pricingUnit: '条定价',
+ noChannelsYet: '暂无渠道',
+ createFirstChannel: '创建第一个渠道来管理模型定价',
+ loadError: '加载渠道列表失败',
+ createSuccess: '渠道创建成功',
+ updateSuccess: '渠道更新成功',
+ deleteSuccess: '渠道删除成功',
+ createError: '创建渠道失败',
+ updateError: '更新渠道失败',
+ deleteError: '删除渠道失败',
+ nameRequired: '请输入渠道名称',
+ duplicateModels: '模型「{0}」在多个定价条目中重复',
+ modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
+ mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
+ deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
+ columns: {
+ name: '名称',
+ description: '描述',
+ status: '状态',
+ groups: '分组',
+ pricing: '定价',
+ createdAt: '创建时间',
+ actions: '操作'
+ },
+ billingMode: {
+ token: 'Token',
+ perRequest: '按次',
+ image: '图片(按次)'
+ },
+ form: {
+ name: '名称',
+ namePlaceholder: '输入渠道名称',
+ description: '描述',
+ descriptionPlaceholder: '可选描述',
+ status: '状态',
+ groups: '关联分组',
+ noGroupsAvailable: '暂无可用分组',
+ inOtherChannel: '已属于「{name}」',
+ modelPricing: '模型定价',
+ models: '模型列表',
+ modelsPlaceholder: '输入完整模型名后按回车添加',
+ modelInputHint: '按回车添加,支持粘贴批量导入',
+ billingMode: '计费模式',
+ defaultPrices: '默认价格(未命中区间时使用)',
+ inputPrice: '输入',
+ outputPrice: '输出',
+ cacheWritePrice: '缓存写入',
+ cacheReadPrice: '缓存读取',
+ imageTokenPrice: '图片输出',
+ imageOutputPrice: '图片输出价格',
+ pricePlaceholder: '默认',
+ intervals: '上下文区间定价(可选)',
+ addInterval: '添加区间',
+ requestTiers: '按次计费层级',
+ imageTiers: '图片计费层级(按次)',
+ addTier: '添加层级',
+ noTiersYet: '暂无层级,点击添加配置按次计费价格',
+ noPricingRules: '暂无定价规则,点击"添加"创建',
+ perRequestPrice: '单次价格',
+ perRequestPriceRequired: '按次/图片计费模式必须设置默认价格或至少一个计费层级',
+ tierLabel: '层级',
+ resolution: '分辨率',
+ modelMapping: '模型映射',
+ modelMappingHint: '将请求中的模型名映射为实际模型名。在账号级别映射之前执行。',
+ noMappingRules: '暂无映射规则,点击"添加"创建',
+ mappingSource: '源模型',
+ mappingTarget: '目标模型',
+ billingModelSource: '计费基准',
+ billingModelSourceChannelMapped: '以渠道映射后的模型计费',
+ billingModelSourceRequested: '以请求模型计费',
+ billingModelSourceUpstream: '以最终模型计费',
+ billingModelSourceHint: '控制使用哪个模型名称进行定价查找',
+ selectedCount: '已选 {count} 个',
+ searchGroups: '搜索分组...',
+ noGroupsMatch: '没有匹配的分组',
+ restrictModels: '限制模型',
+ restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。',
+ defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
+ defaultImagePrice: '默认图片价格(未命中层级时使用)',
+ platformConfig: '平台配置',
+ basicSettings: '基础设置',
+ addPlatform: '添加平台',
+ noPlatforms: '点击"添加平台"开始配置渠道',
+ mappingCount: '条映射',
+ pricingEntry: '定价配置',
+ noModels: '未添加模型'
+ }
+ },
+
// Subscriptions Management
subscriptions: {
title: '订阅管理',
@@ -2026,6 +2117,7 @@ export default {
privacyAntigravityFailed: '隐私设置失败',
setPrivacy: '设置隐私',
subscriptionAbnormal: '异常',
+ subscriptionExpires: '到期',
// 容量状态提示
capacity: {
windowCost: {
@@ -2104,7 +2196,6 @@ export default {
anthropic: 'Anthropic',
gemini: 'Gemini',
antigravity: 'Antigravity',
- sora: 'Sora'
},
types: {
oauth: 'OAuth',
@@ -2114,10 +2205,6 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: '通过 Base URL + API Key 连接',
- soraApiKey: 'API Key / 上游透传',
- soraApiKeyHint: '连接另一个 Sub2API 或兼容 API',
- soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
- soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
upstream: '对接上游',
upstreamDesc: '通过 Base URL + API Key 连接上游',
api_key: 'API Key',
@@ -2346,8 +2433,6 @@ export default {
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
- enableSora: '同时启用 Sora',
- enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
@@ -2361,9 +2446,6 @@ export default {
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
selectedModels: '已选择 {count} 个模型',
supportsAllModels: '(支持所有模型)',
- soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
- soraModelsLoading: '正在加载 Sora 模型...',
- soraModelsRetry: '加载失败,点击重试',
requestModel: '请求模型',
actualModel: '实际模型',
addMapping: '添加映射',
@@ -2510,8 +2592,6 @@ export default {
creating: '创建中...',
updating: '更新中...',
accountCreated: '账号创建成功',
- soraAccountCreated: 'Sora 账号已同时创建',
- soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
accountUpdated: '账号更新成功',
failedToCreate: '创建账号失败',
failedToUpdate: '更新账号失败',
@@ -2619,8 +2699,8 @@ export default {
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
sessionTokenAuth: '手动输入 ST',
- sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
- sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
+ sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
+ sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
sessionTokenRawLabel: '原始字符串',
sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...',
sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。',
@@ -2849,7 +2929,6 @@ export default {
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号',
- soraAccount: 'Sora 账号',
geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号',
inputMethod: '输入方式',
@@ -2881,11 +2960,6 @@ export default {
geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
- soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
- soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
- soraTestTarget: '检测目标:Sora 账号能力',
- soraTestMode: '模式:连通性 + 能力探测',
- soraTestingFlow: '执行 Sora 连通性与能力检测...',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
@@ -3416,6 +3490,11 @@ export default {
allBillingTypes: '全部计费类型',
billingTypeBalance: '钱包余额',
billingTypeSubscription: '订阅套餐',
+ billingMode: '计费模式',
+ billingModeToken: '按量',
+ billingModePerRequest: '按次',
+ billingModeImage: '按次(图片)',
+ allBillingModes: '全部计费模式',
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败',
@@ -4672,7 +4751,19 @@ export default {
errorMessagePlaceholder: '拦截时返回的自定义错误消息',
errorMessageHint: '留空则使用默认错误消息',
saved: 'Beta 策略设置保存成功',
- saveFailed: '保存 Beta 策略设置失败'
+ saveFailed: '保存 Beta 策略设置失败',
+ modelWhitelist: '模型白名单',
+ modelWhitelistHint: '留空则对所有模型生效。支持精确匹配和通配符前缀(如 claude-opus-*)',
+ modelPatternPlaceholder: '例如: claude-opus-* 或 claude-opus-4-6',
+ addModelPattern: '添加模型规则',
+ removePattern: '移除',
+ fallbackAction: '未匹配模型处理方式',
+ fallbackActionHint: '当请求模型不在白名单中时的处理方式',
+ fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息',
+ quickPresets: '快捷预设',
+ presetOpusOnly: '仅 Opus 允许 1M',
+ presetOpusOnlyDesc: 'Opus 透传,其他模型过滤',
+ commonPatterns: '常用模式'
},
saveSettings: '保存设置',
saving: '保存中...',
@@ -5104,99 +5195,4 @@ export default {
}
},
- // Sora 创作
- sora: {
- title: 'Sora 创作',
- description: '使用 Sora AI 生成视频与图片',
- notEnabled: '功能未开放',
- notEnabledDesc: '管理员尚未启用 Sora 创作功能,请联系管理员开通。',
- tabGenerate: '生成',
- tabLibrary: '作品库',
- noActiveGenerations: '暂无生成任务',
- startGenerating: '在下方输入提示词,开始创作',
- storage: '存储',
- promptPlaceholder: '描述你想创作的内容...',
- generate: '生成',
- generating: '生成中...',
- selectModel: '选择模型',
- statusPending: '等待中',
- statusGenerating: '生成中',
- statusCompleted: '已完成',
- statusFailed: '失败',
- statusCancelled: '已取消',
- cancel: '取消',
- delete: '删除',
- save: '保存到云端',
- saved: '已保存',
- retry: '重试',
- download: '下载',
- justNow: '刚刚',
- minutesAgo: '{n} 分钟前',
- hoursAgo: '{n} 小时前',
- noSavedWorks: '暂无保存的作品',
- saveWorksHint: '生成完成后,将作品保存到作品库',
- filterAll: '全部',
- filterVideo: '视频',
- filterImage: '图片',
- confirmDelete: '确定删除此作品?',
- loading: '加载中...',
- loadMore: '加载更多',
- noStorageWarningTitle: '未配置存储',
- noStorageWarningDesc: '生成的内容仅通过上游临时链接提供,约 15 分钟后过期。建议管理员配置 S3 存储。',
- mediaTypeVideo: '视频',
- mediaTypeImage: '图片',
- notificationCompleted: '生成完成',
- notificationFailed: '生成失败',
- notificationCompletedBody: '您的 {model} 任务已完成',
- notificationFailedBody: '您的 {model} 任务失败了',
- upstreamExpiresSoon: '即将过期',
- upstreamExpired: '链接已过期',
- upstreamCountdown: '剩余 {time}',
- previewTitle: '作品预览',
- closePreview: '关闭',
- beforeUnloadWarning: '您有未保存的生成内容,确定要离开吗?',
- downloadTitle: '下载生成内容',
- downloadExpirationWarning: '此链接约 15 分钟后过期,请尽快下载保存。',
- downloadNow: '立即下载',
- referenceImage: '参考图',
- removeImage: '移除',
- imageTooLarge: '图片大小不能超过 20MB',
- // Sora 暗色主题新增
- welcomeTitle: '将你的想象力变成视频',
- welcomeSubtitle: '输入一段描述,Sora 将为你创作逼真的视频或图片。尝试以下示例开始创作。',
- queueTasks: '个任务',
- queueWaiting: '队列中等待',
- waiting: '等待中',
- waited: '已等待',
- errorCategory: '内容策略限制',
- savedToCloud: '已保存到云端',
- downloadLocal: '本地下载',
- canDownload: '可下载',
- regenrate: '重新生成',
- regenerate: '重新生成',
- creatorPlaceholder: '描述你想要生成的视频或图片...',
- videoModels: '视频模型',
- imageModels: '图片模型',
- noStorageConfigured: '存储未配置',
- selectCredential: '选择凭证',
- apiKeys: 'API 密钥',
- subscriptions: '订阅',
- subscription: '订阅',
- noCredentialHint: '请先创建 API Key 或联系管理员分配订阅',
- uploadReference: '上传参考图片',
- generatingCount: '正在生成 {current}/{max}',
- noStorageToastMessage: '管理员未开通云存储,生成完成后请使用"本地下载"保存文件,否则将会丢失。',
- galleryCount: '共 {count} 个作品',
- galleryEmptyTitle: '还没有任何作品',
- galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
- startCreating: '开始创作',
- yesterday: '昨天',
- landscape: '横屏',
- portrait: '竖屏',
- square: '方形',
- examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
- examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
- examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
- examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
- }
}
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 0ffef1a3..6faf6f59 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -201,18 +201,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'purchase.description'
}
},
- {
- path: '/sora',
- name: 'Sora',
- component: () => import('@/views/user/SoraView.vue'),
- meta: {
- requiresAuth: true,
- requiresAdmin: false,
- title: 'Sora',
- titleKey: 'sora.title',
- descriptionKey: 'sora.description'
- }
- },
{
path: '/custom/:id',
name: 'CustomPage',
@@ -278,6 +266,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.groups.description'
}
},
+ {
+ path: '/admin/channels',
+ name: 'AdminChannels',
+ component: () => import('@/views/admin/ChannelsView.vue'),
+ meta: {
+ requiresAuth: true,
+ requiresAdmin: true,
+ title: 'Channel Management',
+ titleKey: 'admin.channels.title',
+ descriptionKey: 'admin.channels.description'
+ }
+ },
{
path: '/admin/subscriptions',
name: 'AdminSubscriptions',
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index c080c2af..24057136 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -332,7 +332,6 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
- sora_client_enabled: false,
backend_mode_enabled: false,
version: siteVersion.value
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index f9425ad0..580126c8 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -45,9 +45,6 @@ export interface AdminUser extends User {
group_rates?: Record
// 当前并发数(仅管理员列表接口返回)
current_concurrency?: number
- // Sora 存储配额(字节)
- sora_storage_quota_bytes: number
- sora_storage_used_bytes: number
}
export interface LoginRequest {
@@ -112,7 +109,6 @@ export interface PublicSettings {
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean
- sora_client_enabled: boolean
backend_mode_enabled: boolean
version: string
}
@@ -366,7 +362,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
-export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
+export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type SubscriptionType = 'standard' | 'subscription'
@@ -386,19 +382,14 @@ export interface Group {
image_price_1k: number | null
image_price_2k: number | null
image_price_4k: number | null
- // Sora 按次计费配置
- sora_image_price_360: number | null
- sora_image_price_540: number | null
- sora_video_price_per_request: number | null
- sora_video_price_per_request_hd: number | null
- // Sora 存储配额(字节)
- sora_storage_quota_bytes: number
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
fallback_group_id_on_invalid_request: number | null
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
allow_messages_dispatch?: boolean
+ require_oauth_only: boolean
+ require_privacy_set: boolean
created_at: string
updated_at: string
}
@@ -499,17 +490,14 @@ export interface CreateGroupRequest {
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
- sora_image_price_360?: number | null
- sora_image_price_540?: number | null
- sora_video_price_per_request?: number | null
- sora_video_price_per_request_hd?: number | null
- sora_storage_quota_bytes?: number
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
+ require_oauth_only?: boolean
+ require_privacy_set?: boolean
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
}
@@ -528,23 +516,20 @@ export interface UpdateGroupRequest {
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
- sora_image_price_360?: number | null
- sora_image_price_540?: number | null
- sora_video_price_per_request?: number | null
- sora_video_price_per_request_hd?: number | null
- sora_storage_quota_bytes?: number
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
+ require_oauth_only?: boolean
+ require_privacy_set?: boolean
copy_accounts_from_group_ids?: number[]
}
// ==================== Account & Proxy Types ====================
-export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
+export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
@@ -1030,6 +1015,9 @@ export interface UsageLog {
// Cache TTL Override
cache_ttl_overridden: boolean
+ // 计费模式
+ billing_mode?: string | null
+
created_at: string
user?: User
@@ -1045,6 +1033,7 @@ export interface UsageLogAccountSummary {
export interface AdminUsageLog extends UsageLog {
upstream_model?: string | null
+ model_mapping_chain?: string | null
// 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null
diff --git a/frontend/src/utils/__tests__/soraTokenParser.spec.ts b/frontend/src/utils/__tests__/soraTokenParser.spec.ts
deleted file mode 100644
index 816e5319..00000000
--- a/frontend/src/utils/__tests__/soraTokenParser.spec.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { parseSoraRawTokens } from '@/utils/soraTokenParser'
-
-describe('parseSoraRawTokens', () => {
- it('parses sessionToken and accessToken from JSON payload', () => {
- const payload = JSON.stringify({
- user: { id: 'u1' },
- accessToken: 'at-json-1',
- sessionToken: 'st-json-1'
- })
-
- const result = parseSoraRawTokens(payload)
-
- expect(result.sessionTokens).toEqual(['st-json-1'])
- expect(result.accessTokens).toEqual(['at-json-1'])
- })
-
- it('supports plain session tokens (one per line)', () => {
- const result = parseSoraRawTokens('st-1\nst-2')
-
- expect(result.sessionTokens).toEqual(['st-1', 'st-2'])
- expect(result.accessTokens).toEqual([])
- })
-
- it('supports non-standard object snippets via regex', () => {
- const raw = "sessionToken: 'st-snippet', access_token: \"at-snippet\""
- const result = parseSoraRawTokens(raw)
-
- expect(result.sessionTokens).toEqual(['st-snippet'])
- expect(result.accessTokens).toEqual(['at-snippet'])
- })
-
- it('keeps unique tokens and extracts JWT-like plain line as AT too', () => {
- const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature'
- const raw = `st-dup\nst-dup\n${jwt}\n${JSON.stringify({ sessionToken: 'st-json', accessToken: jwt })}`
- const result = parseSoraRawTokens(raw)
-
- expect(result.sessionTokens).toEqual(['st-json', 'st-dup'])
- expect(result.accessTokens).toEqual([jwt])
- })
-
- it('parses session token from Set-Cookie line and strips cookie attributes', () => {
- const raw =
- '__Secure-next-auth.session-token.0=st-cookie-part-0; Domain=.chatgpt.com; Path=/; Expires=Thu, 28 May 2026 11:43:36 GMT; HttpOnly; Secure; SameSite=Lax'
- const result = parseSoraRawTokens(raw)
-
- expect(result.sessionTokens).toEqual(['st-cookie-part-0'])
- expect(result.accessTokens).toEqual([])
- })
-
- it('merges chunked session-token cookies by numeric suffix order', () => {
- const raw = [
- 'Set-Cookie: __Secure-next-auth.session-token.1=part-1; Path=/; HttpOnly',
- 'Set-Cookie: __Secure-next-auth.session-token.0=part-0; Path=/; HttpOnly'
- ].join('\n')
- const result = parseSoraRawTokens(raw)
-
- expect(result.sessionTokens).toEqual(['part-0part-1'])
- expect(result.accessTokens).toEqual([])
- })
-
- it('prefers latest duplicate chunk values when multiple cookie groups exist', () => {
- const raw = [
- 'Set-Cookie: __Secure-next-auth.session-token.0=old-0; Path=/; HttpOnly',
- 'Set-Cookie: __Secure-next-auth.session-token.1=old-1; Path=/; HttpOnly',
- 'Set-Cookie: __Secure-next-auth.session-token.0=new-0; Path=/; HttpOnly',
- 'Set-Cookie: __Secure-next-auth.session-token.1=new-1; Path=/; HttpOnly'
- ].join('\n')
- const result = parseSoraRawTokens(raw)
-
- expect(result.sessionTokens).toEqual(['new-0new-1'])
- expect(result.accessTokens).toEqual([])
- })
-
- it('uses latest complete chunk group and ignores incomplete latest group', () => {
- const raw = [
- 'set-cookie',
- '__Secure-next-auth.session-token.0=ok-0; Domain=.chatgpt.com; Path=/',
- 'set-cookie',
- '__Secure-next-auth.session-token.1=ok-1; Domain=.chatgpt.com; Path=/',
- 'set-cookie',
- '__Secure-next-auth.session-token.0=partial-0; Domain=.chatgpt.com; Path=/'
- ].join('\n')
-
- const result = parseSoraRawTokens(raw)
-
- expect(result.sessionTokens).toEqual(['ok-0ok-1'])
- expect(result.accessTokens).toEqual([])
- })
-})
diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts
new file mode 100644
index 00000000..ac56b28f
--- /dev/null
+++ b/frontend/src/utils/formatters.ts
@@ -0,0 +1,18 @@
+/**
+ * 格式化缓存 token 数量(1K/1M 缩写)
+ */
+export function formatCacheTokens(tokens: number): string {
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
+ return tokens.toLocaleString()
+}
+
+/**
+ * 自适应精度格式化倍率(确保小数值如 0.001 不被截断)
+ */
+export function formatMultiplier(val: number): string {
+ if (val >= 0.01) return val.toFixed(2)
+ if (val >= 0.001) return val.toFixed(3)
+ if (val >= 0.0001) return val.toFixed(4)
+ return val.toPrecision(2)
+}
diff --git a/frontend/src/utils/soraTokenParser.ts b/frontend/src/utils/soraTokenParser.ts
deleted file mode 100644
index 87e36649..00000000
--- a/frontend/src/utils/soraTokenParser.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-export interface ParsedSoraTokens {
- sessionTokens: string[]
- accessTokens: string[]
-}
-
-const sessionKeyNames = new Set(['sessiontoken', 'session_token', 'st'])
-const accessKeyNames = new Set(['accesstoken', 'access_token', 'at'])
-
-const sessionRegexes = [
- /\bsessionToken\b\s*:\s*["']([^"']+)["']/gi,
- /\bsession_token\b\s*:\s*["']([^"']+)["']/gi
-]
-
-const accessRegexes = [
- /\baccessToken\b\s*:\s*["']([^"']+)["']/gi,
- /\baccess_token\b\s*:\s*["']([^"']+)["']/gi
-]
-
-const sessionCookieRegex =
- /(?:^|[\n\r;])\s*(?:(?:set-cookie|cookie)\s*:\s*)?__Secure-(?:next-auth|authjs)\.session-token(?:\.(\d+))?=([^;\r\n]+)/gi
-
-interface SessionCookieChunk {
- index: number
- value: string
-}
-
-const ignoredPlainLines = new Set([
- 'set-cookie',
- 'cookie',
- 'strict-transport-security',
- 'vary',
- 'x-content-type-options',
- 'x-openai-proxy-wasm'
-])
-
-function sanitizeToken(raw: string): string {
- return raw.trim().replace(/^["'`]+|["'`,;]+$/g, '')
-}
-
-function addUnique(list: string[], seen: Set, rawValue: string): void {
- const token = sanitizeToken(rawValue)
- if (!token || seen.has(token)) {
- return
- }
- seen.add(token)
- list.push(token)
-}
-
-function isLikelyJWT(token: string): boolean {
- if (!token.startsWith('eyJ')) {
- return false
- }
- return token.split('.').length === 3
-}
-
-function collectFromObject(
- value: unknown,
- sessionTokens: string[],
- sessionSeen: Set,
- accessTokens: string[],
- accessSeen: Set
-): void {
- if (Array.isArray(value)) {
- for (const item of value) {
- collectFromObject(item, sessionTokens, sessionSeen, accessTokens, accessSeen)
- }
- return
- }
- if (!value || typeof value !== 'object') {
- return
- }
-
- for (const [key, fieldValue] of Object.entries(value as Record)) {
- if (typeof fieldValue === 'string') {
- const normalizedKey = key.toLowerCase()
- if (sessionKeyNames.has(normalizedKey)) {
- addUnique(sessionTokens, sessionSeen, fieldValue)
- }
- if (accessKeyNames.has(normalizedKey)) {
- addUnique(accessTokens, accessSeen, fieldValue)
- }
- continue
- }
- collectFromObject(fieldValue, sessionTokens, sessionSeen, accessTokens, accessSeen)
- }
-}
-
-function collectFromJSONString(
- raw: string,
- sessionTokens: string[],
- sessionSeen: Set,
- accessTokens: string[],
- accessSeen: Set
-): void {
- const trimmed = raw.trim()
- if (!trimmed) {
- return
- }
-
- const candidates = [trimmed]
- const firstBrace = trimmed.indexOf('{')
- const lastBrace = trimmed.lastIndexOf('}')
- if (firstBrace >= 0 && lastBrace > firstBrace) {
- candidates.push(trimmed.slice(firstBrace, lastBrace + 1))
- }
-
- for (const candidate of candidates) {
- try {
- const parsed = JSON.parse(candidate)
- collectFromObject(parsed, sessionTokens, sessionSeen, accessTokens, accessSeen)
- return
- } catch {
- // ignore and keep trying other candidates
- }
- }
-}
-
-function collectByRegex(
- raw: string,
- regexes: RegExp[],
- tokens: string[],
- seen: Set
-): void {
- for (const regex of regexes) {
- regex.lastIndex = 0
- let match: RegExpExecArray | null
- match = regex.exec(raw)
- while (match) {
- if (match[1]) {
- addUnique(tokens, seen, match[1])
- }
- match = regex.exec(raw)
- }
- }
-}
-
-function collectFromSessionCookies(
- raw: string,
- sessionTokens: string[],
- sessionSeen: Set
-): void {
- const chunkMatches: SessionCookieChunk[] = []
- const singleValues: string[] = []
-
- sessionCookieRegex.lastIndex = 0
- let match: RegExpExecArray | null
- match = sessionCookieRegex.exec(raw)
- while (match) {
- const chunkIndex = match[1]
- const rawValue = match[2]
- const value = sanitizeToken(rawValue || '')
- if (value) {
- if (chunkIndex !== undefined && chunkIndex !== '') {
- const idx = Number.parseInt(chunkIndex, 10)
- if (Number.isInteger(idx) && idx >= 0) {
- chunkMatches.push({ index: idx, value })
- }
- } else {
- singleValues.push(value)
- }
- }
- match = sessionCookieRegex.exec(raw)
- }
-
- const mergedChunkToken = mergeLatestChunkedSessionToken(chunkMatches)
- if (mergedChunkToken) {
- addUnique(sessionTokens, sessionSeen, mergedChunkToken)
- }
-
- for (const value of singleValues) {
- addUnique(sessionTokens, sessionSeen, value)
- }
-}
-
-function mergeChunkSegment(
- chunks: SessionCookieChunk[],
- requiredMaxIndex: number,
- requireComplete: boolean
-): string {
- if (chunks.length === 0) {
- return ''
- }
-
- const byIndex = new Map()
- for (const chunk of chunks) {
- byIndex.set(chunk.index, chunk.value)
- }
-
- if (!byIndex.has(0)) {
- return ''
- }
- if (requireComplete) {
- for (let i = 0; i <= requiredMaxIndex; i++) {
- if (!byIndex.has(i)) {
- return ''
- }
- }
- }
-
- const orderedIndexes = Array.from(byIndex.keys()).sort((a, b) => a - b)
- return orderedIndexes.map((idx) => byIndex.get(idx) || '').join('')
-}
-
-function mergeLatestChunkedSessionToken(chunks: SessionCookieChunk[]): string {
- if (chunks.length === 0) {
- return ''
- }
-
- const requiredMaxIndex = chunks.reduce((max, chunk) => Math.max(max, chunk.index), 0)
-
- const groupStarts: number[] = []
- chunks.forEach((chunk, idx) => {
- if (chunk.index === 0) {
- groupStarts.push(idx)
- }
- })
-
- if (groupStarts.length === 0) {
- return mergeChunkSegment(chunks, requiredMaxIndex, false)
- }
-
- for (let i = groupStarts.length - 1; i >= 0; i--) {
- const start = groupStarts[i]
- const end = i + 1 < groupStarts.length ? groupStarts[i + 1] : chunks.length
- const merged = mergeChunkSegment(chunks.slice(start, end), requiredMaxIndex, true)
- if (merged) {
- return merged
- }
- }
-
- return mergeChunkSegment(chunks, requiredMaxIndex, false)
-}
-
-function collectPlainLines(
- raw: string,
- sessionTokens: string[],
- sessionSeen: Set,
- accessTokens: string[],
- accessSeen: Set
-): void {
- const lines = raw
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line.length > 0)
-
- for (const line of lines) {
- const normalized = line.toLowerCase()
- if (ignoredPlainLines.has(normalized)) {
- continue
- }
- if (/^__secure-(next-auth|authjs)\.session-token(\.\d+)?=/i.test(line)) {
- continue
- }
- if (line.includes(';')) {
- continue
- }
-
- if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(line)) {
- const parts = line.split('=', 2)
- const key = parts[0]?.trim().toLowerCase()
- const value = parts[1]?.trim() || ''
- if (key && sessionKeyNames.has(key)) {
- addUnique(sessionTokens, sessionSeen, value)
- continue
- }
- if (key && accessKeyNames.has(key)) {
- addUnique(accessTokens, accessSeen, value)
- continue
- }
- }
-
- if (line.includes('{') || line.includes('}') || line.includes(':') || /\s/.test(line)) {
- continue
- }
-
- if (isLikelyJWT(line)) {
- addUnique(accessTokens, accessSeen, line)
- continue
- }
- addUnique(sessionTokens, sessionSeen, line)
- }
-}
-
-export function parseSoraRawTokens(rawInput: string): ParsedSoraTokens {
- const raw = rawInput.trim()
- if (!raw) {
- return {
- sessionTokens: [],
- accessTokens: []
- }
- }
-
- const sessionTokens: string[] = []
- const accessTokens: string[] = []
- const sessionSeen = new Set()
- const accessSeen = new Set()
-
- collectFromJSONString(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
- collectByRegex(raw, sessionRegexes, sessionTokens, sessionSeen)
- collectByRegex(raw, accessRegexes, accessTokens, accessSeen)
- collectFromSessionCookies(raw, sessionTokens, sessionSeen)
- collectPlainLines(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
-
- return {
- sessionTokens,
- accessTokens
- }
-}
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index 35e0fcec..0cc8341c 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -182,7 +182,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.createChannel', 'Create Channel') }}
+
+
+
+
+
+
+
+
+ {{ value }}
+
+
+
+ {{ value || '-' }}
+
+
+
+
+
+
+
+
+ {{ (row.group_ids || []).length }}
+ {{ t('admin.channels.groupsUnit', 'groups') }}
+
+
+
+
+
+ {{ (row.model_pricing || []).length }}
+ {{ t('admin.channels.pricingUnit', 'pricing rules') }}
+
+
+
+
+
+ {{ formatDate(value) }}
+
+
+
+
+
+
+
+ {{ t('common.edit', 'Edit') }}
+
+
+
+ {{ t('common.delete', 'Delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.channels.form.basicSettings', '基础设置') }}
+
+
+
+
+ {{ t('admin.groups.platforms.' + section.platform, section.platform) }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel', 'Cancel') }}
+
+
+ {{ submitting
+ ? t('common.submitting', 'Submitting...')
+ : editingChannel
+ ? t('common.update', 'Update')
+ : t('common.create', 'Create')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue
index 20dd90d2..430b7cee 100644
--- a/frontend/src/views/admin/DashboardView.vue
+++ b/frontend/src/views/admin/DashboardView.vue
@@ -219,6 +219,9 @@
@change="onDateRangeChange"
/>
+
+ {{ t('common.refresh') }}
+
{{ t('admin.dashboard.granularity') }}:
-
-
-
-
-
- {{ t('admin.settings.soraS3.title') }}
-
-
- {{ t('admin.settings.soraS3.description') }}
-
-
-
-
- {{ t('admin.settings.soraS3.newProfile') }}
-
-
- {{ loadingSoraProfiles ? t('common.loading') : t('admin.settings.soraS3.reloadProfiles') }}
-
-
-
-
-
-
-
-
- {{ t('admin.settings.soraS3.columns.profile') }}
- {{ t('admin.settings.soraS3.columns.active') }}
- {{ t('admin.settings.soraS3.columns.endpoint') }}
- {{ t('admin.settings.soraS3.columns.bucket') }}
- {{ t('admin.settings.soraS3.columns.quota') }}
- {{ t('admin.settings.soraS3.columns.updatedAt') }}
- {{ t('admin.settings.soraS3.columns.actions') }}
-
-
-
-
-
- {{ profile.profile_id }}
- {{ profile.name }}
-
-
-
- {{ profile.is_active ? t('common.enabled') : t('common.disabled') }}
-
-
-
- {{ profile.endpoint || '-' }}
- {{ profile.region || '-' }}
-
- {{ profile.bucket || '-' }}
- {{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}
- {{ formatDate(profile.updated_at) }}
-
-
-
- {{ t('common.edit') }}
-
-
- {{ t('admin.settings.soraS3.activateProfile') }}
-
-
- {{ t('common.delete') }}
-
-
-
-
-
-
- {{ t('admin.settings.soraS3.empty') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
-
-
- ✕
-
-
-
-
-
-
-
- {{ t('common.cancel') }}
-
-
- {{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
-
-
- {{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue
index a7c1a10d..5bfe62c3 100644
--- a/frontend/src/views/admin/GroupsView.vue
+++ b/frontend/src/views/admin/GroupsView.vue
@@ -522,80 +522,7 @@
-
-
-
- {{ t('admin.groups.soraPricing.title') }}
-
-
- {{ t('admin.groups.soraPricing.description') }}
-
-
-
-
-
{{ t('admin.groups.soraPricing.storageQuota') }}
-
-
- GB
-
-
- {{ t('admin.groups.soraPricing.storageQuotaHint') }}
-
-
-
+
@@ -792,6 +719,61 @@
+
+
+
账号过滤控制
+
+
+
+
+
仅允许 OAuth 账号
+
+ {{ createForm.require_oauth_only ? '已启用 — 排除 API Key 类型账号' : '未启用' }}
+
+
+
+
+
+
+
+
+
+
+
仅允许隐私保护已设置的账号
+
+ {{ createForm.require_privacy_set ? '已启用 — Privacy 未设置的账号将被排除' : '未启用' }}
+
+
+
+
+
+
+
+
-
-
-
- {{ t('admin.groups.soraPricing.title') }}
-
-
- {{ t('admin.groups.soraPricing.description') }}
-
-
-
-
-
{{ t('admin.groups.soraPricing.storageQuota') }}
-
-
- GB
-
-
- {{ t('admin.groups.soraPricing.storageQuotaHint') }}
-
-
-
+
@@ -1527,6 +1436,61 @@
+
+
+
账号过滤控制
+
+
+
+
+
仅允许 OAuth 账号
+
+ {{ editForm.require_oauth_only ? '已启用 — 排除 API Key 类型账号' : '未启用' }}
+
+
+
+
+
+
+
+
+
+
+
仅允许隐私保护已设置的账号
+
+ {{ editForm.require_privacy_set ? '已启用 — Privacy 未设置的账号将被排除' : '未启用' }}
+
+
+
+
+
+
+
+
[
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
- { value: 'antigravity', label: 'Antigravity' },
- { value: 'sora', label: 'Sora' }
+ { value: 'antigravity', label: 'Antigravity' }
])
const platformFilterOptions = computed(() => [
@@ -1900,8 +1863,7 @@ const platformFilterOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
- { value: 'antigravity', label: 'Antigravity' },
- { value: 'sora', label: 'Sora' }
+ { value: 'antigravity', label: 'Antigravity' }
])
const editStatusOptions = computed(() => [
@@ -2050,12 +2012,6 @@ const createForm = reactive({
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
- // Sora 按次计费配置
- sora_image_price_360: null as number | null,
- sora_image_price_540: null as number | null,
- sora_video_price_per_request: null as number | null,
- sora_video_price_per_request_hd: null as number | null,
- sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
@@ -2063,6 +2019,9 @@ const createForm = reactive({
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false,
default_mapped_model: 'gpt-5.4',
+ // 账号过滤控制(OpenAI/Antigravity 平台)
+ require_oauth_only: false,
+ require_privacy_set: false,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
@@ -2294,12 +2253,6 @@ const editForm = reactive({
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
- // Sora 按次计费配置
- sora_image_price_360: null as number | null,
- sora_image_price_540: null as number | null,
- sora_video_price_per_request: null as number | null,
- sora_video_price_per_request_hd: null as number | null,
- sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
@@ -2307,6 +2260,9 @@ const editForm = reactive({
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false,
default_mapped_model: '',
+ // 账号过滤控制(OpenAI/Antigravity 平台)
+ require_oauth_only: false,
+ require_privacy_set: false,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
@@ -2443,15 +2399,12 @@ const closeCreateModal = () => {
createForm.image_price_1k = null
createForm.image_price_2k = null
createForm.image_price_4k = null
- createForm.sora_image_price_360 = null
- createForm.sora_image_price_540 = null
- createForm.sora_video_price_per_request = null
- createForm.sora_video_price_per_request_hd = null
- createForm.sora_storage_quota_gb = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
createForm.allow_messages_dispatch = false
+ createForm.require_oauth_only = false
+ createForm.require_privacy_set = false
createForm.default_mapped_model = 'gpt-5.4'
createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image']
createForm.mcp_xml_inject = true
@@ -2484,13 +2437,11 @@ const handleCreateGroup = async () => {
submitting.value = true
try {
// 构建请求数据,包含模型路由配置
- const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm
const requestData = {
- ...createRest,
+ ...createForm,
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)
}
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
@@ -2530,15 +2481,12 @@ const handleEdit = async (group: AdminGroup) => {
editForm.image_price_1k = group.image_price_1k
editForm.image_price_2k = group.image_price_2k
editForm.image_price_4k = group.image_price_4k
- editForm.sora_image_price_360 = group.sora_image_price_360
- editForm.sora_image_price_540 = group.sora_image_price_540
- editForm.sora_video_price_per_request = group.sora_video_price_per_request
- editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
- editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
editForm.allow_messages_dispatch = group.allow_messages_dispatch || false
+ editForm.require_oauth_only = group.require_oauth_only ?? false
+ editForm.require_privacy_set = group.require_privacy_set ?? false
editForm.default_mapped_model = group.default_mapped_model || ''
editForm.model_routing_enabled = group.model_routing_enabled || false
editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image']
@@ -2570,13 +2518,11 @@ const handleUpdateGroup = async () => {
submitting.value = true
try {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
- const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm
const payload = {
- ...editRest,
+ ...editForm,
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:
editForm.fallback_group_id_on_invalid_request === null
@@ -2647,6 +2593,10 @@ watch(
createForm.allow_messages_dispatch = false
createForm.default_mapped_model = ''
}
+ if (!['openai', 'antigravity', 'anthropic', 'gemini'].includes(newVal)) {
+ createForm.require_oauth_only = false
+ createForm.require_privacy_set = false
+ }
}
)
diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue
index 032a0a2f..b0292b2d 100644
--- a/frontend/src/views/admin/ProxiesView.vue
+++ b/frontend/src/views/admin/ProxiesView.vue
@@ -1563,8 +1563,6 @@ const qualityTargetLabel = (target: string) => {
return 'Anthropic'
case 'gemini':
return 'Gemini'
- case 'sora':
- return 'Sora'
default:
return target
}
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 198d484b..9ae40aeb 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -630,6 +630,108 @@
{{ t('admin.settings.betaPolicy.errorMessageHint') }}
+
+
+
+
+ {{ t('admin.settings.betaPolicy.quickPresets') }}
+
+
+
+ {{ preset.label }}
+
+
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.modelWhitelist') }}
+
+
+ {{ t('admin.settings.betaPolicy.modelWhitelistHint') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.addModelPattern') }}
+
+
+
+ {{ t('admin.settings.betaPolicy.commonPatterns') }}:
+
+ {{ pattern }}
+
+
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.fallbackAction') }}
+
+
+
+ {{ t('admin.settings.betaPolicy.fallbackActionHint') }}
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.errorMessageHint') }}
+
+
+
@@ -1577,31 +1679,6 @@
-
-
-
-
- {{ t('admin.settings.soraClient.title') }}
-
-
- {{ t('admin.settings.soraClient.description') }}
-
-
-
-
-
-
{{
- t('admin.settings.soraClient.enabled')
- }}
-
- {{ t('admin.settings.soraClient.enabledHint') }}
-
-
-
-
-
-
-
@@ -1956,13 +2033,8 @@
-
-
-
-
-
-
+
('general')
const settingsTabs = [
{ key: 'general' as SettingsTab, icon: 'home' as const },
@@ -2029,7 +2100,6 @@ const settingsTabs = [
{ key: 'gateway' as SettingsTab, icon: 'server' as const },
{ key: 'email' as SettingsTab, icon: 'mail' as const },
{ key: 'backup' as SettingsTab, icon: 'database' as const },
- { key: 'data' as SettingsTab, icon: 'cube' as const },
]
const { copyToClipboard } = useClipboard()
@@ -2090,6 +2160,9 @@ const betaPolicyForm = reactive({
action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string
+ model_whitelist?: string[]
+ fallback_action?: 'pass' | 'filter' | 'block'
+ fallback_error_message?: string
}>
})
@@ -2132,7 +2205,6 @@ const form = reactive({
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
- sora_client_enabled: false,
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
frontend_url: '',
@@ -2456,7 +2528,6 @@ async function saveSettings() {
hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url,
- sora_client_enabled: form.sora_client_enabled,
custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url,
@@ -2750,10 +2821,48 @@ const betaDisplayNames: Record = {
'context-1m-2025-08-07': 'Context 1M'
}
+// 快捷预设:按 beta_token 定义预设方案
+const betaPresets: Record> = {
+ 'context-1m-2025-08-07': [
+ {
+ label: t('admin.settings.betaPolicy.presetOpusOnly'),
+ description: t('admin.settings.betaPolicy.presetOpusOnlyDesc'),
+ action: 'pass',
+ model_whitelist: ['claude-opus-4-6'],
+ fallback_action: 'filter',
+ },
+ ],
+}
+
+// 常用模型模式(具体 ID + 通配符示例)
+const commonModelPatterns = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-*', 'claude-sonnet-*']
+
function getBetaDisplayName(token: string): string {
return betaDisplayNames[token] || token
}
+function applyBetaPreset(
+ rule: (typeof betaPolicyForm.rules)[number],
+ preset: { action: 'pass' | 'filter' | 'block'; model_whitelist: string[]; fallback_action: 'pass' | 'filter' | 'block' }
+) {
+ rule.action = preset.action
+ rule.model_whitelist = [...preset.model_whitelist]
+ rule.fallback_action = preset.fallback_action
+}
+
+function addQuickPattern(rule: (typeof betaPolicyForm.rules)[number], pattern: string) {
+ if (!rule.model_whitelist) rule.model_whitelist = []
+ if (!rule.model_whitelist.includes(pattern)) {
+ rule.model_whitelist.push(pattern)
+ }
+}
+
async function loadBetaPolicySettings() {
betaPolicyLoading.value = true
try {
@@ -2769,8 +2878,22 @@ async function loadBetaPolicySettings() {
async function saveBetaPolicySettings() {
betaPolicySaving.value = true
try {
+ // Clean up empty patterns before saving
+ const cleanedRules = betaPolicyForm.rules.map(rule => {
+ const whitelist = rule.model_whitelist?.filter(p => p.trim() !== '')
+ const hasWhitelist = whitelist && whitelist.length > 0
+ return {
+ beta_token: rule.beta_token,
+ action: rule.action,
+ scope: rule.scope,
+ error_message: rule.error_message,
+ model_whitelist: hasWhitelist ? whitelist : undefined,
+ fallback_action: hasWhitelist ? (rule.fallback_action || 'pass') : undefined,
+ fallback_error_message: hasWhitelist && rule.fallback_action === 'block' ? rule.fallback_error_message : undefined,
+ }
+ })
const updated = await adminAPI.settings.updateBetaPolicySettings({
- rules: betaPolicyForm.rules
+ rules: cleanedRules
})
betaPolicyForm.rules = updated.rules
appStore.showSuccess(t('admin.settings.betaPolicy.saved'))
diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue
index bb9b5d9a..0363236c 100644
--- a/frontend/src/views/admin/SubscriptionsView.vue
+++ b/frontend/src/views/admin/SubscriptionsView.vue
@@ -965,8 +965,7 @@ const platformFilterOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
- { value: 'antigravity', label: 'Antigravity' },
- { value: 'sora', label: 'Sora' }
+ { value: 'antigravity', label: 'Antigravity' }
])
// Group options for assign (only subscription type groups)
diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue
index 4911f7b3..50de8feb 100644
--- a/frontend/src/views/admin/UsageView.vue
+++ b/frontend/src/views/admin/UsageView.vue
@@ -34,6 +34,7 @@
:show-metric-toggle="true"
:start-date="startDate"
:end-date="endDate"
+ :filters="breakdownFilters"
/>
@@ -57,6 +59,7 @@
:title="t('usage.endpointDistribution')"
:start-date="startDate"
:end-date="endDate"
+ :filters="breakdownFilters"
/>
@@ -169,6 +172,17 @@ const cleanupDialogVisible = ref(false)
const showBalanceHistoryModal = ref(false)
const balanceHistoryUser = ref
(null)
+const breakdownFilters = computed(() => {
+ const f: Record = {}
+ if (filters.value.user_id) f.user_id = filters.value.user_id
+ if (filters.value.api_key_id) f.api_key_id = filters.value.api_key_id
+ if (filters.value.account_id) f.account_id = filters.value.account_id
+ if (filters.value.group_id) f.group_id = filters.value.group_id
+ if (filters.value.request_type != null) f.request_type = filters.value.request_type
+ if (filters.value.billing_type != null) f.billing_type = filters.value.billing_type
+ return f
+})
+
const handleUserClick = async (userId: number) => {
try {
const user = await adminAPI.users.getById(userId)
@@ -392,7 +406,7 @@ const resetFilters = () => {
const range = getLast24HoursRangeDates()
startDate.value = range.start
endDate.value = range.end
- filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }
+ filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null, billing_mode: undefined }
granularity.value = getGranularityForRange(startDate.value, endDate.value)
applyFilters()
}
@@ -440,7 +454,7 @@ const exportToExcel = async () => {
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
- log.rate_multiplier?.toFixed(2) || '1.00', (log.account_rate_multiplier ?? 1).toFixed(2),
+ log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4),
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
log.request_id || '', log.user_agent || '', log.ip_address || ''
@@ -477,6 +491,7 @@ const allColumns = computed(() => [
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false },
{ key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
+ { key: 'billing_mode', label: t('admin.usage.billingMode'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue
index 55bbc66b..3b97c7a0 100644
--- a/frontend/src/views/user/DashboardView.vue
+++ b/frontend/src/views/user/DashboardView.vue
@@ -4,7 +4,7 @@
-
+
@@ -31,6 +31,7 @@ const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endD
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } }
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }
const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch (error) { console.error('Failed to load recent usage:', error) } finally { loadingUsage.value = false } }
+const refreshAll = () => { loadStats(); loadCharts(); loadRecent() }
-onMounted(() => { loadStats(); loadCharts(); loadRecent() })
+onMounted(() => { refreshAll() })
diff --git a/frontend/src/views/user/SoraView.vue b/frontend/src/views/user/SoraView.vue
deleted file mode 100644
index 0ebea5b0..00000000
--- a/frontend/src/views/user/SoraView.vue
+++ /dev/null
@@ -1,369 +0,0 @@
-
-
-
-
-
-
-
-
-
-
{{ t('sora.notEnabled') }}
-
{{ t('sora.notEnabledDesc') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue
index 3b8ef2e0..401ec3f9 100644
--- a/frontend/src/views/user/UsageView.vue
+++ b/frontend/src/views/user/UsageView.vue
@@ -181,9 +181,16 @@
+
+
+ {{ getBillingModeLabel(row.billing_mode) }}
+
+
+
-
-
+
+
{{ t('usage.rate') }}
{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x {{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x
@@ -497,6 +504,7 @@ import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/t
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
+import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
@@ -525,6 +533,7 @@ const columns = computed(() => [
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
+ { key: 'billing_mode', label: t('admin.usage.billingMode'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
@@ -615,6 +624,18 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
+const getBillingModeLabel = (mode: string | null | undefined): string => {
+ if (mode === 'per_request') return t('admin.usage.billingModePerRequest')
+ if (mode === 'image') return t('admin.usage.billingModeImage')
+ return t('admin.usage.billingModeToken')
+}
+
+const getBillingModeBadgeClass = (mode: string | null | undefined): string => {
+ if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200'
+ if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
+}
+
const getRequestTypeExportText = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log)
if (requestType === 'ws_v2') return 'WS'
@@ -639,15 +660,6 @@ const formatTokens = (value: number): string => {
return value.toLocaleString()
}
-// Compact format for cache tokens in table cells
-const formatCacheTokens = (value: number): string => {
- if (value >= 1_000_000) {
- return `${(value / 1_000_000).toFixed(1)}M`
- } else if (value >= 1_000) {
- return `${(value / 1_000).toFixed(1)}K`
- }
- return value.toLocaleString()
-}
const loadUsageLogs = async () => {
if (abortController) {
@@ -804,6 +816,7 @@ const exportToCSV = async () => {
'Reasoning Effort',
'Inbound Endpoint',
'Type',
+ 'Billing Mode',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
@@ -822,6 +835,7 @@ const exportToCSV = async () => {
formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '',
getRequestTypeExportText(log),
+ getBillingModeLabel(log.billing_mode),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,