Commit 900cce20 authored by yangjianbo's avatar yangjianbo
Browse files

feat(sora): 对齐 Sora OAuth 流程并隔离网关请求路径



- 新增并接通 Sora 专用 OAuth 接口与 ST/RT 换取能力
- 完成前端 Sora 授权、RT/ST 手动导入与账号创建流程
- 强化 Sora token 恢复、转发日志与网关路由隔离行为
- 补充后端服务层与路由层相关测试覆盖
Co-Authored-By: default avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 36bb3270
......@@ -18,6 +18,8 @@ type stubSoraClientForPoll struct {
videoStatus *SoraVideoTaskStatus
imageCalls int
videoCalls int
enhanced string
enhanceErr error
}
func (s *stubSoraClientForPoll) Enabled() bool { return true }
......@@ -30,6 +32,12 @@ func (s *stubSoraClientForPoll) CreateImageTask(ctx context.Context, account *Ac
func (s *stubSoraClientForPoll) CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error) {
return "task-video", nil
}
func (s *stubSoraClientForPoll) EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error) {
if s.enhanced != "" {
return s.enhanced, s.enhanceErr
}
return "enhanced prompt", s.enhanceErr
}
func (s *stubSoraClientForPoll) GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error) {
s.imageCalls++
return s.imageStatus, nil
......@@ -62,6 +70,33 @@ func TestSoraGatewayService_PollImageTaskCompleted(t *testing.T) {
require.Equal(t, 1, client.imageCalls)
}
func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
client := &stubSoraClientForPoll{
enhanced: "cinematic prompt",
}
cfg := &config.Config{
Sora: config.SoraConfig{
Client: config.SoraClientConfig{
PollIntervalSeconds: 1,
MaxPollAttempts: 1,
},
},
}
svc := NewSoraGatewayService(client, nil, nil, cfg)
account := &Account{
ID: 1,
Platform: PlatformSora,
Status: StatusActive,
}
body := []byte(`{"model":"prompt-enhance-short-10s","messages":[{"role":"user","content":"cat running"}],"stream":false}`)
result, err := svc.Forward(context.Background(), nil, account, body, false)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "prompt", result.MediaType)
require.Equal(t, "prompt-enhance-short-10s", result.Model)
}
func TestSoraGatewayService_PollVideoTaskFailed(t *testing.T) {
client := &stubSoraClientForPoll{
videoStatus: &SoraVideoTaskStatus{
......@@ -178,6 +213,7 @@ func TestSoraProErrorMessage(t *testing.T) {
func TestShouldFailoverUpstreamError(t *testing.T) {
svc := NewSoraGatewayService(nil, nil, nil, &config.Config{})
require.True(t, svc.shouldFailoverUpstreamError(401))
require.True(t, svc.shouldFailoverUpstreamError(404))
require.True(t, svc.shouldFailoverUpstreamError(429))
require.True(t, svc.shouldFailoverUpstreamError(500))
require.True(t, svc.shouldFailoverUpstreamError(502))
......
......@@ -17,6 +17,9 @@ type SoraModelConfig struct {
Model string
Size string
RequirePro bool
// Prompt-enhance 专用参数
ExpansionLevel string
DurationS int
}
var soraModelConfigs = map[string]SoraModelConfig{
......@@ -160,31 +163,49 @@ var soraModelConfigs = map[string]SoraModelConfig{
RequirePro: true,
},
"prompt-enhance-short-10s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "short",
DurationS: 10,
},
"prompt-enhance-short-15s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "short",
DurationS: 15,
},
"prompt-enhance-short-20s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "short",
DurationS: 20,
},
"prompt-enhance-medium-10s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "medium",
DurationS: 10,
},
"prompt-enhance-medium-15s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "medium",
DurationS: 15,
},
"prompt-enhance-medium-20s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "medium",
DurationS: 20,
},
"prompt-enhance-long-10s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "long",
DurationS: 10,
},
"prompt-enhance-long-15s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "long",
DurationS: 15,
},
"prompt-enhance-long-20s": {
Type: "prompt_enhance",
Type: "prompt_enhance",
ExpansionLevel: "long",
DurationS: 20,
},
}
......
......@@ -43,10 +43,13 @@ func NewTokenRefreshService(
stopCh: make(chan struct{}),
}
openAIRefresher := NewOpenAITokenRefresher(openaiOAuthService, accountRepo)
openAIRefresher.SetSyncLinkedSoraAccounts(cfg.TokenRefresh.SyncLinkedSoraAccounts)
// 注册平台特定的刷新器
s.refreshers = []TokenRefresher{
NewClaudeTokenRefresher(oauthService),
NewOpenAITokenRefresher(openaiOAuthService, accountRepo),
openAIRefresher,
NewGeminiTokenRefresher(geminiOAuthService),
NewAntigravityTokenRefresher(antigravityOAuthService),
}
......
......@@ -86,6 +86,7 @@ type OpenAITokenRefresher struct {
openaiOAuthService *OpenAIOAuthService
accountRepo AccountRepository
soraAccountRepo SoraAccountRepository // Sora 扩展表仓储,用于双表同步
syncLinkedSora bool
}
// NewOpenAITokenRefresher 创建 OpenAI token刷新器
......@@ -103,11 +104,15 @@ func (r *OpenAITokenRefresher) SetSoraAccountRepo(repo SoraAccountRepository) {
r.soraAccountRepo = repo
}
// SetSyncLinkedSoraAccounts 控制是否同步覆盖关联的 Sora 账号 token。
func (r *OpenAITokenRefresher) SetSyncLinkedSoraAccounts(enabled bool) {
r.syncLinkedSora = enabled
}
// CanRefresh 检查是否能处理此账号
// 只处理 openai 平台的 oauth 类型账号
// 只处理 openai 平台的 oauth 类型账号(不直接刷新 sora 平台账号)
func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool {
return (account.Platform == PlatformOpenAI || account.Platform == PlatformSora) &&
account.Type == AccountTypeOAuth
return account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth
}
// NeedsRefresh 检查token是否需要刷新
......@@ -141,7 +146,7 @@ func (r *OpenAITokenRefresher) Refresh(ctx context.Context, account *Account) (m
}
// 异步同步关联的 Sora 账号(不阻塞主流程)
if r.accountRepo != nil {
if r.accountRepo != nil && r.syncLinkedSora {
go r.syncLinkedSoraAccounts(context.Background(), account.ID, newCredentials)
}
......
......@@ -226,3 +226,43 @@ func TestClaudeTokenRefresher_CanRefresh(t *testing.T) {
})
}
}
func TestOpenAITokenRefresher_CanRefresh(t *testing.T) {
refresher := &OpenAITokenRefresher{}
tests := []struct {
name string
platform string
accType string
want bool
}{
{
name: "openai oauth - can refresh",
platform: PlatformOpenAI,
accType: AccountTypeOAuth,
want: true,
},
{
name: "sora oauth - cannot refresh directly",
platform: PlatformSora,
accType: AccountTypeOAuth,
want: false,
},
{
name: "openai apikey - cannot refresh",
platform: PlatformOpenAI,
accType: AccountTypeAPIKey,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: tt.platform,
Type: tt.accType,
}
require.Equal(t, tt.want, refresher.CanRefresh(account))
})
}
}
......@@ -206,6 +206,18 @@ func ProvideSoraMediaStorage(cfg *config.Config) *SoraMediaStorage {
return NewSoraMediaStorage(cfg)
}
func ProvideSoraDirectClient(
cfg *config.Config,
httpUpstream HTTPUpstream,
tokenProvider *OpenAITokenProvider,
accountRepo AccountRepository,
soraAccountRepo SoraAccountRepository,
) *SoraDirectClient {
client := NewSoraDirectClient(cfg, httpUpstream, tokenProvider)
client.SetAccountRepositories(accountRepo, soraAccountRepo)
return client
}
// ProvideSoraMediaCleanupService 创建并启动 Sora 媒体清理服务
func ProvideSoraMediaCleanupService(storage *SoraMediaStorage, cfg *config.Config) *SoraMediaCleanupService {
svc := NewSoraMediaCleanupService(storage, cfg)
......@@ -255,7 +267,7 @@ var ProviderSet = wire.NewSet(
NewGatewayService,
ProvideSoraMediaStorage,
ProvideSoraMediaCleanupService,
NewSoraDirectClient,
ProvideSoraDirectClient,
wire.Bind(new(SoraClient), new(*SoraDirectClient)),
NewSoraGatewayService,
NewOpenAIGatewayService,
......
......@@ -86,6 +86,7 @@ func (s *FrontendServer) Middleware() gin.HandlerFunc {
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/sora/") ||
strings.HasPrefix(path, "/antigravity/") ||
strings.HasPrefix(path, "/setup/") ||
path == "/health" ||
......@@ -209,6 +210,7 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/sora/") ||
strings.HasPrefix(path, "/antigravity/") ||
strings.HasPrefix(path, "/setup/") ||
path == "/health" ||
......
......@@ -362,6 +362,7 @@ func TestFrontendServer_Middleware(t *testing.T) {
"/api/v1/users",
"/v1/models",
"/v1beta/chat",
"/sora/v1/models",
"/antigravity/test",
"/setup/init",
"/health",
......@@ -537,6 +538,7 @@ func TestServeEmbeddedFrontend(t *testing.T) {
"/api/users",
"/v1/models",
"/v1beta/chat",
"/sora/v1/models",
"/antigravity/test",
"/setup/init",
"/health",
......
......@@ -388,7 +388,11 @@ sora:
recent_task_limit_max: 200
# Enable debug logs for Sora upstream requests
# 启用 Sora 直连调试日志
# 调试日志会输出上游请求尝试、重试、响应摘要;Authorization/openai-sentinel-token 等敏感头会自动脱敏
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)
# 额外请求头(键值对)
headers: {}
......@@ -431,6 +435,13 @@ sora:
# Cron 调度表达式
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 认证缓存配置
......
......@@ -220,7 +220,7 @@ export async function generateAuthUrl(
*/
export async function exchangeCode(
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>> {
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
return data
......@@ -442,7 +442,8 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
*/
export async function refreshOpenAIToken(
refreshToken: string,
proxyId?: number | null
proxyId?: number | null,
endpoint: string = '/admin/openai/refresh-token'
): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = {
refresh_token: refreshToken
......@@ -450,7 +451,29 @@ export async function refreshOpenAIToken(
if (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
}
......@@ -475,6 +498,7 @@ export const accountsAPI = {
generateAuthUrl,
exchangeCode,
refreshOpenAIToken,
validateSoraSessionToken,
batchCreate,
batchUpdateCredentials,
bulkUpdate,
......
......@@ -48,6 +48,17 @@
t(getOAuthKey('refreshTokenAuth'))
}}</span>
</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>
......@@ -135,6 +146,87 @@
</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 -->
<div v-if="inputMethod === 'cookie'" class="space-y-4">
<div
......@@ -521,13 +613,14 @@ interface Props {
error?: string
showHelp?: boolean
showProxyWarning?: boolean
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
const props = withDefaults(defineProps<Props>(), {
authUrl: '',
......@@ -540,6 +633,7 @@ const props = withDefaults(defineProps<Props>(), {
methodLabel: 'Authorization Method',
showCookieOption: true,
showRefreshTokenOption: false,
showSessionTokenOption: false,
platform: 'anthropic',
showProjectId: true
})
......@@ -549,6 +643,7 @@ const emit = defineEmits<{
'exchange-code': [code: string]
'cookie-auth': [sessionKey: string]
'validate-refresh-token': [refreshToken: string]
'validate-session-token': [sessionToken: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
......@@ -587,12 +682,13 @@ const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'ma
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// 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
const { copied, copyToClipboard } = useClipboard()
......@@ -613,6 +709,13 @@ const parsedRefreshTokenCount = computed(() => {
.filter((rt) => rt).length
})
const parsedSessionTokenCount = computed(() => {
return sessionTokenInput.value
.split('\n')
.map((st) => st.trim())
.filter((st) => st).length
})
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
......@@ -631,7 +734,7 @@ watch(authCodeInput, (newVal) => {
const url = new URL(trimmed)
const code = url.searchParams.get('code')
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
}
if (code && code !== trimmed) {
......@@ -642,7 +745,7 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
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]
}
if (match && match[1] && match[1] !== trimmed) {
......@@ -680,6 +783,12 @@ const handleValidateRefreshToken = () => {
}
}
const handleValidateSessionToken = () => {
if (sessionTokenInput.value.trim()) {
emit('validate-session-token', sessionTokenInput.value.trim())
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
......@@ -687,6 +796,7 @@ defineExpose({
projectId,
sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput,
sessionToken: sessionTokenInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
......@@ -694,6 +804,7 @@ defineExpose({
projectId.value = ''
sessionKeyInput.value = ''
refreshTokenInput.value = ''
sessionTokenInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
}
......
......@@ -14,7 +14,7 @@
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI
isOpenAILike
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
......@@ -33,6 +33,8 @@
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isSora
? t('admin.accounts.soraAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: isAntigravity
......@@ -128,7 +130,7 @@
:show-cookie-option="isAnthropic"
:allow-multiple="false"
: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'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
......@@ -224,7 +226,8 @@ const { t } = useI18n()
// OAuth composables
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
......@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isSora = computed(() => props.account?.platform === 'sora')
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
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 (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
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 (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
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 (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
......@@ -269,8 +275,8 @@ const currentError = computed(() => {
// Computed
const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
......@@ -313,6 +319,7 @@ const resetState = () => {
geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState()
openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
......@@ -325,8 +332,8 @@ const handleClose = () => {
const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
if (isOpenAILike.value) {
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
......@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return
if (isOpenAI.value) {
if (isOpenAILike.value) {
// OpenAI OAuth flow
const sessionId = openaiOAuth.sessionId.value
const oauthClient = activeOpenAIOAuth.value
const sessionId = oauthClient.sessionId.value
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(),
sessionId,
stateToUse,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
const credentials = oauthClient.buildCredentials(tokenInfo)
const extra = oauthClient.buildExtraInfo(tokenInfo)
try {
// Update account with new credentials
......@@ -376,8 +391,8 @@ const handleExchangeCode = async () => {
emit('reauthorized')
handleClose()
} catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value)
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
}
} else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value
......@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
}
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return
if (!props.account || isOpenAILike.value) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
......
......@@ -238,6 +238,11 @@ const loadAvailableModels = async () => {
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
selectedModelId.value = preferred?.id || availableModels.value[0].id
} else if (props.account.platform === 'sora') {
const preferred =
availableModels.value.find((m) => m.id === 'gpt-image') ||
availableModels.value.find((m) => !m.id.startsWith('prompt-enhance'))
selectedModelId.value = preferred?.id || availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
......
......@@ -14,7 +14,7 @@
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI
isOpenAILike
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
......@@ -33,6 +33,8 @@
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isSora
? t('admin.accounts.soraAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: isAntigravity
......@@ -128,7 +130,7 @@
:show-cookie-option="isAnthropic"
:allow-multiple="false"
: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'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
......@@ -224,7 +226,8 @@ const { t } = useI18n()
// OAuth composables
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
......@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isSora = computed(() => props.account?.platform === 'sora')
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
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 (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
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 (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
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 (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
......@@ -269,8 +275,8 @@ const currentError = computed(() => {
// Computed
const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
......@@ -313,6 +319,7 @@ const resetState = () => {
geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState()
openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
......@@ -325,8 +332,8 @@ const handleClose = () => {
const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
if (isOpenAILike.value) {
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
......@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return
if (isOpenAI.value) {
if (isOpenAILike.value) {
// OpenAI OAuth flow
const sessionId = openaiOAuth.sessionId.value
const oauthClient = activeOpenAIOAuth.value
const sessionId = oauthClient.sessionId.value
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(),
sessionId,
stateToUse,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
const credentials = oauthClient.buildCredentials(tokenInfo)
const extra = oauthClient.buildExtraInfo(tokenInfo)
try {
// Update account with new credentials
......@@ -376,8 +391,8 @@ const handleExchangeCode = async () => {
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value)
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
}
} else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value
......@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
}
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return
if (!props.account || isOpenAILike.value) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
......
......@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
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 {
authUrl: string
......
......@@ -19,12 +19,21 @@ export interface OpenAITokenInfo {
[key: string]: unknown
}
export function useOpenAIOAuth() {
export type OpenAIOAuthPlatform = 'openai' | 'sora'
interface UseOpenAIOAuthOptions {
platform?: OpenAIOAuthPlatform
}
export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
const appStore = useAppStore()
const oauthPlatform = options?.platform ?? 'openai'
const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
// State
const authUrl = ref('')
const sessionId = ref('')
const oauthState = ref('')
const loading = ref(false)
const error = ref('')
......@@ -32,6 +41,7 @@ export function useOpenAIOAuth() {
const resetState = () => {
authUrl.value = ''
sessionId.value = ''
oauthState.value = ''
loading.value = false
error.value = ''
}
......@@ -44,6 +54,7 @@ export function useOpenAIOAuth() {
loading.value = true
authUrl.value = ''
sessionId.value = ''
oauthState.value = ''
error.value = ''
try {
......@@ -56,11 +67,17 @@ export function useOpenAIOAuth() {
}
const response = await adminAPI.accounts.generateAuthUrl(
'/admin/openai/generate-auth-url',
`${endpointPrefix}/generate-auth-url`,
payload
)
authUrl.value = response.auth_url
sessionId.value = response.session_id
try {
const parsed = new URL(response.auth_url)
oauthState.value = parsed.searchParams.get('state') || ''
} catch {
oauthState.value = ''
}
return true
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL'
......@@ -75,10 +92,11 @@ export function useOpenAIOAuth() {
const exchangeAuthCode = async (
code: string,
currentSessionId: string,
state: string,
proxyId?: number | null
): Promise<OpenAITokenInfo | null> => {
if (!code.trim() || !currentSessionId) {
error.value = 'Missing auth code or session ID'
if (!code.trim() || !currentSessionId || !state.trim()) {
error.value = 'Missing auth code, session ID, or state'
return null
}
......@@ -86,15 +104,16 @@ export function useOpenAIOAuth() {
error.value = ''
try {
const payload: { session_id: string; code: string; proxy_id?: number } = {
const payload: { session_id: string; code: string; state: string; proxy_id?: number } = {
session_id: currentSessionId,
code: code.trim()
code: code.trim(),
state: state.trim()
}
if (proxyId) {
payload.proxy_id = proxyId
}
const tokenInfo = await adminAPI.accounts.exchangeCode('/admin/openai/exchange-code', payload)
const tokenInfo = await adminAPI.accounts.exchangeCode(`${endpointPrefix}/exchange-code`, payload)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code'
......@@ -120,7 +139,11 @@ export function useOpenAIOAuth() {
try {
// Use dedicated refresh-token endpoint
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(refreshToken.trim(), proxyId)
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(
refreshToken.trim(),
proxyId,
`${endpointPrefix}/refresh-token`
)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to validate refresh token'
......@@ -131,6 +154,33 @@ export function useOpenAIOAuth() {
}
}
// Validate Sora session token and get access token
const validateSessionToken = async (
sessionToken: string,
proxyId?: number | null
): Promise<OpenAITokenInfo | null> => {
if (!sessionToken.trim()) {
error.value = 'Missing session token'
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.accounts.validateSoraSessionToken(
sessionToken.trim(),
proxyId,
`${endpointPrefix}/st2at`
)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to validate session token'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
// Build credentials for OpenAI OAuth account
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
const creds: Record<string, unknown> = {
......@@ -172,6 +222,7 @@ export function useOpenAIOAuth() {
// State
authUrl,
sessionId,
oauthState,
loading,
error,
// Methods
......@@ -179,6 +230,7 @@ export function useOpenAIOAuth() {
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
validateSessionToken,
buildCredentials,
buildExtraInfo
}
......
......@@ -1740,9 +1740,13 @@ export default {
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
sessionTokenAuth: 'Manual ST Input',
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token'
pleaseEnterRefreshToken: 'Please enter Refresh Token',
pleaseEnterSessionToken: 'Please enter Session Token'
},
// Gemini specific
gemini: {
......@@ -1963,6 +1967,7 @@ export default {
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account',
soraAccount: 'Sora Account',
geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account',
inputMethod: 'Input Method',
......
......@@ -1879,9 +1879,13 @@ export default {
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
sessionTokenAuth: '手动输入 ST',
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token'
pleaseEnterRefreshToken: '请输入 Refresh Token',
pleaseEnterSessionToken: '请输入 Session Token'
},
// Gemini specific
gemini: {
......@@ -2097,6 +2101,7 @@ export default {
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号',
soraAccount: 'Sora 账号',
geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号',
inputMethod: '输入方式',
......
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