diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 63c5ed0e..2c1ac5b0 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -114,6 +114,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient) + openAIOAuthService.SetPrivacyClientFactory(privacyClientFactory) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() driveClient := repository.NewGeminiDriveClient() diff --git a/backend/internal/pkg/openai/oauth.go b/backend/internal/pkg/openai/oauth.go index a35a5ea6..6b8521bd 100644 --- a/backend/internal/pkg/openai/oauth.go +++ b/backend/internal/pkg/openai/oauth.go @@ -270,6 +270,7 @@ type OpenAIAuthClaims struct { ChatGPTUserID string `json:"chatgpt_user_id"` ChatGPTPlanType string `json:"chatgpt_plan_type"` UserID string `json:"user_id"` + POID string `json:"poid"` // organization ID in access_token JWT Organizations []OrganizationClaim `json:"organizations"` } diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index bd82e107..0a1266d9 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -29,9 +29,10 @@ type soraSessionChunk struct { // OpenAIOAuthService handles OpenAI OAuth authentication flows type OpenAIOAuthService struct { - sessionStore *openai.SessionStore - proxyRepo ProxyRepository - oauthClient OpenAIOAuthClient + sessionStore *openai.SessionStore + proxyRepo ProxyRepository + oauthClient OpenAIOAuthClient + privacyClientFactory PrivacyClientFactory // 用于调用 chatgpt.com/backend-api(ImpersonateChrome) } // NewOpenAIOAuthService creates a new OpenAI OAuth service @@ -43,6 +44,12 @@ func NewOpenAIOAuthService(proxyRepo ProxyRepository, oauthClient OpenAIOAuthCli } } +// SetPrivacyClientFactory 注入 ImpersonateChrome 客户端工厂, +// 用于调用 chatgpt.com/backend-api 获取账号信息(plan_type 等)。 +func (s *OpenAIOAuthService) SetPrivacyClientFactory(factory PrivacyClientFactory) { + s.privacyClientFactory = factory +} + // OpenAIAuthURLResult contains the authorization URL and session info type OpenAIAuthURLResult struct { AuthURL string `json:"auth_url"` @@ -131,6 +138,7 @@ type OpenAITokenInfo struct { ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` OrganizationID string `json:"organization_id,omitempty"` PlanType string `json:"plan_type,omitempty"` + PrivacyMode string `json:"privacy_mode,omitempty"` } // ExchangeCode exchanges authorization code for tokens @@ -251,6 +259,30 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre tokenInfo.PlanType = userInfo.PlanType } + // id_token 中缺少 plan_type 时(如 Mobile RT),尝试通过 ChatGPT backend-api 补全 + if tokenInfo.PlanType == "" && tokenInfo.AccessToken != "" && s.privacyClientFactory != nil { + // 从 access_token JWT 中提取 orgID(poid),用于匹配正确的账号 + orgID := tokenInfo.OrganizationID + if orgID == "" { + if atClaims, err := openai.DecodeIDToken(tokenInfo.AccessToken); err == nil && atClaims.OpenAIAuth != nil { + orgID = atClaims.OpenAIAuth.POID + } + } + if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { + if tokenInfo.PlanType == "" && info.PlanType != "" { + tokenInfo.PlanType = info.PlanType + } + if tokenInfo.Email == "" && info.Email != "" { + tokenInfo.Email = info.Email + } + } + } + + // 尝试设置隐私(关闭训练数据共享),best-effort + if tokenInfo.AccessToken != "" && s.privacyClientFactory != nil { + tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL) + } + return tokenInfo, nil } diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index 90cd522d..d5966006 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -69,6 +69,139 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto return PrivacyModeTrainingOff } +// ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息 +type ChatGPTAccountInfo struct { + PlanType string + Email string +} + +const chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" + +// fetchChatGPTAccountInfo calls ChatGPT backend-api to get account info (plan_type, etc.). +// Used as fallback when id_token doesn't contain these fields (e.g., Mobile RT). +// orgID is used to match the correct account when multiple accounts exist (e.g., personal + team). +// Returns nil on any failure (best-effort, non-blocking). +func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, orgID string) *ChatGPTAccountInfo { + if accessToken == "" || clientFactory == nil { + return nil + } + + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + client, err := clientFactory(proxyURL) + if err != nil { + slog.Debug("chatgpt_account_check_client_error", "error", err.Error()) + return nil + } + + var result map[string]any + resp, err := client.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+accessToken). + SetHeader("Origin", "https://chatgpt.com"). + SetHeader("Referer", "https://chatgpt.com/"). + SetHeader("Accept", "application/json"). + SetSuccessResult(&result). + Get(chatGPTAccountsCheckURL) + + if err != nil { + slog.Debug("chatgpt_account_check_request_error", "error", err.Error()) + return nil + } + + if !resp.IsSuccessState() { + slog.Debug("chatgpt_account_check_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200)) + return nil + } + + info := &ChatGPTAccountInfo{} + + accounts, ok := result["accounts"].(map[string]any) + if !ok { + slog.Debug("chatgpt_account_check_no_accounts", "body", truncate(resp.String(), 300)) + return nil + } + + // 优先匹配 orgID 对应的账号(access_token JWT 中的 poid) + if orgID != "" { + if matched := extractPlanFromAccount(accounts, orgID); matched != "" { + info.PlanType = matched + } + } + + // 未匹配到时,遍历所有账号:优先 is_default,次选非 free + if info.PlanType == "" { + var defaultPlan, paidPlan, anyPlan string + for _, acctRaw := range accounts { + acct, ok := acctRaw.(map[string]any) + if !ok { + continue + } + planType := extractPlanType(acct) + if planType == "" { + continue + } + if anyPlan == "" { + anyPlan = planType + } + if account, ok := acct["account"].(map[string]any); ok { + if isDefault, _ := account["is_default"].(bool); isDefault { + defaultPlan = planType + } + } + if !strings.EqualFold(planType, "free") && paidPlan == "" { + paidPlan = planType + } + } + // 优先级:default > 非 free > 任意 + switch { + case defaultPlan != "": + info.PlanType = defaultPlan + case paidPlan != "": + info.PlanType = paidPlan + default: + info.PlanType = anyPlan + } + } + + if info.PlanType == "" { + slog.Debug("chatgpt_account_check_no_plan_type", "body", truncate(resp.String(), 300)) + return nil + } + + slog.Info("chatgpt_account_check_success", "plan_type", info.PlanType, "org_id", orgID) + return info +} + +// extractPlanFromAccount 从 accounts map 中按 key(account_id)精确匹配并提取 plan_type +func extractPlanFromAccount(accounts map[string]any, accountKey string) string { + acctRaw, ok := accounts[accountKey] + if !ok { + return "" + } + acct, ok := acctRaw.(map[string]any) + if !ok { + return "" + } + return extractPlanType(acct) +} + +// extractPlanType 从单个 account 对象中提取 plan_type +func extractPlanType(acct map[string]any) string { + if account, ok := acct["account"].(map[string]any); ok { + if planType, ok := account["plan_type"].(string); ok && planType != "" { + return planType + } + } + if entitlement, ok := acct["entitlement"].(map[string]any); ok { + if subPlan, ok := entitlement["subscription_plan"].(string); ok && subPlan != "" { + return subPlan + } + } + return "" +} + func truncate(s string, n int) string { if len(s) <= n { return s diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 751da25f..d9b537d8 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -550,14 +550,18 @@ export async function getAntigravityDefaultModelMapping(): Promise> { - const payload: { refresh_token: string; proxy_id?: number } = { + const payload: { refresh_token: string; proxy_id?: number; client_id?: string } = { refresh_token: refreshToken } if (proxyId) { payload.proxy_id = proxyId } + if (clientId) { + payload.client_id = clientId + } const { data } = await apiClient.post>(endpoint, payload) return data } diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 6f02a9d9..cff7ae1c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2504,6 +2504,7 @@ :allow-multiple="form.platform === 'anthropic'" :show-cookie-option="form.platform === 'anthropic'" :show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'" + :show-mobile-refresh-token-option="form.platform === 'openai'" :show-session-token-option="form.platform === 'sora'" :show-access-token-option="form.platform === 'sora'" :platform="form.platform" @@ -2511,6 +2512,7 @@ @generate-url="handleGenerateUrl" @cookie-auth="handleCookieAuth" @validate-refresh-token="handleValidateRefreshToken" + @validate-mobile-refresh-token="handleOpenAIValidateMobileRT" @validate-session-token="handleValidateSessionToken" @import-access-token="handleImportAccessToken" /> @@ -4360,11 +4362,14 @@ const handleOpenAIExchange = async (authCode: string) => { } // OpenAI 手动 RT 批量验证和创建 -const handleOpenAIValidateRT = async (refreshTokenInput: string) => { +// OpenAI Mobile RT 使用的 client_id(与后端 openai.SoraClientID 一致) +const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK' + +// OpenAI/Sora RT 批量验证和创建(共享逻辑) +const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => { const oauthClient = activeOpenAIOAuth.value if (!refreshTokenInput.trim()) return - // Parse multiple refresh tokens (one per line) const refreshTokens = refreshTokenInput .split('\n') .map((rt) => rt.trim()) @@ -4389,7 +4394,8 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { try { const tokenInfo = await oauthClient.validateRefreshToken( refreshTokens[i], - form.proxy_id + form.proxy_id, + clientId ) if (!tokenInfo) { failedCount++ @@ -4399,6 +4405,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { } const credentials = oauthClient.buildCredentials(tokenInfo) + if (clientId) { + credentials.client_id = clientId + } const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record | undefined const extra = buildOpenAIExtra(oauthExtra) @@ -4410,8 +4419,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { } } - // Generate account name with index for batch - const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name + // Generate account name; fallback to email if name is empty (ent schema requires NotEmpty) + const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account' + const accountName = refreshTokens.length > 1 ? `${baseName} #${i + 1}` : baseName let openaiAccountId: string | number | undefined @@ -4494,6 +4504,12 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { } } +// 手动输入 RT(Codex CLI client_id,默认) +const handleOpenAIValidateRT = (rt: string) => handleOpenAIBatchRT(rt) + +// 手动输入 Mobile RT(SoraClientID) +const handleOpenAIValidateMobileRT = (rt: string) => handleOpenAIBatchRT(rt, OPENAI_MOBILE_RT_CLIENT_ID) + // Sora 手动 ST 批量验证和创建 const handleSoraValidateST = async (sessionTokenInput: string) => { const oauthClient = activeOpenAIOAuth.value diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index cc74f8ce..b4c299db 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -48,6 +48,17 @@ t(getOAuthKey('refreshTokenAuth')) }} +