| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285 |
- 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_token,accessToken 过期后无法自动续期")
- }
- 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()
- }
|