Files
sub2api/backend/internal/handler/admin/account_codex_import.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1286 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const codexImportClockSkewSeconds int64 = 120
type CodexSessionImportRequest struct {
Content string `json:"content"`
Contents []string `json:"contents"`
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"`
UpdateExisting *bool `json:"update_existing"`
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
}
type CodexSessionImportResult struct {
Total int `json:"total"`
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Items []CodexSessionImportItem `json:"items,omitempty"`
Warnings []CodexSessionImportMessage `json:"warnings,omitempty"`
Errors []CodexSessionImportMessage `json:"errors,omitempty"`
}
type CodexSessionImportItem struct {
Index int `json:"index"`
Name string `json:"name,omitempty"`
Action string `json:"action"`
AccountID int64 `json:"account_id,omitempty"`
Message string `json:"message,omitempty"`
}
type CodexSessionImportMessage struct {
Index int `json:"index"`
Name string `json:"name,omitempty"`
Message string `json:"message"`
}
type codexImportEntry struct {
Index int
Value any
}
type codexImportAccount struct {
Name string
AccessToken string
RefreshToken string
IDToken string
Email string
AccountID string
UserID string
PlanType string
Organization string
AgentRuntimeID string
AgentPrivateKey string
AgentTaskID string
AgentFedRAMP bool
IsAgentIdentity bool
Credentials map[string]any
Extra map[string]any
TokenExpiresAt *time.Time
IdentityKeys []string
WarningTexts []string
}
type codexJWTClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
OpenAIAuth *codexJWTOpenAIClaims `json:"https://api.openai.com/auth,omitempty"`
}
type codexJWTOpenAIClaims struct {
ChatGPTAccountID string `json:"chatgpt_account_id"`
ChatGPTUserID string `json:"chatgpt_user_id"`
ChatGPTPlanType string `json:"chatgpt_plan_type"`
UserID string `json:"user_id"`
POID string `json:"poid"`
Organizations []openai.OrganizationClaim `json:"organizations"`
}
type codexAccountIndex struct {
accountsByKey map[string][]service.Account
keysByAccountID map[int64]map[string]struct{}
}
func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
var req CodexSessionImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
response.ErrorFrom(c, err)
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
}
entries, err := parseCodexSessionImportEntries(req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
if len(entries) == 0 {
response.BadRequest(c, "请输入 accessToken 或 Codex session JSON")
return
}
executeAdminIdempotentJSON(c, "admin.accounts.import_codex_session", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
return h.importCodexSessions(ctx, req, entries)
})
}
func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) {
result := CodexSessionImportResult{
Total: len(entries),
Items: make([]CodexSessionImportItem, 0, len(entries)),
}
existingAccounts, err := h.listAccountsFiltered(ctx, service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, "", "created_at", "desc")
if err != nil {
return result, err
}
index := buildCodexAccountIndex(existingAccounts)
updateExisting := true
if req.UpdateExisting != nil {
updateExisting = *req.UpdateExisting
}
concurrency := 3
if req.Concurrency != nil {
concurrency = *req.Concurrency
}
priority := 50
if req.Priority != nil {
priority = *req.Priority
}
credentialExtras := sanitizeCodexImportCredentialExtras(req.CredentialExtras)
skipDefaultGroupBind := false
if req.SkipDefaultGroupBind != nil {
skipDefaultGroupBind = *req.SkipDefaultGroupBind
}
skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
seenIdentity := map[string]codexSeenIdentity{}
for _, entry := range entries {
item, err := normalizeCodexImportEntry(entry)
if err != nil {
result.Failed++
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Action: "failed",
Message: err.Error(),
})
result.Errors = append(result.Errors, CodexSessionImportMessage{
Index: entry.Index,
Message: err.Error(),
})
continue
}
accountName := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries))
effectiveExpiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, expiryErr := resolveCodexImportExpiry(req, item)
if expiryErr != nil {
result.Failed++
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Name: accountName,
Action: "failed",
Message: expiryErr.Error(),
})
result.Errors = append(result.Errors, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: expiryErr.Error(),
})
continue
}
item.WarningTexts = append(item.WarningTexts, expiryWarnings...)
if credentialExpiresAt != nil {
item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339)
}
credentials := mergeCodexImportMap(item.Credentials, credentialExtras)
extra := mergeCodexImportMap(req.Extra, item.Extra)
for _, warning := range item.WarningTexts {
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: warning,
})
}
if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok {
message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex)
result.Skipped++
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Name: accountName,
Action: "skipped",
Message: message,
})
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: message,
})
continue
}
markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID)
existing, matchedKey := index.Find(item.IdentityKeys, item.UserID)
if existing != nil && updateExisting {
if strings.HasPrefix(matchedKey, "account:") && item.UserID != "" &&
codexCredentialString(existing.Credentials, "chatgpt_user_id") == "" {
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户",
})
}
preserveExistingRefresh := item.RefreshToken == "" &&
codexCredentialString(existing.Credentials, "refresh_token") != ""
if preserveExistingRefresh {
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据",
})
effectiveExpiresAt = nil
autoPauseOnExpired = nil
}
mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item)
mergedExtra := mergeCodexImportMap(existing.Extra, extra)
updateInput := &service.UpdateAccountInput{
Credentials: mergedCredentials,
Extra: mergedExtra,
Concurrency: req.Concurrency,
Priority: req.Priority,
RateMultiplier: req.RateMultiplier,
LoadFactor: req.LoadFactor,
ExpiresAt: effectiveExpiresAt,
AutoPauseOnExpired: autoPauseOnExpired,
}
if req.ProxyID != nil {
updateInput.ProxyID = req.ProxyID
}
if len(req.GroupIDs) > 0 {
groupIDs := append([]int64(nil), req.GroupIDs...)
updateInput.GroupIDs = &groupIDs
updateInput.SkipMixedChannelCheck = skipMixedChannelCheck
}
updated, updateErr := h.adminService.UpdateAccount(ctx, existing.ID, updateInput)
if updateErr != nil {
result.Failed++
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Name: accountName,
Action: "failed",
Message: updateErr.Error(),
})
result.Errors = append(result.Errors, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: updateErr.Error(),
})
continue
}
if h.tokenCacheInvalidator != nil && updated != nil {
_ = h.tokenCacheInvalidator.InvalidateToken(ctx, updated)
}
result.Updated++
accountID := existing.ID
if updated != nil {
accountID = updated.ID
index.Add(*updated)
}
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Name: accountName,
Action: "updated",
AccountID: accountID,
})
continue
}
account, createErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
Name: accountName,
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: effectiveExpiresAt,
AutoPauseOnExpired: autoPauseOnExpired,
SkipDefaultGroupBind: skipDefaultGroupBind,
SkipMixedChannelCheck: skipMixedChannelCheck,
})
if createErr != nil {
result.Failed++
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Name: accountName,
Action: "failed",
Message: createErr.Error(),
})
result.Errors = append(result.Errors, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: createErr.Error(),
})
continue
}
if account != nil {
index.Add(*account)
}
result.Created++
accountID := int64(0)
if account != nil {
accountID = account.ID
}
result.Items = append(result.Items, CodexSessionImportItem{
Index: entry.Index,
Name: accountName,
Action: "created",
AccountID: accountID,
})
}
return result, nil
}
func parseCodexSessionImportEntries(req CodexSessionImportRequest) ([]codexImportEntry, error) {
contents := make([]string, 0, 1+len(req.Contents))
if strings.TrimSpace(req.Content) != "" {
contents = append(contents, req.Content)
}
for _, content := range req.Contents {
if strings.TrimSpace(content) != "" {
contents = append(contents, content)
}
}
var entries []codexImportEntry
for _, content := range contents {
values, err := parseCodexSessionImportContent(content)
if err != nil {
return nil, err
}
for _, value := range values {
entries = append(entries, codexImportEntry{
Index: len(entries) + 1,
Value: value,
})
}
}
return entries, nil
}
func parseCodexSessionImportContent(content string) ([]any, error) {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return nil, nil
}
if looksLikeJSON(trimmed) {
values, err := decodeCodexJSONStream(trimmed)
if err != nil {
if strings.Contains(trimmed, "\n") {
if lineValues, lineErr := parseCodexSessionImportLines(trimmed); lineErr == nil {
return lineValues, nil
}
}
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return flattenCodexImportValues(values), nil
}
return parseCodexSessionImportLines(trimmed)
}
func parseCodexSessionImportLines(content string) ([]any, error) {
values := make([]any, 0)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if looksLikeJSON(line) {
lineValues, err := decodeCodexJSONStream(line)
if err != nil {
return nil, fmt.Errorf("第 %d 行 JSON 解析失败: %w", len(values)+1, err)
}
values = append(values, flattenCodexImportValues(lineValues)...)
continue
}
values = append(values, line)
}
return values, nil
}
func decodeCodexJSONStream(content string) ([]any, error) {
decoder := json.NewDecoder(strings.NewReader(content))
decoder.UseNumber()
values := make([]any, 0, 1)
for {
var value any
err := decoder.Decode(&value)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
values = append(values, value)
}
if len(values) == 0 {
return nil, errors.New("空 JSON 内容")
}
return values, nil
}
func flattenCodexImportValues(values []any) []any {
out := make([]any, 0, len(values))
var appendValue func(any)
appendValue = func(value any) {
if arr, ok := value.([]any); ok {
for _, item := range arr {
appendValue(item)
}
return
}
out = append(out, value)
}
for _, value := range values {
appendValue(value)
}
return out
}
func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, error) {
now := time.Now().UTC()
item := &codexImportAccount{
Credentials: map[string]any{},
Extra: map[string]any{
"import_source": "codex_session",
"imported_at": now.Format(time.RFC3339),
},
}
switch raw := entry.Value.(type) {
case string:
item.AccessToken = strings.TrimSpace(raw)
case map[string]any:
if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) {
if !ok {
agentIdentity = raw
}
item.IsAgentIdentity = true
item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"})
item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"})
item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"})
item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"})
item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"})
item.Email = firstCodexString(agentIdentity, []string{"email"})
item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"})
item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"})
if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" {
return nil, errors.New("agent identity 缺少必要字段")
}
if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil {
return nil, errors.New("agent identity private key 格式无效")
}
item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity
item.Credentials["agent_runtime_id"] = item.AgentRuntimeID
item.Credentials["agent_private_key"] = item.AgentPrivateKey
item.Credentials["chatgpt_account_id"] = item.AccountID
item.Credentials["chatgpt_user_id"] = item.UserID
item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP
setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID)
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
if item.AgentTaskID == "" {
item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
}
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID)
item.Name = buildCodexImportAccountName(item, entry.Index)
return item, nil
}
item.AccessToken = firstCodexString(raw,
[]string{"tokens", "access_token"},
[]string{"tokens", "accessToken"},
[]string{"access_token"},
[]string{"accessToken"},
[]string{"token"},
)
item.RefreshToken = firstCodexString(raw,
[]string{"tokens", "refresh_token"},
[]string{"tokens", "refreshToken"},
[]string{"refresh_token"},
[]string{"refreshToken"},
)
item.IDToken = firstCodexString(raw,
[]string{"tokens", "id_token"},
[]string{"tokens", "idToken"},
[]string{"id_token"},
[]string{"idToken"},
)
item.Email = firstCodexString(raw, []string{"email"}, []string{"user", "email"})
item.AccountID = firstCodexString(raw,
[]string{"chatgpt_account_id"},
[]string{"chatgptAccountId"},
[]string{"account_id"},
[]string{"accountId"},
[]string{"account", "id"},
[]string{"account", "account_id"},
[]string{"account", "chatgpt_account_id"},
)
item.UserID = firstCodexString(raw,
[]string{"chatgpt_user_id"},
[]string{"chatgptUserId"},
[]string{"user_id"},
[]string{"userId"},
[]string{"user", "id"},
)
item.PlanType = firstCodexString(raw,
[]string{"plan_type"},
[]string{"planType"},
[]string{"account", "plan_type"},
[]string{"account", "planType"},
)
item.Organization = firstCodexString(raw,
[]string{"organization_id"},
[]string{"organizationId"},
[]string{"org_id"},
[]string{"orgId"},
)
item.Name = firstCodexString(raw, []string{"name"}, []string{"user", "name"})
authProvider := firstCodexString(raw, []string{"auth_provider"}, []string{"authProvider"})
if authProvider != "" {
item.Extra["auth_provider"] = authProvider
}
if sessionToken := firstCodexString(raw, []string{"session_token"}, []string{"sessionToken"}); sessionToken != "" {
item.Extra["session_token_present"] = true
item.WarningTexts = append(item.WarningTexts, "sessionToken 已忽略,不会作为 OAuth refresh_token 存储")
}
if sessionExpiresAt, ok := codexTimeAt(raw, []string{"expires"}); ok {
item.Extra["session_expires_at"] = sessionExpiresAt.Format(time.RFC3339)
}
if tokenExpiresAt, ok := firstCodexTime(raw,
[]string{"tokens", "expires_at"},
[]string{"tokens", "expiresAt"},
[]string{"expires_at"},
[]string{"expiresAt"},
); ok {
if tokenExpiresAt.Unix() <= now.Unix()-codexImportClockSkewSeconds {
return nil, fmt.Errorf("access_token 已过期: %s", tokenExpiresAt.Format(time.RFC3339))
}
item.TokenExpiresAt = &tokenExpiresAt
item.Credentials["expires_at"] = tokenExpiresAt.Format(time.RFC3339)
}
copyCodexExtraString(raw, item.Extra, "user_image", []string{"user", "image"})
copyCodexExtraString(raw, item.Extra, "user_picture", []string{"user", "picture"})
copyCodexExtraString(raw, item.Extra, "account_structure", []string{"account", "structure"})
copyCodexExtraString(raw, item.Extra, "account_residency_region", []string{"account", "residencyRegion"})
copyCodexExtraString(raw, item.Extra, "compute_residency", []string{"account", "computeResidency"})
default:
return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index)
}
if item.IsAgentIdentity {
return item, nil
}
if item.AccessToken == "" {
return nil, errors.New("缺少 accessToken/access_token")
}
item.Credentials["access_token"] = item.AccessToken
if item.RefreshToken != "" {
item.Credentials["refresh_token"] = item.RefreshToken
item.Credentials["client_id"] = openai.ClientID
}
if item.IDToken != "" {
item.Credentials["id_token"] = item.IDToken
_ = enrichCodexImportAccountFromJWT(item, item.IDToken, false, now)
}
if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil {
return nil, err
}
if _, ok := item.Credentials["expires_at"]; !ok {
item.WarningTexts = append(item.WarningTexts, "无法从 accessToken 解析过期时间,导入后需自行确认令牌有效性")
}
if item.RefreshToken == "" {
item.WarningTexts = append(item.WarningTexts, "未包含 refresh_tokenaccessToken 过期后无法自动续期")
}
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_account_id", item.AccountID)
setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_user_id", item.UserID)
setCodexCredentialIfNotEmpty(item.Credentials, "organization_id", item.Organization)
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
fingerprint := codexTokenFingerprint(item.AccessToken)
item.Extra["access_token_sha256"] = fingerprint
item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken)
item.Name = buildCodexImportAccountName(item, entry.Index)
return item, nil
}
func enrichCodexImportAccountFromJWT(item *codexImportAccount, token string, validateExpiry bool, now time.Time) error {
claims, err := decodeCodexJWTClaims(token)
if err != nil {
if validateExpiry {
item.WarningTexts = append(item.WarningTexts, "accessToken 不是可解析 JWT,无法校验过期时间和账号身份")
}
return nil
}
if validateExpiry && claims.Exp > 0 {
if now.Unix() > claims.Exp+codexImportClockSkewSeconds {
return fmt.Errorf("access_token 已过期: %s", time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339))
}
expiresAt := time.Unix(claims.Exp, 0).UTC()
item.TokenExpiresAt = &expiresAt
item.Credentials["expires_at"] = expiresAt.Format(time.RFC3339)
}
if item.Email == "" {
item.Email = strings.TrimSpace(claims.Email)
}
if claims.OpenAIAuth == nil {
if item.UserID == "" {
item.UserID = strings.TrimSpace(claims.Sub)
}
return nil
}
if item.AccountID == "" {
item.AccountID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTAccountID)
}
if item.UserID == "" {
item.UserID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTUserID)
}
if item.UserID == "" {
item.UserID = strings.TrimSpace(claims.OpenAIAuth.UserID)
}
if item.PlanType == "" {
item.PlanType = strings.TrimSpace(claims.OpenAIAuth.ChatGPTPlanType)
}
if item.Organization == "" {
item.Organization = strings.TrimSpace(claims.OpenAIAuth.POID)
}
if item.Organization == "" {
for _, org := range claims.OpenAIAuth.Organizations {
if org.IsDefault {
item.Organization = org.ID
break
}
}
}
if item.Organization == "" && len(claims.OpenAIAuth.Organizations) > 0 {
item.Organization = claims.OpenAIAuth.Organizations[0].ID
}
if item.UserID == "" {
item.UserID = strings.TrimSpace(claims.Sub)
}
return nil
}
func decodeCodexJWTClaims(token string) (*codexJWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
payload, err := decodeCodexJWTSegment(parts[1])
if err != nil {
return nil, err
}
var claims codexJWTClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return &claims, nil
}
func decodeCodexJWTSegment(segment string) ([]byte, error) {
if decoded, err := base64.RawURLEncoding.DecodeString(segment); err == nil {
return decoded, nil
}
if decoded, err := base64.RawStdEncoding.DecodeString(segment); err == nil {
return decoded, nil
}
padded := segment
if rem := len(padded) % 4; rem > 0 {
padded += strings.Repeat("=", 4-rem)
}
if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil {
return decoded, nil
}
return base64.StdEncoding.DecodeString(padded)
}
func buildCodexImportAccountName(item *codexImportAccount, index int) string {
for _, candidate := range []string{item.Name, item.Email, item.AccountID, item.UserID} {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
return candidate
}
}
return fmt.Sprintf("Codex 导入账号 %d", index)
}
func buildCodexCreateAccountName(base string, item *codexImportAccount, index, total int) string {
base = strings.TrimSpace(base)
if base == "" {
if item == nil {
return fmt.Sprintf("Codex 导入账号 %d", index)
}
return item.Name
}
if total > 1 {
return fmt.Sprintf("%s #%d", base, index)
}
return base
}
func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAccount) (*int64, *time.Time, *bool, []string, error) {
if item == nil {
return nil, nil, nil, nil, errors.New("导入项为空")
}
// Agent Identity has no OAuth access-token lifetime. Its runtime/task
// lifecycle is handled by the upstream task recovery path, so it must not
// be rejected or auto-paused by the OAuth import expiry policy.
if item.IsAgentIdentity {
return nil, nil, nil, nil, nil
}
var requestExpiresAt *time.Time
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
t := time.Unix(*req.ExpiresAt, 0).UTC()
requestExpiresAt = &t
}
var accountExpiresAt *time.Time
var credentialExpiresAt *time.Time
warnings := make([]string, 0, 2)
if item.RefreshToken == "" {
if item.TokenExpiresAt != nil {
tokenExpiresAt := item.TokenExpiresAt.UTC()
accountExpiresAt = &tokenExpiresAt
credentialExpiresAt = &tokenExpiresAt
}
if requestExpiresAt != nil {
accountExpiresAt = earlierCodexTime(accountExpiresAt, requestExpiresAt)
credentialExpiresAt = earlierCodexTime(credentialExpiresAt, requestExpiresAt)
}
if accountExpiresAt == nil {
return nil, nil, nil, nil, errors.New("未包含 refresh_token,且无法解析 accessToken 过期时间;请在第一步设置过期时间后再导入")
}
if accountExpiresAt.Unix() <= time.Now().UTC().Unix()-codexImportClockSkewSeconds {
return nil, nil, nil, nil, fmt.Errorf("过期时间已过期: %s", accountExpiresAt.Format(time.RFC3339))
}
warnings = append(warnings, "未包含 refresh_token,已按 accessToken/账号过期时间设置自动停止调度")
if req.AutoPauseOnExpired != nil && !*req.AutoPauseOnExpired {
warnings = append(warnings, "未包含 refresh_token,已强制开启过期自动暂停")
}
autoPause := true
expiresAtUnix := accountExpiresAt.Unix()
return &expiresAtUnix, credentialExpiresAt, &autoPause, warnings, nil
}
if requestExpiresAt != nil {
accountExpiresAt = requestExpiresAt
}
if item.TokenExpiresAt != nil {
tokenExpiresAt := item.TokenExpiresAt.UTC()
credentialExpiresAt = &tokenExpiresAt
}
var expiresAtUnix *int64
if accountExpiresAt != nil {
v := accountExpiresAt.Unix()
expiresAtUnix = &v
}
return expiresAtUnix, credentialExpiresAt, req.AutoPauseOnExpired, warnings, nil
}
func earlierCodexTime(current, candidate *time.Time) *time.Time {
if candidate == nil {
return current
}
if current == nil || candidate.Before(*current) {
t := candidate.UTC()
return &t
}
t := current.UTC()
return &t
}
func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
if len(input) == 0 {
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": {},
"auth_mode": {},
"openai_auth_mode": {},
"token_type": {},
"chatgpt_account_is_fedramp": {},
"agent_runtime_id": {},
"agent_private_key": {},
"task_id": {},
}
out := make(map[string]any, len(input))
for key, value := range input {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" {
continue
}
if _, ok := protected[strings.ToLower(normalizedKey)]; ok {
continue
}
out[normalizedKey] = value
}
if len(out) == 0 {
return nil
}
return out
}
// buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时
// Codex session 只能作为 accessToken-only 凭据使用,此时以 access token
// 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。
func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string {
accessToken = strings.TrimSpace(accessToken)
refreshToken = strings.TrimSpace(refreshToken)
if refreshToken == "" && accessToken != "" {
return []string{"access:" + codexTokenFingerprint(accessToken)}
}
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
}
func buildCodexAgentIdentityKeys(accountID string) []string {
// Agent Identity credentials belonging to the same ChatGPT account are
// intentionally merged, while the same user may own multiple accounts.
// Do not use user/email/runtime as fallback keys here: user_id is shared
// across Team workspaces and runtime_id changes when a new runtime is
// registered for the same account.
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil
}
return []string{"account:" + accountID}
}
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
keys := make([]string, 0, 3)
accountID = strings.TrimSpace(accountID)
userID = strings.TrimSpace(userID)
accessToken = strings.TrimSpace(accessToken)
if userID != "" {
keys = append(keys, "user:"+userID)
}
if accountID == "" && userID == "" {
if email = strings.ToLower(strings.TrimSpace(email)); email != "" {
keys = append(keys, "email:"+email)
}
}
if accessToken != "" {
keys = append(keys, "access:"+codexTokenFingerprint(accessToken))
}
if accountID != "" {
keys = append(keys, "account:"+accountID)
}
return keys
}
func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
index := &codexAccountIndex{
accountsByKey: map[string][]service.Account{},
keysByAccountID: map[int64]map[string]struct{}{},
}
for _, account := range accounts {
index.Add(account)
}
return index
}
func (i *codexAccountIndex) Add(account service.Account) {
if i == nil {
return
}
if i.accountsByKey == nil {
i.accountsByKey = map[string][]service.Account{}
}
if i.keysByAccountID == nil {
i.keysByAccountID = map[int64]map[string]struct{}{}
}
keys := buildCodexStoredIdentityKeys(
codexCredentialString(account.Credentials, "chatgpt_account_id"),
codexCredentialString(account.Credentials, "chatgpt_user_id"),
codexCredentialString(account.Credentials, "email"),
codexCredentialString(account.Credentials, "access_token"),
)
orderedKeys := make([]string, 0, len(keys)+1)
accountKeys := make(map[string]struct{}, len(keys)+1)
for _, key := range keys {
if _, exists := accountKeys[key]; exists {
continue
}
accountKeys[key] = struct{}{}
orderedKeys = append(orderedKeys, key)
}
if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
key := "agent:" + runtimeID
if _, exists := accountKeys[key]; !exists {
accountKeys[key] = struct{}{}
orderedKeys = append(orderedKeys, key)
}
}
previousKeys := i.keysByAccountID[account.ID]
for key := range previousKeys {
if _, retained := accountKeys[key]; retained {
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
continue
}
i.removeFromKey(key, account.ID)
}
for _, key := range orderedKeys {
if _, retained := previousKeys[key]; retained {
continue
}
i.accountsByKey[key] = append(i.accountsByKey[key], account)
}
if len(accountKeys) > 0 {
i.keysByAccountID[account.ID] = accountKeys
return
}
delete(i.keysByAccountID, account.ID)
}
func (i *codexAccountIndex) removeFromKey(key string, accountID int64) {
accounts := i.accountsByKey[key]
kept := accounts[:0]
for _, account := range accounts {
if account.ID != accountID {
kept = append(kept, account)
}
}
if len(kept) == 0 {
delete(i.accountsByKey, key)
return
}
i.accountsByKey[key] = kept
}
// upsertCodexAccount keeps all candidates for shared keys while replacing an
// existing account in place so ambiguous legacy matches retain their order.
func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account {
for idx := range accounts {
if accounts[idx].ID == account.ID {
accounts[idx] = account
return accounts
}
}
return append(accounts, account)
}
// Find 返回第一个通过跨用户校验的候选账号及其命中的匹配键。
func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account, string) {
if i == nil {
return nil, ""
}
for _, key := range keys {
for _, account := range i.accountsByKey[key] {
if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) {
continue
}
return &account, key
}
}
return nil, ""
}
// codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个
// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧
// 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token
// 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。
func codexIdentityConflicts(key, userID, storedUserID string) bool {
if !strings.HasPrefix(key, "account:") {
return false
}
userID = strings.TrimSpace(userID)
storedUserID = strings.TrimSpace(storedUserID)
return userID != "" && storedUserID != "" && userID != storedUserID
}
type codexSeenIdentity struct {
index int
userID string
}
func firstSeenCodexIdentity(seen map[string]codexSeenIdentity, keys []string, userID string) (int, bool) {
for _, key := range keys {
entry, ok := seen[key]
if !ok {
continue
}
if codexIdentityConflicts(key, userID, entry.userID) {
continue
}
return entry.index, true
}
return 0, false
}
func markCodexIdentitySeen(seen map[string]codexSeenIdentity, keys []string, index int, userID string) {
for _, key := range keys {
seen[key] = codexSeenIdentity{index: index, userID: userID}
}
}
func mergeCodexImportMap(existing, incoming map[string]any) map[string]any {
out := make(map[string]any, len(existing)+len(incoming))
for k, v := range existing {
out[k] = v
}
for k, v := range incoming {
out[k] = v
}
return out
}
func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexImportAccount) map[string]any {
out := mergeCodexImportMap(existing, incoming)
if item == nil {
return out
}
if strings.TrimSpace(item.RefreshToken) == "" {
if codexCredentialString(existing, "refresh_token") == "" {
delete(out, "refresh_token")
delete(out, "client_id")
} else {
out["refresh_token"] = existing["refresh_token"]
if clientID, ok := existing["client_id"]; ok {
out["client_id"] = clientID
}
}
}
if strings.TrimSpace(item.IDToken) == "" {
delete(out, "id_token")
}
return out
}
func codexCredentialString(credentials map[string]any, key string) string {
if credentials == nil {
return ""
}
return codexStringValue(credentials[key])
}
func codexTokenFingerprint(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func looksLikeJSON(content string) bool {
if content == "" {
return false
}
switch content[0] {
case '{', '[':
return true
default:
return false
}
}
func firstCodexString(obj map[string]any, paths ...[]string) string {
for _, path := range paths {
if value, ok := codexPathValue(obj, path); ok {
if str := codexStringValue(value); str != "" {
return str
}
}
}
return ""
}
func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) {
for _, path := range paths {
value, ok := codexPathValue(obj, path)
if !ok || value == nil {
continue
}
if mapped, ok := value.(map[string]any); ok {
return mapped, true
}
}
return nil, false
}
func firstCodexBool(obj map[string]any, paths ...[]string) bool {
for _, path := range paths {
value, ok := codexPathValue(obj, path)
if !ok {
continue
}
switch value := value.(type) {
case bool:
return value
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err == nil {
return parsed
}
}
}
return false
}
func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) {
value := firstCodexString(obj, path)
if value != "" {
extra[key] = value
}
}
func firstCodexTime(obj map[string]any, paths ...[]string) (time.Time, bool) {
for _, path := range paths {
if value, ok := codexTimeAt(obj, path); ok {
return value, true
}
}
return time.Time{}, false
}
func codexTimeAt(obj map[string]any, path []string) (time.Time, bool) {
value, ok := codexPathValue(obj, path)
if !ok {
return time.Time{}, false
}
return parseCodexTimeValue(value)
}
func codexPathValue(obj map[string]any, path []string) (any, bool) {
var current any = obj
for _, key := range path {
currentObj, ok := current.(map[string]any)
if !ok {
return nil, false
}
value, ok := currentObj[key]
if !ok {
return nil, false
}
current = value
}
return current, true
}
func codexStringValue(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case json.Number:
return strings.TrimSpace(v.String())
case float64:
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
case float32:
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case int32:
return strconv.FormatInt(int64(v), 10)
default:
return ""
}
}
func setCodexCredentialIfNotEmpty(credentials map[string]any, key, value string) {
value = strings.TrimSpace(value)
if value != "" {
credentials[key] = value
}
}
func parseCodexTimeValue(value any) (time.Time, bool) {
switch v := value.(type) {
case string:
v = strings.TrimSpace(v)
if v == "" {
return time.Time{}, false
}
if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil {
return parsed.UTC(), true
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return codexUnixTime(n), true
}
case json.Number:
if n, err := v.Int64(); err == nil {
return codexUnixTime(n), true
}
if f, err := v.Float64(); err == nil {
return codexUnixTime(int64(f)), true
}
case float64:
return codexUnixTime(int64(v)), true
case int:
return codexUnixTime(int64(v)), true
case int64:
return codexUnixTime(v), true
}
return time.Time{}, false
}
func codexUnixTime(value int64) time.Time {
if value > 1_000_000_000_000 {
return time.UnixMilli(value).UTC()
}
return time.Unix(value, 0).UTC()
}