mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat: add codex personal access token auth
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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支持多个,每行一个',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user