Commit 987589ea authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'test' into release

parents 372e04f6 03f69dd3
-- Drop legacy cache token columns that lack the underscore separator.
-- These were created by GORM's automatic snake_case conversion:
-- CacheCreation5mTokens → cache_creation5m_tokens (incorrect)
-- CacheCreation1hTokens → cache_creation1h_tokens (incorrect)
--
-- The canonical columns are:
-- cache_creation_5m_tokens (defined in 001_init.sql)
-- cache_creation_1h_tokens (defined in 001_init.sql)
--
-- Migration 009 already copied data from legacy → canonical columns.
-- But upgraded instances may still have post-009 writes in legacy columns.
-- Backfill once more before dropping to prevent data loss.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'usage_logs'
AND column_name = 'cache_creation5m_tokens'
) THEN
UPDATE usage_logs
SET cache_creation_5m_tokens = cache_creation5m_tokens
WHERE cache_creation_5m_tokens = 0
AND cache_creation5m_tokens <> 0;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'usage_logs'
AND column_name = 'cache_creation1h_tokens'
) THEN
UPDATE usage_logs
SET cache_creation_1h_tokens = cache_creation1h_tokens
WHERE cache_creation_1h_tokens = 0
AND cache_creation1h_tokens <> 0;
END IF;
END $$;
ALTER TABLE usage_logs DROP COLUMN IF EXISTS cache_creation5m_tokens;
ALTER TABLE usage_logs DROP COLUMN IF EXISTS cache_creation1h_tokens;
-- Add cache_ttl_overridden flag to usage_logs for tracking cache TTL override per account.
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS cache_ttl_overridden BOOLEAN NOT NULL DEFAULT FALSE;
...@@ -374,6 +374,9 @@ sora: ...@@ -374,6 +374,9 @@ sora:
# Max retries for upstream requests # Max retries for upstream requests
# 上游请求最大重试次数 # 上游请求最大重试次数
max_retries: 3 max_retries: 3
# Account+proxy cooldown window after Cloudflare challenge (seconds, 0 to disable)
# Cloudflare challenge 后按账号+代理冷却窗口(秒,0 表示关闭)
cloudflare_challenge_cooldown_seconds: 900
# Poll interval (seconds) # Poll interval (seconds)
# 轮询间隔(秒) # 轮询间隔(秒)
poll_interval_seconds: 2 poll_interval_seconds: 2
...@@ -388,7 +391,11 @@ sora: ...@@ -388,7 +391,11 @@ sora:
recent_task_limit_max: 200 recent_task_limit_max: 200
# Enable debug logs for Sora upstream requests # Enable debug logs for Sora upstream requests
# 启用 Sora 直连调试日志 # 启用 Sora 直连调试日志
# 调试日志会输出上游请求尝试、重试、响应摘要;Authorization/openai-sentinel-token 等敏感头会自动脱敏
debug: false debug: false
# Allow Sora client to fetch token via OpenAI token provider
# 是否允许 Sora 客户端通过 OpenAI token provider 取 token(默认 false,避免误走 OpenAI 刷新链路)
use_openai_token_provider: false
# Optional custom headers (key-value) # Optional custom headers (key-value)
# 额外请求头(键值对) # 额外请求头(键值对)
headers: {} headers: {}
...@@ -398,6 +405,27 @@ sora: ...@@ -398,6 +405,27 @@ sora:
# Disable TLS fingerprint for Sora upstream # Disable TLS fingerprint for Sora upstream
# 关闭 Sora 上游 TLS 指纹伪装 # 关闭 Sora 上游 TLS 指纹伪装
disable_tls_fingerprint: false disable_tls_fingerprint: false
# curl_cffi sidecar for Sora only (required)
# 仅 Sora 链路使用的 curl_cffi sidecar(必需)
curl_cffi_sidecar:
# Sora 强制通过 sidecar 请求,必须启用
# Sora is forced to use sidecar only; keep enabled=true
enabled: true
# Sidecar base URL (default endpoint: /request)
# sidecar 基础地址(默认请求端点:/request)
base_url: "http://sora-curl-cffi-sidecar:8080"
# curl_cffi impersonate profile, e.g. chrome131/chrome124/safari18_0
# curl_cffi 指纹伪装 profile,例如 chrome131/chrome124/safari18_0
impersonate: "chrome131"
# Sidecar request timeout (seconds)
# sidecar 请求超时(秒)
timeout_seconds: 60
# Reuse session key per account+proxy to let sidecar persist cookies/session
# 按账号+代理复用 session key,让 sidecar 持久化 cookies/session
session_reuse_enabled: true
# Session TTL in sidecar (seconds)
# sidecar 会话 TTL(秒)
session_ttl_seconds: 3600
storage: storage:
# Storage type (local only for now) # Storage type (local only for now)
# 存储类型(首发仅支持 local) # 存储类型(首发仅支持 local)
...@@ -431,6 +459,13 @@ sora: ...@@ -431,6 +459,13 @@ sora:
# Cron 调度表达式 # Cron 调度表达式
schedule: "0 3 * * *" schedule: "0 3 * * *"
# Token refresh behavior
# token 刷新行为控制
token_refresh:
# Whether OpenAI refresh flow is allowed to sync linked Sora accounts
# 是否允许 OpenAI 刷新流程同步覆盖 linked_openai_account_id 关联的 Sora 账号 token
sync_linked_sora_accounts: false
# ============================================================================= # =============================================================================
# API Key Auth Cache Configuration # API Key Auth Cache Configuration
# API Key 认证缓存配置 # API Key 认证缓存配置
......
...@@ -173,6 +173,7 @@ services: ...@@ -173,6 +173,7 @@ services:
- POSTGRES_USER=${POSTGRES_USER:-sub2api} - POSTGRES_USER=${POSTGRES_USER:-sub2api}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- POSTGRES_DB=${POSTGRES_DB:-sub2api} - POSTGRES_DB=${POSTGRES_DB:-sub2api}
- PGDATA=/var/lib/postgresql/data
- TZ=${TZ:-Asia/Shanghai} - TZ=${TZ:-Asia/Shanghai}
networks: networks:
- sub2api-network - sub2api-network
......
...@@ -32,6 +32,7 @@ export async function list( ...@@ -32,6 +32,7 @@ export async function list(
platform?: string platform?: string
type?: string type?: string
status?: string status?: string
group?: string
search?: string search?: string
}, },
options?: { options?: {
...@@ -271,7 +272,7 @@ export async function generateAuthUrl( ...@@ -271,7 +272,7 @@ export async function generateAuthUrl(
*/ */
export async function exchangeCode( export async function exchangeCode(
endpoint: string, endpoint: string,
exchangeData: { session_id: string; code: string; proxy_id?: number } exchangeData: { session_id: string; code: string; state?: string; proxy_id?: number }
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData) const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
return data return data
...@@ -493,7 +494,8 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string ...@@ -493,7 +494,8 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
*/ */
export async function refreshOpenAIToken( export async function refreshOpenAIToken(
refreshToken: string, refreshToken: string,
proxyId?: number | null proxyId?: number | null,
endpoint: string = '/admin/openai/refresh-token'
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = { const payload: { refresh_token: string; proxy_id?: number } = {
refresh_token: refreshToken refresh_token: refreshToken
...@@ -501,7 +503,29 @@ export async function refreshOpenAIToken( ...@@ -501,7 +503,29 @@ export async function refreshOpenAIToken(
if (proxyId) { if (proxyId) {
payload.proxy_id = proxyId payload.proxy_id = proxyId
} }
const { data } = await apiClient.post<Record<string, unknown>>('/admin/openai/refresh-token', payload) const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}
/**
* Validate Sora session token and exchange to access token
* @param sessionToken - Sora session token
* @param proxyId - Optional proxy ID
* @param endpoint - API endpoint path
* @returns Token information including access_token
*/
export async function validateSoraSessionToken(
sessionToken: string,
proxyId?: number | null,
endpoint: string = '/admin/sora/st2at'
): Promise<Record<string, unknown>> {
const payload: { session_token: string; proxy_id?: number } = {
session_token: sessionToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data return data
} }
...@@ -527,6 +551,7 @@ export const accountsAPI = { ...@@ -527,6 +551,7 @@ export const accountsAPI = {
generateAuthUrl, generateAuthUrl,
exchangeCode, exchangeCode,
refreshOpenAIToken, refreshOpenAIToken,
validateSoraSessionToken,
batchCreate, batchCreate,
batchUpdateCredentials, batchUpdateCredentials,
bulkUpdate, bulkUpdate,
......
...@@ -7,6 +7,7 @@ import { apiClient } from '../client' ...@@ -7,6 +7,7 @@ import { apiClient } from '../client'
import type { import type {
Proxy, Proxy,
ProxyAccountSummary, ProxyAccountSummary,
ProxyQualityCheckResult,
CreateProxyRequest, CreateProxyRequest,
UpdateProxyRequest, UpdateProxyRequest,
PaginatedResponse, PaginatedResponse,
...@@ -143,6 +144,16 @@ export async function testProxy(id: number): Promise<{ ...@@ -143,6 +144,16 @@ export async function testProxy(id: number): Promise<{
return data return data
} }
/**
* Check proxy quality across common AI targets
* @param id - Proxy ID
* @returns Quality check result
*/
export async function checkProxyQuality(id: number): Promise<ProxyQualityCheckResult> {
const { data } = await apiClient.post<ProxyQualityCheckResult>(`/admin/proxies/${id}/quality-check`)
return data
}
/** /**
* Get proxy usage statistics * Get proxy usage statistics
* @param id - Proxy ID * @param id - Proxy ID
...@@ -248,6 +259,7 @@ export const proxiesAPI = { ...@@ -248,6 +259,7 @@ export const proxiesAPI = {
delete: deleteProxy, delete: deleteProxy,
toggleStatus, toggleStatus,
testProxy, testProxy,
checkProxyQuality,
getStats, getStats,
getProxyAccounts, getProxyAccounts,
batchCreate, batchCreate,
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
> >
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400"> <span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.allGroups', { count: groups.length }) }} {{ t('admin.accounts.groupCountTotal', { count: groups.length }) }}
</span> </span>
<button <button
@click="showPopover = false" @click="showPopover = false"
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
</span> </span>
</div> </div>
<div class="space-y-1.5"> <div v-if="!isSoraAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }} {{ t('admin.accounts.selectTestModel') }}
</label> </label>
...@@ -54,6 +54,12 @@ ...@@ -54,6 +54,12 @@
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')" :placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/> />
</div> </div>
<div
v-else
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{ t('admin.accounts.soraTestHint') }}
</div>
<!-- Terminal Output --> <!-- Terminal Output -->
<div class="group relative"> <div class="group relative">
...@@ -135,12 +141,12 @@ ...@@ -135,12 +141,12 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Icon name="cpu" size="sm" :stroke-width="2" /> <Icon name="cpu" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testModel') }} {{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
</span> </span>
</div> </div>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Icon name="chatBubble" size="sm" :stroke-width="2" /> <Icon name="chatBubble" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testPrompt') }} {{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
</span> </span>
</div> </div>
</div> </div>
...@@ -156,10 +162,10 @@ ...@@ -156,10 +162,10 @@
</button> </button>
<button <button
@click="startTest" @click="startTest"
:disabled="status === 'connecting' || !selectedModelId" :disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
:class="[ :class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId status === 'connecting' || (!isSoraAccount && !selectedModelId)
? 'cursor-not-allowed bg-primary-400 text-white' ? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success' : status === 'success'
? 'bg-green-500 text-white hover:bg-green-600' ? 'bg-green-500 text-white hover:bg-green-600'
...@@ -232,7 +238,7 @@ ...@@ -232,7 +238,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -267,6 +273,7 @@ const availableModels = ref<ClaudeModel[]>([]) ...@@ -267,6 +273,7 @@ const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('') const selectedModelId = ref('')
const loadingModels = ref(false) const loadingModels = ref(false)
let eventSource: EventSource | null = null let eventSource: EventSource | null = null
const isSoraAccount = computed(() => props.account?.platform === 'sora')
// Load available models when modal opens // Load available models when modal opens
watch( watch(
...@@ -283,6 +290,12 @@ watch( ...@@ -283,6 +290,12 @@ watch(
const loadAvailableModels = async () => { const loadAvailableModels = async () => {
if (!props.account) return if (!props.account) return
if (props.account.platform === 'sora') {
availableModels.value = []
selectedModelId.value = ''
loadingModels.value = false
return
}
loadingModels.value = true loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading selectedModelId.value = '' // Reset selection before loading
...@@ -350,7 +363,7 @@ const scrollToBottom = async () => { ...@@ -350,7 +363,7 @@ const scrollToBottom = async () => {
} }
const startTest = async () => { const startTest = async () => {
if (!props.account || !selectedModelId.value) return if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
resetState() resetState()
status.value = 'connecting' status.value = 'connecting'
...@@ -371,7 +384,9 @@ const startTest = async () => { ...@@ -371,7 +384,9 @@ const startTest = async () => {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`, Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ model_id: selectedModelId.value }) body: JSON.stringify(
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
)
}) })
if (!response.ok) { if (!response.ok) {
...@@ -428,7 +443,10 @@ const handleEvent = (event: { ...@@ -428,7 +443,10 @@ const handleEvent = (event: {
if (event.model) { if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400') addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
} }
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400') addLine(
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
addLine('', 'text-gray-300') addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400') addLine(t('admin.accounts.response'), 'text-yellow-400')
break break
......
...@@ -710,6 +710,7 @@ const groupIds = ref<number[]>([]) ...@@ -710,6 +710,7 @@ const groupIds = ref<number[]>([])
// All models list (combined Anthropic + OpenAI) // All models list (combined Anthropic + OpenAI)
const allModels = [ const allModels = [
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
...@@ -757,6 +758,13 @@ const presetMappings = [ ...@@ -757,6 +758,13 @@ const presetMappings = [
color: color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
}, },
{
label: 'Sonnet 4.6',
from: 'claude-sonnet-4-6',
to: 'claude-sonnet-4-6',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{ {
label: 'Opus->Sonnet', label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101', from: 'claude-opus-4-5-20251101',
......
...@@ -109,6 +109,28 @@ ...@@ -109,6 +109,28 @@
</svg> </svg>
OpenAI OpenAI
</button> </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'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Sora
</button>
<button <button
type="button" type="button"
@click="form.platform = 'gemini'" @click="form.platform = 'gemini'"
...@@ -150,6 +172,38 @@ ...@@ -150,6 +172,38 @@
</div> </div>
</div> </div>
<!-- Account Type Selection (Sora) -->
<div v-if="form.platform === 'sora'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3" data-tour="account-form-type">
<button
type="button"
@click="accountCategory = 'oauth-based'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-rose-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="key" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
</div>
</button>
</div>
</div>
<!-- Account Type Selection (Anthropic) --> <!-- Account Type Selection (Anthropic) -->
<div v-if="form.platform === 'anthropic'"> <div v-if="form.platform === 'anthropic'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <label class="input-label">{{ t('admin.accounts.accountType') }}</label>
...@@ -1538,6 +1592,46 @@ ...@@ -1538,6 +1592,46 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Cache TTL Override -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.cacheTTLOverride.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.cacheTTLOverride.hint') }}
</p>
</div>
<button
type="button"
@click="cacheTTLOverrideEnabled = !cacheTTLOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
cacheTTLOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
cacheTTLOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="cacheTTLOverrideEnabled" class="mt-3">
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.cacheTTLOverride.target') }}</label>
<select
v-model="cacheTTLOverrideTarget"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-white"
>
<option value="5m">5m</option>
<option value="1h">1h</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.cacheTTLOverride.targetHint') }}
</p>
</div>
</div>
</div> </div>
<div> <div>
...@@ -1707,32 +1801,6 @@ ...@@ -1707,32 +1801,6 @@
<!-- Step 2: OAuth Authorization --> <!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5"> <div v-else class="space-y-5">
<!-- 同时启用 Sora 开关 ( OpenAI OAuth) -->
<div v-if="form.platform === 'openai' && accountCategory === 'oauth-based'" class="mb-4">
<label class="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.openai.enableSora') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.enableSoraHint') }}
</span>
</div>
</div>
<label :class="['switch', { 'switch-active': enableSoraOnOpenAIOAuth }]">
<input type="checkbox" v-model="enableSoraOnOpenAIOAuth" class="sr-only" />
<span class="switch-thumb"></span>
</label>
</label>
</div>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'" :add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
...@@ -1741,15 +1809,17 @@ ...@@ -1741,15 +1809,17 @@
:loading="currentOAuthLoading" :loading="currentOAuthLoading"
:error="currentOAuthError" :error="currentOAuthError"
:show-help="form.platform === 'anthropic'" :show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id" :show-proxy-warning="form.platform !== 'openai' && form.platform !== 'sora' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'" :allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'" :show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'" :show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'"
:show-session-token-option="form.platform === 'sora'"
:platform="form.platform" :platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'" :show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
@validate-refresh-token="handleValidateRefreshToken" @validate-refresh-token="handleValidateRefreshToken"
@validate-session-token="handleValidateSessionToken"
/> />
</div> </div>
...@@ -2108,6 +2178,7 @@ interface OAuthFlowExposed { ...@@ -2108,6 +2178,7 @@ interface OAuthFlowExposed {
projectId: string projectId: string
sessionKey: string sessionKey: string
refreshToken: string refreshToken: string
sessionToken: string
inputMethod: AuthInputMethod inputMethod: AuthInputMethod
reset: () => void reset: () => void
} }
...@@ -2116,7 +2187,7 @@ const { t } = useI18n() ...@@ -2116,7 +2187,7 @@ const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const oauthStepTitle = computed(() => { const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') if (form.platform === 'openai' || form.platform === 'sora') return t('admin.accounts.oauth.openai.title')
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title') if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title') if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title')
return t('admin.accounts.oauth.title') return t('admin.accounts.oauth.title')
...@@ -2124,13 +2195,13 @@ const oauthStepTitle = computed(() => { ...@@ -2124,13 +2195,13 @@ const oauthStepTitle = computed(() => {
// Platform-specific hints for API Key type // Platform-specific hints for API Key type
const baseUrlHint = computed(() => { const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (form.platform === 'openai' || form.platform === 'sora') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint') return t('admin.accounts.baseUrlHint')
}) })
const apiKeyHint = computed(() => { const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint') if (form.platform === 'openai' || form.platform === 'sora') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
return t('admin.accounts.apiKeyHint') return t('admin.accounts.apiKeyHint')
}) })
...@@ -2151,34 +2222,36 @@ const appStore = useAppStore() ...@@ -2151,34 +2222,36 @@ const appStore = useAppStore()
// OAuth composables // OAuth composables
const oauth = useAccountOAuth() // For Anthropic OAuth const oauth = useAccountOAuth() // For Anthropic OAuth
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth const openaiOAuth = useOpenAIOAuth({ platform: 'openai' }) // For OpenAI OAuth
const soraOAuth = useOpenAIOAuth({ platform: 'sora' }) // For Sora OAuth
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
const activeOpenAIOAuth = computed(() => (form.platform === 'sora' ? soraOAuth : openaiOAuth))
// Computed: current OAuth state for template binding // Computed: current OAuth state for template binding
const currentAuthUrl = computed(() => { const currentAuthUrl = computed(() => {
if (form.platform === 'openai') return openaiOAuth.authUrl.value if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.authUrl.value
if (form.platform === 'gemini') return geminiOAuth.authUrl.value if (form.platform === 'gemini') return geminiOAuth.authUrl.value
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
return oauth.authUrl.value return oauth.authUrl.value
}) })
const currentSessionId = computed(() => { const currentSessionId = computed(() => {
if (form.platform === 'openai') return openaiOAuth.sessionId.value if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.sessionId.value
if (form.platform === 'gemini') return geminiOAuth.sessionId.value if (form.platform === 'gemini') return geminiOAuth.sessionId.value
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
return oauth.sessionId.value return oauth.sessionId.value
}) })
const currentOAuthLoading = computed(() => { const currentOAuthLoading = computed(() => {
if (form.platform === 'openai') return openaiOAuth.loading.value if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.loading.value
if (form.platform === 'gemini') return geminiOAuth.loading.value if (form.platform === 'gemini') return geminiOAuth.loading.value
if (form.platform === 'antigravity') return antigravityOAuth.loading.value if (form.platform === 'antigravity') return antigravityOAuth.loading.value
return oauth.loading.value return oauth.loading.value
}) })
const currentOAuthError = computed(() => { const currentOAuthError = computed(() => {
if (form.platform === 'openai') return openaiOAuth.error.value if (form.platform === 'openai' || form.platform === 'sora') return activeOpenAIOAuth.value.error.value
if (form.platform === 'gemini') return geminiOAuth.error.value if (form.platform === 'gemini') return geminiOAuth.error.value
if (form.platform === 'antigravity') return antigravityOAuth.error.value if (form.platform === 'antigravity') return antigravityOAuth.error.value
return oauth.error.value return oauth.error.value
...@@ -2217,7 +2290,6 @@ const interceptWarmupRequests = ref(false) ...@@ -2217,7 +2290,6 @@ const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true) const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const enableSoraOnOpenAIOAuth = ref(false) // OpenAI OAuth 时同时启用 Sora
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const upstreamBaseUrl = ref('') // For upstream type: base URL const upstreamBaseUrl = ref('') // For upstream type: base URL
...@@ -2250,6 +2322,8 @@ const maxSessions = ref<number | null>(null) ...@@ -2250,6 +2322,8 @@ const maxSessions = ref<number | null>(null)
const sessionIdleTimeout = ref<number | null>(null) const sessionIdleTimeout = ref<number | null>(null)
const tlsFingerprintEnabled = ref(false) const tlsFingerprintEnabled = ref(false)
const sessionIdMaskingEnabled = ref(false) const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails) // Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free') const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
...@@ -2356,8 +2430,8 @@ const expiresAtInput = computed({ ...@@ -2356,8 +2430,8 @@ const expiresAtInput = computed({
const canExchangeCode = computed(() => { const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (form.platform === 'openai') { if (form.platform === 'openai' || form.platform === 'sora') {
return authCode.trim() && openaiOAuth.sessionId.value && !openaiOAuth.loading.value return authCode.trim() && activeOpenAIOAuth.value.sessionId.value && !activeOpenAIOAuth.value.loading.value
} }
if (form.platform === 'gemini') { if (form.platform === 'gemini') {
return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value
...@@ -2417,7 +2491,7 @@ watch( ...@@ -2417,7 +2491,7 @@ watch(
(newPlatform) => { (newPlatform) => {
// Reset base URL based on platform // Reset base URL based on platform
apiKeyBaseUrl.value = apiKeyBaseUrl.value =
newPlatform === 'openai' (newPlatform === 'openai' || newPlatform === 'sora')
? 'https://api.openai.com' ? 'https://api.openai.com'
: newPlatform === 'gemini' : newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com' ? 'https://generativelanguage.googleapis.com'
...@@ -2443,6 +2517,11 @@ watch( ...@@ -2443,6 +2517,11 @@ watch(
if (newPlatform !== 'anthropic') { if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
} }
if (newPlatform === 'sora') {
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
form.type = 'oauth'
}
if (newPlatform !== 'openai') { if (newPlatform !== 'openai') {
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
...@@ -2450,6 +2529,7 @@ watch( ...@@ -2450,6 +2529,7 @@ watch(
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
antigravityOAuth.resetState() antigravityOAuth.resetState()
} }
...@@ -2711,7 +2791,6 @@ const resetForm = () => { ...@@ -2711,7 +2791,6 @@ const resetForm = () => {
autoPauseOnExpired.value = true autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
enableSoraOnOpenAIOAuth.value = false
// Reset quota control state // Reset quota control state
windowCostEnabled.value = false windowCostEnabled.value = false
windowCostLimit.value = null windowCostLimit.value = null
...@@ -2721,6 +2800,8 @@ const resetForm = () => { ...@@ -2721,6 +2800,8 @@ const resetForm = () => {
sessionIdleTimeout.value = null sessionIdleTimeout.value = null
tlsFingerprintEnabled.value = false tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
antigravityAccountType.value = 'oauth' antigravityAccountType.value = 'oauth'
upstreamBaseUrl.value = '' upstreamBaseUrl.value = ''
upstreamApiKey.value = '' upstreamApiKey.value = ''
...@@ -2732,6 +2813,7 @@ const resetForm = () => { ...@@ -2732,6 +2813,7 @@ const resetForm = () => {
geminiTierAIStudio.value = 'aistudio_free' geminiTierAIStudio.value = 'aistudio_free'
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
antigravityOAuth.resetState() antigravityOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
...@@ -2763,6 +2845,23 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow ...@@ -2763,6 +2845,23 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
return Object.keys(extra).length > 0 ? extra : undefined return Object.keys(extra).length > 0 ? extra : undefined
} }
const buildSoraExtra = (
base?: Record<string, unknown>,
linkedOpenAIAccountId?: string | number
): Record<string, unknown> | undefined => {
const extra: Record<string, unknown> = { ...(base || {}) }
if (linkedOpenAIAccountId !== undefined && linkedOpenAIAccountId !== null) {
const id = String(linkedOpenAIAccountId).trim()
if (id) {
extra.linked_openai_account_id = id
}
}
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
delete extra.codex_cli_only
return Object.keys(extra).length > 0 ? extra : undefined
}
// Helper function to create account with mixed channel warning handling // Helper function to create account with mixed channel warning handling
const doCreateAccount = async (payload: any) => { const doCreateAccount = async (payload: any) => {
submitting.value = true submitting.value = true
...@@ -2878,7 +2977,7 @@ const handleSubmit = async () => { ...@@ -2878,7 +2977,7 @@ const handleSubmit = async () => {
// Determine default base URL based on platform // Determine default base URL based on platform
const defaultBaseUrl = const defaultBaseUrl =
form.platform === 'openai' (form.platform === 'openai' || form.platform === 'sora')
? 'https://api.openai.com' ? 'https://api.openai.com'
: form.platform === 'gemini' : form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com' ? 'https://generativelanguage.googleapis.com'
...@@ -2930,14 +3029,15 @@ const goBackToBasicInfo = () => { ...@@ -2930,14 +3029,15 @@ const goBackToBasicInfo = () => {
step.value = 1 step.value = 1
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
antigravityOAuth.resetState() antigravityOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
const handleGenerateUrl = async () => { const handleGenerateUrl = async () => {
if (form.platform === 'openai') { if (form.platform === 'openai' || form.platform === 'sora') {
await openaiOAuth.generateAuthUrl(form.proxy_id) await activeOpenAIOAuth.value.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') { } else if (form.platform === 'gemini') {
await geminiOAuth.generateAuthUrl( await geminiOAuth.generateAuthUrl(
form.proxy_id, form.proxy_id,
...@@ -2953,13 +3053,19 @@ const handleGenerateUrl = async () => { ...@@ -2953,13 +3053,19 @@ const handleGenerateUrl = async () => {
} }
const handleValidateRefreshToken = (rt: string) => { const handleValidateRefreshToken = (rt: string) => {
if (form.platform === 'openai') { if (form.platform === 'openai' || form.platform === 'sora') {
handleOpenAIValidateRT(rt) handleOpenAIValidateRT(rt)
} else if (form.platform === 'antigravity') { } else if (form.platform === 'antigravity') {
handleAntigravityValidateRT(rt) handleAntigravityValidateRT(rt)
} }
} }
const handleValidateSessionToken = (sessionToken: string) => {
if (form.platform === 'sora') {
handleSoraValidateST(sessionToken)
}
}
const formatDateTimeLocal = formatDateTimeLocalInput const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput const parseDateTimeLocal = parseDateTimeLocalInput
...@@ -2995,100 +3101,101 @@ const createAccountAndFinish = async ( ...@@ -2995,100 +3101,101 @@ const createAccountAndFinish = async (
// OpenAI OAuth 授权码兑换 // OpenAI OAuth 授权码兑换
const handleOpenAIExchange = async (authCode: string) => { const handleOpenAIExchange = async (authCode: string) => {
if (!authCode.trim() || !openaiOAuth.sessionId.value) return const oauthClient = activeOpenAIOAuth.value
if (!authCode.trim() || !oauthClient.sessionId.value) return
openaiOAuth.loading.value = true oauthClient.loading.value = true
openaiOAuth.error.value = '' oauthClient.error.value = ''
try { try {
const tokenInfo = await openaiOAuth.exchangeAuthCode( const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
if (!stateToUse) {
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
return
}
const tokenInfo = await oauthClient.exchangeAuthCode(
authCode.trim(), authCode.trim(),
openaiOAuth.sessionId.value, oauthClient.sessionId.value,
stateToUse,
form.proxy_id form.proxy_id
) )
if (!tokenInfo) return if (!tokenInfo) return
const credentials = openaiOAuth.buildCredentials(tokenInfo) const credentials = oauthClient.buildCredentials(tokenInfo)
const oauthExtra = openaiOAuth.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined
const extra = buildOpenAIExtra(oauthExtra) const extra = buildOpenAIExtra(oauthExtra)
const shouldCreateOpenAI = form.platform === 'openai'
const shouldCreateSora = form.platform === 'sora'
// 应用临时不可调度配置 // 应用临时不可调度配置
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
// 1. 创建 OpenAI 账号 let openaiAccountId: string | number | undefined
const openaiAccount = await adminAPI.accounts.create({
name: form.name, if (shouldCreateOpenAI) {
notes: form.notes, const openaiAccount = await adminAPI.accounts.create({
platform: 'openai', name: form.name,
type: 'oauth', notes: form.notes,
credentials, platform: 'openai',
extra, type: 'oauth',
proxy_id: form.proxy_id, credentials,
concurrency: form.concurrency, extra,
priority: form.priority, proxy_id: form.proxy_id,
rate_multiplier: form.rate_multiplier, concurrency: form.concurrency,
group_ids: form.group_ids, priority: form.priority,
expires_at: form.expires_at, rate_multiplier: form.rate_multiplier,
auto_pause_on_expired: autoPauseOnExpired.value group_ids: form.group_ids,
}) expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
appStore.showSuccess(t('admin.accounts.accountCreated')) })
openaiAccountId = openaiAccount.id
// 2. 如果启用了 Sora,同时创建 Sora 账号 appStore.showSuccess(t('admin.accounts.accountCreated'))
if (enableSoraOnOpenAIOAuth.value) { }
try {
// Sora 使用相同的 OAuth credentials
const soraCredentials = {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
expires_at: credentials.expires_at
}
// 建立关联关系
const soraExtra: Record<string, unknown> = {
...(extra || {}),
linked_openai_account_id: String(openaiAccount.id)
}
delete soraExtra.openai_passthrough
delete soraExtra.openai_oauth_passthrough
await adminAPI.accounts.create({
name: `${form.name} (Sora)`,
notes: form.notes,
platform: 'sora',
type: 'oauth',
credentials: soraCredentials,
extra: soraExtra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.soraAccountCreated')) if (shouldCreateSora) {
} catch (error: any) { const soraCredentials = {
console.error('创建 Sora 账号失败:', error) access_token: credentials.access_token,
appStore.showWarning(t('admin.accounts.soraAccountFailed')) refresh_token: credentials.refresh_token,
expires_at: credentials.expires_at
} }
const soraName = shouldCreateOpenAI ? `${form.name} (Sora)` : form.name
const soraExtra = buildSoraExtra(shouldCreateOpenAI ? extra : oauthExtra, openaiAccountId)
await adminAPI.accounts.create({
name: soraName,
notes: form.notes,
platform: 'sora',
type: 'oauth',
credentials: soraCredentials,
extra: soraExtra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
} }
emit('created') emit('created')
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value) appStore.showError(oauthClient.error.value)
} finally { } finally {
openaiOAuth.loading.value = false oauthClient.loading.value = false
} }
} }
// OpenAI 手动 RT 批量验证和创建 // OpenAI 手动 RT 批量验证和创建
const handleOpenAIValidateRT = async (refreshTokenInput: string) => { const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
const oauthClient = activeOpenAIOAuth.value
if (!refreshTokenInput.trim()) return if (!refreshTokenInput.trim()) return
// Parse multiple refresh tokens (one per line) // Parse multiple refresh tokens (one per line)
...@@ -3098,45 +3205,164 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { ...@@ -3098,45 +3205,164 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
.filter((rt) => rt) .filter((rt) => rt)
if (refreshTokens.length === 0) { if (refreshTokens.length === 0) {
openaiOAuth.error.value = t('admin.accounts.oauth.openai.pleaseEnterRefreshToken') oauthClient.error.value = t('admin.accounts.oauth.openai.pleaseEnterRefreshToken')
return return
} }
openaiOAuth.loading.value = true oauthClient.loading.value = true
openaiOAuth.error.value = '' oauthClient.error.value = ''
let successCount = 0 let successCount = 0
let failedCount = 0 let failedCount = 0
const errors: string[] = [] const errors: string[] = []
const shouldCreateOpenAI = form.platform === 'openai'
const shouldCreateSora = form.platform === 'sora'
try { try {
for (let i = 0; i < refreshTokens.length; i++) { for (let i = 0; i < refreshTokens.length; i++) {
try { try {
const tokenInfo = await openaiOAuth.validateRefreshToken( const tokenInfo = await oauthClient.validateRefreshToken(
refreshTokens[i], refreshTokens[i],
form.proxy_id form.proxy_id
) )
if (!tokenInfo) { if (!tokenInfo) {
failedCount++ failedCount++
errors.push(`#${i + 1}: ${openaiOAuth.error.value || 'Validation failed'}`) errors.push(`#${i + 1}: ${oauthClient.error.value || 'Validation failed'}`)
openaiOAuth.error.value = '' oauthClient.error.value = ''
continue continue
} }
const credentials = openaiOAuth.buildCredentials(tokenInfo) const credentials = oauthClient.buildCredentials(tokenInfo)
const oauthExtra = openaiOAuth.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined
const extra = buildOpenAIExtra(oauthExtra) const extra = buildOpenAIExtra(oauthExtra)
// Generate account name with index for batch // Generate account name with index for batch
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
let openaiAccountId: string | number | undefined
if (shouldCreateOpenAI) {
const openaiAccount = await adminAPI.accounts.create({
name: accountName,
notes: form.notes,
platform: 'openai',
type: 'oauth',
credentials,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
openaiAccountId = openaiAccount.id
}
if (shouldCreateSora) {
const soraCredentials = {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
expires_at: credentials.expires_at
}
const soraName = shouldCreateOpenAI ? `${accountName} (Sora)` : accountName
const soraExtra = buildSoraExtra(shouldCreateOpenAI ? extra : oauthExtra, openaiAccountId)
await adminAPI.accounts.create({
name: soraName,
notes: form.notes,
platform: 'sora',
type: 'oauth',
credentials: soraCredentials,
extra: soraExtra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
}
successCount++
} catch (error: any) {
failedCount++
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
errors.push(`#${i + 1}: ${errMsg}`)
}
}
// Show results
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
refreshTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
oauthClient.error.value = errors.join('\n')
emit('created')
} else {
oauthClient.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} finally {
oauthClient.loading.value = false
}
}
// Sora 手动 ST 批量验证和创建
const handleSoraValidateST = async (sessionTokenInput: string) => {
const oauthClient = activeOpenAIOAuth.value
if (!sessionTokenInput.trim()) return
const sessionTokens = sessionTokenInput
.split('\n')
.map((st) => st.trim())
.filter((st) => st)
if (sessionTokens.length === 0) {
oauthClient.error.value = t('admin.accounts.oauth.openai.pleaseEnterSessionToken')
return
}
oauthClient.loading.value = true
oauthClient.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < sessionTokens.length; i++) {
try {
const tokenInfo = await oauthClient.validateSessionToken(sessionTokens[i], form.proxy_id)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${oauthClient.error.value || 'Validation failed'}`)
oauthClient.error.value = ''
continue
}
const credentials = oauthClient.buildCredentials(tokenInfo)
credentials.session_token = sessionTokens[i]
const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined
const soraExtra = buildSoraExtra(oauthExtra)
const accountName = sessionTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
await adminAPI.accounts.create({ await adminAPI.accounts.create({
name: accountName, name: accountName,
notes: form.notes, notes: form.notes,
platform: 'openai', platform: 'sora',
type: 'oauth', type: 'oauth',
credentials, credentials,
extra, extra: soraExtra,
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
...@@ -3153,10 +3379,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { ...@@ -3153,10 +3379,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
} }
} }
// Show results
if (successCount > 0 && failedCount === 0) { if (successCount > 0 && failedCount === 0) {
appStore.showSuccess( appStore.showSuccess(
refreshTokens.length > 1 sessionTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount }) ? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated') : t('admin.accounts.accountCreated')
) )
...@@ -3166,14 +3391,14 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { ...@@ -3166,14 +3391,14 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
appStore.showWarning( appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount }) t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
) )
openaiOAuth.error.value = errors.join('\n') oauthClient.error.value = errors.join('\n')
emit('created') emit('created')
} else { } else {
openaiOAuth.error.value = errors.join('\n') oauthClient.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed')) appStore.showError(t('admin.accounts.oauth.batchFailed'))
} }
} finally { } finally {
openaiOAuth.loading.value = false oauthClient.loading.value = false
} }
} }
...@@ -3393,6 +3618,12 @@ const handleAnthropicExchange = async (authCode: string) => { ...@@ -3393,6 +3618,12 @@ const handleAnthropicExchange = async (authCode: string) => {
extra.session_id_masking_enabled = true extra.session_id_masking_enabled = true
} }
// Add cache TTL override settings
if (cacheTTLOverrideEnabled.value) {
extra.cache_ttl_override_enabled = true
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
}
const credentials = { const credentials = {
...tokenInfo, ...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {}) ...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
...@@ -3412,6 +3643,7 @@ const handleExchangeCode = async () => { ...@@ -3412,6 +3643,7 @@ const handleExchangeCode = async () => {
switch (form.platform) { switch (form.platform) {
case 'openai': case 'openai':
case 'sora':
return handleOpenAIExchange(authCode) return handleOpenAIExchange(authCode)
case 'gemini': case 'gemini':
return handleGeminiExchange(authCode) return handleGeminiExchange(authCode)
...@@ -3486,6 +3718,12 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -3486,6 +3718,12 @@ const handleCookieAuth = async (sessionKey: string) => {
extra.session_id_masking_enabled = true extra.session_id_masking_enabled = true
} }
// Add cache TTL override settings
if (cacheTTLOverrideEnabled.value) {
extra.cache_ttl_override_enabled = true
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
}
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
// Merge interceptWarmupRequests into credentials // Merge interceptWarmupRequests into credentials
......
...@@ -975,6 +975,46 @@ ...@@ -975,6 +975,46 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Cache TTL Override -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.cacheTTLOverride.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.cacheTTLOverride.hint') }}
</p>
</div>
<button
type="button"
@click="cacheTTLOverrideEnabled = !cacheTTLOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
cacheTTLOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
cacheTTLOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="cacheTTLOverrideEnabled" class="mt-3">
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.cacheTTLOverride.target') }}</label>
<select
v-model="cacheTTLOverrideTarget"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-white"
>
<option value="5m">5m</option>
<option value="1h">1h</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.cacheTTLOverride.targetHint') }}
</p>
</div>
</div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
...@@ -1177,6 +1217,8 @@ const maxSessions = ref<number | null>(null) ...@@ -1177,6 +1217,8 @@ const maxSessions = ref<number | null>(null)
const sessionIdleTimeout = ref<number | null>(null) const sessionIdleTimeout = ref<number | null>(null)
const tlsFingerprintEnabled = ref(false) const tlsFingerprintEnabled = ref(false)
const sessionIdMaskingEnabled = ref(false) const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
// OpenAI 自动透传开关(OAuth/API Key) // OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
...@@ -1581,6 +1623,8 @@ function loadQuotaControlSettings(account: Account) { ...@@ -1581,6 +1623,8 @@ function loadQuotaControlSettings(account: Account) {
sessionIdleTimeout.value = null sessionIdleTimeout.value = null
tlsFingerprintEnabled.value = false tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
// Only applies to Anthropic OAuth/SetupToken accounts // Only applies to Anthropic OAuth/SetupToken accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
...@@ -1609,6 +1653,12 @@ function loadQuotaControlSettings(account: Account) { ...@@ -1609,6 +1653,12 @@ function loadQuotaControlSettings(account: Account) {
if (account.session_id_masking_enabled === true) { if (account.session_id_masking_enabled === true) {
sessionIdMaskingEnabled.value = true sessionIdMaskingEnabled.value = true
} }
// Load cache TTL override setting
if (account.cache_ttl_override_enabled === true) {
cacheTTLOverrideEnabled.value = true
cacheTTLOverrideTarget.value = account.cache_ttl_override_target || '5m'
}
} }
function formatTempUnschedKeywords(value: unknown) { function formatTempUnschedKeywords(value: unknown) {
...@@ -1820,6 +1870,15 @@ const handleSubmit = async () => { ...@@ -1820,6 +1870,15 @@ const handleSubmit = async () => {
delete newExtra.session_id_masking_enabled delete newExtra.session_id_masking_enabled
} }
// Cache TTL override setting
if (cacheTTLOverrideEnabled.value) {
newExtra.cache_ttl_override_enabled = true
newExtra.cache_ttl_override_target = cacheTTLOverrideTarget.value
} else {
delete newExtra.cache_ttl_override_enabled
delete newExtra.cache_ttl_override_target
}
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
......
...@@ -48,6 +48,17 @@ ...@@ -48,6 +48,17 @@
t(getOAuthKey('refreshTokenAuth')) t(getOAuthKey('refreshTokenAuth'))
}}</span> }}</span>
</label> </label>
<label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="session_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t(getOAuthKey('sessionTokenAuth'))
}}</span>
</label>
</div> </div>
</div> </div>
...@@ -135,6 +146,87 @@ ...@@ -135,6 +146,87 @@
</div> </div>
</div> </div>
<!-- Session Token Input (Sora) -->
<div v-if="inputMethod === 'session_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t(getOAuthKey('sessionTokenDesc')) }}
</p>
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
Session Token
<span
v-if="parsedSessionTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedSessionTokenCount }) }}
</span>
</label>
<textarea
v-model="sessionTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('sessionTokenPlaceholder'))"
></textarea>
<p
v-if="parsedSessionTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedSessionTokenCount }) }}
</p>
</div>
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !sessionTokenInput.trim()"
@click="handleValidateSessionToken"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t(getOAuthKey('validating'))
: t(getOAuthKey('validateAndCreate'))
}}
</button>
</div>
</div>
<!-- Cookie Auto-Auth Form --> <!-- Cookie Auto-Auth Form -->
<div v-if="inputMethod === 'cookie'" class="space-y-4"> <div v-if="inputMethod === 'cookie'" class="space-y-4">
<div <div
...@@ -521,13 +613,14 @@ interface Props { ...@@ -521,13 +613,14 @@ interface Props {
error?: string error?: string
showHelp?: boolean showHelp?: boolean
showProxyWarning?: boolean showProxyWarning?: boolean
allowMultiple?: boolean allowMultiple?: boolean
methodLabel?: string methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option showCookieOption?: boolean // Whether to show cookie auto-auth option
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only) showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
platform?: AccountPlatform // Platform type for different UI/text showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
showProjectId?: boolean // New prop to control project ID visibility platform?: AccountPlatform // Platform type for different UI/text
} showProjectId?: boolean // New prop to control project ID visibility
}
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
authUrl: '', authUrl: '',
...@@ -540,6 +633,7 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -540,6 +633,7 @@ const props = withDefaults(defineProps<Props>(), {
methodLabel: 'Authorization Method', methodLabel: 'Authorization Method',
showCookieOption: true, showCookieOption: true,
showRefreshTokenOption: false, showRefreshTokenOption: false,
showSessionTokenOption: false,
platform: 'anthropic', platform: 'anthropic',
showProjectId: true showProjectId: true
}) })
...@@ -549,6 +643,7 @@ const emit = defineEmits<{ ...@@ -549,6 +643,7 @@ const emit = defineEmits<{
'exchange-code': [code: string] 'exchange-code': [code: string]
'cookie-auth': [sessionKey: string] 'cookie-auth': [sessionKey: string]
'validate-refresh-token': [refreshToken: string] 'validate-refresh-token': [refreshToken: string]
'validate-session-token': [sessionToken: string]
'update:inputMethod': [method: AuthInputMethod] 'update:inputMethod': [method: AuthInputMethod]
}>() }>()
...@@ -587,12 +682,13 @@ const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'ma ...@@ -587,12 +682,13 @@ const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'ma
const authCodeInput = ref('') const authCodeInput = ref('')
const sessionKeyInput = ref('') const sessionKeyInput = ref('')
const refreshTokenInput = ref('') const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
const showHelpDialog = ref(false) const showHelpDialog = ref(false)
const oauthState = ref('') const oauthState = ref('')
const projectId = ref('') const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled // Computed: show method selection when either cookie or refresh token option is enabled
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption) const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption)
// Clipboard // Clipboard
const { copied, copyToClipboard } = useClipboard() const { copied, copyToClipboard } = useClipboard()
...@@ -613,6 +709,13 @@ const parsedRefreshTokenCount = computed(() => { ...@@ -613,6 +709,13 @@ const parsedRefreshTokenCount = computed(() => {
.filter((rt) => rt).length .filter((rt) => rt).length
}) })
const parsedSessionTokenCount = computed(() => {
return sessionTokenInput.value
.split('\n')
.map((st) => st.trim())
.filter((st) => st).length
})
// Watchers // Watchers
watch(inputMethod, (newVal) => { watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal) emit('update:inputMethod', newVal)
...@@ -631,7 +734,7 @@ watch(authCodeInput, (newVal) => { ...@@ -631,7 +734,7 @@ watch(authCodeInput, (newVal) => {
const url = new URL(trimmed) const url = new URL(trimmed)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state') const stateParam = url.searchParams.get('state')
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) { if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
oauthState.value = stateParam oauthState.value = stateParam
} }
if (code && code !== trimmed) { if (code && code !== trimmed) {
...@@ -642,7 +745,7 @@ watch(authCodeInput, (newVal) => { ...@@ -642,7 +745,7 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction // If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/) const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/) const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) { if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1] oauthState.value = stateMatch[1]
} }
if (match && match[1] && match[1] !== trimmed) { if (match && match[1] && match[1] !== trimmed) {
...@@ -680,6 +783,12 @@ const handleValidateRefreshToken = () => { ...@@ -680,6 +783,12 @@ const handleValidateRefreshToken = () => {
} }
} }
const handleValidateSessionToken = () => {
if (sessionTokenInput.value.trim()) {
emit('validate-session-token', sessionTokenInput.value.trim())
}
}
// Expose methods and state // Expose methods and state
defineExpose({ defineExpose({
authCode: authCodeInput, authCode: authCodeInput,
...@@ -687,6 +796,7 @@ defineExpose({ ...@@ -687,6 +796,7 @@ defineExpose({
projectId, projectId,
sessionKey: sessionKeyInput, sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput, refreshToken: refreshTokenInput,
sessionToken: sessionTokenInput,
inputMethod, inputMethod,
reset: () => { reset: () => {
authCodeInput.value = '' authCodeInput.value = ''
...@@ -694,6 +804,7 @@ defineExpose({ ...@@ -694,6 +804,7 @@ defineExpose({
projectId.value = '' projectId.value = ''
sessionKeyInput.value = '' sessionKeyInput.value = ''
refreshTokenInput.value = '' refreshTokenInput.value = ''
sessionTokenInput.value = ''
inputMethod.value = 'manual' inputMethod.value = 'manual'
showHelpDialog.value = false showHelpDialog.value = false
} }
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<div <div
:class="[ :class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br', 'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI isOpenAILike
? 'from-green-500 to-green-600' ? 'from-green-500 to-green-600'
: isGemini : isGemini
? 'from-blue-500 to-blue-600' ? 'from-blue-500 to-blue-600'
...@@ -33,6 +33,8 @@ ...@@ -33,6 +33,8 @@
{{ {{
isOpenAI isOpenAI
? t('admin.accounts.openaiAccount') ? t('admin.accounts.openaiAccount')
: isSora
? t('admin.accounts.soraAccount')
: isGemini : isGemini
? t('admin.accounts.geminiAccount') ? t('admin.accounts.geminiAccount')
: isAntigravity : isAntigravity
...@@ -128,7 +130,7 @@ ...@@ -128,7 +130,7 @@
:show-cookie-option="isAnthropic" :show-cookie-option="isAnthropic"
:allow-multiple="false" :allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')" :method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'" :platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'" :show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
...@@ -224,7 +226,8 @@ const { t } = useI18n() ...@@ -224,7 +226,8 @@ const { t } = useI18n()
// OAuth composables // OAuth composables
const claudeOAuth = useAccountOAuth() const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth() const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
const geminiOAuth = useGeminiOAuth() const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth() const antigravityOAuth = useAntigravityOAuth()
...@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as ...@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform // Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai') const isOpenAI = computed(() => props.account?.platform === 'openai')
const isSora = computed(() => props.account?.platform === 'sora')
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
const isGemini = computed(() => props.account?.platform === 'gemini') const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic') const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity') const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform // Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => { const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value return claudeOAuth.authUrl.value
}) })
const currentSessionId = computed(() => { const currentSessionId = computed(() => {
if (isOpenAI.value) return openaiOAuth.sessionId.value if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value return claudeOAuth.sessionId.value
}) })
const currentLoading = computed(() => { const currentLoading = computed(() => {
if (isOpenAI.value) return openaiOAuth.loading.value if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
if (isGemini.value) return geminiOAuth.loading.value if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value return claudeOAuth.loading.value
}) })
const currentError = computed(() => { const currentError = computed(() => {
if (isOpenAI.value) return openaiOAuth.error.value if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
if (isGemini.value) return geminiOAuth.error.value if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value return claudeOAuth.error.value
...@@ -269,8 +275,8 @@ const currentError = computed(() => { ...@@ -269,8 +275,8 @@ const currentError = computed(() => {
// Computed // Computed
const isManualInputMethod = computed(() => { const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option) // OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual' return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
}) })
const canExchangeCode = computed(() => { const canExchangeCode = computed(() => {
...@@ -313,6 +319,7 @@ const resetState = () => { ...@@ -313,6 +319,7 @@ const resetState = () => {
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
antigravityOAuth.resetState() antigravityOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
...@@ -325,8 +332,8 @@ const handleClose = () => { ...@@ -325,8 +332,8 @@ const handleClose = () => {
const handleGenerateUrl = async () => { const handleGenerateUrl = async () => {
if (!props.account) return if (!props.account) return
if (isOpenAI.value) { if (isOpenAILike.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) { } else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown> const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
...@@ -345,21 +352,29 @@ const handleExchangeCode = async () => { ...@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return if (!authCode.trim()) return
if (isOpenAI.value) { if (isOpenAILike.value) {
// OpenAI OAuth flow // OpenAI OAuth flow
const sessionId = openaiOAuth.sessionId.value const oauthClient = activeOpenAIOAuth.value
const sessionId = oauthClient.sessionId.value
if (!sessionId) return if (!sessionId) return
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
if (!stateToUse) {
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
return
}
const tokenInfo = await openaiOAuth.exchangeAuthCode( const tokenInfo = await oauthClient.exchangeAuthCode(
authCode.trim(), authCode.trim(),
sessionId, sessionId,
stateToUse,
props.account.proxy_id props.account.proxy_id
) )
if (!tokenInfo) return if (!tokenInfo) return
// Build credentials and extra info // Build credentials and extra info
const credentials = openaiOAuth.buildCredentials(tokenInfo) const credentials = oauthClient.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo) const extra = oauthClient.buildExtraInfo(tokenInfo)
try { try {
// Update account with new credentials // Update account with new credentials
...@@ -376,8 +391,8 @@ const handleExchangeCode = async () => { ...@@ -376,8 +391,8 @@ const handleExchangeCode = async () => {
emit('reauthorized') emit('reauthorized')
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value) appStore.showError(oauthClient.error.value)
} }
} else if (isGemini.value) { } else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value const sessionId = geminiOAuth.sessionId.value
...@@ -490,7 +505,7 @@ const handleExchangeCode = async () => { ...@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
} }
const handleCookieAuth = async (sessionKey: string) => { const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return if (!props.account || isOpenAILike.value) return
claudeOAuth.loading.value = true claudeOAuth.loading.value = true
claudeOAuth.error.value = '' claudeOAuth.error.value = ''
......
...@@ -10,16 +10,21 @@ ...@@ -10,16 +10,21 @@
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" /> <Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" /> <Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" /> <Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
<Select :model-value="filters.group" class="w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue' import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n() import type { AdminGroup } from '@/types'
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) } 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 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 updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: 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' }, { value: 'sora', label: 'Sora' }])
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 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') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }]) 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') }])
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
</script> </script>
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
</span> </span>
</div> </div>
<div class="space-y-1.5"> <div v-if="!isSoraAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }} {{ t('admin.accounts.selectTestModel') }}
</label> </label>
...@@ -54,6 +54,12 @@ ...@@ -54,6 +54,12 @@
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')" :placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/> />
</div> </div>
<div
v-else
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{ t('admin.accounts.soraTestHint') }}
</div>
<!-- Terminal Output --> <!-- Terminal Output -->
<div class="group relative"> <div class="group relative">
...@@ -114,12 +120,12 @@ ...@@ -114,12 +120,12 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Icon name="grid" size="sm" :stroke-width="2" /> <Icon name="grid" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testModel') }} {{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
</span> </span>
</div> </div>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" /> <Icon name="chat" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testPrompt') }} {{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
</span> </span>
</div> </div>
</div> </div>
...@@ -135,10 +141,10 @@ ...@@ -135,10 +141,10 @@
</button> </button>
<button <button
@click="startTest" @click="startTest"
:disabled="status === 'connecting' || !selectedModelId" :disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
:class="[ :class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId status === 'connecting' || (!isSoraAccount && !selectedModelId)
? 'cursor-not-allowed bg-primary-400 text-white' ? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success' : status === 'success'
? 'bg-green-500 text-white hover:bg-green-600' ? 'bg-green-500 text-white hover:bg-green-600'
...@@ -172,7 +178,7 @@ ...@@ -172,7 +178,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -207,6 +213,7 @@ const availableModels = ref<ClaudeModel[]>([]) ...@@ -207,6 +213,7 @@ const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('') const selectedModelId = ref('')
const loadingModels = ref(false) const loadingModels = ref(false)
let eventSource: EventSource | null = null let eventSource: EventSource | null = null
const isSoraAccount = computed(() => props.account?.platform === 'sora')
// Load available models when modal opens // Load available models when modal opens
watch( watch(
...@@ -223,6 +230,12 @@ watch( ...@@ -223,6 +230,12 @@ watch(
const loadAvailableModels = async () => { const loadAvailableModels = async () => {
if (!props.account) return if (!props.account) return
if (props.account.platform === 'sora') {
availableModels.value = []
selectedModelId.value = ''
loadingModels.value = false
return
}
loadingModels.value = true loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading selectedModelId.value = '' // Reset selection before loading
...@@ -290,7 +303,7 @@ const scrollToBottom = async () => { ...@@ -290,7 +303,7 @@ const scrollToBottom = async () => {
} }
const startTest = async () => { const startTest = async () => {
if (!props.account || !selectedModelId.value) return if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
resetState() resetState()
status.value = 'connecting' status.value = 'connecting'
...@@ -311,7 +324,9 @@ const startTest = async () => { ...@@ -311,7 +324,9 @@ const startTest = async () => {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`, Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ model_id: selectedModelId.value }) body: JSON.stringify(
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
)
}) })
if (!response.ok) { if (!response.ok) {
...@@ -368,7 +383,10 @@ const handleEvent = (event: { ...@@ -368,7 +383,10 @@ const handleEvent = (event: {
if (event.model) { if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400') addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
} }
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400') addLine(
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
addLine('', 'text-gray-300') addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400') addLine(t('admin.accounts.response'), 'text-yellow-400')
break break
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<div <div
:class="[ :class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br', 'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI isOpenAILike
? 'from-green-500 to-green-600' ? 'from-green-500 to-green-600'
: isGemini : isGemini
? 'from-blue-500 to-blue-600' ? 'from-blue-500 to-blue-600'
...@@ -33,6 +33,8 @@ ...@@ -33,6 +33,8 @@
{{ {{
isOpenAI isOpenAI
? t('admin.accounts.openaiAccount') ? t('admin.accounts.openaiAccount')
: isSora
? t('admin.accounts.soraAccount')
: isGemini : isGemini
? t('admin.accounts.geminiAccount') ? t('admin.accounts.geminiAccount')
: isAntigravity : isAntigravity
...@@ -128,7 +130,7 @@ ...@@ -128,7 +130,7 @@
:show-cookie-option="isAnthropic" :show-cookie-option="isAnthropic"
:allow-multiple="false" :allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')" :method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'" :platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'" :show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
...@@ -224,7 +226,8 @@ const { t } = useI18n() ...@@ -224,7 +226,8 @@ const { t } = useI18n()
// OAuth composables // OAuth composables
const claudeOAuth = useAccountOAuth() const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth() const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
const geminiOAuth = useGeminiOAuth() const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth() const antigravityOAuth = useAntigravityOAuth()
...@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as ...@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform // Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai') const isOpenAI = computed(() => props.account?.platform === 'openai')
const isSora = computed(() => props.account?.platform === 'sora')
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
const isGemini = computed(() => props.account?.platform === 'gemini') const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic') const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity') const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform // Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => { const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value return claudeOAuth.authUrl.value
}) })
const currentSessionId = computed(() => { const currentSessionId = computed(() => {
if (isOpenAI.value) return openaiOAuth.sessionId.value if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value return claudeOAuth.sessionId.value
}) })
const currentLoading = computed(() => { const currentLoading = computed(() => {
if (isOpenAI.value) return openaiOAuth.loading.value if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
if (isGemini.value) return geminiOAuth.loading.value if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value return claudeOAuth.loading.value
}) })
const currentError = computed(() => { const currentError = computed(() => {
if (isOpenAI.value) return openaiOAuth.error.value if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
if (isGemini.value) return geminiOAuth.error.value if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value return claudeOAuth.error.value
...@@ -269,8 +275,8 @@ const currentError = computed(() => { ...@@ -269,8 +275,8 @@ const currentError = computed(() => {
// Computed // Computed
const isManualInputMethod = computed(() => { const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option) // OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual' return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
}) })
const canExchangeCode = computed(() => { const canExchangeCode = computed(() => {
...@@ -313,6 +319,7 @@ const resetState = () => { ...@@ -313,6 +319,7 @@ const resetState = () => {
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
antigravityOAuth.resetState() antigravityOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
...@@ -325,8 +332,8 @@ const handleClose = () => { ...@@ -325,8 +332,8 @@ const handleClose = () => {
const handleGenerateUrl = async () => { const handleGenerateUrl = async () => {
if (!props.account) return if (!props.account) return
if (isOpenAI.value) { if (isOpenAILike.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) { } else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown> const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
...@@ -345,21 +352,29 @@ const handleExchangeCode = async () => { ...@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return if (!authCode.trim()) return
if (isOpenAI.value) { if (isOpenAILike.value) {
// OpenAI OAuth flow // OpenAI OAuth flow
const sessionId = openaiOAuth.sessionId.value const oauthClient = activeOpenAIOAuth.value
const sessionId = oauthClient.sessionId.value
if (!sessionId) return if (!sessionId) return
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
if (!stateToUse) {
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
return
}
const tokenInfo = await openaiOAuth.exchangeAuthCode( const tokenInfo = await oauthClient.exchangeAuthCode(
authCode.trim(), authCode.trim(),
sessionId, sessionId,
stateToUse,
props.account.proxy_id props.account.proxy_id
) )
if (!tokenInfo) return if (!tokenInfo) return
// Build credentials and extra info // Build credentials and extra info
const credentials = openaiOAuth.buildCredentials(tokenInfo) const credentials = oauthClient.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo) const extra = oauthClient.buildExtraInfo(tokenInfo)
try { try {
// Update account with new credentials // Update account with new credentials
...@@ -376,8 +391,8 @@ const handleExchangeCode = async () => { ...@@ -376,8 +391,8 @@ const handleExchangeCode = async () => {
emit('reauthorized', updatedAccount) emit('reauthorized', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value) appStore.showError(oauthClient.error.value)
} }
} else if (isGemini.value) { } else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value const sessionId = geminiOAuth.sessionId.value
...@@ -490,7 +505,7 @@ const handleExchangeCode = async () => { ...@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
} }
const handleCookieAuth = async (sessionKey: string) => { const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return if (!props.account || isOpenAILike.value) return
claudeOAuth.loading.value = true claudeOAuth.loading.value = true
claudeOAuth.error.value = '' claudeOAuth.error.value = ''
......
...@@ -70,6 +70,8 @@ ...@@ -70,6 +70,8 @@
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1"> <div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg> <svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span> <span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -157,9 +159,36 @@ ...@@ -157,9 +159,36 @@
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span> <span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span> <span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div> </div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4"> <div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span> <!-- 有 5m/1h 明细时,展开显示 -->
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span> <template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation5mTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation1hTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
</div>
</template>
<!-- 无明细时,只显示聚合值 -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('usage.cacheTtlOverriddenLabel') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
</span>
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
</div> </div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4"> <div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span> <span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="stat-label truncate">{{ title }}</p> <p class="stat-label truncate">{{ title }}</p>
<div class="mt-1 flex items-baseline gap-2"> <div class="mt-1 flex items-baseline gap-2">
<p class="stat-value">{{ formattedValue }}</p> <p class="stat-value" :title="String(formattedValue)">{{ formattedValue }}</p>
<span v-if="change !== undefined" :class="['stat-trend', trendClass]"> <span v-if="change !== undefined" :class="['stat-trend', trendClass]">
<Icon <Icon
v-if="changeType !== 'neutral'" v-if="changeType !== 'neutral'"
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<div class="sidebar-header"> <div class="sidebar-header">
<!-- Custom Logo or Default Logo --> <!-- Custom Logo or Default Logo -->
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow"> <div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" /> <img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div> </div>
<transition name="fade"> <transition name="fade">
<div v-if="!sidebarCollapsed" class="flex flex-col"> <div v-if="!sidebarCollapsed" class="flex flex-col">
...@@ -167,6 +167,7 @@ const isDark = ref(document.documentElement.classList.contains('dark')) ...@@ -167,6 +167,7 @@ const isDark = ref(document.documentElement.classList.contains('dark'))
const siteName = computed(() => appStore.siteName) const siteName = computed(() => appStore.siteName)
const siteLogo = computed(() => appStore.siteLogo) const siteLogo = computed(() => appStore.siteLogo)
const siteVersion = computed(() => appStore.siteVersion) const siteVersion = computed(() => appStore.siteVersion)
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
// SVG Icon Components // SVG Icon Components
const DashboardIcon = { const DashboardIcon = {
......
...@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app' ...@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token' export type AddMethod = 'oauth' | 'setup-token'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'session_token'
export interface OAuthState { export interface OAuthState {
authUrl: string authUrl: string
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment