feat: add codex personal access token auth

This commit is contained in:
syx0310
2026-06-22 16:07:41 +00:00
parent 85a3b12254
commit 32df33a1c3
26 changed files with 848 additions and 65 deletions
@@ -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 {
@@ -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 {
@@ -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) {
+1
View File
@@ -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)
}
+51
View File
@@ -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 ""
@@ -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 {
@@ -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
@@ -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 {
@@ -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 {
@@ -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")
}
}
@@ -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 ""
}
}
@@ -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"])
}
@@ -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
@@ -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
@@ -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) {
@@ -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
@@ -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"
@@ -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))
}
@@ -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
}
+7
View File
@@ -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<Account> {
const { data } = await apiClient.post<Account>('/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,
@@ -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"
/>
</div>
@@ -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
@@ -92,6 +92,17 @@
t('admin.accounts.oauth.openai.codexSessionAuth')
}}</span>
</label>
<label v-if="showCodexPatOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="codex_pat"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.codexPatAuth')
}}</span>
</label>
</div>
</div>
@@ -179,7 +190,7 @@
</div>
</div>
<!-- Codex JSON / AT 批量输入 -->
<!-- Codex OAuth/session JSON batch import -->
<div v-if="inputMethod === 'codex_session'" 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"
@@ -258,6 +269,79 @@
</div>
</div>
<!-- Codex Personal Access Token -->
<div v-if="inputMethod === 'codex_pat'" 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('admin.accounts.oauth.openai.codexPatDesc') }}
</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" />
{{ t('admin.accounts.oauth.openai.codexPatInputLabel') }}
</label>
<textarea
v-model="codexPATInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.codexPatPlaceholder')"
spellcheck="false"
></textarea>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
{{ t('admin.accounts.oauth.openai.codexPatHint') }}
</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 || !codexPATInput.trim()"
@click="handleImportCodexPAT"
>
<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('admin.accounts.oauth.openai.validating')
: t('admin.accounts.oauth.openai.codexPatImportAndCreate')
}}
</button>
</div>
</div>
<!-- Cookie Auto-Auth Form -->
<div v-if="inputMethod === 'cookie'" class="space-y-4">
<div
@@ -652,6 +736,7 @@ interface Props {
showSessionTokenOption?: boolean
showAccessTokenOption?: boolean
showCodexSessionImportOption?: boolean
showCodexPatOption?: boolean
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -671,6 +756,7 @@ const props = withDefaults(defineProps<Props>(), {
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
}
+1 -1
View File
@@ -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
+8
View File
@@ -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',
+8
View File
@@ -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支持多个,每行一个',
+18
View File
@@ -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<string, unknown>
extra?: Record<string, unknown>
skip_default_group_bind?: boolean
confirm_mixed_channel_risk?: boolean
}
export interface CodexSessionImportMessage {
index: number
name?: string