feat(sora): 新增 Sora 平台支持并修复高危安全和性能问题

新增功能:
- 新增 Sora 账号管理和 OAuth 认证
- 新增 Sora 视频/图片生成 API 网关
- 新增 Sora 任务调度和缓存机制
- 新增 Sora 使用统计和计费支持
- 前端增加 Sora 平台配置界面

安全修复(代码审核):
- [SEC-001] 限制媒体下载响应体大小(图片 20MB、视频 200MB),防止 DoS 攻击
- [SEC-002] 限制 SDK API 响应大小(1MB),防止内存耗尽
- [SEC-003] 修复 SSRF 风险,添加 URL 验证并强制使用代理配置

BUG 修复(代码审核):
- [BUG-001] 修复 for 循环内 defer 累积导致的资源泄漏
- [BUG-002] 修复图片并发槽位获取失败时已持有锁未释放的永久泄漏

性能优化(代码审核):
- [PERF-001] 添加 Sentinel Token 缓存(3 分钟有效期),减少 PoW 计算开销

技术细节:
- 使用 io.LimitReader 限制所有外部输入的大小
- 添加 urlvalidator 验证防止 SSRF 攻击
- 使用 sync.Map 实现线程安全的包级缓存
- 优化并发槽位管理,添加 releaseAll 模式防止泄漏

影响范围:
- 后端:新增 Sora 相关数据模型、服务、网关和管理接口
- 前端:新增 Sora 平台配置、账号管理和监控界面
- 配置:新增 Sora 相关配置项和环境变量

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-29 16:18:38 +08:00
parent bece1b5201
commit 13262a5698
97 changed files with 29541 additions and 68 deletions

View File

@@ -55,6 +55,25 @@ export interface SystemSettings {
enable_identity_patch: boolean
identity_patch_prompt: string
// Sora configuration
sora_base_url: string
sora_timeout: number
sora_max_retries: number
sora_poll_interval: number
sora_call_logic_mode: string
sora_cache_enabled: boolean
sora_cache_base_dir: string
sora_cache_video_dir: string
sora_cache_max_bytes: number
sora_cache_allowed_hosts: string[]
sora_cache_user_dir_enabled: boolean
sora_watermark_free_enabled: boolean
sora_watermark_free_parse_method: string
sora_watermark_free_custom_parse_url: string
sora_watermark_free_custom_parse_token: string
sora_watermark_free_fallback_on_failure: boolean
sora_token_refresh_enabled: boolean
// Ops Monitoring (vNext)
ops_monitoring_enabled: boolean
ops_realtime_monitoring_enabled: boolean
@@ -97,6 +116,23 @@ export interface UpdateSettingsRequest {
fallback_model_antigravity?: string
enable_identity_patch?: boolean
identity_patch_prompt?: string
sora_base_url?: string
sora_timeout?: number
sora_max_retries?: number
sora_poll_interval?: number
sora_call_logic_mode?: string
sora_cache_enabled?: boolean
sora_cache_base_dir?: string
sora_cache_video_dir?: string
sora_cache_max_bytes?: number
sora_cache_allowed_hosts?: string[]
sora_cache_user_dir_enabled?: boolean
sora_watermark_free_enabled?: boolean
sora_watermark_free_parse_method?: string
sora_watermark_free_custom_parse_url?: string
sora_watermark_free_custom_parse_token?: string
sora_watermark_free_fallback_on_failure?: boolean
sora_token_refresh_enabled?: boolean
ops_monitoring_enabled?: boolean
ops_realtime_monitoring_enabled?: boolean
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string

View File

@@ -147,6 +147,19 @@
<Icon name="cloud" size="sm" />
Antigravity
</button>
<button
type="button"
@click="form.platform = 'sora'"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'sora'
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<Icon name="play" size="sm" />
Sora
</button>
</div>
</div>
@@ -672,6 +685,8 @@
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: form.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
"
/>
@@ -689,6 +704,8 @@
? 'sk-proj-...'
: form.platform === 'gemini'
? 'AIza...'
: form.platform === 'sora'
? 'access-token...'
: 'sk-ant-...'
"
/>
@@ -1850,12 +1867,14 @@ const oauthStepTitle = computed(() => {
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (form.platform === 'sora') return t('admin.accounts.sora.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
if (form.platform === 'sora') return t('admin.accounts.sora.apiKeyHint')
return t('admin.accounts.apiKeyHint')
})
@@ -2100,7 +2119,9 @@ watch(
? 'https://api.openai.com'
: newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newPlatform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
// Clear model-related settings
allowedModels.value = []
modelMappings.value = []
@@ -2112,6 +2133,9 @@ watch(
if (newPlatform === 'antigravity') {
accountCategory.value = 'oauth-based'
}
if (newPlatform === 'sora') {
accountCategory.value = 'apikey'
}
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
@@ -2383,12 +2407,17 @@ const handleSubmit = async () => {
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: form.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim()
const credentials: Record<string, unknown> = {}
if (form.platform === 'sora') {
credentials.access_token = apiKeyValue.value.trim()
} else {
credentials.base_url = apiKeyBaseUrl.value.trim() || defaultBaseUrl
credentials.api_key = apiKeyValue.value.trim()
}
if (form.platform === 'gemini') {
credentials.tier_id = geminiTierAIStudio.value

View File

@@ -39,6 +39,8 @@
? 'https://api.openai.com'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: account.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
"
/>
@@ -55,6 +57,8 @@
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
: account.platform === 'sora'
? 'access-token...'
: 'sk-ant-...'
"
/>
@@ -919,6 +923,7 @@ const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (props.account.platform === 'sora') return t('admin.accounts.sora.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
@@ -997,6 +1002,7 @@ const tempUnschedPresets = computed(() => [
const defaultBaseUrl = computed(() => {
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
if (props.account?.platform === 'sora') return 'https://sora.chatgpt.com/backend'
return 'https://api.anthropic.com'
})
@@ -1061,7 +1067,9 @@ watch(
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newAccount.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
@@ -1104,7 +1112,9 @@ watch(
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newAccount.platform === 'sora'
? 'https://sora.chatgpt.com/backend'
: 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
@@ -1381,17 +1391,32 @@ const handleSubmit = async () => {
if (props.account.type === 'apikey') {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
const isSora = props.account.platform === 'sora'
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
// Always update credentials for apikey type to handle model mapping changes
const newCredentials: Record<string, unknown> = {
base_url: newBaseUrl
const newCredentials: Record<string, unknown> = {}
if (!isSora) {
newCredentials.base_url = newBaseUrl
}
// Handle API key
if (editApiKey.value.trim()) {
// User provided a new API key
newCredentials.api_key = editApiKey.value.trim()
if (isSora) {
newCredentials.access_token = editApiKey.value.trim()
} else {
newCredentials.api_key = editApiKey.value.trim()
}
} else if (isSora) {
const existingToken = (currentCredentials.access_token || currentCredentials.token) as string | undefined
if (existingToken) {
newCredentials.access_token = existingToken
} else {
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
submitting.value = false
return
}
} else if (currentCredentials.api_key) {
// Preserve existing api_key
newCredentials.api_key = currentCredentials.api_key

View File

@@ -428,7 +428,7 @@ interface Props {
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora' // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}

View File

@@ -19,7 +19,7 @@ const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: 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' }])
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'sora', label: 'Sora' }, { 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') }])
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') }])
</script>

View File

@@ -97,6 +97,9 @@ 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`
})
@@ -118,6 +121,11 @@ 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'

View File

@@ -19,6 +19,10 @@
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
</svg>
<!-- Sora logo (play icon) -->
<svg v-else-if="platform === 'sora'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm-1 6 6 4-6 4V8z" />
</svg>
<!-- Fallback: generic platform icon -->
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
<path

View File

@@ -48,6 +48,7 @@ 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'
})
@@ -74,6 +75,9 @@ 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'
})
@@ -87,6 +91,9 @@ 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'
})
</script>

View File

@@ -180,6 +180,8 @@ const defaultClientTab = computed(() => {
switch (props.platform) {
case 'openai':
return 'codex'
case 'sora':
return 'codex'
case 'gemini':
return 'gemini'
case 'antigravity':
@@ -266,6 +268,7 @@ const clientTabs = computed((): TabConfig[] => {
if (!props.platform) return []
switch (props.platform) {
case 'openai':
case 'sora':
return [
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
@@ -306,7 +309,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
const currentTabs = computed(() => {
if (!showShellTabs.value) return []
if (props.platform === 'openai') {
if (props.platform === 'openai' || props.platform === 'sora') {
return openaiTabs
}
return shellTabs
@@ -315,6 +318,7 @@ const currentTabs = computed(() => {
const platformDescription = computed(() => {
switch (props.platform) {
case 'openai':
case 'sora':
return t('keys.useKeyModal.openai.description')
case 'gemini':
return t('keys.useKeyModal.gemini.description')
@@ -328,6 +332,7 @@ const platformDescription = computed(() => {
const platformNote = computed(() => {
switch (props.platform) {
case 'openai':
case 'sora':
return activeTab.value === 'windows'
? t('keys.useKeyModal.openai.noteWindows')
: t('keys.useKeyModal.openai.note')
@@ -386,6 +391,7 @@ const currentFiles = computed((): FileConfig[] => {
case 'anthropic':
return [generateOpenCodeConfig('anthropic', apiBase, apiKey)]
case 'openai':
case 'sora':
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
case 'gemini':
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
@@ -401,6 +407,7 @@ const currentFiles = computed((): FileConfig[] => {
switch (props.platform) {
case 'openai':
case 'sora':
return generateOpenAIFiles(baseUrl, apiKey)
case 'gemini':
return [generateGeminiCliContent(baseUrl, apiKey)]

View File

@@ -52,6 +52,38 @@ const geminiModels = [
'gemini-3-pro-preview'
]
// OpenAI 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'
]
// 智谱 GLM
const zhipuModels = [
'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520',
@@ -182,6 +214,7 @@ const allModelsList: string[] = [
...openaiModels,
...claudeModels,
...geminiModels,
...soraModels,
...zhipuModels,
...qwenModels,
...deepseekModels,
@@ -258,6 +291,7 @@ export function getModelsByPlatform(platform: string): string[] {
case 'anthropic':
case 'claude': return claudeModels
case 'gemini': return geminiModels
case 'sora': return soraModels
case 'zhipu': return zhipuModels
case 'qwen': return qwenModels
case 'deepseek': return deepseekModels
@@ -281,6 +315,7 @@ 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 []
return anthropicPresetMappings
}

View File

@@ -895,6 +895,7 @@ export default {
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
sora: 'Sora',
antigravity: 'Antigravity'
},
deleteConfirm:
@@ -1079,6 +1080,7 @@ export default {
claude: 'Claude',
openai: 'OpenAI',
gemini: 'Gemini',
sora: 'Sora',
antigravity: 'Antigravity'
},
types: {
@@ -1247,6 +1249,11 @@ export default {
baseUrlHint: 'Leave default for official OpenAI API',
apiKeyHint: 'Your OpenAI API Key'
},
// Sora specific hints
sora: {
baseUrlHint: 'Leave empty to use global Sora Base URL',
apiKeyHint: 'Your Sora access token'
},
modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping',
@@ -2784,6 +2791,47 @@ export default {
defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users'
},
sora: {
title: 'Sora Settings',
description: 'Configure Sora upstream requests, cache, and watermark-free flow',
baseUrl: 'Sora Base URL',
baseUrlPlaceholder: 'https://sora.chatgpt.com/backend',
baseUrlHint: 'Base URL for the Sora backend API',
callLogicMode: 'Call Mode',
callLogicModeDefault: 'Default',
callLogicModeNative: 'Native',
callLogicModeProxy: 'Proxy',
callLogicModeHint: 'Default keeps the existing behavior',
timeout: 'Timeout (seconds)',
timeoutHint: 'Timeout for single request',
maxRetries: 'Max Retries',
maxRetriesHint: 'Retry count for upstream failures',
pollInterval: 'Poll Interval (seconds)',
pollIntervalHint: 'Polling interval for task status',
cacheEnabled: 'Enable Cache',
cacheEnabledHint: 'Cache generated media for local downloads',
cacheBaseDir: 'Cache Base Dir',
cacheVideoDir: 'Video Cache Dir',
cacheMaxBytes: 'Cache Size (bytes)',
cacheMaxBytesHint: '0 means unlimited',
cacheUserDirEnabled: 'User Directory Isolation',
cacheUserDirEnabledHint: 'Create per-user subdirectories',
cacheAllowedHosts: 'Cache Allowlist',
cacheAllowedHostsPlaceholder: 'One host per line, e.g. oscdn2.dyysy.com',
cacheAllowedHostsHint: 'Empty falls back to the global URL allowlist',
watermarkFreeEnabled: 'Enable Watermark-Free',
watermarkFreeEnabledHint: 'Try to resolve watermark-free videos',
watermarkFreeParseMethod: 'Parse Method',
watermarkFreeParseMethodThirdParty: 'Third-party',
watermarkFreeParseMethodCustom: 'Custom',
watermarkFreeParseMethodHint: 'Select the watermark-free parse method',
watermarkFreeCustomParseUrl: 'Custom Parse URL',
watermarkFreeCustomParseToken: 'Custom Parse Token',
watermarkFreeFallback: 'Fallback on Failure',
watermarkFreeFallbackHint: 'Return the original video on failure',
tokenRefreshEnabled: 'Enable Token Refresh',
tokenRefreshEnabledHint: 'Periodic token refresh (requires scheduler)'
},
site: {
title: 'Site Settings',
description: 'Customize site branding',

View File

@@ -941,6 +941,7 @@ export default {
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
sora: 'Sora',
antigravity: 'Antigravity'
},
saving: '保存中...',
@@ -1199,6 +1200,7 @@ export default {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini',
sora: 'Sora',
antigravity: 'Antigravity'
},
types: {
@@ -1382,6 +1384,11 @@ export default {
baseUrlHint: '留空使用官方 OpenAI API',
apiKeyHint: '您的 OpenAI API Key'
},
// Sora specific hints
sora: {
baseUrlHint: '留空使用全局 Sora Base URL',
apiKeyHint: '您的 Sora access token'
},
modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单',
modelMapping: '模型映射',
@@ -2936,6 +2943,47 @@ export default {
defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数'
},
sora: {
title: 'Sora 设置',
description: '配置 Sora 上游请求、缓存与去水印策略',
baseUrl: 'Sora Base URL',
baseUrlPlaceholder: 'https://sora.chatgpt.com/backend',
baseUrlHint: 'Sora 后端 API 基础地址',
callLogicMode: '调用模式',
callLogicModeDefault: '默认',
callLogicModeNative: '原生',
callLogicModeProxy: '代理',
callLogicModeHint: '默认保持当前策略',
timeout: '请求超时(秒)',
timeoutHint: '单次任务超时控制',
maxRetries: '最大重试次数',
maxRetriesHint: '上游请求失败时的重试次数',
pollInterval: '轮询间隔(秒)',
pollIntervalHint: '任务状态轮询间隔',
cacheEnabled: '启用缓存',
cacheEnabledHint: '启用生成结果缓存并提供本地下载',
cacheBaseDir: '缓存根目录',
cacheVideoDir: '视频缓存目录',
cacheMaxBytes: '缓存容量(字节)',
cacheMaxBytesHint: '0 表示不限制',
cacheUserDirEnabled: '按用户隔离缓存目录',
cacheUserDirEnabledHint: '开启后按用户创建子目录',
cacheAllowedHosts: '缓存下载白名单',
cacheAllowedHostsPlaceholder: '每行一个域名,例如: oscdn2.dyysy.com',
cacheAllowedHostsHint: '为空时回退全局 URL 白名单',
watermarkFreeEnabled: '启用去水印',
watermarkFreeEnabledHint: '尝试通过解析服务获取无水印视频',
watermarkFreeParseMethod: '解析方式',
watermarkFreeParseMethodThirdParty: '第三方解析',
watermarkFreeParseMethodCustom: '自定义解析',
watermarkFreeParseMethodHint: '选择去水印解析方式',
watermarkFreeCustomParseUrl: '自定义解析地址',
watermarkFreeCustomParseToken: '自定义解析 Token',
watermarkFreeFallback: '解析失败降级',
watermarkFreeFallbackHint: '失败时返回原视频',
tokenRefreshEnabled: '启用 Token 刷新',
tokenRefreshEnabledHint: '定时刷新 Sora Token需配置调度'
},
site: {
title: '站点设置',
description: '自定义站点品牌',

View File

@@ -252,7 +252,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
export type SubscriptionType = 'standard' | 'subscription'
@@ -355,7 +355,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
export type AccountType = 'oauth' | 'setup-token' | 'apikey'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'

View File

@@ -1152,6 +1152,7 @@ const platformOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'sora', label: 'Sora' },
{ value: 'antigravity', label: 'Antigravity' }
])
@@ -1160,6 +1161,7 @@ const platformFilterOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'sora', label: 'Sora' },
{ value: 'antigravity', label: 'Antigravity' }
])

View File

@@ -561,6 +561,221 @@
</div>
</div>
<!-- Sora Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.sora.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.description') }}
</p>
</div>
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.baseUrl') }}
</label>
<input
v-model="form.sora_base_url"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.sora.baseUrlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.baseUrlHint') }}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.callLogicMode') }}
</label>
<select v-model="form.sora_call_logic_mode" class="input">
<option value="default">{{ t('admin.settings.sora.callLogicModeDefault') }}</option>
<option value="native">{{ t('admin.settings.sora.callLogicModeNative') }}</option>
<option value="proxy">{{ t('admin.settings.sora.callLogicModeProxy') }}</option>
</select>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.callLogicModeHint') }}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.timeout') }}
</label>
<input v-model.number="form.sora_timeout" type="number" min="1" class="input" />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.timeoutHint') }}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.maxRetries') }}
</label>
<input v-model.number="form.sora_max_retries" type="number" min="0" class="input" />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.maxRetriesHint') }}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.pollInterval') }}
</label>
<input
v-model.number="form.sora_poll_interval"
type="number"
min="0.5"
step="0.1"
class="input"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.pollIntervalHint') }}
</p>
</div>
</div>
<div class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.sora.cacheEnabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.cacheEnabledHint') }}
</p>
</div>
<Toggle v-model="form.sora_cache_enabled" />
</div>
<div v-if="form.sora_cache_enabled" class="space-y-4">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.cacheBaseDir') }}
</label>
<input v-model="form.sora_cache_base_dir" type="text" class="input font-mono text-sm" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.cacheVideoDir') }}
</label>
<input v-model="form.sora_cache_video_dir" type="text" class="input font-mono text-sm" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.cacheMaxBytes') }}
</label>
<input v-model.number="form.sora_cache_max_bytes" type="number" min="0" class="input" />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.cacheMaxBytesHint') }}
</p>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.sora.cacheUserDirEnabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.cacheUserDirEnabledHint') }}
</p>
</div>
<Toggle v-model="form.sora_cache_user_dir_enabled" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.cacheAllowedHosts') }}
</label>
<textarea
v-model="form.sora_cache_allowed_hosts_text"
rows="3"
class="input font-mono text-sm"
:placeholder="t('admin.settings.sora.cacheAllowedHostsPlaceholder')"
></textarea>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.cacheAllowedHostsHint') }}
</p>
</div>
</div>
</div>
<div class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.sora.watermarkFreeEnabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.watermarkFreeEnabledHint') }}
</p>
</div>
<Toggle v-model="form.sora_watermark_free_enabled" />
</div>
<div v-if="form.sora_watermark_free_enabled" class="space-y-4">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.watermarkFreeParseMethod') }}
</label>
<select v-model="form.sora_watermark_free_parse_method" class="input">
<option value="third_party">{{ t('admin.settings.sora.watermarkFreeParseMethodThirdParty') }}</option>
<option value="custom">{{ t('admin.settings.sora.watermarkFreeParseMethodCustom') }}</option>
</select>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.watermarkFreeParseMethodHint') }}
</p>
</div>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.sora.watermarkFreeFallback')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.watermarkFreeFallbackHint') }}
</p>
</div>
<Toggle v-model="form.sora_watermark_free_fallback_on_failure" />
</div>
</div>
<div v-if="form.sora_watermark_free_parse_method === 'custom'" class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.watermarkFreeCustomParseUrl') }}
</label>
<input v-model="form.sora_watermark_free_custom_parse_url" type="text" class="input font-mono text-sm" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.sora.watermarkFreeCustomParseToken') }}
</label>
<input v-model="form.sora_watermark_free_custom_parse_token" type="password" class="input font-mono text-sm" />
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.sora.tokenRefreshEnabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.sora.tokenRefreshEnabledHint') }}
</p>
</div>
<Toggle v-model="form.sora_token_refresh_enabled" />
</div>
</div>
</div>
<!-- Site Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
@@ -1023,6 +1238,7 @@ type SettingsForm = SystemSettings & {
smtp_password: string
turnstile_secret_key: string
linuxdo_connect_client_secret: string
sora_cache_allowed_hosts_text: string
}
const form = reactive<SettingsForm>({
@@ -1067,6 +1283,25 @@ const form = reactive<SettingsForm>({
// Identity patch (Claude -> Gemini)
enable_identity_patch: true,
identity_patch_prompt: '',
// Sora
sora_base_url: 'https://sora.chatgpt.com/backend',
sora_timeout: 120,
sora_max_retries: 3,
sora_poll_interval: 2.5,
sora_call_logic_mode: 'default',
sora_cache_enabled: false,
sora_cache_base_dir: 'tmp/sora',
sora_cache_video_dir: 'data/video',
sora_cache_max_bytes: 0,
sora_cache_allowed_hosts: [],
sora_cache_user_dir_enabled: true,
sora_watermark_free_enabled: false,
sora_watermark_free_parse_method: 'third_party',
sora_watermark_free_custom_parse_url: '',
sora_watermark_free_custom_parse_token: '',
sora_watermark_free_fallback_on_failure: true,
sora_token_refresh_enabled: false,
sora_cache_allowed_hosts_text: '',
// Ops monitoring (vNext)
ops_monitoring_enabled: true,
ops_realtime_monitoring_enabled: true,
@@ -1136,6 +1371,7 @@ async function loadSettings() {
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
form.sora_cache_allowed_hosts_text = (settings.sora_cache_allowed_hosts || []).join('\n')
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
@@ -1148,6 +1384,11 @@ async function loadSettings() {
async function saveSettings() {
saving.value = true
try {
const soraAllowedHosts = form.sora_cache_allowed_hosts_text
.split(/\r?\n/)
.map((value) => value.trim())
.filter((value) => value.length > 0)
const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
@@ -1182,13 +1423,31 @@ async function saveSettings() {
fallback_model_gemini: form.fallback_model_gemini,
fallback_model_antigravity: form.fallback_model_antigravity,
enable_identity_patch: form.enable_identity_patch,
identity_patch_prompt: form.identity_patch_prompt
identity_patch_prompt: form.identity_patch_prompt,
sora_base_url: form.sora_base_url,
sora_timeout: form.sora_timeout,
sora_max_retries: form.sora_max_retries,
sora_poll_interval: form.sora_poll_interval,
sora_call_logic_mode: form.sora_call_logic_mode,
sora_cache_enabled: form.sora_cache_enabled,
sora_cache_base_dir: form.sora_cache_base_dir,
sora_cache_video_dir: form.sora_cache_video_dir,
sora_cache_max_bytes: form.sora_cache_max_bytes,
sora_cache_allowed_hosts: soraAllowedHosts,
sora_cache_user_dir_enabled: form.sora_cache_user_dir_enabled,
sora_watermark_free_enabled: form.sora_watermark_free_enabled,
sora_watermark_free_parse_method: form.sora_watermark_free_parse_method,
sora_watermark_free_custom_parse_url: form.sora_watermark_free_custom_parse_url,
sora_watermark_free_custom_parse_token: form.sora_watermark_free_custom_parse_token,
sora_watermark_free_fallback_on_failure: form.sora_watermark_free_fallback_on_failure,
sora_token_refresh_enabled: form.sora_token_refresh_enabled
}
const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated)
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
form.sora_cache_allowed_hosts_text = (updated.sora_cache_allowed_hosts || []).join('\n')
// Refresh cached public settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true)
appStore.showSuccess(t('admin.settings.settingsSaved'))

View File

@@ -111,6 +111,7 @@ const platformOptions = computed(() => [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'sora', label: 'Sora' },
{ value: 'antigravity', label: 'Antigravity' }
])

View File

@@ -916,6 +916,7 @@ const executeCcsImport = (row: ApiKey, clientType: 'claude' | 'gemini') => {
} else {
switch (platform) {
case 'openai':
case 'sora':
app = 'codex'
endpoint = baseUrl
break