diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index c5fcfd1dd9..523f261c43 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -774,16 +774,20 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { return nil } protected := map[string]struct{}{ - "access_token": {}, - "refresh_token": {}, - "id_token": {}, - "expires_at": {}, - "email": {}, - "chatgpt_account_id": {}, - "chatgpt_user_id": {}, - "organization_id": {}, - "plan_type": {}, - "client_id": {}, + "access_token": {}, + "refresh_token": {}, + "id_token": {}, + "expires_at": {}, + "email": {}, + "chatgpt_account_id": {}, + "chatgpt_user_id": {}, + "organization_id": {}, + "plan_type": {}, + "client_id": {}, + "auth_mode": {}, + "openai_auth_mode": {}, + "token_type": {}, + "chatgpt_account_is_fedramp": {}, } out := make(map[string]any, len(input)) for key, value := range input { diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 65ad5804fc..7967c15388 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -849,6 +849,7 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv newCredentials[k] = v } } + newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials) } else if account.Platform == service.PlatformGemini { tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account) if err != nil { diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 89010f6982..c98eb5abfe 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -3,6 +3,7 @@ package admin import ( "strconv" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -106,6 +107,24 @@ type OpenAIRefreshTokenRequest struct { ProxyID *int64 `json:"proxy_id"` } +type OpenAICodexPATCreateRequest struct { + AccessToken string `json:"access_token" binding:"required"` + Name string `json:"name"` + Notes *string `json:"notes"` + GroupIDs []int64 `json:"group_ids"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + RateMultiplier *float64 `json:"rate_multiplier"` + LoadFactor *int `json:"load_factor"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` + CredentialExtras map[string]any `json:"credential_extras"` + Extra map[string]any `json:"extra"` + SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` + ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` +} + // RefreshToken refreshes an OpenAI OAuth token // POST /api/v1/admin/openai/refresh-token func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) { @@ -191,6 +210,7 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) { newCredentials[k] = v } } + newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials) updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ Credentials: newCredentials, @@ -269,6 +289,114 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) { response.Success(c, dto.AccountFromService(account)) } +// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token. +// POST /api/v1/admin/openai/create-from-codex-pat +func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) { + var req OpenAICodexPATCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Concurrency != nil && *req.Concurrency < 0 { + response.BadRequest(c, "concurrency must be >= 0") + return + } + if req.Priority != nil && *req.Priority < 0 { + response.BadRequest(c, "priority must be >= 0") + return + } + if req.RateMultiplier != nil && *req.RateMultiplier < 0 { + response.BadRequest(c, "rate_multiplier must be >= 0") + return + } + if req.LoadFactor != nil && *req.LoadFactor > 10000 { + response.BadRequest(c, "load_factor must be <= 10000") + return + } + + var proxyURL string + if req.ProxyID != nil { + proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if proxy != nil { + proxyURL = proxy.URL() + } + } + + tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL) + if err != nil { + response.ErrorFrom(c, err) + return + } + + credentials := mergeCodexImportMap( + h.openaiOAuthService.BuildAccountCredentials(tokenInfo), + sanitizeCodexImportCredentialExtras(req.CredentialExtras), + ) + extra := mergeCodexImportMap(req.Extra, map[string]any{ + "import_source": "codex_personal_access_token", + "auth_provider": "codex_personal_access_token", + "imported_at": time.Now().UTC().Format(time.RFC3339), + "access_token_sha256": codexTokenFingerprint(req.AccessToken), + }) + + concurrency := 3 + if req.Concurrency != nil { + concurrency = *req.Concurrency + } + priority := 50 + if req.Priority != nil { + priority = *req.Priority + } + skipDefaultGroupBind := false + if req.SkipDefaultGroupBind != nil { + skipDefaultGroupBind = *req.SkipDefaultGroupBind + } + + account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{ + Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo), + Notes: req.Notes, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: credentials, + Extra: extra, + ProxyID: req.ProxyID, + Concurrency: concurrency, + Priority: priority, + RateMultiplier: req.RateMultiplier, + LoadFactor: req.LoadFactor, + GroupIDs: req.GroupIDs, + ExpiresAt: req.ExpiresAt, + AutoPauseOnExpired: req.AutoPauseOnExpired, + SkipDefaultGroupBind: skipDefaultGroupBind, + SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.AccountFromService(account)) +} + +func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string { + name = strings.TrimSpace(name) + if name != "" { + return name + } + if tokenInfo != nil { + for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} { + if candidate = strings.TrimSpace(candidate); candidate != "" { + return candidate + } + } + } + return "Codex PAT Account" +} + // QueryQuota queries the rate-limit / quota usage for an OpenAI account. // GET /api/v1/admin/openai/accounts/:id/quota func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 24c52acb9e..224300f12f 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -362,6 +362,7 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken) openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken) openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth) + openai.POST("/create-from-codex-pat", h.Admin.OpenAIOAuth.CreateAccountFromCodexPAT) openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 42c7674462..1f86be69b1 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -77,6 +77,21 @@ const ( const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities" +const ( + OpenAIAuthModePersonalAccessToken = "personalAccessToken" + openAIAuthModeCredentialKey = "auth_mode" + openAIAuthModeLegacyCredentialKey = "openai_auth_mode" +) + +func isOpenAIPersonalAccessTokenAuthMode(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "personalaccesstoken", "personal_access_token": + return true + default: + return false + } +} + type TempUnschedulableRule struct { ErrorCode int `json:"error_code"` Keywords []string `json:"keywords"` @@ -1060,6 +1075,14 @@ func (a *Account) IsOpenAIOAuth() bool { return a.IsOpenAI() && a.Type == AccountTypeOAuth } +func (a *Account) IsOpenAIPersonalAccessToken() bool { + if !a.IsOpenAIOAuth() { + return false + } + return isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeCredentialKey)) || + isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeLegacyCredentialKey)) +} + func (a *Account) IsOpenAIApiKey() bool { return a.IsOpenAI() && a.Type == AccountTypeAPIKey } @@ -1119,6 +1142,34 @@ func (a *Account) GetChatGPTAccountID() string { return a.GetCredential("chatgpt_account_id") } +func (a *Account) IsChatGPTAccountFedRAMP() bool { + if !a.IsOpenAIOAuth() || a.Credentials == nil { + return false + } + v, ok := a.Credentials["chatgpt_account_is_fedramp"] + if !ok || v == nil { + return false + } + switch value := v.(type) { + case bool: + return value + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed + case json.Number: + parsed, err := strconv.ParseBool(value.String()) + return err == nil && parsed + case float64: + return value != 0 + case int: + return value != 0 + case int64: + return value != 0 + default: + return false + } +} + func (a *Account) GetOpenAIDeviceID() string { if !a.IsOpenAIOAuth() { return "" diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 032c13b18e..7a9cd18a25 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -526,7 +526,6 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account var authToken string var apiURL string var isOAuth bool - var chatgptAccountID string if account.IsOAuth() { isOAuth = true @@ -538,7 +537,6 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account // OAuth uses ChatGPT internal API apiURL = chatgptCodexAPIURL - chatgptAccountID = account.GetChatGPTAccountID() } else if account.Type == "apikey" { // API Key - use Platform API authToken = account.GetOpenAIApiKey() @@ -590,9 +588,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if isOAuth { req.Host = "chatgpt.com" req.Header.Set("accept", "text/event-stream") - if chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(req.Header, account) } // Get proxy URL @@ -699,7 +695,6 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account authToken := "" apiURL := "" isOAuth := false - chatgptAccountID := "" switch { case account.IsOAuth(): @@ -709,7 +704,6 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account return s.sendErrorAndEnd(c, "No access token available") } apiURL = chatgptCodexAPIURL + "/compact" - chatgptAccountID = account.GetChatGPTAccountID() case account.Type == AccountTypeAPIKey: authToken = account.GetOpenAIApiKey() if authToken == "" { @@ -756,9 +750,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account if isOAuth { req.Host = "chatgpt.com" - if chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(req.Header, account) } proxyURL := "" @@ -1609,9 +1601,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co } else { req.Header.Set("User-Agent", codexCLIUserAgent) } - if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index c9849e04f3..7e2ec88ce0 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -27,8 +27,9 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi Schedulable: true, Concurrency: 1, Credentials: map[string]any{ - "access_token": "oauth-token", - "chatgpt_account_id": "chatgpt-acc", + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + "chatgpt_account_is_fedramp": true, }, } repo := &snapshotUpdateAccountRepo{ @@ -60,6 +61,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "chatgpt-acc", upstream.lastReq.Header.Get("chatgpt-account-id")) + require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp")) require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) updates := <-updateCalls diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 416bf09181..8092688d89 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -637,9 +637,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent)) } } - if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index b69b063932..02d36b0cbc 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -1271,6 +1271,7 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account newCredentials[k] = v } } + newCredentials = NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials) } case PlatformGemini: if s.geminiOAuthService == nil { diff --git a/backend/internal/service/openai_chatgpt_headers.go b/backend/internal/service/openai_chatgpt_headers.go new file mode 100644 index 0000000000..5bd1b8f1bc --- /dev/null +++ b/backend/internal/service/openai_chatgpt_headers.go @@ -0,0 +1,17 @@ +package service + +import "net/http" + +func setOpenAIChatGPTAccountHeaders(headers http.Header, account *Account) { + if headers == nil || account == nil || !account.IsOpenAIOAuth() { + return + } + if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { + headers.Set("chatgpt-account-id", chatgptAccountID) + } + if account.IsChatGPTAccountFedRAMP() { + headers.Set("x-openai-fedramp", "true") + } else { + headers.Del("x-openai-fedramp") + } +} diff --git a/backend/internal/service/openai_codex_pat_service.go b/backend/internal/service/openai_codex_pat_service.go new file mode 100644 index 0000000000..e109265aba --- /dev/null +++ b/backend/internal/service/openai_codex_pat_service.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" +) + +const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami" + +var openAICodexPATWhoamiURL = openAICodexPATWhoamiURLDefault + +var openAIPersonalAccessTokenOAuthCredentialKeys = [...]string{ + "refresh_token", + "id_token", + "expires_at", + "expires_in", + "client_id", +} + +type openAICodexPATWhoamiResponse struct { + Email string `json:"email"` + ChatGPTUserID string `json:"chatgpt_user_id"` + ChatGPTAccountID string `json:"chatgpt_account_id"` + ChatGPTPlanType string `json:"chatgpt_plan_type"` + ChatGPTAccountIsFedRAMP *bool `json:"chatgpt_account_is_fedramp"` +} + +// ValidateCodexPersonalAccessToken validates a Codex at-* token using the same +// first-class PAT endpoint used by the Codex client. +func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Context, accessToken, proxyURL string) (*OpenAITokenInfo, error) { + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_REQUIRED", "access token is required") + } + if !strings.HasPrefix(accessToken, "at-") { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_INVALID_PREFIX", "Codex personal access token must start with at-") + } + + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: 20 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + }) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_CODEX_PAT_PROXY_INVALID", "invalid proxy configuration: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, openAICodexPATWhoamiURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_PAT_REQUEST_FAILED", "failed to build validation request: %v", err) + } + req.Header.Set("authorization", "Bearer "+accessToken) + req.Header.Set("accept", "application/json") + req.Header.Set("originator", "codex_cli_rs") + req.Header.Set("user-agent", codexCLIUserAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_VALIDATE_FAILED", "failed to validate Codex personal access token: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_INVALID", "Codex personal access token is invalid or expired") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + message := strings.TrimSpace(string(body)) + if message == "" { + message = resp.Status + } + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_VALIDATE_FAILED", "Codex personal access token validation failed: %s", message) + } + + var whoami openAICodexPATWhoamiResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&whoami); err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_RESPONSE_INVALID", "invalid Codex personal access token validation response: %v", err) + } + if err := validateOpenAICodexPATWhoami(whoami); err != nil { + return nil, err + } + + return &OpenAITokenInfo{ + AccessToken: accessToken, + AuthMode: OpenAIAuthModePersonalAccessToken, + Email: strings.TrimSpace(whoami.Email), + ChatGPTAccountID: strings.TrimSpace(whoami.ChatGPTAccountID), + ChatGPTUserID: strings.TrimSpace(whoami.ChatGPTUserID), + ChatGPTAccountFedRAMP: *whoami.ChatGPTAccountIsFedRAMP, + PlanType: strings.TrimSpace(whoami.ChatGPTPlanType), + }, nil +} + +func validateOpenAICodexPATWhoami(whoami openAICodexPATWhoamiResponse) error { + required := map[string]string{ + "email": whoami.Email, + "chatgpt_user_id": whoami.ChatGPTUserID, + "chatgpt_account_id": whoami.ChatGPTAccountID, + "chatgpt_plan_type": whoami.ChatGPTPlanType, + } + for key, value := range required { + if strings.TrimSpace(value) == "" { + return infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_RESPONSE_INVALID", "Codex personal access token validation response is missing %s", key) + } + } + if whoami.ChatGPTAccountIsFedRAMP == nil { + return infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_PAT_RESPONSE_INVALID", "Codex personal access token validation response is missing chatgpt_account_is_fedramp") + } + return nil +} + +// NormalizeOpenAIPersonalAccessTokenCredentials removes OAuth-only credential +// fields from Codex personal access token accounts while preserving local +// routing, mapping, quota, and metadata fields. +func NormalizeOpenAIPersonalAccessTokenCredentials(account *Account, tokenInfo *OpenAITokenInfo, credentials map[string]any) map[string]any { + if credentials == nil || !isOpenAIPersonalAccessTokenCredentialSet(account, tokenInfo, credentials) { + return credentials + } + + for _, key := range openAIPersonalAccessTokenOAuthCredentialKeys { + delete(credentials, key) + } + credentials[openAIAuthModeCredentialKey] = OpenAIAuthModePersonalAccessToken + credentials[openAIAuthModeLegacyCredentialKey] = "personal_access_token" + credentials["token_type"] = "Bearer" + return credentials +} + +func isOpenAIPersonalAccessTokenCredentialSet(account *Account, tokenInfo *OpenAITokenInfo, credentials map[string]any) bool { + if tokenInfo != nil && isOpenAIPersonalAccessTokenAuthMode(tokenInfo.AuthMode) { + return true + } + if account != nil && account.IsOpenAIPersonalAccessToken() { + return true + } + return isOpenAIPersonalAccessTokenAuthMode(openAICredentialString(credentials[openAIAuthModeCredentialKey])) || + isOpenAIPersonalAccessTokenAuthMode(openAICredentialString(credentials[openAIAuthModeLegacyCredentialKey])) +} + +func openAICredentialString(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + default: + return "" + } +} diff --git a/backend/internal/service/openai_codex_pat_service_test.go b/backend/internal/service/openai_codex_pat_service_test.go new file mode 100644 index 0000000000..24ac7a7c16 --- /dev/null +++ b/backend/internal/service/openai_codex_pat_service_test.go @@ -0,0 +1,122 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) { + var gotAuthorization string + var gotOriginator string + var gotUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("authorization") + gotOriginator = r.Header.Get("originator") + gotUserAgent = r.Header.Get("user-agent") + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{ + "email":"user@example.com", + "chatgpt_user_id":"user-123", + "chatgpt_account_id":"acct-123", + "chatgpt_plan_type":"plus", + "chatgpt_account_is_fedramp":true + }`)) + })) + defer server.Close() + + originalURL := openAICodexPATWhoamiURL + openAICodexPATWhoamiURL = server.URL + defer func() { openAICodexPATWhoamiURL = originalURL }() + + svc := NewOpenAIOAuthService(nil, nil) + defer svc.Stop() + + info, err := svc.ValidateCodexPersonalAccessToken(context.Background(), " at-test-token ", "") + require.NoError(t, err) + require.Equal(t, "Bearer at-test-token", gotAuthorization) + require.Equal(t, "codex_cli_rs", gotOriginator) + require.Equal(t, codexCLIUserAgent, gotUserAgent) + require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode) + require.Equal(t, "user@example.com", info.Email) + require.Equal(t, "user-123", info.ChatGPTUserID) + require.Equal(t, "acct-123", info.ChatGPTAccountID) + require.Equal(t, "plus", info.PlanType) + require.True(t, info.ChatGPTAccountFedRAMP) + require.Zero(t, info.ExpiresAt) + require.Empty(t, info.RefreshToken) +} + +func TestOpenAIOAuthService_ValidateCodexPersonalAccessTokenRequiresATPrefix(t *testing.T) { + svc := NewOpenAIOAuthService(nil, nil) + defer svc.Stop() + + _, err := svc.ValidateCodexPersonalAccessToken(context.Background(), "eyJ.jwt", "") + require.Error(t, err) + require.Contains(t, err.Error(), "at-") +} + +func TestOpenAIOAuthService_BuildAccountCredentialsForPAT(t *testing.T) { + svc := NewOpenAIOAuthService(nil, nil) + defer svc.Stop() + + credentials := svc.BuildAccountCredentials(&OpenAITokenInfo{ + AccessToken: "at-test-token", + AuthMode: OpenAIAuthModePersonalAccessToken, + Email: "user@example.com", + ChatGPTAccountID: "acct-123", + ChatGPTUserID: "user-123", + ChatGPTAccountFedRAMP: true, + PlanType: "plus", + }) + + require.Equal(t, "at-test-token", credentials["access_token"]) + require.Equal(t, OpenAIAuthModePersonalAccessToken, credentials["auth_mode"]) + require.Equal(t, "personal_access_token", credentials["openai_auth_mode"]) + require.Equal(t, "Bearer", credentials["token_type"]) + require.Equal(t, true, credentials["chatgpt_account_is_fedramp"]) + require.NotContains(t, credentials, "expires_at") + require.NotContains(t, credentials, "refresh_token") + require.NotContains(t, credentials, "id_token") +} + +func TestNormalizeOpenAIPersonalAccessTokenCredentialsRemovesOAuthFields(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "auth_mode": "personal_access_token", + }, + } + credentials := map[string]any{ + "access_token": "at-test-token", + "refresh_token": "stale-refresh-token", + "id_token": "stale-id-token", + "expires_at": "2026-01-01T00:00:00Z", + "expires_in": 3600, + "client_id": "stale-client", + "model_mapping": map[string]any{"gpt-5": "gpt-5-codex"}, + "chatgpt_account_is_fedramp": true, + "subscription_expires_at": "2026-12-31T00:00:00Z", + "openai_usage_channel_fields": []any{"custom"}, + } + + got := NormalizeOpenAIPersonalAccessTokenCredentials(account, nil, credentials) + + require.Equal(t, "at-test-token", got["access_token"]) + require.Equal(t, OpenAIAuthModePersonalAccessToken, got["auth_mode"]) + require.Equal(t, "personal_access_token", got["openai_auth_mode"]) + require.Equal(t, "Bearer", got["token_type"]) + require.NotContains(t, got, "refresh_token") + require.NotContains(t, got, "id_token") + require.NotContains(t, got, "expires_at") + require.NotContains(t, got, "expires_in") + require.NotContains(t, got, "client_id") + require.Equal(t, map[string]any{"gpt-5": "gpt-5-codex"}, got["model_mapping"]) + require.Equal(t, true, got["chatgpt_account_is_fedramp"]) + require.Equal(t, "2026-12-31T00:00:00Z", got["subscription_expires_at"]) + require.Equal(t, []any{"custom"}, got["openai_usage_channel_fields"]) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 2bed9e0394..e39af398e9 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -3436,9 +3436,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if account.Type == AccountTypeOAuth { promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) req.Host = "chatgpt.com" - if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(req.Header, account) apiKeyID := getAPIKeyIDFromContext(c) // 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。 clientSessionID := strings.TrimSpace(req.Header.Get("session_id")) @@ -4182,11 +4180,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if account.Type == AccountTypeOAuth { // Required: set Host for ChatGPT API (must use req.Host, not Header.Set) req.Host = "chatgpt.com" - // Required: set chatgpt-account-id header - chatgptAccountID := account.GetChatGPTAccountID() - if chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(req.Header, account) } // Whitelist passthrough headers diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 0ee357a97c..11d462a04b 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -118,9 +118,11 @@ type OpenAITokenInfo struct { ExpiresIn int64 `json:"expires_in"` ExpiresAt int64 `json:"expires_at"` ClientID string `json:"client_id,omitempty"` + AuthMode string `json:"auth_mode,omitempty"` Email string `json:"email,omitempty"` ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` + ChatGPTAccountFedRAMP bool `json:"chatgpt_account_is_fedramp,omitempty"` OrganizationID string `json:"organization_id,omitempty"` PlanType string `json:"plan_type,omitempty"` SubscriptionExpiresAt string `json:"subscription_expires_at,omitempty"` @@ -311,16 +313,23 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A } var proxyURL string - if account.ProxyID != nil { + if account.ProxyID != nil && s.proxyRepo != nil { proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID) if err == nil && proxy != nil { proxyURL = proxy.URL() } } + accessToken := account.GetCredential("access_token") + if account.IsOpenAIPersonalAccessToken() { + if accessToken == "" { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_REQUIRED", "access token is required") + } + return s.ValidateCodexPersonalAccessToken(ctx, accessToken, proxyURL) + } + refreshToken := account.GetCredential("refresh_token") if refreshToken == "" { - accessToken := account.GetCredential("access_token") if accessToken != "" { tokenInfo := &OpenAITokenInfo{ AccessToken: accessToken, @@ -350,11 +359,11 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A // BuildAccountCredentials builds credentials map from token info func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) map[string]any { - expiresAt := time.Unix(tokenInfo.ExpiresAt, 0).Format(time.RFC3339) - creds := map[string]any{ "access_token": tokenInfo.AccessToken, - "expires_at": expiresAt, + } + if tokenInfo.ExpiresAt > 0 { + creds["expires_at"] = time.Unix(tokenInfo.ExpiresAt, 0).Format(time.RFC3339) } // 仅在刷新响应返回了新的 refresh_token 时才更新,防止用空值覆盖已有令牌 if strings.TrimSpace(tokenInfo.RefreshToken) != "" { @@ -385,8 +394,16 @@ func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) if strings.TrimSpace(tokenInfo.ClientID) != "" { creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID) } + if tokenInfo.AuthMode == OpenAIAuthModePersonalAccessToken { + creds[openAIAuthModeCredentialKey] = OpenAIAuthModePersonalAccessToken + creds[openAIAuthModeLegacyCredentialKey] = "personal_access_token" + creds["token_type"] = "Bearer" + creds["chatgpt_account_is_fedramp"] = tokenInfo.ChatGPTAccountFedRAMP + } else if tokenInfo.ChatGPTAccountFedRAMP { + creds["chatgpt_account_is_fedramp"] = true + } - return creds + return NormalizeOpenAIPersonalAccessTokenCredentials(nil, tokenInfo, creds) } // Stop stops the session store cleanup goroutine diff --git a/backend/internal/service/openai_oauth_service_refresh_test.go b/backend/internal/service/openai_oauth_service_refresh_test.go index 75588c8db4..0a39a2a9b2 100644 --- a/backend/internal/service/openai_oauth_service_refresh_test.go +++ b/backend/internal/service/openai_oauth_service_refresh_test.go @@ -3,6 +3,8 @@ package service import ( "context" "errors" + "net/http" + "net/http/httptest" "sync/atomic" "testing" "time" @@ -60,6 +62,50 @@ func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccess require.Positive(t, atomic.LoadInt32(&privacyClientCalls), "existing access token should still run enrichment") } +func TestOpenAIOAuthService_RefreshAccountToken_PATIgnoresStaleRefreshToken(t *testing.T) { + client := &openaiOAuthClientRefreshStub{} + var whoamiCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&whoamiCalls, 1) + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{ + "email":"user@example.com", + "chatgpt_user_id":"user-123", + "chatgpt_account_id":"acct-123", + "chatgpt_plan_type":"plus", + "chatgpt_account_is_fedramp":false + }`)) + })) + defer server.Close() + + originalURL := openAICodexPATWhoamiURL + openAICodexPATWhoamiURL = server.URL + defer func() { openAICodexPATWhoamiURL = originalURL }() + + svc := NewOpenAIOAuthService(nil, client) + defer svc.Stop() + + account := &Account{ + ID: 77, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "at-test-token", + "refresh_token": "stale-refresh-token", + "expires_at": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), + "auth_mode": "personal_access_token", + }, + } + + info, err := svc.RefreshAccountToken(context.Background(), account) + require.NoError(t, err) + require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode) + require.Equal(t, "at-test-token", info.AccessToken) + require.Empty(t, info.RefreshToken) + require.Equal(t, int32(1), atomic.LoadInt32(&whoamiCalls)) + require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "PAT accounts must not call OAuth refresh even if stale refresh_token remains") +} + func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) { refresher := NewOpenAITokenRefresher(nil, nil) expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339) @@ -84,6 +130,67 @@ func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *te }, } require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute)) + + patWithStaleRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "at-test-token", + "refresh_token": "stale-refresh-token", + "expires_at": expiresAt, + "auth_mode": OpenAIAuthModePersonalAccessToken, + }, + } + require.False(t, refresher.NeedsRefresh(patWithStaleRT, 5*time.Minute)) +} + +func TestOpenAITokenRefresher_Refresh_PATRemovesStaleOAuthFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{ + "email":"user@example.com", + "chatgpt_user_id":"user-123", + "chatgpt_account_id":"acct-123", + "chatgpt_plan_type":"plus", + "chatgpt_account_is_fedramp":true + }`)) + })) + defer server.Close() + + originalURL := openAICodexPATWhoamiURL + openAICodexPATWhoamiURL = server.URL + defer func() { openAICodexPATWhoamiURL = originalURL }() + + svc := NewOpenAIOAuthService(nil, nil) + defer svc.Stop() + refresher := NewOpenAITokenRefresher(svc, nil) + + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "at-test-token", + "refresh_token": "stale-refresh-token", + "id_token": "stale-id-token", + "expires_at": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), + "expires_in": 3600, + "client_id": "stale-client", + "auth_mode": OpenAIAuthModePersonalAccessToken, + "model_mapping": map[string]any{"gpt-5": "gpt-5-codex"}, + }, + } + + credentials, err := refresher.Refresh(context.Background(), account) + require.NoError(t, err) + require.Equal(t, "at-test-token", credentials["access_token"]) + require.Equal(t, OpenAIAuthModePersonalAccessToken, credentials["auth_mode"]) + require.Equal(t, "personal_access_token", credentials["openai_auth_mode"]) + require.NotContains(t, credentials, "refresh_token") + require.NotContains(t, credentials, "id_token") + require.NotContains(t, credentials, "expires_at") + require.NotContains(t, credentials, "expires_in") + require.NotContains(t, credentials, "client_id") + require.Equal(t, map[string]any{"gpt-5": "gpt-5-codex"}, credentials["model_mapping"]) } func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) { diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 1ca91b7a47..9707e96f85 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -122,7 +122,7 @@ func NewOpenAIQuotaService( // OAuth account. Returns infraerrors so the handler layer can map them to // stable error codes / HTTP statuses. func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) { - accessToken, chatGPTAccountID, proxyURL, err := s.prepareUpstreamCall(ctx, accountID) + accessToken, chatGPTAccountID, proxyURL, fedRAMP, err := s.prepareUpstreamCall(ctx, accountID) if err != nil { return nil, err } @@ -138,7 +138,7 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* var payload OpenAIQuotaUsage resp, err := client.R(). SetContext(callCtx). - SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID)). + SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)). SetSuccessResult(&payload). Get(chatGPTUsageURL) if err != nil { @@ -159,7 +159,7 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* // The redeem_request_id is auto-generated (uuid-like) — upstream uses it for // idempotency. Returns the consumed credit metadata so the UI can refresh. func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) { - accessToken, chatGPTAccountID, proxyURL, err := s.prepareUpstreamCall(ctx, accountID) + accessToken, chatGPTAccountID, proxyURL, fedRAMP, err := s.prepareUpstreamCall(ctx, accountID) if err != nil { return nil, err } @@ -177,7 +177,7 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) defer cancel() - headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID) + headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP) headers["content-type"] = "application/json" var payload OpenAIQuotaResetResult @@ -208,23 +208,23 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( // prepareUpstreamCall loads the account, validates it, obtains a fresh access // token via the shared TokenProvider, and resolves the chatgpt-account-id and // proxy URL. Centralized so QueryUsage / ResetCredit share validation. -func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID int64) (accessToken, chatGPTAccountID, proxyURL string, err error) { +func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID int64) (accessToken, chatGPTAccountID, proxyURL string, fedRAMP bool, err error) { if s == nil || s.accountRepo == nil || s.tokenProvider == nil || s.privacyClientFactory == nil { - return "", "", "", infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota service is not configured") + return "", "", "", false, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota service is not configured") } account, err := s.accountRepo.GetByID(ctx, accountID) if err != nil { - return "", "", "", infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err) + return "", "", "", false, infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err) } if account == nil { - return "", "", "", infraerrors.New(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found") + return "", "", "", false, infraerrors.New(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found") } if account.Platform != PlatformOpenAI { - return "", "", "", infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_PLATFORM", "account is not an OpenAI account") + return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_PLATFORM", "account is not an OpenAI account") } if account.Type != AccountTypeOAuth { - return "", "", "", infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_TYPE", "account is not an OAuth account") + return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_TYPE", "account is not an OAuth account") } chatGPTAccountID = strings.TrimSpace(account.GetCredential("chatgpt_account_id")) @@ -233,16 +233,17 @@ func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID chatGPTAccountID = strings.TrimSpace(account.GetCredential("organization_id")) } if chatGPTAccountID == "" { - return "", "", "", infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_MISSING_ACCOUNT_ID", "chatgpt_account_id is missing; please re-authorize this account") + return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_MISSING_ACCOUNT_ID", "chatgpt_account_id is missing; please re-authorize this account") } accessToken, err = s.tokenProvider.GetAccessToken(ctx, account) if err != nil { - return "", "", "", infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err) + return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err) } if strings.TrimSpace(accessToken) == "" { - return "", "", "", infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty") + return "", "", "", false, infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty") } + fedRAMP = account.IsChatGPTAccountFedRAMP() // account.Proxy is eager-loaded by accountRepo.GetByID (see // repository.accountsToService), so we can read the proxy URL directly @@ -260,13 +261,13 @@ func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID } } - return accessToken, chatGPTAccountID, proxyURL, nil + return accessToken, chatGPTAccountID, proxyURL, fedRAMP, nil } // buildCodexCommonHeaders sets the request headers expected by the chatgpt.com // backend so calls succeed past Cloudflare/WASM checks. -func buildCodexCommonHeaders(accessToken, chatGPTAccountID string) map[string]string { - return map[string]string{ +func buildCodexCommonHeaders(accessToken, chatGPTAccountID string, fedRAMP bool) map[string]string { + headers := map[string]string{ "authorization": "Bearer " + accessToken, "chatgpt-account-id": chatGPTAccountID, "oai-language": openaiQuotaCodexLanguageTag, @@ -277,6 +278,10 @@ func buildCodexCommonHeaders(accessToken, chatGPTAccountID string) map[string]st "sec-fetch-dest": openaiQuotaSecFetchDest, "priority": "u=4, i", } + if fedRAMP { + headers["x-openai-fedramp"] = "true" + } + return headers } // generateRedeemRequestID produces a UUID-v4-shaped string without pulling in a diff --git a/backend/internal/service/openai_token_provider.go b/backend/internal/service/openai_token_provider.go index 947911891d..b10f52f65e 100644 --- a/backend/internal/service/openai_token_provider.go +++ b/backend/internal/service/openai_token_provider.go @@ -156,7 +156,7 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou // 2) Refresh if needed (pre-expiry skew). expiresAt := account.GetCredentialAsTime("expires_at") - needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew + needsRefresh := !account.IsOpenAIPersonalAccessToken() && (expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew) if needsRefresh && strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { if expiresAt != nil && !time.Now().Before(*expiresAt) { const reason = "openai access_token expired and refresh_token is missing" diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index b8c5903507..03aa6fe227 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -1152,9 +1152,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } if account != nil && account.Type == AccountTypeOAuth { - if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { - headers.Set("chatgpt-account-id", chatgptAccountID) - } + setOpenAIChatGPTAccountHeaders(headers, account) headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } diff --git a/backend/internal/service/token_refresher.go b/backend/internal/service/token_refresher.go index 823f98123a..9ad65291dd 100644 --- a/backend/internal/service/token_refresher.go +++ b/backend/internal/service/token_refresher.go @@ -96,6 +96,9 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool { // NeedsRefresh 检查token是否需要刷新 // expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期 func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { + if account.IsOpenAIPersonalAccessToken() { + return false + } if strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { return false } @@ -118,6 +121,7 @@ func (r *OpenAITokenRefresher) Refresh(ctx context.Context, account *Account) (m // 使用服务提供的方法构建新凭证,并保留原有字段 newCredentials := r.openaiOAuthService.BuildAccountCredentials(tokenInfo) newCredentials = MergeCredentials(account.Credentials, newCredentials) + newCredentials = NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials) return newCredentials, nil } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index c297943d91..ec2156fa2d 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -18,6 +18,7 @@ import type { AdminDataImportResult, CodexSessionImportRequest, CodexSessionImportResult, + OpenAICodexPATCreateRequest, CheckMixedChannelRequest, CheckMixedChannelResponse } from '@/types' @@ -612,6 +613,11 @@ export async function importCodexSession(payload: CodexSessionImportRequest): Pr return data } +export async function createOpenAICodexPAT(payload: OpenAICodexPATCreateRequest): Promise { + const { data } = await apiClient.post('/admin/openai/create-from-codex-pat', payload) + return data +} + /** * Get Antigravity default model mapping from backend * @returns Default model mapping (from -> to) @@ -812,6 +818,7 @@ export const accountsAPI = { exportData, importData, importCodexSession, + createOpenAICodexPAT, getAntigravityDefaultModelMapping, batchClearError, batchRefresh, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index d1a7729a58..c2a26b1c48 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2869,6 +2869,7 @@ :show-session-token-option="false" :show-access-token-option="false" :show-codex-session-import-option="form.platform === 'openai'" + :show-codex-pat-option="form.platform === 'openai'" :platform="form.platform" :show-project-id="geminiOAuthType === 'code_assist'" @generate-url="handleGenerateUrl" @@ -2877,6 +2878,7 @@ @validate-mobile-refresh-token="handleOpenAIValidateMobileRT" @validate-session-token="handleValidateSessionToken" @import-codex-session="handleOpenAIImportCodexSession" + @import-codex-pat="handleOpenAIImportCodexPAT" /> @@ -3262,6 +3264,7 @@ interface OAuthFlowExposed { refreshToken: string sessionToken: string codexSession: string + codexPAT: string inputMethod: AuthInputMethod reset: () => void } @@ -4976,6 +4979,55 @@ const handleOpenAIImportCodexSession = async (content: string) => { } } +const handleOpenAIImportCodexPAT = async (accessToken: string) => { + const oauthClient = openaiOAuth + const trimmed = accessToken.trim() + if (!trimmed) { + oauthClient.error.value = t('admin.accounts.oauth.openai.codexPatEmpty') + return + } + + const credentialExtras = buildOpenAICodexImportCredentialExtras() + if (credentialExtras === null) { + return + } + + oauthClient.loading.value = true + oauthClient.error.value = '' + + try { + const extra = buildOpenAIExtra() + await adminAPI.accounts.createOpenAICodexPAT({ + access_token: trimmed, + name: form.name, + notes: form.notes || null, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + load_factor: form.load_factor ?? undefined, + priority: form.priority, + rate_multiplier: form.rate_multiplier, + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value, + credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, + extra + }) + + appStore.showSuccess(t('admin.accounts.messages.accountCreated')) + emit('created') + handleClose() + } catch (error: any) { + oauthClient.error.value = + error.response?.data?.detail || + error.response?.data?.message || + error.message || + t('admin.accounts.oauth.openai.codexPatImportFailed') + appStore.showError(oauthClient.error.value) + } finally { + oauthClient.loading.value = false + } +} + // OpenAI RT 批量验证和创建(共享逻辑) const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => { const oauthClient = openaiOAuth diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 9526e8785a..c4cdba9a84 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -92,6 +92,17 @@ t('admin.accounts.oauth.openai.codexSessionAuth') }} + @@ -179,7 +190,7 @@ - +
+ +
+
+

+ {{ t('admin.accounts.oauth.openai.codexPatDesc') }} +

+ +
+ + +

+ {{ t('admin.accounts.oauth.openai.codexPatHint') }} +

+
+ +
+

+ {{ error }} +

+
+ + +
+
+
(), { showSessionTokenOption: false, showAccessTokenOption: false, showCodexSessionImportOption: false, + showCodexPatOption: false, platform: 'anthropic', showProjectId: true }) @@ -684,6 +770,7 @@ const emit = defineEmits<{ 'validate-session-token': [sessionToken: string] 'import-access-token': [accessToken: string] 'import-codex-session': [content: string] + 'import-codex-pat': [accessToken: string] 'update:inputMethod': [method: AuthInputMethod] }>() @@ -724,12 +811,13 @@ const sessionKeyInput = ref('') const refreshTokenInput = ref('') const sessionTokenInput = ref('') const codexSessionInput = ref('') +const codexPATInput = 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 || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption) +const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption || props.showCodexPatOption) // Clipboard const { copied, copyToClipboard } = useClipboard() @@ -837,6 +925,12 @@ const handleImportCodexSession = () => { } } +const handleImportCodexPAT = () => { + if (codexPATInput.value.trim()) { + emit('import-codex-pat', codexPATInput.value.trim()) + } +} + // Expose methods and state defineExpose({ authCode: authCodeInput, @@ -846,6 +940,7 @@ defineExpose({ refreshToken: refreshTokenInput, sessionToken: sessionTokenInput, codexSession: codexSessionInput, + codexPAT: codexPATInput, inputMethod, reset: () => { authCodeInput.value = '' @@ -855,6 +950,7 @@ defineExpose({ refreshTokenInput.value = '' sessionTokenInput.value = '' codexSessionInput.value = '' + codexPATInput.value = '' inputMethod.value = 'manual' showHelpDialog.value = false } diff --git a/frontend/src/composables/useAccountOAuth.ts b/frontend/src/composables/useAccountOAuth.ts index ab4c640ae5..569e7010f7 100644 --- a/frontend/src/composables/useAccountOAuth.ts +++ b/frontend/src/composables/useAccountOAuth.ts @@ -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' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' +export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat' export interface OAuthState { authUrl: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0ac93a9811..8e71326694 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3802,6 +3802,14 @@ export default { codexSessionImportFailed: 'Failed to import Codex account', codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}', codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}', + codexPatAuth: 'Codex Personal Access Token', + codexPatDesc: 'Enter a Codex at- personal access token. The system validates it with OpenAI whoami before creating the account.', + codexPatInputLabel: 'Codex PAT', + codexPatPlaceholder: 'at-...', + codexPatHint: 'This is a separate auth mode. It does not save refresh_token or write an OAuth access_token expiration.', + codexPatImportAndCreate: 'Validate & Create Codex PAT Account', + codexPatEmpty: 'Please enter a Codex personal access token', + codexPatImportFailed: 'Failed to create Codex PAT account', sessionTokenAuth: 'Manual ST Input', sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ec9c51a171..15bbd883c8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3945,6 +3945,14 @@ export default { codexSessionImportFailed: 'Codex 账号导入失败', codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}', codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}', + codexPatAuth: 'Codex Personal Access Token', + codexPatDesc: '输入 Codex at- Personal Access Token,系统会先调用 OpenAI whoami 校验后再创建账号。', + codexPatInputLabel: 'Codex PAT', + codexPatPlaceholder: 'at-...', + codexPatHint: '这是独立认证模式,不保存 refresh_token,也不会写入 OAuth access_token 过期时间。', + codexPatImportAndCreate: '校验并创建 Codex PAT 账号', + codexPatEmpty: '请输入 Codex Personal Access Token', + codexPatImportFailed: 'Codex PAT 账号创建失败', sessionTokenAuth: '手动输入 ST', sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。', sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 04ae60b8ef..d9d35a7c3f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1175,6 +1175,24 @@ export interface CodexSessionImportRequest { confirm_mixed_channel_risk?: boolean } +export interface OpenAICodexPATCreateRequest { + access_token: string + name?: string + notes?: string | null + group_ids?: number[] + proxy_id?: number | null + concurrency?: number + priority?: number + rate_multiplier?: number + load_factor?: number | null + expires_at?: number | null + auto_pause_on_expired?: boolean + credential_extras?: Record + extra?: Record + skip_default_group_bind?: boolean + confirm_mixed_channel_risk?: boolean +} + export interface CodexSessionImportMessage { index: number name?: string